@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/creds.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { DEFAULT_TIMEOUT, PROVIDERS } from "@stringsync/splates-server/creds";
2
+ import { fail } from "@stringsync/splates-server/exit";
3
+ import { repository } from "@stringsync/splates-server/records";
4
+ import type { SplatesDeps } from "@stringsync/splates-server/server";
5
+ import { untilAfter } from "@stringsync/splates-server/tasks";
6
+ import { cli } from "webappwiz/cmd";
7
+ import { color } from "webappwiz/log";
8
+ import { Duration } from "webappwiz/time";
9
+ import { z } from "zod";
10
+ import { here } from "./flags";
11
+
12
+ /** `splates creds`, which the root cli mounts after `splates lane`. */
13
+ export const splatesCreds = cli<SplatesDeps>("creds").description(
14
+ "creds an agent needs a person for, such as an API token: request one saying what it is for, with instructions for getting it, which the Creds page of `splates server` shows for the person to paste, then wait for it; the value goes into the system's store (the macOS Keychain, Windows Credential Manager, a Linux secret service), never to the agent, and code reads it through webappwiz/creds",
15
+ );
16
+
17
+ splatesCreds
18
+ .command("request")
19
+ .use(repository({ needs: "own" }))
20
+ .description(
21
+ "ask a person for a cred, saying why it is needed and what it will be used for, with markdown instructions for getting it; returns at once, and does nothing when the store already keeps it unless --replace; run from a worktree, it records the task asking",
22
+ )
23
+ .arg("name", z.string(), {
24
+ description: "what it is kept as, an environment variable's name",
25
+ })
26
+ .option("purpose", z.string(), {
27
+ default: "",
28
+ description:
29
+ "why it is needed and what it will be used for, in a sentence the person reads before getting it",
30
+ })
31
+ .option("instructions", z.string(), {
32
+ default: "",
33
+ description:
34
+ "how to get it, in markdown: where to go, what to pick, what to paste",
35
+ })
36
+ .option("device", z.boolean(), {
37
+ default: false,
38
+ description:
39
+ "keep it for every project on this device, as for a person's own account, rather than this project alone",
40
+ })
41
+ .option("provider", z.optional(z.enum(PROVIDERS)), {
42
+ description:
43
+ "who keeps it: `system`, the machine's own store, or `ssm`, AWS Parameter Store, where that is offered",
44
+ })
45
+ .option("replace", z.boolean(), {
46
+ default: false,
47
+ description:
48
+ "ask again although one is kept, since the kept one does not work; --purpose and --instructions left off are those of the last request",
49
+ })
50
+ .action(async (opts, ctx) => {
51
+ const scope = opts.device ? "device" : "project";
52
+ const provider = opts.provider ?? "system";
53
+ const request = await ctx.journal.record(
54
+ "creds request",
55
+ null,
56
+ async () =>
57
+ ctx.creds.request(opts.name, {
58
+ scope,
59
+ provider,
60
+ purpose: opts.purpose,
61
+ instructions: opts.instructions,
62
+ task: await here(ctx),
63
+ replace: opts.replace,
64
+ }),
65
+ (asked) => ({ name: opts.name, asked: asked !== null }),
66
+ );
67
+ if (request === null) {
68
+ ctx.log.info(
69
+ `${color.green("kept")} ${opts.name} in ${ctx.creds.label(scope, provider)}: nothing to ask`,
70
+ );
71
+ return;
72
+ }
73
+ ctx.log.info(
74
+ `${color.yellow("requested")} ${opts.name} ${color.dim(request.purpose)}`,
75
+ );
76
+ ctx.log.error(
77
+ `It shows on the Creds page of \`splates server\`. \`splates creds wait ${opts.name}\` until it is saved.`,
78
+ );
79
+ });
80
+
81
+ splatesCreds
82
+ .command("wait")
83
+ .use(repository({ needs: "own" }))
84
+ .description(
85
+ "block until a person saves the cred requested, or dismisses the request (`dismissed`); gives up with `timeout` rather than waiting forever",
86
+ )
87
+ .arg("name", z.string(), { description: "the cred requested" })
88
+ .option("timeout-secs", z.coerce.number(), {
89
+ default: DEFAULT_TIMEOUT.secs,
90
+ description: "how long to wait before giving up",
91
+ })
92
+ .action(async (opts, ctx) => {
93
+ const timeout = Duration.secs(opts["timeout-secs"]);
94
+ await ctx.journal.waiting(
95
+ "creds wait",
96
+ null,
97
+ { name: opts.name, until: untilAfter(timeout) },
98
+ async () => {
99
+ const ended = await ctx.creds.wait(opts.name, { timeout });
100
+ if (ended === "dismissed") {
101
+ fail(
102
+ "dismissed",
103
+ `${opts.name} was dismissed: carry on without it, or ask the person why`,
104
+ { name: opts.name },
105
+ );
106
+ }
107
+ },
108
+ );
109
+ ctx.log.info(`${color.green("saved")} ${opts.name}`);
110
+ });
111
+
112
+ splatesCreds
113
+ .command("list")
114
+ .use(repository({ needs: "read" }))
115
+ .description(
116
+ "the creds requested and still open, with what each is for, and every cred the project's store and the device's keep, by name; never a value",
117
+ )
118
+ .option("json", z.boolean(), { default: false, description: "emit JSON" })
119
+ .action(async (opts, ctx) => {
120
+ const open = (await ctx.creds.requests()).filter(
121
+ (each) => each.status === "open",
122
+ );
123
+ const kept = await ctx.creds.kept();
124
+ if (opts.json) {
125
+ ctx.log.info(JSON.stringify({ requested: open, kept }, null, 2));
126
+ return;
127
+ }
128
+ for (const each of open) {
129
+ ctx.log.info(
130
+ `${color.yellow("requested")} ${each.name} ${color.dim(each.scope)}${each.task === null ? "" : color.dim(` by ${each.task}`)}`,
131
+ );
132
+ if (each.purpose !== "") {
133
+ ctx.log.info(` ${each.purpose}`);
134
+ }
135
+ }
136
+ for (const each of kept) {
137
+ ctx.log.info(
138
+ `${color.green("kept")} ${each.name} ${color.dim(`${each.provider} ${each.scope}`)}${each.savedBy === null ? "" : color.dim(` saved by ${each.savedBy}`)}`,
139
+ );
140
+ }
141
+ if (open.length === 0 && kept.length === 0) {
142
+ ctx.log.info(color.dim("no creds requested or kept"));
143
+ }
144
+ });
package/dev/dev.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { SplatesDeps } from "@stringsync/splates-server/server";
2
+ import type { Cli } from "webappwiz/cmd";
3
+ import { z } from "zod";
4
+ import { evalSkill } from "./eval";
5
+ import { fix } from "./fix";
6
+ import { PROOFS, prove } from "./prove";
7
+ import { ship } from "./ship";
8
+ import { test } from "./test";
9
+
10
+ /** Adds `splates dev`, the commands for working on this repository, to `program`. */
11
+ export function mountDev(program: Cli<SplatesDeps>): void {
12
+ // Only `checkout.ts`, the entry `bin/splates` runs, calls this, so the
13
+ // published cli never carries these: a release bundles what its `bin` and
14
+ // `exports` entries import, and `index.ts` imports none of this.
15
+ const dev = program
16
+ .group("dev")
17
+ .description("work on the splates repository");
18
+
19
+ dev
20
+ .command("eval")
21
+ .description(
22
+ "compare versions of the splates skill by running agents on it; uses tokens billed to an API key or counted against a Claude plan, so run it only when asked",
23
+ )
24
+ .rest("args", z.string(), {
25
+ description:
26
+ "[fast|list|report] [--probe id] [--target agent:model] [--parallel n], or explicit e2e [run|report]",
27
+ })
28
+ .allowUnknownOption()
29
+ .action((opts, { ps }) => evalSkill({ ...opts, ps }));
30
+
31
+ dev
32
+ .command("fix")
33
+ .description("format, check, and typecheck the repository")
34
+ .option("check", z.boolean(), {
35
+ default: false,
36
+ description: "report problems without writing fixes (for CI)",
37
+ })
38
+ .option("code-only", z.boolean(), {
39
+ default: false,
40
+ description: "run Scry code rules without credentials or model calls",
41
+ })
42
+ .action(({ "code-only": codeOnly, ...opts }, { log, ps }) =>
43
+ fix({ ...opts, codeOnly, log, ps }),
44
+ );
45
+
46
+ dev
47
+ .command("prove")
48
+ .description(
49
+ "prove splates on Linux end to end with Docker, nothing of yours used: `coordinator` runs the server in a Debian container as the user splates, as the AWS box will, with this machine as its worker, and drives a task, a restart, an archive and a cred through it; `worker` runs a container as a worker of a server here. Read docker/prove-<which>.sh for what each checks",
50
+ )
51
+ .arg("proof", z.enum(PROOFS), {
52
+ description: "coordinator or worker",
53
+ })
54
+ .rest("args", z.string(), {
55
+ description:
56
+ "handed to the script: a port, and for `worker` --agent <id> --model <model> to hand the task to a roster agent",
57
+ })
58
+ .allowUnknownOption()
59
+ .action(({ proof, args }, { log, ps }) => prove({ proof, args, log, ps }));
60
+
61
+ dev
62
+ .command("ship")
63
+ .description("release the package, with its skill stamped at its version")
64
+ .action((_opts, { log, fs, ps }) => ship({ log, fs, ps }));
65
+
66
+ dev
67
+ .command("test")
68
+ .description("run the package's tests; eval harness tests are separate")
69
+ .option("evals", z.boolean(), {
70
+ default: false,
71
+ description: "run only free eval harness tests (no model calls)",
72
+ })
73
+ .option("all", z.boolean(), {
74
+ default: false,
75
+ description: "run the package's and the eval harness's tests together",
76
+ })
77
+ .option("linux", z.boolean(), {
78
+ default: false,
79
+ description:
80
+ "run them on Linux, in a Docker container built from the checkout, as the box and the workers run; needs Docker",
81
+ })
82
+ .rest("files", z.string(), {
83
+ description: "run only the test files whose path has one of these",
84
+ })
85
+ .action((opts, { log, ps }) => test({ ...opts, log, ps }));
86
+ }
package/dev/eval.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { NodePs, type Ps } from "webappwiz/system";
2
+ import { ROOT } from "./root";
3
+
4
+ export interface EvalOptions {
5
+ /** What splates's evals entry is given: fast selection, report, or explicit e2e, then its arguments. */
6
+ args: string[];
7
+ ps?: Ps;
8
+ }
9
+
10
+ /** Run the focused suite by default; legacy end-to-end evaluation requires `e2e`. */
11
+ export async function evalSkill(opts: EvalOptions): Promise<void> {
12
+ const ps = opts.ps ?? new NodePs();
13
+ const args = opts.args.length ? opts.args : ["fast"];
14
+ const { exitCode } = await ps.spawn(
15
+ ["bun", "evals/splates-skill/index.ts", ...args],
16
+ { cwd: ROOT },
17
+ );
18
+ if (exitCode !== 0) {
19
+ throw new Error("The evals failed");
20
+ }
21
+ }
package/dev/fix.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { ConsoleLogger, color, type Logger } from "webappwiz/log";
2
+ import { NodePs, type Ps } from "webappwiz/system";
3
+ import { ROOT } from "./root";
4
+
5
+ export interface FixOptions {
6
+ /** Report problems without writing fixes, as CI wants it. */
7
+ check: boolean;
8
+ /** Check only code rules, without credentials or model calls. */
9
+ codeOnly?: boolean;
10
+ /** Skip Scry when checking a release that was already reviewed. */
11
+ skipScry?: boolean;
12
+ log?: Logger;
13
+ ps?: Ps;
14
+ }
15
+
16
+ /**
17
+ * Runs biome, the type checker, then scry on what changed since main, in
18
+ * that order. Scry leaves off each model its budget no longer covers, so a
19
+ * spent budget narrows the check rather than fail it.
20
+ */
21
+ export async function fix(opts: FixOptions): Promise<void> {
22
+ const log = opts.log ?? new ConsoleLogger();
23
+ const ps = opts.ps ?? new NodePs();
24
+
25
+ // Every step says how it went and the first failure ends the run, so the
26
+ // verdict a person reads last is the one that stopped them.
27
+ const step = (name: string, ok: boolean, failure: string): void => {
28
+ log.info(`${name}: ${ok ? color.green("success") : color.red("failed")}`);
29
+ if (!ok) {
30
+ throw new Error(failure);
31
+ }
32
+ };
33
+
34
+ const flags = opts.check ? [] : ["--write", "--unsafe"];
35
+ const biome = await ps.spawn(["bunx", "biome", "check", ...flags, "."], {
36
+ cwd: ROOT,
37
+ });
38
+ step("biome", biome.exitCode === 0, "Biome check failed");
39
+
40
+ const typecheck = await ps.spawn(["bunx", "tsc", "--noEmit"], {
41
+ cwd: ROOT,
42
+ });
43
+ step("typecheck", typecheck.exitCode === 0, "Typechecking failed");
44
+
45
+ if (opts.skipScry) {
46
+ return;
47
+ }
48
+
49
+ const scry = await ps.spawn(
50
+ [
51
+ "bunx",
52
+ "@webappwiz/cli",
53
+ "scry",
54
+ "check",
55
+ "--since",
56
+ "main",
57
+ "--disable-over-budget",
58
+ ...(opts.codeOnly ? ["--disable", "som", "--disable", "llm"] : []),
59
+ ],
60
+ { cwd: ROOT },
61
+ );
62
+ step("scry", scry.exitCode === 0, "Scry check failed");
63
+ }
package/dev/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { mountDev } from "./dev";
package/dev/prove.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { join } from "node:path";
2
+ import { ConsoleLogger, type Logger } from "webappwiz/log";
3
+ import { NodePs, type Ps } from "webappwiz/system";
4
+ import { ROOT } from "./root";
5
+
6
+ /** The proofs, each a script in `docker/`. */
7
+ export const PROOFS = ["coordinator", "worker"] as const;
8
+ export type Proof = (typeof PROOFS)[number];
9
+
10
+ export interface ProveOptions {
11
+ proof: Proof;
12
+ /** What the script takes after its name, such as a port or `--agent`. */
13
+ args?: string[];
14
+ ps?: Ps;
15
+ log?: Logger;
16
+ }
17
+
18
+ /**
19
+ * Runs a proof of splates on Linux end to end: `coordinator` runs the server
20
+ * in a Docker container with this machine as its worker, and `worker` the
21
+ * inverse. Each prints what it checked and fails on the first thing wrong.
22
+ */
23
+ export async function prove(opts: ProveOptions): Promise<void> {
24
+ const ps = opts.ps ?? new NodePs();
25
+ const log = opts.log ?? new ConsoleLogger();
26
+ log.info(`proving the ${opts.proof} on Linux; needs Docker`);
27
+ const { exitCode } = await ps.spawn(
28
+ [
29
+ "sh",
30
+ join(ROOT, "docker", `prove-${opts.proof}.sh`),
31
+ ...(opts.args ?? []),
32
+ ],
33
+ { cwd: ROOT },
34
+ );
35
+ if (exitCode !== 0) {
36
+ throw new Error(`The ${opts.proof} proof failed`);
37
+ }
38
+ }
package/dev/root.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { join } from "node:path";
2
+
3
+ /**
4
+ * The checkout this `splates dev` runs from, which `bin/_run` picks as the one you
5
+ * are standing in: a git worktree's own copy, so testing it tests your work.
6
+ */
7
+ export const ROOT = join(import.meta.dir, "../../..");
package/dev/ship.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { ConsoleLogger, type Logger } from "webappwiz/log";
2
+ import { releases } from "webappwiz/ship";
3
+ import { type Fs, NodePs, type Ps } from "webappwiz/system";
4
+ import { fix } from "./fix";
5
+
6
+ /**
7
+ * The agent skill this repository ships. Projects install it from the
8
+ * release's tag with `bunx skills add`, so stamping it is what has a
9
+ * project's copy say which release it came from.
10
+ */
11
+ const SKILL = "skills/splates/SKILL.md";
12
+
13
+ export interface ShipOptions {
14
+ log?: Logger;
15
+ fs?: Fs;
16
+ ps?: Ps;
17
+ // scry-ignore objects-over-callbacks: the platform's own prompt() is the
18
+ // dependency here, and it is a bare function
19
+ prompt?: (message: string) => string | null;
20
+ }
21
+
22
+ /**
23
+ * Releases the package, always a patch, with its skill stamped at the same
24
+ * version. A release that died is finished first, at the version it already
25
+ * holds.
26
+ */
27
+ export async function ship(opts: ShipOptions = {}): Promise<void> {
28
+ const log = opts.log ?? new ConsoleLogger();
29
+ const ps = opts.ps ?? new NodePs();
30
+ // The build compiles without checking types, so a typecheck is the only
31
+ // gate there is: run it before anything is stamped or pushed. The suite is
32
+ // not run here; whoever ships is expected to have run it already.
33
+ await fix({ check: true, skipScry: true, log, ps });
34
+
35
+ const declared = releases.lockstep(
36
+ await releases.workspace({ fs: opts.fs, ps }),
37
+ releases.skill(SKILL, { fs: opts.fs }),
38
+ );
39
+ await declared.release({
40
+ bump: "patch",
41
+ prompt: opts.prompt,
42
+ log,
43
+ fs: opts.fs,
44
+ ps,
45
+ });
46
+ }
package/dev/test.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { availableParallelism } from "node:os";
2
+ import {
3
+ type CoreClaims,
4
+ coreClaimsOfMachine,
5
+ heavyJobsOfMachine,
6
+ } from "@stringsync/splates-server/server";
7
+ import { ConsoleLogger, type Logger } from "webappwiz/log";
8
+ import { type HeavyCap, NodeFs, NodePs, type Ps } from "webappwiz/system";
9
+ import { Duration } from "webappwiz/time";
10
+ import { ROOT } from "./root";
11
+
12
+ export interface TestOptions {
13
+ /** Run only the free eval harness tests. */
14
+ evals?: boolean;
15
+ /** Run the package's and the eval harness's tests together. */
16
+ all?: boolean;
17
+ /**
18
+ * Run the tests on Linux, in a Docker container built from the checkout
19
+ * (`docker/test.Dockerfile`), as the coordinator's box and the workers
20
+ * run them.
21
+ */
22
+ linux?: boolean;
23
+ /** Run only the test files whose path has one of these, as `bun test` filters. */
24
+ files?: string[];
25
+ /**
26
+ * Test files run at once; half the cores when left out, fewer (down to half
27
+ * of that) when the machine's other heavy jobs hold some of its cores.
28
+ */
29
+ workers?: number;
30
+ ps?: Ps;
31
+ log?: Logger;
32
+ /** The machine's heavy-job slots, one of which the run waits for. */
33
+ heavy?: HeavyCap;
34
+ /** The machine's cores, which the run claims its workers from. */
35
+ cores?: CoreClaims;
36
+ /** How long the run may take before it is stopped. */
37
+ deadline?: Duration;
38
+ }
39
+
40
+ /**
41
+ * How long a test run may hold its heavy slot. Tests should not hang this
42
+ * long, even under load.
43
+ */
44
+ // At its fewest workers, a quarter of 16 cores, with load near 40, the suite
45
+ // took 111s, its time spread over hundreds of files rather than a few slow
46
+ // ones, and 140s at 8 workers beside two other runs: 3 minutes left a gate on
47
+ // a busier machine too little headroom.
48
+ export const TEST_DEADLINE = Duration.mins(6);
49
+
50
+ /** The image `--linux` builds from the checkout and runs the tests in. */
51
+ export const LINUX_IMAGE = "splates-test";
52
+
53
+ export async function test(opts: TestOptions): Promise<void> {
54
+ const ps = opts.ps ?? new NodePs();
55
+ const log = opts.log ?? new ConsoleLogger();
56
+
57
+ if (opts.evals && opts.all) {
58
+ throw new Error("choose either --evals or --all");
59
+ }
60
+ const filter = opts.all
61
+ ? []
62
+ : opts.evals
63
+ ? ["evals/"]
64
+ : ["--path-ignore-patterns=evals/**"];
65
+
66
+ // a worker per file; suites opt into within-file concurrency themselves,
67
+ // since most still share a fixture built in beforeEach. Half the cores, not
68
+ // bun's one per core: several agents test at once, and a worker per core
69
+ // each starves their git-spawning suites past the 5s timeout. Three
70
+ // overlapping runs on 16 cores failed 20 to 30 tests each at 16 workers and
71
+ // 0 to 2 at 8, finishing sooner too; one run alone is about 1.5s slower.
72
+ // Even so, those suites run many times slower under load than alone, so
73
+ // each test gets 30s rather than 5s.
74
+ const want =
75
+ opts.workers ?? Math.max(1, Math.floor(availableParallelism() / 2));
76
+ // one of the machine's few heavy-job slots, as splates's `preMerge` takes,
77
+ // so agents testing at once queue rather than run out of memory; run from
78
+ // a `preMerge` hook, it runs in the slot the merge already holds
79
+ const heavy = opts.heavy ?? heavyJobsOfMachine({ fs: new NodeFs(), ps, log });
80
+ // `splates merge` names where its gate wants a JUnit report, to queue a
81
+ // task for each slow test; bun keeps printing its own output beside it.
82
+ // Not on Linux, where the path would be the container's.
83
+ const report = opts.linux ? undefined : ps.env("SPLATES_TEST_REPORT");
84
+ await using slot = await heavy.take({ what: "splates dev test" });
85
+ // a worker is a core claimed: when other heavy jobs hold the machine's
86
+ // cores the run waits (five minutes at most) for half its workers, rather
87
+ // than starve the server and the agents; asked for workers by number, it
88
+ // waits for exactly those. Run in a merge's gate, it takes the cores the
89
+ // merge claimed, first in line, and claims none of its own
90
+ const claims =
91
+ opts.cores ?? coreClaimsOfMachine({ fs: new NodeFs(), ps, log });
92
+ await using claim = await claims.claim({
93
+ what: "splates dev test",
94
+ want,
95
+ min: opts.workers === undefined ? Math.max(1, Math.floor(want / 2)) : want,
96
+ });
97
+ const workers = claim.cores;
98
+ const bunTest = [
99
+ "bun",
100
+ "test",
101
+ "--pass-with-no-tests",
102
+ `--parallel=${workers}`,
103
+ "--timeout",
104
+ "30000",
105
+ ...filter,
106
+ ...(report ? ["--reporter=junit", `--reporter-outfile=${report}`] : []),
107
+ ...(opts.files ?? []),
108
+ ];
109
+ // The image is built in the slot, since an install is a heavy job, but
110
+ // ahead of the deadline: the first build pulls the base image and
111
+ // installs every dependency, minutes that are not the tests'.
112
+ if (opts.linux) {
113
+ const build = await ps.spawn(
114
+ [
115
+ "docker",
116
+ "build",
117
+ "-f",
118
+ "docker/test.Dockerfile",
119
+ "-t",
120
+ LINUX_IMAGE,
121
+ ".",
122
+ ],
123
+ { cwd: ROOT, env: slot.env },
124
+ );
125
+ if (build.exitCode !== 0) {
126
+ throw new Error(
127
+ "Building the Linux test image failed: is Docker running?",
128
+ );
129
+ }
130
+ }
131
+ // bun's per-test timeout cannot stop a test spinning in a synchronous
132
+ // loop, and such a run held its slot for ten hours while every add, merge
133
+ // and conversation on the machine queued behind it. Ending bun's runner
134
+ // ends its workers too; `docker run` passes the signal on to the
135
+ // container's bun, and `--init` reaps what the tests spawn under it.
136
+ const deadline = opts.deadline ?? TEST_DEADLINE;
137
+ const signal = AbortSignal.timeout(deadline.ms);
138
+ const argv = opts.linux
139
+ ? ["docker", "run", "--rm", "--init", LINUX_IMAGE, ...bunTest]
140
+ : bunTest;
141
+ const { exitCode } = await ps
142
+ .spawn(argv, { cwd: ROOT, env: slot.env, signal })
143
+ .catch((error: unknown) => {
144
+ if (signal.aborted) {
145
+ throw new Error(
146
+ `Tests ran past ${deadline.mins} minutes, so they were stopped: tests should not hang this long, even under load; a test may be stuck in a loop`,
147
+ );
148
+ }
149
+ throw error;
150
+ });
151
+ if (exitCode !== 0) {
152
+ throw new Error("Tests failed");
153
+ }
154
+ }