@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/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
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
|
+
}
|