@webappwiz/arbor 0.0.30 → 0.0.31

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 (118) hide show
  1. package/add.d.ts +23 -0
  2. package/age.d.ts +2 -0
  3. package/arbor.d.ts +11 -0
  4. package/attachments.d.ts +36 -0
  5. package/claim.d.ts +11 -0
  6. package/config.d.ts +55 -0
  7. package/config.js +7 -0
  8. package/dev/assets.d.ts +11 -0
  9. package/dev.d.ts +48 -0
  10. package/escalate.d.ts +28 -0
  11. package/exit.d.ts +42 -0
  12. package/git.d.ts +77 -0
  13. package/index.d.ts +2 -0
  14. package/index.js +4712 -0
  15. package/journal.d.ts +29 -0
  16. package/list.d.ts +18 -0
  17. package/load-config.d.ts +15 -0
  18. package/log.d.ts +14 -0
  19. package/merge.d.ts +24 -0
  20. package/package.json +15 -32
  21. package/path.d.ts +13 -0
  22. package/plan.d.ts +83 -0
  23. package/remove.d.ts +19 -0
  24. package/repository.d.ts +25 -0
  25. package/retry.d.ts +17 -0
  26. package/shell.d.ts +17 -0
  27. package/show.d.ts +53 -0
  28. package/snapshot.d.ts +35 -0
  29. package/table.d.ts +5 -0
  30. package/todo.d.ts +264 -0
  31. package/wait.d.ts +22 -0
  32. package/worktree-service.d.ts +51 -0
  33. package/worktree.d.ts +95 -0
  34. package/AGENTS.md +0 -7
  35. package/add.test.ts +0 -125
  36. package/add.ts +0 -192
  37. package/age.ts +0 -11
  38. package/arbor.test.ts +0 -145
  39. package/arbor.ts +0 -429
  40. package/assets.d.ts +0 -9
  41. package/attachments.ts +0 -87
  42. package/build.ts +0 -98
  43. package/claim.test.ts +0 -89
  44. package/claim.ts +0 -73
  45. package/components.json +0 -21
  46. package/config.ts +0 -58
  47. package/dev/api.ts +0 -120
  48. package/dev/app.test.tsx +0 -645
  49. package/dev/app.tsx +0 -80
  50. package/dev/assets.ts +0 -16
  51. package/dev/build/main.txt +0 -68
  52. package/dev/build/shell.txt +0 -21
  53. package/dev/build/styles.txt +0 -2263
  54. package/dev/components/ui/badge.tsx +0 -51
  55. package/dev/components/ui/button.tsx +0 -57
  56. package/dev/components/ui/dialog.tsx +0 -154
  57. package/dev/components/ui/empty.tsx +0 -100
  58. package/dev/components/ui/input-group.tsx +0 -155
  59. package/dev/components/ui/input.tsx +0 -19
  60. package/dev/components/ui/progress.tsx +0 -80
  61. package/dev/components/ui/textarea.tsx +0 -17
  62. package/dev/components/ui/toast.tsx +0 -227
  63. package/dev/feed.ts +0 -69
  64. package/dev/files.tsx +0 -191
  65. package/dev/index.html +0 -21
  66. package/dev/lib/utils.ts +0 -7
  67. package/dev/main.tsx +0 -22
  68. package/dev/markdown.test.tsx +0 -133
  69. package/dev/markdown.tsx +0 -124
  70. package/dev/mentions.tsx +0 -319
  71. package/dev/shadcn.css +0 -649
  72. package/dev/styles.css +0 -107
  73. package/dev/tags.tsx +0 -44
  74. package/dev/tasks.tsx +0 -151
  75. package/dev/todos.tsx +0 -759
  76. package/dev.test.ts +0 -439
  77. package/dev.ts +0 -386
  78. package/e2e.test.ts +0 -256
  79. package/escalate.test.ts +0 -76
  80. package/escalate.ts +0 -112
  81. package/exit.test.ts +0 -71
  82. package/exit.ts +0 -74
  83. package/git.ts +0 -294
  84. package/index.ts +0 -9
  85. package/journal.ts +0 -89
  86. package/list.test.ts +0 -82
  87. package/list.ts +0 -137
  88. package/load-config.test.ts +0 -121
  89. package/load-config.ts +0 -62
  90. package/log.test.ts +0 -65
  91. package/log.ts +0 -40
  92. package/merge.test.ts +0 -332
  93. package/merge.ts +0 -283
  94. package/path.test.ts +0 -35
  95. package/path.ts +0 -38
  96. package/plan.test.ts +0 -318
  97. package/plan.ts +0 -302
  98. package/progress.test.ts +0 -54
  99. package/progress.ts +0 -31
  100. package/remove.test.ts +0 -68
  101. package/remove.ts +0 -88
  102. package/repository.ts +0 -83
  103. package/retry.test.ts +0 -43
  104. package/retry.ts +0 -48
  105. package/shell.ts +0 -44
  106. package/show.test.ts +0 -91
  107. package/show.ts +0 -151
  108. package/snapshot.test.ts +0 -33
  109. package/snapshot.ts +0 -84
  110. package/table.ts +0 -20
  111. package/testing.ts +0 -229
  112. package/todo.test.ts +0 -490
  113. package/todo.ts +0 -834
  114. package/wait.test.ts +0 -61
  115. package/wait.ts +0 -77
  116. package/worktree-service.test.ts +0 -158
  117. package/worktree-service.ts +0 -193
  118. 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
- }