@webappwiz/arbor 0.0.30 → 0.0.32

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 (120) hide show
  1. package/README.md +2 -2
  2. package/add.d.ts +23 -0
  3. package/age.d.ts +2 -0
  4. package/arbor.d.ts +11 -0
  5. package/attachments.d.ts +36 -0
  6. package/claim.d.ts +11 -0
  7. package/config.d.ts +55 -0
  8. package/config.js +7 -0
  9. package/dev/assets.d.ts +11 -0
  10. package/dev.d.ts +48 -0
  11. package/escalate.d.ts +28 -0
  12. package/exit.d.ts +42 -0
  13. package/git.d.ts +82 -0
  14. package/index.d.ts +2 -0
  15. package/index.js +4763 -0
  16. package/journal.d.ts +29 -0
  17. package/list.d.ts +18 -0
  18. package/load-config.d.ts +15 -0
  19. package/log.d.ts +14 -0
  20. package/merge.d.ts +24 -0
  21. package/package.json +15 -32
  22. package/path.d.ts +13 -0
  23. package/plan.d.ts +83 -0
  24. package/remove.d.ts +19 -0
  25. package/repository.d.ts +25 -0
  26. package/retry.d.ts +17 -0
  27. package/shell.d.ts +17 -0
  28. package/show.d.ts +53 -0
  29. package/snapshot.d.ts +36 -0
  30. package/table.d.ts +5 -0
  31. package/todo.d.ts +202 -0
  32. package/todos.d.ts +75 -0
  33. package/wait.d.ts +22 -0
  34. package/worktree-service.d.ts +51 -0
  35. package/worktree.d.ts +95 -0
  36. package/AGENTS.md +0 -7
  37. package/add.test.ts +0 -125
  38. package/add.ts +0 -192
  39. package/age.ts +0 -11
  40. package/arbor.test.ts +0 -145
  41. package/arbor.ts +0 -429
  42. package/assets.d.ts +0 -9
  43. package/attachments.ts +0 -87
  44. package/build.ts +0 -98
  45. package/claim.test.ts +0 -89
  46. package/claim.ts +0 -73
  47. package/components.json +0 -21
  48. package/config.ts +0 -58
  49. package/dev/api.ts +0 -120
  50. package/dev/app.test.tsx +0 -645
  51. package/dev/app.tsx +0 -80
  52. package/dev/assets.ts +0 -16
  53. package/dev/build/main.txt +0 -68
  54. package/dev/build/shell.txt +0 -21
  55. package/dev/build/styles.txt +0 -2263
  56. package/dev/components/ui/badge.tsx +0 -51
  57. package/dev/components/ui/button.tsx +0 -57
  58. package/dev/components/ui/dialog.tsx +0 -154
  59. package/dev/components/ui/empty.tsx +0 -100
  60. package/dev/components/ui/input-group.tsx +0 -155
  61. package/dev/components/ui/input.tsx +0 -19
  62. package/dev/components/ui/progress.tsx +0 -80
  63. package/dev/components/ui/textarea.tsx +0 -17
  64. package/dev/components/ui/toast.tsx +0 -227
  65. package/dev/feed.ts +0 -69
  66. package/dev/files.tsx +0 -191
  67. package/dev/index.html +0 -21
  68. package/dev/lib/utils.ts +0 -7
  69. package/dev/main.tsx +0 -22
  70. package/dev/markdown.test.tsx +0 -133
  71. package/dev/markdown.tsx +0 -124
  72. package/dev/mentions.tsx +0 -319
  73. package/dev/shadcn.css +0 -649
  74. package/dev/styles.css +0 -107
  75. package/dev/tags.tsx +0 -44
  76. package/dev/tasks.tsx +0 -151
  77. package/dev/todos.tsx +0 -759
  78. package/dev.test.ts +0 -439
  79. package/dev.ts +0 -386
  80. package/e2e.test.ts +0 -256
  81. package/escalate.test.ts +0 -76
  82. package/escalate.ts +0 -112
  83. package/exit.test.ts +0 -71
  84. package/exit.ts +0 -74
  85. package/git.ts +0 -294
  86. package/index.ts +0 -9
  87. package/journal.ts +0 -89
  88. package/list.test.ts +0 -82
  89. package/list.ts +0 -137
  90. package/load-config.test.ts +0 -121
  91. package/load-config.ts +0 -62
  92. package/log.test.ts +0 -65
  93. package/log.ts +0 -40
  94. package/merge.test.ts +0 -332
  95. package/merge.ts +0 -283
  96. package/path.test.ts +0 -35
  97. package/path.ts +0 -38
  98. package/plan.test.ts +0 -318
  99. package/plan.ts +0 -302
  100. package/progress.test.ts +0 -54
  101. package/progress.ts +0 -31
  102. package/remove.test.ts +0 -68
  103. package/remove.ts +0 -88
  104. package/repository.ts +0 -83
  105. package/retry.test.ts +0 -43
  106. package/retry.ts +0 -48
  107. package/shell.ts +0 -44
  108. package/show.test.ts +0 -91
  109. package/show.ts +0 -151
  110. package/snapshot.test.ts +0 -33
  111. package/snapshot.ts +0 -84
  112. package/table.ts +0 -20
  113. package/testing.ts +0 -229
  114. package/todo.test.ts +0 -490
  115. package/todo.ts +0 -834
  116. package/wait.test.ts +0 -61
  117. package/wait.ts +0 -77
  118. package/worktree-service.test.ts +0 -158
  119. package/worktree-service.ts +0 -193
  120. package/worktree.ts +0 -239
package/wait.test.ts DELETED
@@ -1,61 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
- import { Duration, sleep } from "webappwiz/time";
3
- import { add } from "./add";
4
- import { remove } from "./remove";
5
- import { Testing } from "./testing";
6
- import { wait } from "./wait";
7
-
8
- /** Long enough that a test only reaches it when waiting is genuinely stuck. */
9
- const PATIENT = { timeout: Duration.secs(5), poll: Duration.ms(5) };
10
-
11
- describe("wait", () => {
12
- let deps: Testing;
13
-
14
- beforeEach(async () => {
15
- deps = await Testing.open();
16
- });
17
-
18
- afterEach(() => deps.disposeAsync());
19
-
20
- it("returns with the reason once a task escalates while it waits", async () => {
21
- await add(deps, "alpha");
22
- deps.log.clear();
23
-
24
- const waiting = wait(deps, "alpha", PATIENT);
25
- await sleep(Duration.ms(20));
26
- await (await deps.service.find("alpha")).save({
27
- status: "escalated",
28
- escalations: [{ reason: "needs a human", at: new Date().toISOString() }],
29
- });
30
- await waiting;
31
-
32
- expect(deps.out()).toContain("escalated");
33
- expect(deps.out()).toContain("needs a human");
34
- });
35
-
36
- it("returns once nothing is left of the task", async () => {
37
- await add(deps, "alpha");
38
- await remove(deps, "alpha");
39
- deps.log.clear();
40
-
41
- await wait(deps, "alpha", PATIENT);
42
-
43
- expect(deps.out()).toContain("removed");
44
- expect(deps.out()).toContain("arbor log");
45
- });
46
-
47
- it("gives up on a task that keeps working", async () => {
48
- await add(deps, "alpha");
49
-
50
- await expect(
51
- wait(deps, "alpha", { timeout: Duration.ms(20), poll: Duration.ms(5) }),
52
- ).toBail("timeout", {
53
- message: "still working",
54
- data: { task: "alpha", status: "working" },
55
- });
56
- });
57
-
58
- it("refuses a name nothing remembers", async () => {
59
- await expect(wait(deps, "nope", PATIENT)).toBail("not_found");
60
- });
61
- });
package/wait.ts DELETED
@@ -1,77 +0,0 @@
1
- import { color, type Logger } from "webappwiz/log";
2
- import { Duration, sleep } from "webappwiz/time";
3
- import { fail } from "./exit";
4
- import type { Worktree, WorktreeStatus } from "./worktree";
5
- import type { WorktreeService } from "./worktree-service";
6
-
7
- /** How long `wait` gives a task before handing the wait back to its caller. */
8
- export const DEFAULT_TIMEOUT = Duration.mins(15);
9
-
10
- /** How often the task's record is re-read. Cheap: a file and two git refs. */
11
- const POLL = Duration.secs(2);
12
-
13
- /** The only two statuses a task moves on from by itself. */
14
- const RUNNING: WorktreeStatus[] = ["working", "merging"];
15
-
16
- export interface WaitOptions {
17
- /** How long to wait before giving up. */
18
- timeout?: Duration;
19
- /** How long between reads of the record; the default suits a human's patience. */
20
- poll?: Duration;
21
- }
22
-
23
- /**
24
- * Blocks until a task stops moving: merged or removed (both leave the name
25
- * `removed`), escalated to a human, or broken. Waiting is for the agent whose
26
- * own work overlaps this one's and would rather rebase onto the result than
27
- * against it, which is why the timeout is short enough to come back and think
28
- * again rather than block a session for an afternoon.
29
- */
30
- export async function wait(
31
- { service, log }: { service: WorktreeService; log: Logger },
32
- task: string,
33
- { timeout = DEFAULT_TIMEOUT, poll = POLL }: WaitOptions = {},
34
- ): Promise<void> {
35
- const deadline = Date.now() + timeout.ms;
36
- for (;;) {
37
- const worktree = await service.find(task);
38
- // `removed` is a task that landed or was discarded, and is what waiting
39
- // for one usually ends in. `absent` is a name nothing remembers, which
40
- // is a typo, not an ending.
41
- if (worktree.status === "absent") {
42
- fail(
43
- "not_found",
44
- `no task '${task}': run \`arbor list\` to see what there is`,
45
- { task },
46
- );
47
- }
48
- if (!RUNNING.includes(worktree.status)) {
49
- log.info(report(worktree));
50
- return;
51
- }
52
- const left = deadline - Date.now();
53
- if (left <= 0) {
54
- fail(
55
- "timeout",
56
- `'${task}' is still ${worktree.status} after ${timeout.secs}s: wait again, work alongside it and accept the rebase, or ask the human`,
57
- { task, status: worktree.status },
58
- );
59
- }
60
- await sleep(Duration.min(poll, Duration.ms(left)));
61
- }
62
- }
63
-
64
- function report(worktree: Worktree): string {
65
- const lines = [`${color.bold(worktree.task)} ${worktree.status}`];
66
- const escalation = worktree.state?.escalations?.at(-1)?.reason;
67
- if (escalation) {
68
- lines.push(` ${color.yellow(`escalated: ${escalation}`)}`);
69
- }
70
- if (worktree.status === "removed") {
71
- lines.push(
72
- "",
73
- `Nothing left of it here: \`arbor log\` says whether it landed.`,
74
- );
75
- }
76
- return lines.join("\n");
77
- }
@@ -1,158 +0,0 @@
1
- // scry-ignore-file one-class-per-file: the same two fakes are local to this suite
2
- import { beforeEach, describe, expect, it } from "bun:test";
3
- import type { Fs, MkdirOptions, RmOptions, StatResult } from "webappwiz/system";
4
- import { FakeFs, FakePs } from "webappwiz/system/testing";
5
- import type { Config } from "./config";
6
- import { Git } from "./git";
7
- import { WorktreeService } from "./worktree-service";
8
-
9
- /** Records every filesystem call so the write path can be asserted on. */
10
- class RecordingFs implements Fs {
11
- readonly ops: string[] = [];
12
-
13
- constructor(private readonly inner: Fs) {}
14
-
15
- exists(path: string): Promise<boolean> {
16
- return this.inner.exists(path);
17
- }
18
- mkdir(path: string, opts?: MkdirOptions): Promise<void> {
19
- return this.inner.mkdir(path, opts);
20
- }
21
- read(path: string): Promise<string> {
22
- return this.inner.read(path);
23
- }
24
- write(path: string, data: string): Promise<void> {
25
- this.ops.push(`write ${path}`);
26
- return this.inner.write(path, data);
27
- }
28
- readBytes(path: string): Promise<Uint8Array> {
29
- return this.inner.readBytes(path);
30
- }
31
- writeBytes(path: string, data: Uint8Array): Promise<void> {
32
- this.ops.push(`writeBytes ${path}`);
33
- return this.inner.writeBytes(path, data);
34
- }
35
- rename(from: string, to: string): Promise<void> {
36
- this.ops.push(`rename ${from} -> ${to}`);
37
- return this.inner.rename(from, to);
38
- }
39
- readdir(path: string): Promise<string[]> {
40
- return this.inner.readdir(path);
41
- }
42
- stat(path: string): Promise<StatResult> {
43
- return this.inner.stat(path);
44
- }
45
- rm(path: string, opts?: RmOptions): Promise<void> {
46
- return this.inner.rm(path, opts);
47
- }
48
- }
49
-
50
- /** Dies halfway through a write, leaving a truncated file behind. */
51
- class CrashingFs extends FakeFs {
52
- crash = false;
53
-
54
- override async write(path: string, data: string): Promise<void> {
55
- if (!this.crash) {
56
- return super.write(path, data);
57
- }
58
- await super.write(path, data.slice(0, data.length / 2));
59
- throw new Error("boom");
60
- }
61
- }
62
-
63
- describe("WorktreeService", () => {
64
- const ARBOR_DIR = "/repo/.git/arbor";
65
- let ps: FakePs;
66
- let config: Config;
67
-
68
- beforeEach(() => {
69
- ps = new FakePs();
70
- config = {
71
- trunk: "main",
72
- worktreeRoot: "/repo-arbor",
73
- postCheckout: null,
74
- postRewrite: null,
75
- preMerge: "true",
76
- postMerge: null,
77
- leaseStalenessMs: 90_000,
78
- mergeRetryCount: 2,
79
- removedCapacity: 50,
80
- logCapacity: 200,
81
- todoStalenessMs: 30 * 24 * 60 * 60 * 1000,
82
- };
83
- });
84
-
85
- const git = (fs: Fs) => new Git("/repo", { ps: ps, fs: fs });
86
-
87
- it("lands a record by rename, never by writing in place", async () => {
88
- const fs = new RecordingFs(new FakeFs());
89
- const worktrees = new WorktreeService(git(fs), config, ARBOR_DIR, {
90
- fs: fs,
91
- ps: ps,
92
- });
93
-
94
- await (await worktrees.find("alpha")).save();
95
-
96
- const path = worktrees.recordPath("alpha");
97
- expect(fs.ops).toEqual([
98
- `write ${path}.4242.tmp`,
99
- `rename ${path}.4242.tmp -> ${path}`,
100
- ]);
101
- expect((await worktrees.find("alpha")).state).toMatchObject({
102
- task: "alpha",
103
- });
104
- });
105
-
106
- it("leaves the previous record readable when a write dies partway", async () => {
107
- const fs = new CrashingFs();
108
- const worktrees = new WorktreeService(git(fs), config, ARBOR_DIR, {
109
- fs: fs,
110
- ps: ps,
111
- });
112
- const saved = await (await worktrees.find("alpha")).save({
113
- mergeAttempts: 1,
114
- });
115
-
116
- fs.crash = true;
117
- await expect(saved.save({ mergeAttempts: 2 })).rejects.toThrow("boom");
118
-
119
- // The truncated bytes went to the temp path; the record itself never moved.
120
- expect((await worktrees.find("alpha")).state).toMatchObject({
121
- mergeAttempts: 1,
122
- });
123
- });
124
-
125
- it("reports unknown, not absent, when a record will not parse", async () => {
126
- const fs = new FakeFs();
127
- ps.exit(1); // every spawn now fails, so git reports no such branch
128
- const worktrees = new WorktreeService(git(fs), config, ARBOR_DIR, {
129
- fs: fs,
130
- ps: ps,
131
- });
132
- await fs.write(worktrees.recordPath("alpha"), "{not json");
133
-
134
- expect((await worktrees.find("alpha")).status).toBe("unknown");
135
- expect((await worktrees.find("missing")).status).toBe("absent");
136
- });
137
-
138
- it("drops the oldest removed names when the memory is full", async () => {
139
- const fs = new FakeFs();
140
- config.removedCapacity = 2;
141
- const worktrees = new WorktreeService(git(fs), config, ARBOR_DIR, {
142
- fs: fs,
143
- ps: ps,
144
- });
145
- const removed = `${ARBOR_DIR}/removed`;
146
- await worktrees.init();
147
-
148
- // Seeded with distinct timestamps, since four discards in the same
149
- // millisecond would have nothing to order them by.
150
- await fs.write(`${removed}/oldest`, "2026-01-01T00:00:00.000Z\n");
151
- await fs.write(`${removed}/middle`, "2026-02-01T00:00:00.000Z\n");
152
- await fs.write(`${removed}/newest`, "2026-03-01T00:00:00.000Z\n");
153
-
154
- await worktrees.discard(await worktrees.find("alpha"));
155
-
156
- expect((await fs.readdir(removed)).sort()).toEqual(["alpha", "newest"]);
157
- });
158
- });
@@ -1,193 +0,0 @@
1
- import { resolve } from "node:path";
2
- import { type Fs, NodeFs, NodePs, type Ps } from "webappwiz/system";
3
- import type { Config } from "./config";
4
- import type { Git, GitResult } from "./git";
5
- import { type TaskState, Worktree } from "./worktree";
6
-
7
- const BRANCH_PREFIX = "task/";
8
-
9
- export interface AddOptions {
10
- /** Branch the new worktree starts from. Defaults to the trunk. */
11
- base?: string;
12
- }
13
-
14
- /**
15
- * Where tasks live and everything persistent about them: the worktree
16
- * directories, the records under `.git/arbor/tasks`, and the names of tasks
17
- * already removed. One name, one lookup, whatever state it turns out to be in.
18
- */
19
- /** What a `WorktreeService` works through; the real ones by default. */
20
- export interface WorktreeServiceOptions {
21
- fs?: Fs;
22
- ps?: Ps;
23
- }
24
-
25
- export class WorktreeService {
26
- private readonly tasksDir: string;
27
- private readonly removedDir: string;
28
- private readonly fs: Fs;
29
- readonly ps: Ps;
30
-
31
- // scry-ignore classes-over-function-exports: Git is the only way arbor speaks git and a second implementation is not coming, so there is no interface to write; tests run the real Git against a throwaway repo rather than faking twenty methods
32
- constructor(
33
- readonly git: Git,
34
- readonly config: Config,
35
- arborDir: string,
36
- opts: WorktreeServiceOptions = {},
37
- ) {
38
- this.fs = opts.fs ?? new NodeFs();
39
- this.ps = opts.ps ?? new NodePs();
40
- this.tasksDir = `${arborDir}/tasks`;
41
- this.removedDir = `${arborDir}/removed`;
42
- }
43
-
44
- async init(): Promise<void> {
45
- await this.fs.mkdir(this.tasksDir);
46
- await this.fs.mkdir(this.removedDir);
47
- }
48
-
49
- get trunk(): string {
50
- return this.config.trunk;
51
- }
52
-
53
- /** Where a task's worktree lives: a sibling of the repo, never inside it. */
54
- pathFor(task: string): string {
55
- return resolve(this.config.worktreeRoot, task);
56
- }
57
-
58
- branchFor(task: string): string {
59
- return `${BRANCH_PREFIX}${task}`;
60
- }
61
-
62
- taskFor(branch: string): string | null {
63
- return branch.startsWith(BRANCH_PREFIX)
64
- ? branch.slice(BRANCH_PREFIX.length)
65
- : null;
66
- }
67
-
68
- recordPath(task: string): string {
69
- return `${this.tasksDir}/${task}.json`;
70
- }
71
-
72
- /** Always answers; the returned worktree's status says what was found. */
73
- async find(task: string): Promise<Worktree> {
74
- let state: TaskState | null = null;
75
- let corrupt = false;
76
- try {
77
- state = await this.readRecord(task);
78
- } catch {
79
- corrupt = true; // a record that will not parse is still a record
80
- }
81
- const path = state?.worktree ?? this.pathFor(task);
82
- const exists = await this.fs.exists(path);
83
- const hasBranch = await this.git.branchExists(this.branchFor(task));
84
- return new Worktree(this, {
85
- task,
86
- branch: state?.branch ?? this.branchFor(task),
87
- path,
88
- state,
89
- exists,
90
- hasBranch,
91
- corrupt,
92
- // Only worth asking when there is nothing else left of the task.
93
- removedAt:
94
- state || exists || hasBranch || corrupt
95
- ? null
96
- : await this.removedAt(task),
97
- });
98
- }
99
-
100
- async list(): Promise<Worktree[]> {
101
- const entries = await this.fs.readdir(this.tasksDir).catch(() => []);
102
- const tasks = entries
103
- .filter((entry) => entry.endsWith(".json"))
104
- .map((entry) => entry.slice(0, -".json".length))
105
- .sort();
106
- const found: Worktree[] = [];
107
- for (const task of tasks) {
108
- found.push(await this.find(task));
109
- }
110
- return found;
111
- }
112
-
113
- /** Adds the branch and the working directory. The record comes after. */
114
- async add(
115
- task: string,
116
- { base = this.config.trunk }: AddOptions = {},
117
- ): Promise<GitResult> {
118
- await this.fs.mkdir(this.config.worktreeRoot);
119
- return this.git.addWorktree(this.branchFor(task), this.pathFor(task), base);
120
- }
121
-
122
- async discard(worktree: Worktree): Promise<GitResult> {
123
- const removed = worktree.exists
124
- ? await this.git.removeWorktree(worktree.path)
125
- : // Directory gone by other means: drop the metadata git still holds.
126
- await this.git.pruneWorktrees();
127
- if (removed.code !== 0) {
128
- return removed;
129
- }
130
- if (worktree.hasBranch) {
131
- const deleted = await this.git.deleteBranch(worktree.branch);
132
- if (deleted.code !== 0) {
133
- return deleted;
134
- }
135
- }
136
- await this.fs.rm(this.recordPath(worktree.task), { force: true });
137
- await this.rememberRemoved(worktree.task);
138
- return removed;
139
- }
140
-
141
- /** Writes a task's record. A concurrent reader sees the old one or the new. */
142
- async saveRecord(state: TaskState): Promise<void> {
143
- // A half-written record read by a concurrent agent is a real failure mode,
144
- // and rename is the only way to make the swap atomic.
145
- const path = this.recordPath(state.task);
146
- const tmp = `${path}.${this.ps.pid}.tmp`;
147
- await this.fs.write(tmp, `${JSON.stringify(state, null, "\t")}\n`);
148
- await this.fs.rename(tmp, path);
149
- }
150
-
151
- private async readRecord(task: string): Promise<TaskState | null> {
152
- const raw = await this.fs.read(this.recordPath(task)).catch(() => null);
153
- return raw === null ? null : (JSON.parse(raw) as TaskState);
154
- }
155
-
156
- private removedPath(task: string): string {
157
- return `${this.removedDir}/${task}`;
158
- }
159
-
160
- private async removedAt(task: string): Promise<string | null> {
161
- const raw = await this.fs.read(this.removedPath(task)).catch(() => null);
162
- return raw?.trim() ?? null;
163
- }
164
-
165
- /**
166
- * Remembers a removed name so a second `remove` can say "already removed"
167
- * instead of "never existed", then drops the oldest so the list of
168
- * remembered names cannot grow without bound.
169
- */
170
- private async rememberRemoved(
171
- task: string,
172
- at = new Date().toISOString(),
173
- ): Promise<void> {
174
- const { removedCapacity } = this.config;
175
- await this.fs.write(this.removedPath(task), `${at}\n`);
176
- const names = await this.fs.readdir(this.removedDir).catch(() => []);
177
- if (names.length <= removedCapacity) {
178
- return;
179
- }
180
- // Each file holds the ISO timestamp it was written with, which sorts
181
- // chronologically. An unreadable one sorts first and goes first.
182
- const dated = await Promise.all(
183
- names.map(async (name) => ({
184
- name,
185
- at: (await this.removedAt(name)) ?? "",
186
- })),
187
- );
188
- dated.sort((left, right) => left.at.localeCompare(right.at));
189
- for (const { name } of dated.slice(0, names.length - removedCapacity)) {
190
- await this.fs.rm(this.removedPath(name), { force: true });
191
- }
192
- }
193
- }