@stringsync/splates 0.0.52 → 0.0.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/artifact.ts +120 -0
- package/budget.ts +117 -0
- package/checkout.ts +8 -0
- package/creds.test.ts +336 -0
- package/creds.ts +144 -0
- package/dev/dev.ts +86 -0
- package/dev/eval.ts +21 -0
- package/dev/fix.ts +63 -0
- package/dev/index.ts +1 -0
- package/dev/prove.ts +38 -0
- package/dev/root.ts +7 -0
- package/dev/ship.ts +46 -0
- package/dev/test.ts +154 -0
- package/e2e.test.ts +472 -0
- package/flags.ts +157 -0
- package/hook.ts +328 -0
- package/index.ts +18 -0
- package/infra.ts +107 -0
- package/lane.test.ts +29 -0
- package/lane.ts +172 -0
- package/notes.ts +19 -0
- package/package.json +14 -12
- package/projects.test.ts +191 -0
- package/projects.ts +37 -0
- package/queue.ts +82 -0
- package/screenshots/annotate.png +0 -0
- package/screenshots/demo.png +0 -0
- package/screenshots/parallel.png +0 -0
- package/screenshots/questions.png +0 -0
- package/server.ts +287 -0
- package/setup.ts +233 -0
- package/slots.ts +65 -0
- package/splates.test.ts +338 -0
- package/splates.ts +567 -0
- package/task.ts +354 -0
- package/tunnel.ts +106 -0
- package/wait-journal.test.ts +96 -0
- package/waiting.ts +59 -0
- package/worker-relay.test.ts +233 -0
- package/worker.ts +300 -0
- package/artifact.d.ts +0 -3
- package/budget.d.ts +0 -3
- package/creds.d.ts +0 -3
- package/flags.d.ts +0 -37
- package/hook.d.ts +0 -3
- package/index.d.ts +0 -2
- package/index.js +0 -1776
- package/infra.d.ts +0 -3
- package/lane.d.ts +0 -3
- package/notes.d.ts +0 -4
- package/projects.d.ts +0 -3
- package/queue.d.ts +0 -3
- package/server.d.ts +0 -7
- package/setup.d.ts +0 -22
- package/slots.d.ts +0 -3
- package/splates.d.ts +0 -10
- package/task.d.ts +0 -3
- package/tunnel.d.ts +0 -3
- package/waiting.d.ts +0 -3
- package/worker.d.ts +0 -3
package/setup.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { SYSTEM_STORES } from "@stringsync/splates-server/creds";
|
|
3
|
+
import { fail } from "@stringsync/splates-server/exit";
|
|
4
|
+
import { splatesHome } from "@stringsync/splates-server/models";
|
|
5
|
+
import { Projects } from "@stringsync/splates-server/projects";
|
|
6
|
+
import type { SplatesDeps } from "@stringsync/splates-server/server";
|
|
7
|
+
import {
|
|
8
|
+
SETUP_AREAS,
|
|
9
|
+
Setup,
|
|
10
|
+
type SetupProgress,
|
|
11
|
+
type StepProgress,
|
|
12
|
+
type StepStatus,
|
|
13
|
+
setupProjectAt,
|
|
14
|
+
type UndoPlan,
|
|
15
|
+
} from "@stringsync/splates-server/setup";
|
|
16
|
+
import {
|
|
17
|
+
fetchWorkerBudget,
|
|
18
|
+
fetchWorkers,
|
|
19
|
+
putWorkerLimit,
|
|
20
|
+
} from "@stringsync/splates-server/workers";
|
|
21
|
+
import type { Logger } from "webappwiz/log";
|
|
22
|
+
import { color } from "webappwiz/log";
|
|
23
|
+
import type { Ps } from "webappwiz/system";
|
|
24
|
+
|
|
25
|
+
/** What `splates setup` was asked for. */
|
|
26
|
+
export interface SetupOptions {
|
|
27
|
+
/** A step's id, or empty for every area's progress. */
|
|
28
|
+
step: string;
|
|
29
|
+
json: boolean;
|
|
30
|
+
skip: boolean;
|
|
31
|
+
unskip: boolean;
|
|
32
|
+
/** Print what undoing the area `step` names would do. */
|
|
33
|
+
undo: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `splates setup [step]`: progress by area and the one next step, or one step's
|
|
38
|
+
* why and instructions, after skipping or unskipping it when asked.
|
|
39
|
+
*/
|
|
40
|
+
export async function setup(
|
|
41
|
+
deps: SplatesDeps,
|
|
42
|
+
opts: SetupOptions,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
if (opts.step === "" && (opts.skip || opts.unskip || opts.undo)) {
|
|
45
|
+
fail("usage", "--skip and --unskip name a step, --undo an area", {});
|
|
46
|
+
}
|
|
47
|
+
if (opts.undo && (opts.skip || opts.unskip)) {
|
|
48
|
+
fail("usage", "--undo names an area, not a step to skip", {
|
|
49
|
+
area: opts.step,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (opts.skip && opts.unskip) {
|
|
53
|
+
fail("usage", "--skip or --unskip, not both", { step: opts.step });
|
|
54
|
+
}
|
|
55
|
+
const machine = await setupOf(deps);
|
|
56
|
+
const { log } = deps;
|
|
57
|
+
if (opts.undo) {
|
|
58
|
+
const plan = await machine.undoPlan(opts.step);
|
|
59
|
+
if (opts.json) {
|
|
60
|
+
log.info(JSON.stringify(plan, null, 2));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
printUndo(log, plan);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (opts.step !== "") {
|
|
67
|
+
machine.step(opts.step);
|
|
68
|
+
if (opts.skip) {
|
|
69
|
+
await machine.skip(opts.step);
|
|
70
|
+
} else if (opts.unskip) {
|
|
71
|
+
await machine.unskip(opts.step);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const progress = await machine.read();
|
|
75
|
+
if (opts.step === "") {
|
|
76
|
+
if (opts.json) {
|
|
77
|
+
log.info(JSON.stringify(progress, null, 2));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
printProgress(log, progress);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const step = progress.areas
|
|
84
|
+
.flatMap((area) => area.steps)
|
|
85
|
+
.find((each) => each.id === opts.step) as StepProgress;
|
|
86
|
+
if (opts.json) {
|
|
87
|
+
log.info(JSON.stringify(step, null, 2));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
printStep(log, step);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* This machine's setup, read through `deps`, for the project `--project`
|
|
95
|
+
* names or the repository the working directory is in, if either.
|
|
96
|
+
*/
|
|
97
|
+
export async function setupOf(deps: SplatesDeps): Promise<Setup> {
|
|
98
|
+
const { fs, ps, secrets = SYSTEM_STORES, fetch } = deps;
|
|
99
|
+
const root =
|
|
100
|
+
deps.project === undefined
|
|
101
|
+
? await repoRoot(ps)
|
|
102
|
+
: (await Projects.ofMachine(deps).named(deps.project)).root;
|
|
103
|
+
return new Setup(
|
|
104
|
+
{
|
|
105
|
+
fs,
|
|
106
|
+
ps,
|
|
107
|
+
home: splatesHome(ps),
|
|
108
|
+
device: secrets.device,
|
|
109
|
+
fetch,
|
|
110
|
+
// Only a server this agent runs under, or is pointed at, can be asked.
|
|
111
|
+
workers: deps.ps.env("SPLATES_DEV")
|
|
112
|
+
? () => fetchWorkers({ dev: "", log: deps.log, ps: deps.ps })
|
|
113
|
+
: undefined,
|
|
114
|
+
workerBudget: deps.ps.env("SPLATES_DEV")
|
|
115
|
+
? {
|
|
116
|
+
report: () =>
|
|
117
|
+
fetchWorkerBudget({ dev: "", log: deps.log, ps: deps.ps }),
|
|
118
|
+
set: (limit, dollars) =>
|
|
119
|
+
putWorkerLimit(limit, dollars, {
|
|
120
|
+
dev: "",
|
|
121
|
+
log: deps.log,
|
|
122
|
+
ps: deps.ps,
|
|
123
|
+
}),
|
|
124
|
+
}
|
|
125
|
+
: undefined,
|
|
126
|
+
project: root === null ? undefined : setupProjectAt(root, { fs, ps }),
|
|
127
|
+
},
|
|
128
|
+
SETUP_AREAS,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The main tree of the repository the working directory is in, or null. */
|
|
133
|
+
async function repoRoot(ps: Ps): Promise<string | null> {
|
|
134
|
+
const { exitCode, stdout } = await ps.spawnCapture([
|
|
135
|
+
"git",
|
|
136
|
+
"-C",
|
|
137
|
+
ps.cwd(),
|
|
138
|
+
"rev-parse",
|
|
139
|
+
"--path-format=absolute",
|
|
140
|
+
"--git-common-dir",
|
|
141
|
+
]);
|
|
142
|
+
return exitCode === 0 ? dirname(stdout.trim()) : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const MARKS: Record<StepStatus, string> = {
|
|
146
|
+
done: color.green("✓"),
|
|
147
|
+
skipped: color.dim("-"),
|
|
148
|
+
ready: color.yellow("○"),
|
|
149
|
+
waiting: color.dim("○"),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
function printProgress(log: Logger, progress: SetupProgress): void {
|
|
153
|
+
for (const area of progress.areas) {
|
|
154
|
+
const counts = [
|
|
155
|
+
`${area.done}/${area.steps.length} done`,
|
|
156
|
+
area.skipped > 0 ? `${area.skipped} skipped` : "",
|
|
157
|
+
].filter(Boolean);
|
|
158
|
+
log.info(`${color.bold(area.title)} ${color.dim(counts.join(", "))}`);
|
|
159
|
+
for (const step of area.steps) {
|
|
160
|
+
const notes = [
|
|
161
|
+
step.optional ? "optional" : "",
|
|
162
|
+
step.status === "waiting" ? `after ${step.after.join(", ")}` : "",
|
|
163
|
+
step.problem ?? "",
|
|
164
|
+
step.warning === null ? "" : "to put right",
|
|
165
|
+
].filter(Boolean);
|
|
166
|
+
log.info(
|
|
167
|
+
` ${MARKS[step.status]} ${step.id} ${step.title}${notes.length > 0 ? color.dim(` ${notes.join("; ")}`) : ""}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const steps = progress.areas.flatMap((area) => area.steps);
|
|
172
|
+
const next = steps.find((step) => step.id === progress.next);
|
|
173
|
+
for (const step of steps.filter((each) => each.warning !== null)) {
|
|
174
|
+
log.info(
|
|
175
|
+
`${color.yellow("put right:")} ${step.title} ${color.dim(`splates setup ${step.id}`)}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
log.info(
|
|
179
|
+
next === undefined
|
|
180
|
+
? color.green("nothing left to set up")
|
|
181
|
+
: `next: ${next.title} ${color.dim(`splates setup ${next.id}`)}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function printStep(log: Logger, step: StepProgress): void {
|
|
186
|
+
log.info(
|
|
187
|
+
`${color.bold(step.title)} ${color.dim(`${step.status}${step.optional ? ", optional" : ""}`)}`,
|
|
188
|
+
);
|
|
189
|
+
log.info(step.why);
|
|
190
|
+
if (step.detail !== null) {
|
|
191
|
+
log.info(step.detail);
|
|
192
|
+
}
|
|
193
|
+
if (step.warning !== null) {
|
|
194
|
+
log.info(`${color.yellow("put right:")} ${step.warning}`);
|
|
195
|
+
}
|
|
196
|
+
log.info(`you: ${step.user}`);
|
|
197
|
+
log.info(`agent: ${step.agent}`);
|
|
198
|
+
if (step.after.length > 0) {
|
|
199
|
+
log.info(`after: ${step.after.join(", ")}`);
|
|
200
|
+
}
|
|
201
|
+
if (step.problem !== null) {
|
|
202
|
+
log.info(`${color.red("problem:")} ${step.problem}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function printUndo(log: Logger, plan: UndoPlan): void {
|
|
207
|
+
log.info(`${color.bold(`Undo ${plan.title}`)} ${color.dim(plan.area)}`);
|
|
208
|
+
if (plan.refused !== null) {
|
|
209
|
+
log.info(`${color.red("refused:")} ${plan.refused}`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
for (const step of plan.steps) {
|
|
213
|
+
const started = step.done ? "" : ` ${color.yellow("started")}`;
|
|
214
|
+
log.info(` ${step.title} ${color.dim(step.id)}${started}`);
|
|
215
|
+
for (const line of step.removes) {
|
|
216
|
+
log.info(` removes ${line}`);
|
|
217
|
+
}
|
|
218
|
+
for (const line of step.leaves) {
|
|
219
|
+
log.info(` leaves ${line}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const cred of plan.creds) {
|
|
223
|
+
log.info(
|
|
224
|
+
cred.keptFor === null
|
|
225
|
+
? ` forgets ${cred.name}`
|
|
226
|
+
: ` keeps ${cred.name} ${color.dim(`${cred.keptFor} still uses it`)}`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (plan.confirm !== null) {
|
|
230
|
+
log.info(` the page asks for ${plan.confirm} to be typed`);
|
|
231
|
+
}
|
|
232
|
+
log.info(color.dim("the Setup page's Undo does it, after the user confirms"));
|
|
233
|
+
}
|
package/slots.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type Held, Slots } from "@stringsync/splates-server/ports";
|
|
2
|
+
import type { SplatesDeps } from "@stringsync/splates-server/server";
|
|
3
|
+
import { cli } from "webappwiz/cmd";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/** `splates slots`, which the root cli mounts after `splates tunnel`. */
|
|
7
|
+
export const splatesSlots = cli<SplatesDeps>("slots").description(
|
|
8
|
+
"the words `splates server --slot` hands out on this machine, each held by the tree that claimed it: a task's worktree holds its word until the task merges or is removed, which stops its server and gives the word back",
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
splatesSlots
|
|
12
|
+
.command("list")
|
|
13
|
+
.description(
|
|
14
|
+
"print each claimed word, the tree holding it, its server's pid while one runs, and whether it is orphaned: its tree gone, or a splates repository's main tree, which every agent shares",
|
|
15
|
+
)
|
|
16
|
+
.option("json", z.boolean(), { default: false, description: "emit JSON" })
|
|
17
|
+
.action(async (opts, ctx) => {
|
|
18
|
+
const held = await Slots.ofMachine(ctx).status();
|
|
19
|
+
if (opts.json) {
|
|
20
|
+
ctx.log.info(
|
|
21
|
+
JSON.stringify(
|
|
22
|
+
held.map(({ slot, tree, pid, orphaned }) => ({
|
|
23
|
+
word: slot.word,
|
|
24
|
+
tree,
|
|
25
|
+
pid,
|
|
26
|
+
orphaned,
|
|
27
|
+
})),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (held.length === 0) {
|
|
33
|
+
ctx.log.info("no slot is claimed");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
ctx.log.info(held.map(heldLine).join("\n"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
splatesSlots
|
|
40
|
+
.command("prune")
|
|
41
|
+
.description(
|
|
42
|
+
"free every orphaned word, stopping its server, so another tree can claim it; the slots a task's worktree holds are that task's, and left alone. The way to free a slot: never kill another tree's server or edit ~/.splates/slots.json",
|
|
43
|
+
)
|
|
44
|
+
.action(async (_opts, ctx) => {
|
|
45
|
+
const freed = await Slots.ofMachine(ctx).prune();
|
|
46
|
+
if (freed.length === 0) {
|
|
47
|
+
ctx.log.info(
|
|
48
|
+
"nothing to prune: each claimed word is held by a task's worktree",
|
|
49
|
+
);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
ctx.log.info(
|
|
53
|
+
freed
|
|
54
|
+
.map(
|
|
55
|
+
({ slot, tree, pid }) =>
|
|
56
|
+
`freed ${slot.word} ${slot.emoji} from ${tree}${pid === null ? "" : `, its server (pid ${pid}) stopped`}`,
|
|
57
|
+
)
|
|
58
|
+
.join("\n"),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function heldLine({ slot, tree, pid, orphaned }: Held): string {
|
|
63
|
+
const server = pid === null ? "no server" : `server pid ${pid}`;
|
|
64
|
+
return `${slot.word} ${slot.emoji} ${tree} ${server}${orphaned ? " orphaned: `splates slots prune` frees it" : ""}`;
|
|
65
|
+
}
|
package/splates.test.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { SERVING } from "@stringsync/splates-server/server";
|
|
4
|
+
import { Testing } from "@stringsync/splates-server/testing";
|
|
5
|
+
import { splates } from "./splates";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The cli in this process, run with fakes: the same commands `e2e.test.ts`
|
|
9
|
+
* spawns, minus the subprocess. What it proves is the wiring, that every
|
|
10
|
+
* dependency an action uses arrives through `run`.
|
|
11
|
+
*/
|
|
12
|
+
describe("splates cli", () => {
|
|
13
|
+
let env: Testing;
|
|
14
|
+
let deps: Parameters<typeof splates.run>[0];
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
env = await Testing.open();
|
|
18
|
+
env.ps.cd(env.root);
|
|
19
|
+
deps = {
|
|
20
|
+
log: env.log,
|
|
21
|
+
fs: env.fs,
|
|
22
|
+
ps: env.ps,
|
|
23
|
+
assets: env.assets,
|
|
24
|
+
secrets: env.secrets,
|
|
25
|
+
fetch: Object.assign(
|
|
26
|
+
() => Promise.reject(new Error("no network in a test")),
|
|
27
|
+
{ preconnect: () => {} },
|
|
28
|
+
),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => env.disposeAsync());
|
|
33
|
+
|
|
34
|
+
it("prints nothing successfully when there is no notes directory", async () => {
|
|
35
|
+
await splates.run(deps, ["notes"]);
|
|
36
|
+
expect(env.out()).toBe("");
|
|
37
|
+
expect(env.proc.lastExit()).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("prints only note descriptions and does not record consulting them", async () => {
|
|
41
|
+
await env.fs.mkdir(join(env.root, "notes"));
|
|
42
|
+
await env.fs.write(
|
|
43
|
+
join(env.root, "notes/a.md"),
|
|
44
|
+
"---\ndescription: Project conventions\n---\nBody",
|
|
45
|
+
);
|
|
46
|
+
await env.fs.write(
|
|
47
|
+
join(env.root, "notes/b.md"),
|
|
48
|
+
"---\ndescription: Client agreement\n---\nBody",
|
|
49
|
+
);
|
|
50
|
+
const before = await env.journal.tail(10);
|
|
51
|
+
await splates.run(deps, ["notes"]);
|
|
52
|
+
expect(env.log.entries.map((entry) => entry.message)).toEqual([
|
|
53
|
+
"Project conventions",
|
|
54
|
+
"Client agreement",
|
|
55
|
+
]);
|
|
56
|
+
expect(await env.journal.tail(10)).toEqual(before);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("reads the current worktree's notes and configuration from a subdirectory", async () => {
|
|
60
|
+
await splates.run(deps, ["add", "alpha", "--goal", "land it"]);
|
|
61
|
+
const { path } = await env.service.find("alpha");
|
|
62
|
+
await env.fs.mkdir(join(path, "context"));
|
|
63
|
+
await env.fs.mkdir(join(env.root, "notes"));
|
|
64
|
+
await env.fs.write(
|
|
65
|
+
join(path, "splates.config.ts"),
|
|
66
|
+
'export default { notesDir: "context" };\n',
|
|
67
|
+
);
|
|
68
|
+
await env.fs.write(
|
|
69
|
+
join(path, "context/a.md"),
|
|
70
|
+
"---\ndescription: Worktree convention\n---\n",
|
|
71
|
+
);
|
|
72
|
+
await env.fs.write(
|
|
73
|
+
join(env.root, "notes/a.md"),
|
|
74
|
+
"---\ndescription: Main convention\n---\n",
|
|
75
|
+
);
|
|
76
|
+
env.ps.cd(join(path, "context"));
|
|
77
|
+
const count = env.log.entries.length;
|
|
78
|
+
await splates.run(deps, ["notes"]);
|
|
79
|
+
expect(env.log.entries.slice(count).map((entry) => entry.message)).toEqual([
|
|
80
|
+
"Worktree convention",
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("runs its commands against the dependencies it is given", async () => {
|
|
85
|
+
await splates.run(deps, ["add", "alpha", "--goal", "land it"]);
|
|
86
|
+
await splates.run(deps, ["list", "--json"]);
|
|
87
|
+
|
|
88
|
+
const rows = JSON.parse(String(env.log.entries.at(-1)?.message));
|
|
89
|
+
expect(rows).toMatchObject([{ task: "alpha", status: "working" }]);
|
|
90
|
+
expect(
|
|
91
|
+
await env.fs.exists(join(`${env.root}-splates.noindex`, "alpha")),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("prints the roster from outside any repository", async () => {
|
|
96
|
+
const away = join(env.splatesHome, "..");
|
|
97
|
+
await env.fs.mkdir(away);
|
|
98
|
+
env.ps.cd(away);
|
|
99
|
+
await splates.run(deps, ["agents", "--json"]);
|
|
100
|
+
|
|
101
|
+
const roster = JSON.parse(String(env.log.entries.at(-1)?.message));
|
|
102
|
+
expect(roster.map((agent: { id: string }) => agent.id)).toEqual([
|
|
103
|
+
"claude",
|
|
104
|
+
"codex",
|
|
105
|
+
]);
|
|
106
|
+
expect(await env.fs.exists(join(env.splatesHome, "agents.json"))).toBe(
|
|
107
|
+
true,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("prints setup's progress and skips a step from outside any repository", async () => {
|
|
112
|
+
const away = join(env.splatesHome, "..");
|
|
113
|
+
await env.fs.mkdir(away);
|
|
114
|
+
env.ps.cd(away);
|
|
115
|
+
await splates.run(deps, ["setup", "dictation", "--skip"]);
|
|
116
|
+
await splates.run(deps, ["setup", "--json"]);
|
|
117
|
+
|
|
118
|
+
const progress = JSON.parse(String(env.log.entries.at(-1)?.message));
|
|
119
|
+
expect(progress.next).toBe("opencode-install");
|
|
120
|
+
expect(
|
|
121
|
+
progress.areas.find((area: { id: string }) => area.id === "dictation")
|
|
122
|
+
.skipped,
|
|
123
|
+
).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("prints what undoing an area would forget, and undoes nothing", async () => {
|
|
127
|
+
await env.secrets.device.set("OPENAI_API_KEY", "sk-test");
|
|
128
|
+
|
|
129
|
+
await splates.run(deps, ["setup", "dictation", "--undo", "--json"]);
|
|
130
|
+
|
|
131
|
+
const plan = JSON.parse(String(env.log.entries.at(-1)?.message));
|
|
132
|
+
expect(plan).toMatchObject({
|
|
133
|
+
area: "dictation",
|
|
134
|
+
creds: [{ name: "OPENAI_API_KEY", keptFor: null }],
|
|
135
|
+
refused: null,
|
|
136
|
+
});
|
|
137
|
+
expect(await env.secrets.device.get("OPENAI_API_KEY")).toBe("sk-test");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// scry-ignore simple-test-setup: the started task is what numbers the queued task after it, which this case checks.
|
|
141
|
+
it("edits the queued tasks added beside a started one", async () => {
|
|
142
|
+
await splates.run(deps, ["add", "alpha", "--goal", "land it"]);
|
|
143
|
+
await env.fs.write(join(env.root, "a.png"), "a");
|
|
144
|
+
await splates.run(deps, [
|
|
145
|
+
"task",
|
|
146
|
+
"add",
|
|
147
|
+
"write docs",
|
|
148
|
+
"the CLI first",
|
|
149
|
+
"--slug",
|
|
150
|
+
"cli-docs",
|
|
151
|
+
"--file",
|
|
152
|
+
"a.png",
|
|
153
|
+
]);
|
|
154
|
+
// Task alpha took number 1, so the queued task is numbered after it.
|
|
155
|
+
const [queued] = await env.tasks.all();
|
|
156
|
+
const id = queued?.id ?? 0;
|
|
157
|
+
expect(id).toBe(2);
|
|
158
|
+
const [attached = ""] = queued?.files ?? [];
|
|
159
|
+
await splates.run(deps, [
|
|
160
|
+
"task",
|
|
161
|
+
"update",
|
|
162
|
+
String(id),
|
|
163
|
+
"--subject",
|
|
164
|
+
"write the docs",
|
|
165
|
+
"--slug",
|
|
166
|
+
"docs",
|
|
167
|
+
"--position",
|
|
168
|
+
"1",
|
|
169
|
+
"--remove-file",
|
|
170
|
+
basename(attached),
|
|
171
|
+
]);
|
|
172
|
+
expect((await env.tasks.find(id)).state).toMatchObject({
|
|
173
|
+
subject: "write the docs",
|
|
174
|
+
slug: "docs",
|
|
175
|
+
text: "the CLI first",
|
|
176
|
+
position: 1,
|
|
177
|
+
files: [],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await splates.run(deps, ["log", "--json"]);
|
|
181
|
+
const entries = JSON.parse(String(env.log.entries.at(-1)?.message));
|
|
182
|
+
expect(entries.map((entry: { action: string }) => entry.action)).toContain(
|
|
183
|
+
"task update",
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("reports a refusal as a reason, a message and an exit code", async () => {
|
|
188
|
+
await splates.run(deps, ["claim", "nope"]);
|
|
189
|
+
|
|
190
|
+
expect(env.out()).toContain('"reason":"not_found"');
|
|
191
|
+
expect(env.proc.lastExit()).toBe(8);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("refuses a task with no Goal, and writes the one it is given into its plan", async () => {
|
|
195
|
+
await splates.run(deps, ["add", "alpha"]);
|
|
196
|
+
|
|
197
|
+
expect(env.out()).toContain('"reason":"usage"');
|
|
198
|
+
expect(env.out()).toContain("--goal");
|
|
199
|
+
expect((await env.service.find("alpha")).gone).toBe(true);
|
|
200
|
+
|
|
201
|
+
await splates.run(deps, [
|
|
202
|
+
"add",
|
|
203
|
+
"alpha",
|
|
204
|
+
"--goal",
|
|
205
|
+
"Stop the 413 retries.",
|
|
206
|
+
]);
|
|
207
|
+
const { path } = await env.service.find("alpha");
|
|
208
|
+
expect(await env.fs.read(join(path, "SPLATES.md"))).toContain(
|
|
209
|
+
"## Goal\n\nStop the 413 retries.",
|
|
210
|
+
);
|
|
211
|
+
expect((await env.journal.tail(1))[0]).toMatchObject({
|
|
212
|
+
action: "add",
|
|
213
|
+
goal: "Stop the 413 retries.",
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("logs the task it queues, with where it went", async () => {
|
|
218
|
+
await splates.run(deps, ["task", "add", "first", "--slug", "first"]);
|
|
219
|
+
await splates.run(deps, [
|
|
220
|
+
"task",
|
|
221
|
+
"add",
|
|
222
|
+
"Upload retries forever on a 413",
|
|
223
|
+
"--slug",
|
|
224
|
+
"upload-413",
|
|
225
|
+
"--position",
|
|
226
|
+
"1",
|
|
227
|
+
"--lane",
|
|
228
|
+
"new",
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
expect((await env.journal.tail(1))[0]).toMatchObject({
|
|
232
|
+
action: "task add",
|
|
233
|
+
reason: null,
|
|
234
|
+
number: 2,
|
|
235
|
+
subject: "Upload retries forever on a 413",
|
|
236
|
+
lane: 1,
|
|
237
|
+
position: 1,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("lists the thinking levels an agent offers, starts a task on one, and refuses any other", async () => {
|
|
242
|
+
await env.fs.write(
|
|
243
|
+
join(env.splatesHome, "offered.json"),
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
format: 1,
|
|
246
|
+
agents: {
|
|
247
|
+
claude: [
|
|
248
|
+
{
|
|
249
|
+
id: "effort",
|
|
250
|
+
name: "Effort",
|
|
251
|
+
type: "select",
|
|
252
|
+
category: "thought_level",
|
|
253
|
+
current: "default",
|
|
254
|
+
options: ["default", "low", "high"].map((value) => ({
|
|
255
|
+
value,
|
|
256
|
+
name: value,
|
|
257
|
+
description: null,
|
|
258
|
+
})),
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
await splates.run(deps, ["agents"]);
|
|
265
|
+
expect(env.out()).toContain("THINKING default, low, high");
|
|
266
|
+
|
|
267
|
+
await splates.run(deps, [
|
|
268
|
+
"add",
|
|
269
|
+
"alpha",
|
|
270
|
+
"--goal",
|
|
271
|
+
"Style it.",
|
|
272
|
+
"--agent",
|
|
273
|
+
"claude",
|
|
274
|
+
"--effort",
|
|
275
|
+
"max",
|
|
276
|
+
]);
|
|
277
|
+
expect(env.proc.lastExit()).toBe(1);
|
|
278
|
+
expect(env.out()).toContain("use one of default, low, high");
|
|
279
|
+
|
|
280
|
+
await splates.run(deps, [
|
|
281
|
+
"add",
|
|
282
|
+
"alpha",
|
|
283
|
+
"--goal",
|
|
284
|
+
"Style it.",
|
|
285
|
+
"--agent",
|
|
286
|
+
"claude",
|
|
287
|
+
"--effort",
|
|
288
|
+
"High",
|
|
289
|
+
]);
|
|
290
|
+
expect((await env.service.find("alpha")).state?.dispatch).toMatchObject({
|
|
291
|
+
agent: "claude",
|
|
292
|
+
effort: "high",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await splates.run(deps, [
|
|
296
|
+
"task",
|
|
297
|
+
"add",
|
|
298
|
+
"Restyle the header",
|
|
299
|
+
"--slug",
|
|
300
|
+
"header-style",
|
|
301
|
+
"--agent",
|
|
302
|
+
"claude",
|
|
303
|
+
"--effort",
|
|
304
|
+
"low",
|
|
305
|
+
]);
|
|
306
|
+
expect((await env.tasks.all()).at(-1)?.state.assignee).toEqual({
|
|
307
|
+
agent: "claude",
|
|
308
|
+
effort: "low",
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("refuses a port that cannot exist rather than trying to listen", async () => {
|
|
313
|
+
// As the supervisor runs it.
|
|
314
|
+
env.proc.env[SERVING] = "1";
|
|
315
|
+
await splates.run(deps, ["server", "--port", "99999"]);
|
|
316
|
+
|
|
317
|
+
expect(env.out()).not.toContain(" reply ");
|
|
318
|
+
expect(env.proc.lastExit()).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("says no tunnel is saved, and requests the token a setup needs", async () => {
|
|
322
|
+
await splates.run(deps, ["tunnel", "status"]);
|
|
323
|
+
expect(env.out()).toContain("no tunnel saved");
|
|
324
|
+
|
|
325
|
+
await splates.run(deps, [
|
|
326
|
+
"tunnel",
|
|
327
|
+
"setup",
|
|
328
|
+
"splates.example.com",
|
|
329
|
+
"--email",
|
|
330
|
+
"me@example.com",
|
|
331
|
+
]);
|
|
332
|
+
expect(env.out()).toContain("requested on the Creds page");
|
|
333
|
+
expect(env.proc.lastExit()).toBe(25);
|
|
334
|
+
expect(await env.creds.requests()).toMatchObject([
|
|
335
|
+
{ name: "SPLATES_CLOUDFLARE_API_TOKEN", scope: "device", status: "open" },
|
|
336
|
+
]);
|
|
337
|
+
});
|
|
338
|
+
});
|