@stringsync/splates 0.0.53 → 0.0.54
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.d.ts +3 -0
- package/budget.d.ts +3 -0
- package/creds.d.ts +3 -0
- package/flags.d.ts +37 -0
- package/hook.d.ts +3 -0
- package/index.d.ts +2 -0
- package/index.js +1802 -0
- package/infra.d.ts +3 -0
- package/lane.d.ts +3 -0
- package/notes.d.ts +4 -0
- package/package.json +12 -14
- package/projects.d.ts +3 -0
- package/queue.d.ts +3 -0
- package/server.d.ts +7 -0
- package/setup.d.ts +22 -0
- package/slots.d.ts +3 -0
- package/splates.d.ts +10 -0
- package/task.d.ts +3 -0
- package/tunnel.d.ts +3 -0
- package/waiting.d.ts +3 -0
- package/worker.d.ts +3 -0
- package/artifact.ts +0 -120
- package/budget.ts +0 -117
- package/checkout.ts +0 -8
- package/creds.test.ts +0 -336
- package/creds.ts +0 -144
- package/dev/dev.ts +0 -86
- package/dev/eval.ts +0 -21
- package/dev/fix.ts +0 -63
- package/dev/index.ts +0 -1
- package/dev/prove.ts +0 -38
- package/dev/root.ts +0 -7
- package/dev/ship.ts +0 -46
- package/dev/test.ts +0 -154
- package/e2e.test.ts +0 -472
- package/flags.ts +0 -157
- package/hook.ts +0 -328
- package/index.ts +0 -18
- package/infra.ts +0 -107
- package/lane.test.ts +0 -29
- package/lane.ts +0 -172
- package/notes.ts +0 -19
- package/projects.test.ts +0 -191
- package/projects.ts +0 -37
- package/queue.ts +0 -82
- 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 +0 -287
- package/setup.ts +0 -233
- package/slots.ts +0 -65
- package/splates.test.ts +0 -338
- package/splates.ts +0 -567
- package/task.ts +0 -354
- package/tunnel.ts +0 -106
- package/wait-journal.test.ts +0 -96
- package/waiting.ts +0 -59
- package/worker-relay.test.ts +0 -233
- package/worker.ts +0 -300
package/dev/ship.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
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
|
-
}
|
package/e2e.test.ts
DELETED
|
@@ -1,472 +0,0 @@
|
|
|
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
|
-
};
|