@webappwiz/arbor 0.0.28 → 0.0.30

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 (119) hide show
  1. package/AGENTS.md +7 -0
  2. package/README.md +34 -12
  3. package/add.test.ts +125 -0
  4. package/add.ts +192 -0
  5. package/age.ts +11 -0
  6. package/arbor.test.ts +145 -0
  7. package/arbor.ts +429 -0
  8. package/assets.d.ts +9 -0
  9. package/attachments.ts +87 -0
  10. package/build.ts +98 -0
  11. package/claim.test.ts +89 -0
  12. package/claim.ts +73 -0
  13. package/components.json +21 -0
  14. package/config.ts +58 -0
  15. package/dev/api.ts +120 -0
  16. package/dev/app.test.tsx +645 -0
  17. package/dev/app.tsx +80 -0
  18. package/dev/assets.ts +16 -0
  19. package/dev/build/main.txt +68 -0
  20. package/dev/build/shell.txt +21 -0
  21. package/dev/build/styles.txt +2263 -0
  22. package/dev/components/ui/badge.tsx +51 -0
  23. package/dev/components/ui/button.tsx +57 -0
  24. package/dev/components/ui/dialog.tsx +154 -0
  25. package/dev/components/ui/empty.tsx +100 -0
  26. package/dev/components/ui/input-group.tsx +155 -0
  27. package/dev/components/ui/input.tsx +19 -0
  28. package/dev/components/ui/progress.tsx +80 -0
  29. package/dev/components/ui/textarea.tsx +17 -0
  30. package/dev/components/ui/toast.tsx +227 -0
  31. package/dev/feed.ts +69 -0
  32. package/dev/files.tsx +191 -0
  33. package/dev/index.html +21 -0
  34. package/dev/lib/utils.ts +7 -0
  35. package/dev/main.tsx +22 -0
  36. package/dev/markdown.test.tsx +133 -0
  37. package/dev/markdown.tsx +124 -0
  38. package/dev/mentions.tsx +319 -0
  39. package/dev/shadcn.css +649 -0
  40. package/dev/styles.css +107 -0
  41. package/dev/tags.tsx +44 -0
  42. package/dev/tasks.tsx +151 -0
  43. package/dev/todos.tsx +759 -0
  44. package/dev.test.ts +439 -0
  45. package/dev.ts +386 -0
  46. package/e2e.test.ts +256 -0
  47. package/escalate.test.ts +76 -0
  48. package/escalate.ts +112 -0
  49. package/exit.test.ts +71 -0
  50. package/exit.ts +74 -0
  51. package/git.ts +294 -0
  52. package/index.ts +9 -0
  53. package/journal.ts +89 -0
  54. package/list.test.ts +82 -0
  55. package/list.ts +137 -0
  56. package/load-config.test.ts +121 -0
  57. package/load-config.ts +62 -0
  58. package/log.test.ts +65 -0
  59. package/log.ts +40 -0
  60. package/merge.test.ts +332 -0
  61. package/merge.ts +283 -0
  62. package/package.json +32 -15
  63. package/path.test.ts +35 -0
  64. package/path.ts +38 -0
  65. package/plan.test.ts +318 -0
  66. package/plan.ts +302 -0
  67. package/progress.test.ts +54 -0
  68. package/progress.ts +31 -0
  69. package/remove.test.ts +68 -0
  70. package/remove.ts +88 -0
  71. package/repository.ts +83 -0
  72. package/retry.test.ts +43 -0
  73. package/retry.ts +48 -0
  74. package/shell.ts +44 -0
  75. package/show.test.ts +91 -0
  76. package/show.ts +151 -0
  77. package/snapshot.test.ts +33 -0
  78. package/snapshot.ts +84 -0
  79. package/table.ts +20 -0
  80. package/testing.ts +229 -0
  81. package/todo.test.ts +490 -0
  82. package/todo.ts +834 -0
  83. package/wait.test.ts +61 -0
  84. package/wait.ts +77 -0
  85. package/worktree-service.test.ts +158 -0
  86. package/worktree-service.ts +193 -0
  87. package/worktree.ts +239 -0
  88. package/add.d.ts +0 -23
  89. package/age.d.ts +0 -2
  90. package/arbor.d.ts +0 -11
  91. package/attachments.d.ts +0 -36
  92. package/claim.d.ts +0 -11
  93. package/config.d.ts +0 -55
  94. package/config.js +0 -7
  95. package/dev/assets.d.ts +0 -11
  96. package/dev.d.ts +0 -47
  97. package/escalate.d.ts +0 -28
  98. package/exit.d.ts +0 -42
  99. package/git.d.ts +0 -77
  100. package/index.d.ts +0 -2
  101. package/index.js +0 -4669
  102. package/journal.d.ts +0 -29
  103. package/list.d.ts +0 -18
  104. package/load-config.d.ts +0 -15
  105. package/log.d.ts +0 -14
  106. package/merge.d.ts +0 -24
  107. package/path.d.ts +0 -13
  108. package/plan.d.ts +0 -83
  109. package/remove.d.ts +0 -19
  110. package/repository.d.ts +0 -25
  111. package/retry.d.ts +0 -17
  112. package/shell.d.ts +0 -17
  113. package/show.d.ts +0 -53
  114. package/snapshot.d.ts +0 -31
  115. package/table.d.ts +0 -5
  116. package/todo.d.ts +0 -230
  117. package/wait.d.ts +0 -22
  118. package/worktree-service.d.ts +0 -51
  119. package/worktree.d.ts +0 -95
package/dev.ts ADDED
@@ -0,0 +1,386 @@
1
+ import { AsyncDisposer, type AsyncResource } from "webappwiz/disposable";
2
+ import type { Logger } from "webappwiz/log";
3
+ import {
4
+ type Fs,
5
+ MAX_PORT,
6
+ OpenPortProvider,
7
+ type PortProvider,
8
+ type Ps,
9
+ } from "webappwiz/system";
10
+ import type { Attachment } from "./attachments";
11
+ import type { Assets } from "./dev/assets";
12
+ import { Exit, fail, type Reason } from "./exit";
13
+ import type { Journal } from "./journal";
14
+ import { fingerprint, snapshot } from "./snapshot";
15
+ import type { Todos } from "./todo";
16
+ import type { WorktreeService } from "./worktree-service";
17
+
18
+ /** Preferred, not required: `dev` moves up from here when it is taken. */
19
+ export const DEFAULT_PORT = 4269;
20
+
21
+ /** How far above the port asked for `dev` will look before giving up. */
22
+ export const PORT_SPAN = 20;
23
+
24
+ /** How often the repo is re-read to decide whether open pages should refetch. */
25
+ const POLL_MS = 2_000;
26
+
27
+ /** The one address the server binds: this machine, and nothing else. */
28
+ const LOOPBACK = "127.0.0.1";
29
+
30
+ /** Names this machine answers to, which a page opened on it always may use. */
31
+ const LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
32
+
33
+ /** A phone photo fits; a stray video does not. */
34
+ const MAX_BODY_BYTES = 25 * 1024 * 1024;
35
+
36
+ /** The images a todo may hold, by extension, and how each is served. */
37
+ const IMAGE_TYPES: Record<string, string> = {
38
+ png: "image/png",
39
+ jpg: "image/jpeg",
40
+ jpeg: "image/jpeg",
41
+ gif: "image/gif",
42
+ webp: "image/webp",
43
+ svg: "image/svg+xml",
44
+ };
45
+
46
+ /** How a refusal from the core reads over HTTP. */
47
+ const STATUS: Partial<Record<Reason, number>> = {
48
+ usage: 400,
49
+ not_found: 404,
50
+ lease_held: 409,
51
+ exists: 409,
52
+ };
53
+
54
+ /** What `dev` lets a caller choose. */
55
+ export interface DevOptions {
56
+ /** Where to listen; the port `--port` asked for, and the span above it. */
57
+ ports?: PortProvider;
58
+ /**
59
+ * Host names besides this machine's own that the page may be reached by,
60
+ * like the hostname of a tunnel. Anything else is refused, which is what
61
+ * stops another site from reading the repo through a browser pointed at
62
+ * localhost. Who may use those names is the tunnel's business, not arbor's.
63
+ */
64
+ hosts?: string[];
65
+ }
66
+
67
+ /** A running server, and the one thing a caller ever wants to do with it. */
68
+ export interface DevServer extends AsyncResource {
69
+ port: number;
70
+ }
71
+
72
+ /**
73
+ * Serves what `todo list`, `list` and `show` print as one page that refetches
74
+ * when the repo changes, where a person adds, edits, reorders and removes
75
+ * todos. Anything that moves a task (merge, remove, claim) stays in the CLI,
76
+ * so a page that should not have been reachable can at worst change a todo.
77
+ */
78
+ export async function dev(
79
+ {
80
+ service,
81
+ fs,
82
+ ps,
83
+ journal,
84
+ todos,
85
+ log,
86
+ assets,
87
+ }: {
88
+ service: WorktreeService;
89
+ fs: Fs;
90
+ ps: Ps;
91
+ journal: Journal;
92
+ todos: Todos;
93
+ log: Logger;
94
+ assets: Assets;
95
+ },
96
+ { ports = devPorts(DEFAULT_PORT), hosts = [] }: DevOptions = {},
97
+ ): Promise<DevServer> {
98
+ const home = ps.env("HOME");
99
+ const read = () => snapshot({ service, todos, fs, home });
100
+ const allowed = new Set([...LOCAL_HOSTS, ...hosts]);
101
+ // The page itself is a React app under `dev/`, built before publishing and
102
+ // carried in the bundle, so nothing here builds markup and nothing reads it
103
+ // off disk.
104
+ const open = new Set<ReadableStreamDefaultController<Uint8Array>>();
105
+ const encoder = new TextEncoder();
106
+ let last = fingerprint(await read());
107
+
108
+ // ponytail: polls, because arbor's state is spread across records, git refs
109
+ // and ARBOR.md, and one watcher would not cover all three. Watch `.git/arbor`
110
+ // and the worktree roots if two seconds ever feels slow.
111
+ const tick = async (): Promise<void> => {
112
+ const print = fingerprint(await read());
113
+ if (print === last) {
114
+ return;
115
+ }
116
+ last = print;
117
+ for (const stream of open) {
118
+ stream.enqueue(encoder.encode("data: changed\n\n"));
119
+ }
120
+ };
121
+ const poll = setInterval(() => {
122
+ tick().catch((error: unknown) => log.error(String(error)));
123
+ }, POLL_MS);
124
+
125
+ const events = (): Response => {
126
+ let self: ReadableStreamDefaultController<Uint8Array> | null = null;
127
+ return new Response(
128
+ new ReadableStream<Uint8Array>({
129
+ start: (stream) => {
130
+ self = stream;
131
+ open.add(stream);
132
+ // A comment, ignored by EventSource. Without a first chunk the
133
+ // response headers never reach the client and the page hangs
134
+ // waiting to connect.
135
+ stream.enqueue(encoder.encode(": connected\n\n"));
136
+ },
137
+ cancel: () => {
138
+ if (self) {
139
+ open.delete(self);
140
+ }
141
+ },
142
+ }),
143
+ {
144
+ headers: {
145
+ "content-type": "text/event-stream",
146
+ "cache-control": "no-cache",
147
+ },
148
+ },
149
+ );
150
+ };
151
+
152
+ const asset = (body: string, type: string): Response =>
153
+ new Response(body, { headers: { "content-type": type } });
154
+
155
+ // Every route that reads or writes the repo goes through here. The Host
156
+ // check is what DNS rebinding runs into; the Origin check is what a form on
157
+ // another site posting to this one runs into.
158
+ const guarded =
159
+ (handle: (request: Request) => Promise<Response>) =>
160
+ async (request: Request): Promise<Response> => {
161
+ const host = request.headers.get("host") ?? "";
162
+ if (!allowed.has(hostname(host))) {
163
+ return refuse(
164
+ 403,
165
+ "forbidden",
166
+ `host '${host}' is not allowed: pass it to --allow-hosts`,
167
+ );
168
+ }
169
+ const origin = request.headers.get("origin");
170
+ if (
171
+ request.method !== "GET" &&
172
+ (origin === null || new URL(origin).host !== host)
173
+ ) {
174
+ return refuse(403, "forbidden", "cross-origin writes are refused");
175
+ }
176
+ try {
177
+ return await handle(request);
178
+ } catch (error) {
179
+ if (error instanceof Exit) {
180
+ return refuse(
181
+ STATUS[error.reason] ?? 400,
182
+ error.reason,
183
+ error.message,
184
+ );
185
+ }
186
+ log.error(String(error));
187
+ return refuse(500, "error", String(error));
188
+ }
189
+ };
190
+
191
+ // Only a file a todo holds, so the page, and whoever a tunnel lets reach
192
+ // it, can read nothing on this machine that was not put there on purpose.
193
+ const file = async (request: Request): Promise<Response> => {
194
+ const params = new URL(request.url).searchParams;
195
+ const path = params.get("path") ?? "";
196
+ const type = IMAGE_TYPES[path.split(".").at(-1)?.toLowerCase() ?? ""];
197
+ const bytes = todos.owns(path)
198
+ ? await fs.readBytes(path).catch(() => null)
199
+ : null;
200
+ if (bytes === null) {
201
+ return refuse(404, "not_found", `nothing here shows ${path}`);
202
+ }
203
+ return new Response(new Blob([bytes as Uint8Array<ArrayBuffer>]), {
204
+ headers: {
205
+ "content-type": type ?? "application/octet-stream",
206
+ // Anything not an image downloads rather than rendering here.
207
+ ...(type === undefined ? { "content-disposition": "attachment" } : {}),
208
+ // An svg opened on its own could otherwise run a script.
209
+ "content-security-policy":
210
+ "default-src 'none'; style-src 'unsafe-inline'",
211
+ "x-content-type-options": "nosniff",
212
+ },
213
+ });
214
+ };
215
+
216
+ // Names only, and only what git would show anyone with the repo: enough to
217
+ // point an agent at a file with `@`, and nothing inside one.
218
+ const paths = async (request: Request): Promise<Response> => {
219
+ const task = new URL(request.url).searchParams.get("task") ?? "";
220
+ const worktree = task === "" ? null : await service.find(task);
221
+ if (worktree !== null && !worktree.exists) {
222
+ fail("not_found", `no worktree for '${task}'`, { task });
223
+ }
224
+ return Response.json(
225
+ await service.git.paths(worktree?.path ?? service.git.root),
226
+ );
227
+ };
228
+
229
+ const addTodo = async (request: Request): Promise<Response> => {
230
+ const form = await request.formData();
231
+ const todo = await journal.record("todo add", null, async () =>
232
+ todos.add(String(form.get("subject") ?? ""), null, {
233
+ text: String(form.get("text") ?? ""),
234
+ files: await uploads(form),
235
+ position: positionIn(form),
236
+ }),
237
+ );
238
+ await tick();
239
+ return Response.json(todo.state);
240
+ };
241
+
242
+ /** The tags a todo write sends, comma separated. */
243
+ const tagsIn = (form: FormData): string[] =>
244
+ String(form.get("tags") ?? "")
245
+ .split(",")
246
+ .map((tag) => tag.trim())
247
+ .filter(Boolean);
248
+
249
+ /** Where a todo write asks to put it; absent leaves it to the write. */
250
+ const positionIn = (form: FormData): number | undefined =>
251
+ form.has("position") ? Number(form.get("position")) : undefined;
252
+
253
+ /** The todo a `/api/todos/<id>` path names, or `/api/todos/<id>/position`. */
254
+ const todoAt = async (request: Request) => {
255
+ const raw = new URL(request.url).pathname.split("/")[3] ?? "";
256
+ const id = Number(raw);
257
+ if (!Number.isInteger(id) || id <= 0) {
258
+ fail("usage", `'${raw}' is not a todo id`, { todo: raw });
259
+ }
260
+ return todos.find(id);
261
+ };
262
+
263
+ const updateTodo = async (request: Request): Promise<Response> => {
264
+ const form = await request.formData();
265
+ const found = await todoAt(request);
266
+ const todo = await journal.record("todo update", null, async () =>
267
+ found.update({
268
+ subject: form.has("subject") ? String(form.get("subject")) : undefined,
269
+ text: form.has("text") ? String(form.get("text")) : undefined,
270
+ position: positionIn(form),
271
+ files: await uploads(form),
272
+ keep: form.getAll("keep").map(String),
273
+ tags: form.has("tags") ? tagsIn(form) : undefined,
274
+ }),
275
+ );
276
+ await tick();
277
+ return Response.json(todo.state);
278
+ };
279
+
280
+ // Its own route, carrying nothing but the place, so a drag can never touch
281
+ // a todo's words or files.
282
+ const moveTodo = async (request: Request): Promise<Response> => {
283
+ const found = await todoAt(request);
284
+ const { position } = (await request.json()) as { position?: unknown };
285
+ const todo = await journal.record("todo update", null, () =>
286
+ found.update({ position: Number(position) }),
287
+ );
288
+ await tick();
289
+ return Response.json(todo.state);
290
+ };
291
+
292
+ const removeTodo = async (request: Request): Promise<Response> => {
293
+ const found = await todoAt(request);
294
+ await journal.record("todo remove", null, () => found.remove());
295
+ await tick();
296
+ return Response.json(found.state);
297
+ };
298
+
299
+ const requested = await ports.get();
300
+ const server = Bun.serve({
301
+ port: requested,
302
+ // This machine only. Reaching it from anywhere else is a tunnel's job,
303
+ // which connects from here too.
304
+ hostname: LOOPBACK,
305
+ maxRequestBodySize: MAX_BODY_BYTES,
306
+ // Zero never closes an idle connection, which is what the SSE stream needs:
307
+ // it is idle by design between the things it has to say.
308
+ idleTimeout: 0,
309
+ // The static three are handed over without entering JS; the other two have
310
+ // to be asked for, since they answer with the state of the repo right now.
311
+ routes: {
312
+ "/": asset(assets.shell, "text/html; charset=utf-8"),
313
+ "/main.js": asset(assets.script, "text/javascript; charset=utf-8"),
314
+ "/styles.css": asset(assets.styles, "text/css; charset=utf-8"),
315
+ "/api/snapshot": guarded(async () => Response.json(await read())),
316
+ "/api/file": guarded(file),
317
+ "/api/paths": guarded(paths),
318
+ "/api/todos": { POST: guarded(addTodo) },
319
+ "/api/todos/:id": {
320
+ PATCH: guarded(updateTodo),
321
+ DELETE: guarded(removeTodo),
322
+ },
323
+ "/api/todos/:id/position": { PUT: guarded(moveTodo) },
324
+ "/events": guarded(async () => events()),
325
+ },
326
+ fetch: () => new Response("not found", { status: 404 }),
327
+ });
328
+ // Read back, because `ports` may hand over 0 to mean any port at all, and
329
+ // read now, because `server.port` is 0 once the server is stopped and this is
330
+ // handed to a caller that stops it. Undefined only for a unix socket, which
331
+ // this never asks for.
332
+ const port = server.port ?? requested;
333
+
334
+ log.info(`arbor dev on http://localhost:${port}`);
335
+ const disposer = new AsyncDisposer();
336
+ // true drains open connections rather than cutting them mid-response
337
+ disposer.defer(() => server.stop(true));
338
+ disposer.defer(async () => clearInterval(poll));
339
+ return {
340
+ port,
341
+ disposeAsync: disposer.disposeAsync,
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Where `dev` will listen, given the port asked for. A flag is outside input,
347
+ * so a port that cannot exist is a refusal with an exit code rather than the
348
+ * assertion `OpenPortProvider` would raise for a bug in here.
349
+ */
350
+ export function devPorts(from: number): PortProvider {
351
+ if (!Number.isInteger(from) || from < 0 || from > MAX_PORT) {
352
+ fail(
353
+ "usage",
354
+ `invalid port '${from}': use a whole number 0 to ${MAX_PORT}`,
355
+ {
356
+ port: from,
357
+ },
358
+ );
359
+ }
360
+ return OpenPortProvider.span({ from, span: PORT_SPAN, host: LOOPBACK });
361
+ }
362
+
363
+ /** The files a form carries under `file`, as bytes. */
364
+ async function uploads(form: FormData): Promise<Attachment[]> {
365
+ const files: Attachment[] = [];
366
+ for (const file of form.getAll("file")) {
367
+ if (file instanceof File) {
368
+ files.push({
369
+ name: file.name || "pasted",
370
+ bytes: new Uint8Array(await file.arrayBuffer()),
371
+ });
372
+ }
373
+ }
374
+ return files;
375
+ }
376
+
377
+ /** The host a Host header names, without its port. */
378
+ function hostname(host: string): string {
379
+ return host.startsWith("[")
380
+ ? host.slice(0, host.indexOf("]") + 1)
381
+ : (host.split(":")[0] ?? "");
382
+ }
383
+
384
+ function refuse(status: number, reason: string, message: string): Response {
385
+ return Response.json({ reason, message }, { status });
386
+ }
package/e2e.test.ts ADDED
@@ -0,0 +1,256 @@
1
+ import { describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { ensure } from "webappwiz/assert";
4
+ import { color } from "webappwiz/log";
5
+ import { Testing } from "./testing";
6
+
7
+ // Every test here drives real git in a repo of its own, dozens of processes
8
+ // each: fast alone, but starved past bun's 5s default when the whole suite
9
+ // runs in parallel on a busy machine.
10
+ setDefaultTimeout(30_000);
11
+
12
+ const CLI = join(import.meta.dirname, "index.ts");
13
+
14
+ /** A repo of its own per test, so the four of them can run at once. */
15
+ const setup = async () => {
16
+ const env = await Testing.open();
17
+ // A gate that passes, so the happy paths exercise merge rather than the
18
+ // fixture repo's (nonexistent) tests.
19
+ await env.fs.write(
20
+ join(env.root, "arbor.config.ts"),
21
+ `export default { preMerge: "true" };\n`,
22
+ );
23
+
24
+ /** Runs the CLI the way an agent does: a fresh process, a cwd, an exit code. */
25
+ const arbor = async (cwd: string, ...args: string[]) => {
26
+ const { exitCode, stdout, stderr } = await env.ps.spawnCapture(
27
+ ["bun", CLI, ...args],
28
+ { cwd },
29
+ );
30
+ return { exitCode, stdout: color.strip(stdout), stderr };
31
+ };
32
+
33
+ const rows = async () =>
34
+ JSON.parse((await arbor(env.root, "list", "--json")).stdout) as {
35
+ task: string;
36
+ status: string;
37
+ lease: "held" | "stale" | "none";
38
+ worktree: string;
39
+ }[];
40
+
41
+ return { env, arbor, rows, [Symbol.asyncDispose]: () => env.disposeAsync() };
42
+ };
43
+
44
+ describe.concurrent("arbor", () => {
45
+ it("lands both trees on trunk without a merge when two agents work at once", async () => {
46
+ await using cli = await setup();
47
+ const { env, arbor, rows } = cli;
48
+
49
+ expect((await arbor(env.root, "add", "alpha")).exitCode).toBe(0);
50
+ expect((await arbor(env.root, "add", "beta")).exitCode).toBe(0);
51
+
52
+ const listed = await rows();
53
+ expect(listed.map((row) => row.task)).toEqual(["alpha", "beta"]);
54
+ const [alpha, beta] = listed.map((row) => row.worktree) as [string, string];
55
+ expect([alpha, beta]).not.toContain("");
56
+
57
+ // Each agent resumes a tree it did not create: a fresh thread starts here.
58
+ const claimed = await arbor(alpha, "claim", "alpha");
59
+ expect(claimed.exitCode).toBe(0);
60
+ expect(claimed.stdout).toContain("uncommitted: none");
61
+ await env.commit(alpha, "alpha.txt", "alpha\n", "add alpha");
62
+ const merged = await arbor(alpha, "merge");
63
+ expect(merged.exitCode).toBe(0);
64
+ expect(merged.stdout).toContain("merged alpha onto main");
65
+
66
+ // beta rebases onto a trunk that alpha moved out from under it.
67
+ expect((await arbor(beta, "claim", "beta")).exitCode).toBe(0);
68
+ await env.commit(beta, "beta.txt", "beta\n", "add beta");
69
+ expect((await arbor(beta, "merge")).exitCode).toBe(0);
70
+
71
+ const log = await env.gitCli(env.root, "log", "--oneline", "main");
72
+ expect(log).toContain("add alpha");
73
+ expect(log).toContain("add beta");
74
+ expect(
75
+ await env.gitCli(env.root, "rev-list", "--count", "--merges", "main"),
76
+ ).toBe("0");
77
+
78
+ expect(await rows()).toEqual([]);
79
+ const removed = await arbor(env.root, "remove", "alpha");
80
+ expect(removed.exitCode).toBe(13);
81
+ expect(removed.stdout).toContain("already_removed");
82
+
83
+ // The journal outlives the tasks: both merges and the refused remove.
84
+ const journal = JSON.parse(
85
+ (await arbor(env.root, "log", "--json")).stdout,
86
+ ) as { action: string; task: string | null; reason: string | null }[];
87
+ expect(journal).toMatchObject([
88
+ { action: "add", task: "alpha", reason: null },
89
+ { action: "add", task: "beta", reason: null },
90
+ { action: "claim", task: "alpha", reason: null },
91
+ { action: "merge", task: "alpha", reason: null },
92
+ { action: "claim", task: "beta", reason: null },
93
+ { action: "merge", task: "beta", reason: null },
94
+ { action: "remove", task: "alpha", reason: "already_removed" },
95
+ ]);
96
+ });
97
+
98
+ it("lands a stacked task in its parent's worktree, and the parent on trunk", async () => {
99
+ await using cli = await setup();
100
+ const { env, arbor, rows } = cli;
101
+
102
+ expect((await arbor(env.root, "add", "parent")).exitCode).toBe(0);
103
+ const parent = ensure.defined(
104
+ (await rows())[0]?.worktree,
105
+ "add did not record a worktree path",
106
+ );
107
+ await env.commit(parent, "parent.txt", "parent\n", "add parent");
108
+
109
+ // The parent hands out a part from inside its own tree, based on its own
110
+ // branch, so the part lands on it rather than on trunk.
111
+ expect(
112
+ (await arbor(parent, "add", "part", "--base", "task/parent")).exitCode,
113
+ ).toBe(0);
114
+ const part = ensure.defined(
115
+ (await rows()).find((row) => row.task === "part")?.worktree,
116
+ "add did not record a worktree path",
117
+ );
118
+ await env.commit(part, "part.txt", "part\n", "add part");
119
+
120
+ const landed = await arbor(part, "merge");
121
+ expect(landed.exitCode).toBe(0);
122
+ expect(landed.stdout).toContain("merged part onto task/parent");
123
+ expect(landed.stdout).toContain(parent);
124
+ // The parent's own tree has the file, not just the branch.
125
+ expect(await env.fs.exists(join(part, "part.txt"))).toBe(false);
126
+ expect(await env.fs.exists(join(parent, "part.txt"))).toBe(true);
127
+ expect(
128
+ await env.gitCli(env.root, "log", "--oneline", "main"),
129
+ ).not.toContain("add part");
130
+
131
+ // The parent then lands both commits on trunk with its own merge.
132
+ expect((await arbor(parent, "merge")).exitCode).toBe(0);
133
+ const log = await env.gitCli(env.root, "log", "--oneline", "main");
134
+ expect(log).toContain("add parent");
135
+ expect(log).toContain("add part");
136
+ expect(await rows()).toEqual([]);
137
+ });
138
+
139
+ it("lands the work when a second agent picks up an escalated tree", async () => {
140
+ await using cli = await setup();
141
+ const { env, arbor, rows } = cli;
142
+
143
+ // Tests pass only once the committed marker says so, which is how the
144
+ // first agent's merge fails for a reason the second agent can fix.
145
+ await env.fs.write(
146
+ join(env.root, "arbor.config.ts"),
147
+ `export default { preMerge: "grep -q ok status.txt" };\n`,
148
+ );
149
+ expect((await arbor(env.root, "add", "gamma")).exitCode).toBe(0);
150
+ const tree = ensure.defined(
151
+ (await rows())[0]?.worktree,
152
+ "add did not record a worktree path",
153
+ );
154
+
155
+ expect((await arbor(tree, "claim", "gamma")).exitCode).toBe(0);
156
+ await env.commit(tree, "status.txt", "wrong\n", "add status");
157
+ const failed = await arbor(tree, "merge");
158
+ expect(failed.exitCode).not.toBe(0);
159
+ expect(
160
+ await env.gitCli(env.root, "log", "--oneline", "main"),
161
+ ).not.toContain("add status");
162
+ expect(
163
+ (await arbor(tree, "escalate", "cannot tell what ok means")).exitCode,
164
+ ).toBe(0);
165
+
166
+ // Second agent, fresh thread: the escalated tree is still claimable, is
167
+ // someone's work again, and the earlier failure did not eat the whole
168
+ // merge budget.
169
+ const resumed = await arbor(tree, "claim", "gamma");
170
+ expect(resumed.exitCode).toBe(0);
171
+ expect(resumed.stdout).toContain("status: working (was escalated)");
172
+ expect(resumed.stdout).toContain("attempts: 1");
173
+ await env.commit(tree, "status.txt", "ok\n", "fix status");
174
+ const merged = await arbor(tree, "merge");
175
+ expect(merged.exitCode).toBe(0);
176
+ expect(merged.stdout).toContain("merged gamma onto main");
177
+ expect(await env.gitCli(env.root, "log", "--oneline", "main")).toContain(
178
+ "fix status",
179
+ );
180
+ });
181
+
182
+ it("reports the uncommitted work and lands it when an agent dies mid-task", async () => {
183
+ await using cli = await setup();
184
+ const { env, arbor, rows } = cli;
185
+
186
+ expect((await arbor(env.root, "add", "delta")).exitCode).toBe(0);
187
+ const tree = ensure.defined(
188
+ (await rows())[0]?.worktree,
189
+ "add did not record a worktree path",
190
+ );
191
+
192
+ // The first agent claims, writes, and never comes back: the record keeps
193
+ // a lease whose pid died with the process.
194
+ expect((await arbor(tree, "claim", "delta")).exitCode).toBe(0);
195
+ await env.fs.write(join(tree, "delta.txt"), "half done\n");
196
+ expect((await rows())[0]?.lease).toBe("stale");
197
+
198
+ // Second agent: the dead lease does not block the claim, and the
199
+ // half-finished file is reported rather than silently inherited.
200
+ const resumed = await arbor(tree, "claim", "delta");
201
+ expect(resumed.exitCode).toBe(0);
202
+ expect(resumed.stdout).toContain("uncommitted (1)");
203
+ expect(resumed.stdout).toContain("delta.txt");
204
+
205
+ await env.commit(tree, "delta.txt", "finished\n", "finish delta");
206
+ expect((await arbor(tree, "merge")).exitCode).toBe(0);
207
+ expect(await env.gitCli(env.root, "log", "--oneline", "main")).toContain(
208
+ "finish delta",
209
+ );
210
+ });
211
+
212
+ it("lands the tree on a retry when an agent is killed mid-merge", async () => {
213
+ await using cli = await setup();
214
+ const { env, arbor, rows } = cli;
215
+
216
+ // SIGKILL from inside the test gate: the first merge dies after rebasing
217
+ // and before trunk moves, holding both the lease and the merge lock. The
218
+ // marker file makes it happen exactly once, so the retry can pass.
219
+ const die = join(env.root, "die");
220
+ await env.fs.write(
221
+ join(env.root, "arbor.config.ts"),
222
+ `export default { preMerge: "if [ -f ${die} ]; then rm ${die}; kill -9 $PPID; fi" };\n`,
223
+ );
224
+ await env.fs.write(die, "");
225
+ expect((await arbor(env.root, "add", "epsilon")).exitCode).toBe(0);
226
+ const tree = ensure.defined(
227
+ (await rows())[0]?.worktree,
228
+ "add did not record a worktree path",
229
+ );
230
+
231
+ expect((await arbor(tree, "claim", "epsilon")).exitCode).toBe(0);
232
+ await env.commit(tree, "epsilon.txt", "epsilon\n", "add epsilon");
233
+ expect((await arbor(tree, "merge")).exitCode).not.toBe(0);
234
+ expect(await env.fs.exists(die)).toBe(false);
235
+
236
+ const stalled = (await rows())[0];
237
+ expect(stalled?.status).toBe("merging");
238
+ expect(stalled?.lease).toBe("stale");
239
+ expect(
240
+ await env.gitCli(env.root, "log", "--oneline", "main"),
241
+ ).not.toContain("add epsilon");
242
+ expect(await env.fs.exists(join(env.arborDir, "merge.lock"))).toBe(true);
243
+
244
+ // Second agent: the abandoned lock is stolen rather than waited out.
245
+ const resumed = await arbor(tree, "claim", "epsilon");
246
+ expect(resumed.exitCode).toBe(0);
247
+ expect(resumed.stdout).toContain("status: merging");
248
+ const merged = await arbor(tree, "merge");
249
+ expect(merged.exitCode).toBe(0);
250
+ expect(merged.stdout).toContain("merged epsilon onto main");
251
+ expect(await env.gitCli(env.root, "log", "--oneline", "main")).toContain(
252
+ "add epsilon",
253
+ );
254
+ expect(await rows()).toEqual([]);
255
+ });
256
+ });