@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.
Files changed (60) hide show
  1. package/artifact.ts +120 -0
  2. package/budget.ts +117 -0
  3. package/checkout.ts +8 -0
  4. package/creds.test.ts +336 -0
  5. package/creds.ts +144 -0
  6. package/dev/dev.ts +86 -0
  7. package/dev/eval.ts +21 -0
  8. package/dev/fix.ts +63 -0
  9. package/dev/index.ts +1 -0
  10. package/dev/prove.ts +38 -0
  11. package/dev/root.ts +7 -0
  12. package/dev/ship.ts +46 -0
  13. package/dev/test.ts +154 -0
  14. package/e2e.test.ts +472 -0
  15. package/flags.ts +157 -0
  16. package/hook.ts +328 -0
  17. package/index.ts +18 -0
  18. package/infra.ts +107 -0
  19. package/lane.test.ts +29 -0
  20. package/lane.ts +172 -0
  21. package/notes.ts +19 -0
  22. package/package.json +14 -12
  23. package/projects.test.ts +191 -0
  24. package/projects.ts +37 -0
  25. package/queue.ts +82 -0
  26. package/screenshots/annotate.png +0 -0
  27. package/screenshots/demo.png +0 -0
  28. package/screenshots/parallel.png +0 -0
  29. package/screenshots/questions.png +0 -0
  30. package/server.ts +287 -0
  31. package/setup.ts +233 -0
  32. package/slots.ts +65 -0
  33. package/splates.test.ts +338 -0
  34. package/splates.ts +567 -0
  35. package/task.ts +354 -0
  36. package/tunnel.ts +106 -0
  37. package/wait-journal.test.ts +96 -0
  38. package/waiting.ts +59 -0
  39. package/worker-relay.test.ts +233 -0
  40. package/worker.ts +300 -0
  41. package/artifact.d.ts +0 -3
  42. package/budget.d.ts +0 -3
  43. package/creds.d.ts +0 -3
  44. package/flags.d.ts +0 -37
  45. package/hook.d.ts +0 -3
  46. package/index.d.ts +0 -2
  47. package/index.js +0 -1776
  48. package/infra.d.ts +0 -3
  49. package/lane.d.ts +0 -3
  50. package/notes.d.ts +0 -4
  51. package/projects.d.ts +0 -3
  52. package/queue.d.ts +0 -3
  53. package/server.d.ts +0 -7
  54. package/setup.d.ts +0 -22
  55. package/slots.d.ts +0 -3
  56. package/splates.d.ts +0 -10
  57. package/task.d.ts +0 -3
  58. package/tunnel.d.ts +0 -3
  59. package/waiting.d.ts +0 -3
  60. package/worker.d.ts +0 -3
package/e2e.test.ts ADDED
@@ -0,0 +1,472 @@
1
+ import { describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { chmod } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { Testing } from "@stringsync/splates-server/testing";
5
+ import { ensure } from "webappwiz/assert";
6
+ import { color } from "webappwiz/log";
7
+
8
+ // Every test here drives real git in a repo of its own, dozens of processes
9
+ // each: fast alone, but starved past bun's 5s default when the whole suite
10
+ // runs in parallel on a busy machine.
11
+ setDefaultTimeout(30_000);
12
+
13
+ describe.concurrent("splates", () => {
14
+ it("prints help without starting a server when no command is given", async () => {
15
+ await using cli = await setup();
16
+ const { exitCode, stdout } = await cli.splates(cli.env.root);
17
+
18
+ expect(exitCode).toBe(0);
19
+ expect(stdout).toContain("Usage: splates <command>");
20
+ expect(stdout).toContain(" server ");
21
+ expect(stdout).not.toContain("the default command");
22
+ expect(stdout).not.toContain(" dev ");
23
+ });
24
+
25
+ it("does not expose the checkout's development namespace in the published CLI", async () => {
26
+ await using cli = await setup();
27
+ const { stdout } = await cli.splates(
28
+ cli.env.root,
29
+ "dev",
30
+ "--port",
31
+ "99999",
32
+ );
33
+
34
+ expect(stdout).toContain("Usage: splates <command>");
35
+ expect(stdout).not.toContain("Usage: splates dev");
36
+ expect(stdout).not.toContain(" fix ");
37
+ });
38
+
39
+ it.each([{ args: [] }, { args: ["--help"] }])(
40
+ "dispatches a checkout's dev $args to its own namespace",
41
+ async ({ args }) => {
42
+ await using cli = await setup();
43
+ const { exitCode, stdout: styled } = await cli.env.ps.spawnCapture(
44
+ [join(import.meta.dirname, "../../bin/splates"), "dev", ...args],
45
+ // NO_COLOR keeps the help plain even when the terminal forces color.
46
+ { cwd: cli.env.root, env: { SPLATES_ROOT: "", NO_COLOR: "1" } },
47
+ );
48
+ const stdout = Bun.stripANSI(styled);
49
+
50
+ expect(exitCode).toBe(0);
51
+ expect(stdout).toContain("Usage: splates dev <command>");
52
+ expect(stdout).toContain(" fix ");
53
+ expect(stdout).toContain(" test ");
54
+ expect(stdout).not.toContain(" server ");
55
+ },
56
+ );
57
+
58
+ it("exposes server options through the explicit server command", async () => {
59
+ await using cli = await setup();
60
+ const { exitCode, stdout } = await cli.splates(
61
+ cli.env.root,
62
+ "server",
63
+ "run",
64
+ "--help",
65
+ );
66
+
67
+ expect(exitCode).toBe(0);
68
+ expect(stdout).toContain("Usage: splates server run");
69
+ expect(stdout).toContain("--port");
70
+ expect(stdout).toContain("--tunnel");
71
+ });
72
+
73
+ it("lists running the server first among its commands, as what it does bare", async () => {
74
+ await using cli = await setup();
75
+ const { exitCode, stdout } = await cli.splates(
76
+ cli.env.root,
77
+ "server",
78
+ "--help",
79
+ );
80
+
81
+ expect(exitCode).toBe(0);
82
+ expect(stdout).toMatch(/run .*\(default\)/);
83
+ expect(stdout).toContain("deploy");
84
+ });
85
+
86
+ it("lands both trees on trunk without a merge when two agents work at once", async () => {
87
+ await using cli = await setup();
88
+ const { env, splates, rows } = cli;
89
+
90
+ expect(
91
+ (await splates(env.root, "add", "alpha", "--goal", "land it")).exitCode,
92
+ ).toBe(0);
93
+ expect(
94
+ (await splates(env.root, "add", "beta", "--goal", "land it")).exitCode,
95
+ ).toBe(0);
96
+
97
+ const listed = await rows();
98
+ expect(listed.map((row) => row.task)).toEqual(["alpha", "beta"]);
99
+ const [alpha, beta] = listed.map((row) => row.worktree) as [string, string];
100
+ expect([alpha, beta]).not.toContain("");
101
+
102
+ // Each agent resumes a tree it did not create: a fresh thread starts here.
103
+ const claimed = await splates(alpha, "claim", "alpha");
104
+ expect(claimed.exitCode).toBe(0);
105
+ expect(claimed.stdout).toContain("uncommitted: none");
106
+ await env.commit(alpha, "alpha.txt", "alpha\n", "add alpha");
107
+ await env.finish(alpha);
108
+ const merged = await splates(alpha, "merge");
109
+ expect(merged.exitCode).toBe(0);
110
+ expect(merged.stdout).toContain("merged alpha onto main");
111
+ // Last, as what the agent is to write next.
112
+ expect(merged.stdout.trimEnd()).toEndWith(
113
+ "report it as `## ✅ Merged alpha onto main`, then a line on what changed for the user; ask and offer nothing",
114
+ );
115
+
116
+ // beta rebases onto a trunk that alpha moved out from under it.
117
+ expect((await splates(beta, "claim", "beta")).exitCode).toBe(0);
118
+ await env.commit(beta, "beta.txt", "beta\n", "add beta");
119
+ await env.finish(beta);
120
+ expect((await splates(beta, "merge")).exitCode).toBe(0);
121
+
122
+ const log = await env.gitCli(env.root, "log", "--oneline", "main");
123
+ expect(log).toContain("add alpha");
124
+ expect(log).toContain("add beta");
125
+ expect(
126
+ await env.gitCli(env.root, "rev-list", "--count", "--merges", "main"),
127
+ ).toBe("0");
128
+
129
+ expect(await rows()).toEqual([]);
130
+ });
131
+
132
+ it("keeps the journal of a task after it has merged and gone", async () => {
133
+ await using cli = await setup();
134
+ const { env, splates } = cli;
135
+
136
+ const added = await splates(env.root, "add", "alpha", "--goal", "land it");
137
+ expect(added.exitCode).toBe(0);
138
+ const alpha = worktreeFromAdd(added.stdout);
139
+ expect((await splates(alpha, "claim", "alpha")).exitCode).toBe(0);
140
+ await env.commit(alpha, "alpha.txt", "alpha\n", "add alpha");
141
+ await env.finish(alpha);
142
+ expect((await splates(alpha, "merge")).exitCode).toBe(0);
143
+
144
+ const removed = await splates(env.root, "remove", "alpha");
145
+ expect(removed.exitCode).toBe(13);
146
+ expect(removed.stdout).toContain("already_removed");
147
+
148
+ // The journal outlives the task: its merge and the refused remove.
149
+ const journal = JSON.parse(
150
+ (await splates(env.root, "log", "--json")).stdout,
151
+ ) as { action: string; task: string | null; reason: string | null }[];
152
+ expect(journal).toMatchObject([
153
+ { action: "add", task: "alpha", reason: null },
154
+ { action: "claim", task: "alpha", reason: null },
155
+ { action: "merge", task: "alpha", reason: null },
156
+ { action: "remove", task: "alpha", reason: "already_removed" },
157
+ ]);
158
+ });
159
+
160
+ it("lands a stacked task in its parent's worktree, and the parent on trunk", async () => {
161
+ await using cli = await setup();
162
+ const { env, splates, rows } = cli;
163
+
164
+ const parentAdded = await splates(
165
+ env.root,
166
+ "add",
167
+ "parent",
168
+ "--goal",
169
+ "land it",
170
+ );
171
+ expect(parentAdded.exitCode).toBe(0);
172
+ const parent = worktreeFromAdd(parentAdded.stdout);
173
+ await env.commit(parent, "parent.txt", "parent\n", "add parent");
174
+
175
+ // The parent hands out a part from inside its own tree, based on its own
176
+ // branch, so the part lands on it rather than on trunk.
177
+ const partAdded = await splates(
178
+ parent,
179
+ "add",
180
+ "part",
181
+ "--base",
182
+ "task/parent",
183
+ "--goal",
184
+ "land it",
185
+ );
186
+ expect(partAdded.exitCode).toBe(0);
187
+ const part = worktreeFromAdd(partAdded.stdout);
188
+ await env.commit(part, "part.txt", "part\n", "add part");
189
+
190
+ await env.finish(part);
191
+ const landed = await splates(part, "merge");
192
+ expect(landed.exitCode).toBe(0);
193
+ expect(landed.stdout).toContain("merged part onto task/parent");
194
+ expect(landed.stdout).toContain(parent);
195
+ // The parent's own tree has the file, not just the branch.
196
+ expect(await env.fs.exists(join(part, "part.txt"))).toBe(false);
197
+ expect(await env.fs.exists(join(parent, "part.txt"))).toBe(true);
198
+ expect(
199
+ await env.gitCli(env.root, "log", "--oneline", "main"),
200
+ ).not.toContain("add part");
201
+
202
+ // The parent then lands both commits on trunk with its own merge.
203
+ await env.finish(parent);
204
+ expect((await splates(parent, "merge")).exitCode).toBe(0);
205
+ const log = await env.gitCli(env.root, "log", "--oneline", "main");
206
+ expect(log).toContain("add parent");
207
+ expect(log).toContain("add part");
208
+ expect(await rows()).toEqual([]);
209
+ });
210
+
211
+ it("lands the work when a second agent picks up a tree the first left", async () => {
212
+ await using cli = await setup();
213
+ const { env, splates } = cli;
214
+
215
+ // Tests pass only once the committed marker says so, which is how the
216
+ // first agent's merge fails for a reason the second agent can fix.
217
+ await env.fs.write(
218
+ join(env.root, "splates.config.ts"),
219
+ `export default { preMerge: "grep -q ok status.txt" };\n`,
220
+ );
221
+ const added = await splates(env.root, "add", "gamma", "--goal", "land it");
222
+ expect(added.exitCode).toBe(0);
223
+ const tree = worktreeFromAdd(added.stdout);
224
+
225
+ expect((await splates(tree, "claim", "gamma")).exitCode).toBe(0);
226
+ await env.commit(tree, "status.txt", "wrong\n", "add status");
227
+ await env.finish(tree);
228
+ const failed = await splates(tree, "merge");
229
+ expect(failed.exitCode).not.toBe(0);
230
+ expect(
231
+ await env.gitCli(env.root, "log", "--oneline", "main"),
232
+ ).not.toContain("add status");
233
+
234
+ // Second agent, fresh thread: the tree is still claimable, and the
235
+ // unnamed hook failure spent none of the merge budget.
236
+ const resumed = await splates(tree, "claim", "gamma");
237
+ expect(resumed.exitCode).toBe(0);
238
+ expect(resumed.stdout).toContain("status: working");
239
+ expect(resumed.stdout).toContain("attempts: 0");
240
+ await env.commit(tree, "status.txt", "ok\n", "fix status");
241
+ await env.finish(tree);
242
+ const merged = await splates(tree, "merge");
243
+ expect(merged.exitCode).toBe(0);
244
+ expect(merged.stdout).toContain("merged gamma onto main");
245
+ expect(await env.gitCli(env.root, "log", "--oneline", "main")).toContain(
246
+ "fix status",
247
+ );
248
+ });
249
+
250
+ it("reports the uncommitted work and lands it when an agent dies mid-task", async () => {
251
+ await using cli = await setup();
252
+ const { env, splates, rows } = cli;
253
+
254
+ const added = await splates(env.root, "add", "delta", "--goal", "land it");
255
+ expect(added.exitCode).toBe(0);
256
+ const tree = worktreeFromAdd(added.stdout);
257
+
258
+ // The first agent claims, writes, and never comes back: the record keeps
259
+ // a lease whose pid died with the process.
260
+ expect((await splates(tree, "claim", "delta")).exitCode).toBe(0);
261
+ await env.fs.write(join(tree, "delta.txt"), "half done\n");
262
+ expect((await rows())[0]?.lease).toBe("stale");
263
+
264
+ // Second agent: the dead lease does not block the claim, and the
265
+ // half-finished file is reported rather than silently inherited.
266
+ const resumed = await splates(tree, "claim", "delta");
267
+ expect(resumed.exitCode).toBe(0);
268
+ expect(resumed.stdout).toContain("uncommitted (1)");
269
+ expect(resumed.stdout).toContain("delta.txt");
270
+
271
+ await env.commit(tree, "delta.txt", "finished\n", "finish delta");
272
+ await env.finish(tree);
273
+ expect((await splates(tree, "merge")).exitCode).toBe(0);
274
+ expect(await env.gitCli(env.root, "log", "--oneline", "main")).toContain(
275
+ "finish delta",
276
+ );
277
+ });
278
+
279
+ // scry-ignore simple-test-setup: its killing preMerge and marker file are this test's alone, and older than this task
280
+ it("lands the tree on a retry when an agent is killed mid-merge", async () => {
281
+ await using cli = await setup();
282
+ const { env, splates, rows } = cli;
283
+
284
+ // SIGKILL from inside the test gate: the first merge dies after rebasing
285
+ // and before trunk moves, holding the task lease and tree lock. The
286
+ // marker file makes it happen exactly once, so the retry can pass.
287
+ const die = join(env.root, "die");
288
+ await env.fs.write(
289
+ join(env.root, "splates.config.ts"),
290
+ `export default { preMerge: "if [ -f ${die} ]; then rm ${die}; kill -9 $PPID; fi" };\n`,
291
+ );
292
+ await env.fs.write(die, "");
293
+ const added = await splates(
294
+ env.root,
295
+ "add",
296
+ "epsilon",
297
+ "--goal",
298
+ "land it",
299
+ );
300
+ expect(added.exitCode).toBe(0);
301
+ const tree = worktreeFromAdd(added.stdout);
302
+
303
+ expect((await splates(tree, "claim", "epsilon")).exitCode).toBe(0);
304
+ await env.commit(tree, "epsilon.txt", "epsilon\n", "add epsilon");
305
+ await env.finish(tree);
306
+ expect((await splates(tree, "merge")).exitCode).not.toBe(0);
307
+ expect(await env.fs.exists(die)).toBe(false);
308
+
309
+ const stalled = (await rows())[0];
310
+ // Its merge died with it, so it is no longer merging.
311
+ expect(stalled?.status).toBe("working");
312
+ expect(stalled?.lease).toBe("stale");
313
+ expect(
314
+ await env.gitCli(env.root, "log", "--oneline", "main"),
315
+ ).not.toContain("add epsilon");
316
+ expect(await env.fs.exists(join(env.splatesDir, "merge.lock"))).toBe(false);
317
+
318
+ expect(
319
+ await env.fs.exists(join(env.splatesDir, "merge-trees/epsilon.lock")),
320
+ ).toBe(true);
321
+
322
+ // The next merge steals the abandoned tree lock; landings were never blocked.
323
+ const resumed = await splates(tree, "claim", "epsilon");
324
+ expect(resumed.exitCode).toBe(0);
325
+ expect(resumed.stdout).toContain("status: working");
326
+ await env.finish(tree);
327
+ const merged = await splates(tree, "merge");
328
+ expect(merged.exitCode).toBe(0);
329
+ expect(merged.stdout).toContain("merged epsilon onto main");
330
+ expect(await env.gitCli(env.root, "log", "--oneline", "main")).toContain(
331
+ "add epsilon",
332
+ );
333
+ expect(await rows()).toEqual([]);
334
+ });
335
+
336
+ it("refuses every command but `splates init` in a repository it never ran in", async () => {
337
+ await using cli = await setup({ init: false });
338
+ const { env, splates } = cli;
339
+
340
+ const uninitialized = {
341
+ exitCode: 20,
342
+ stderr: expect.stringContaining("Run `splates init` in it once"),
343
+ };
344
+ expect([
345
+ await splates(env.root, "task", "list"),
346
+ await splates(env.root, "add", "alpha", "--goal", "land it"),
347
+ await splates(env.root, "list"),
348
+ ]).toMatchObject([uninitialized, uninitialized, uninitialized]);
349
+ expect(await env.fs.exists(env.splatesDir)).toBe(false);
350
+ expect(await env.projects.list()).toEqual([]);
351
+
352
+ const [registered, again] = [
353
+ await splates(env.root, "init"),
354
+ await splates(env.root, "init"),
355
+ ];
356
+ expect(registered.exitCode).toBe(0);
357
+ expect(registered.stdout).toContain(`registered ${env.root}`);
358
+ expect(await env.fs.read(join(env.splatesDir, "format"))).toBe("4\n");
359
+ expect(again.stdout).toContain(`${env.root} is already registered`);
360
+ expect(
361
+ (await splates(env.root, "add", "alpha", "--goal", "land it")).exitCode,
362
+ ).toBe(0);
363
+ });
364
+
365
+ it("registers a splates project already under way, by its main tree", async () => {
366
+ await using cli = await setup();
367
+ const { env, splates } = cli;
368
+ await env.projects.forget(env.root);
369
+ expect(
370
+ (await splates(env.root, "add", "alpha", "--goal", "land it")).exitCode,
371
+ ).toBe(0);
372
+
373
+ const [alpha] = (await cli.rows()).map((row) => row.worktree);
374
+ expect((await splates(alpha as string, "init")).exitCode).toBe(0);
375
+
376
+ expect((await env.projects.list()).map((each) => each.root)).toEqual([
377
+ env.root,
378
+ ]);
379
+ });
380
+
381
+ it("forgets this repository, keeping its records", async () => {
382
+ await using cli = await setup();
383
+ const { env, splates } = cli;
384
+ expect(
385
+ (await splates(env.root, "add", "alpha", "--goal", "land it")).exitCode,
386
+ ).toBe(0);
387
+
388
+ const forgot = await splates(env.root, "forget");
389
+ const again = await splates(env.root, "forget");
390
+
391
+ expect(forgot.exitCode).toBe(0);
392
+ expect(forgot.stdout).toContain(`forgot ${env.root}`);
393
+ expect(again.stdout).toContain(`${env.root} is not registered`);
394
+ expect(await env.projects.list()).toEqual([]);
395
+ expect((await splates(env.root, "init")).exitCode).toBe(0);
396
+ expect((await cli.rows()).map((row) => row.task)).toEqual(["alpha"]);
397
+ });
398
+
399
+ it("leaves records a newer splates wrote alone", async () => {
400
+ await using cli = await setup();
401
+ const { env, splates } = cli;
402
+ const format = join(env.splatesDir, "format");
403
+
404
+ expect((await splates(env.root, "list")).exitCode).toBe(0);
405
+ expect(await env.fs.read(format)).toBe("4\n");
406
+
407
+ await env.fs.write(format, "5\n");
408
+ const refused = await splates(
409
+ env.root,
410
+ "add",
411
+ "alpha",
412
+ "--goal",
413
+ "land it",
414
+ );
415
+ expect(refused.exitCode).toBe(19);
416
+ expect(refused.stdout).toContain("outdated");
417
+ expect(refused.stderr).toContain("bunx @stringsync/splates@latest");
418
+ expect(await env.fs.exists(join(env.splatesDir, "tasks", "1.md"))).toBe(
419
+ false,
420
+ );
421
+ });
422
+ });
423
+
424
+ const CLI = join(import.meta.dirname, "index.ts");
425
+
426
+ const worktreeFromAdd = (stdout: string) =>
427
+ ensure.defined(
428
+ stdout.match(/^ {2}worktree: (.+)$/m)?.[1],
429
+ "add did not print a worktree path",
430
+ );
431
+
432
+ /** A repo of its own per test, so the four of them can run at once. */
433
+ const setup = async ({ init = true }: { init?: boolean } = {}) => {
434
+ const env = await Testing.open({ init });
435
+ // A gate that passes, so the happy paths exercise merge rather than the
436
+ // fixture repo's (nonexistent) tests.
437
+ await env.fs.write(
438
+ join(env.root, "splates.config.ts"),
439
+ `export default { preMerge: "true" };\n`,
440
+ );
441
+
442
+ // `splates init` installs its skill with `bunx skills add`, which reaches
443
+ // GitHub: a `bunx` that does nothing keeps the tests off the network.
444
+ const bin = join(env.splatesHome, "..", "bin");
445
+ await env.fs.mkdir(bin);
446
+ await env.fs.write(join(bin, "bunx"), "#!/bin/sh\nexit 0\n");
447
+ await chmod(join(bin, "bunx"), 0o755);
448
+
449
+ /** Runs the CLI the way an agent does: a fresh process, a cwd, an exit code. */
450
+ const splates = async (cwd: string, ...args: string[]) => {
451
+ const { exitCode, stdout, stderr } = await env.ps.spawnCapture(
452
+ ["bun", CLI, ...args],
453
+ { cwd, env: { PATH: `${bin}:${process.env.PATH}` } },
454
+ );
455
+ return { exitCode, stdout: color.strip(stdout), stderr };
456
+ };
457
+
458
+ const rows = async () =>
459
+ JSON.parse((await splates(env.root, "list", "--json")).stdout) as {
460
+ task: string;
461
+ status: string;
462
+ lease: "held" | "stale" | "none";
463
+ worktree: string;
464
+ }[];
465
+
466
+ return {
467
+ env,
468
+ splates,
469
+ rows,
470
+ [Symbol.asyncDispose]: () => env.disposeAsync(),
471
+ };
472
+ };
package/flags.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { BACKLOG_LANE } from "@stringsync/splates-server/board";
2
+ import { commaList } from "@stringsync/splates-server/comma-list";
3
+ import { SYSTEM_STORES } from "@stringsync/splates-server/creds";
4
+ import { fail } from "@stringsync/splates-server/exit";
5
+ import {
6
+ type AgentPick,
7
+ Offered,
8
+ pickAgent,
9
+ pickEffort,
10
+ type RosterAgent,
11
+ splatesHome,
12
+ } from "@stringsync/splates-server/models";
13
+ import { SLOT_POOL } from "@stringsync/splates-server/ports";
14
+ import type { Repository } from "@stringsync/splates-server/records";
15
+ import type { SplatesDeps } from "@stringsync/splates-server/server";
16
+ import { rosterOf, seeded } from "@stringsync/splates-server/server";
17
+ import { FsStorage } from "@stringsync/splates-server/storage";
18
+ import { SavedTunnel } from "@stringsync/splates-server/tunnel";
19
+ import { z } from "zod";
20
+
21
+ /** A place in the queue, 1 at the top; absent leaves it to the command. */
22
+ export const position = z.coerce.number().int().positive().optional();
23
+
24
+ /**
25
+ * The agent `--agent`, `--model` and `--effort` name off the roster:
26
+ * undefined when none is given, null for `--agent none`.
27
+ */
28
+ export async function assigneeIn(
29
+ deps: SplatesDeps,
30
+ agent: string,
31
+ model: string,
32
+ effort: string,
33
+ ): Promise<AgentPick | null | undefined> {
34
+ if (agent === "") {
35
+ if (model !== "" || effort !== "") {
36
+ fail("usage", "--model and --effort go with --agent", {
37
+ model,
38
+ effort,
39
+ });
40
+ }
41
+ return undefined;
42
+ }
43
+ if (agent === "none") {
44
+ return null;
45
+ }
46
+ return pickIn(deps, await seeded(rosterOf(deps)), agent, model, effort);
47
+ }
48
+
49
+ /**
50
+ * `agent` on `model` off `agents`, at the thinking level `effort` names
51
+ * among those it offers, each left out when empty.
52
+ */
53
+ export async function pickIn(
54
+ deps: SplatesDeps,
55
+ agents: RosterAgent[],
56
+ agent: string,
57
+ model: string,
58
+ effort: string,
59
+ data: Record<string, unknown> = {},
60
+ ): Promise<AgentPick> {
61
+ const picked = pickAgent(
62
+ agents,
63
+ agent,
64
+ model === "" ? undefined : model,
65
+ data,
66
+ );
67
+ return { ...picked, ...(await effortIn(deps, picked.agent, effort, data)) };
68
+ }
69
+
70
+ /**
71
+ * `{ effort }` for the thinking level `effort` names among those `agent`
72
+ * offered when last started, or nothing when it is empty.
73
+ */
74
+ export async function effortIn(
75
+ deps: SplatesDeps,
76
+ agent: string,
77
+ effort: string,
78
+ data: Record<string, unknown> = {},
79
+ ): Promise<{ effort?: string }> {
80
+ if (effort === "") {
81
+ return {};
82
+ }
83
+ const offered = (await offeredOf(deps).all())[agent];
84
+ return { effort: pickEffort(offered, agent, effort, data) };
85
+ }
86
+
87
+ /** The settings each agent offered when last started, from any project. */
88
+ export function offeredOf({ ps, fs }: SplatesDeps): Offered {
89
+ return new Offered(
90
+ new FsStorage(splatesHome(ps), fs).document("offered.json"),
91
+ );
92
+ }
93
+
94
+ /** The tunnel each slot's home may have saved, for every word of the pool. */
95
+ export function slotTunnelsOf({
96
+ ps,
97
+ fs,
98
+ secrets = SYSTEM_STORES,
99
+ }: SplatesDeps): SavedTunnel[] {
100
+ return SLOT_POOL.map(({ word }) =>
101
+ SavedTunnel.in(
102
+ new FsStorage(`${splatesHome(ps)}/slots/${word}`, fs),
103
+ secrets.device,
104
+ ),
105
+ );
106
+ }
107
+
108
+ /** `--lane 3`, `new` or `none` as a lane; absent leaves it to the command. */
109
+ export function laneIn(raw: string): number | "new" | null | undefined {
110
+ const value = raw.trim();
111
+ if (value === "") {
112
+ return undefined;
113
+ }
114
+ if (value === "new") {
115
+ return "new";
116
+ }
117
+ if (value === "none") {
118
+ return null;
119
+ }
120
+ if (value === "backlog") {
121
+ return BACKLOG_LANE;
122
+ }
123
+ const id = Number(value);
124
+ if (!Number.isInteger(id) || id < 0) {
125
+ fail("usage", `'${value}' is not a lane: pass its number, new or none`, {
126
+ lane: value,
127
+ });
128
+ }
129
+ return id;
130
+ }
131
+
132
+ /** `--task 3,5` as task numbers, refusing anything that is not one. */
133
+ export function taskIds(raw: string): number[] {
134
+ return commaList(raw).map((value) => {
135
+ const id = Number(value);
136
+ if (!Number.isInteger(id) || id <= 0) {
137
+ fail("usage", `'${value}' is not a task number`, { number: value });
138
+ }
139
+ return id;
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Which task a command that takes no task name is about, so the journal can
145
+ * name it. `merge` reads it off the current branch.
146
+ */
147
+ export async function here({
148
+ service,
149
+ git,
150
+ ps,
151
+ project,
152
+ }: SplatesDeps & Repository): Promise<string | null> {
153
+ // Run with `--project`, the working directory is another repository's.
154
+ return project === undefined
155
+ ? service.taskFor(await git.currentBranch(ps.cwd()))
156
+ : null;
157
+ }