@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.
- package/add.d.ts +23 -0
- package/age.d.ts +2 -0
- package/arbor.d.ts +11 -0
- package/attachments.d.ts +36 -0
- package/claim.d.ts +11 -0
- package/config.d.ts +55 -0
- package/config.js +7 -0
- package/dev/assets.d.ts +11 -0
- package/dev.d.ts +48 -0
- package/escalate.d.ts +28 -0
- package/exit.d.ts +42 -0
- package/git.d.ts +77 -0
- package/index.d.ts +2 -0
- package/index.js +4712 -0
- package/journal.d.ts +29 -0
- package/list.d.ts +18 -0
- package/load-config.d.ts +15 -0
- package/log.d.ts +14 -0
- package/merge.d.ts +24 -0
- package/package.json +15 -32
- package/path.d.ts +13 -0
- package/plan.d.ts +83 -0
- package/remove.d.ts +19 -0
- package/repository.d.ts +25 -0
- package/retry.d.ts +17 -0
- package/shell.d.ts +17 -0
- package/show.d.ts +53 -0
- package/snapshot.d.ts +35 -0
- package/table.d.ts +5 -0
- package/todo.d.ts +264 -0
- package/wait.d.ts +22 -0
- package/worktree-service.d.ts +51 -0
- package/worktree.d.ts +95 -0
- package/AGENTS.md +0 -7
- package/add.test.ts +0 -125
- package/add.ts +0 -192
- package/age.ts +0 -11
- package/arbor.test.ts +0 -145
- package/arbor.ts +0 -429
- package/assets.d.ts +0 -9
- package/attachments.ts +0 -87
- package/build.ts +0 -98
- package/claim.test.ts +0 -89
- package/claim.ts +0 -73
- package/components.json +0 -21
- package/config.ts +0 -58
- package/dev/api.ts +0 -120
- package/dev/app.test.tsx +0 -645
- package/dev/app.tsx +0 -80
- package/dev/assets.ts +0 -16
- package/dev/build/main.txt +0 -68
- package/dev/build/shell.txt +0 -21
- package/dev/build/styles.txt +0 -2263
- package/dev/components/ui/badge.tsx +0 -51
- package/dev/components/ui/button.tsx +0 -57
- package/dev/components/ui/dialog.tsx +0 -154
- package/dev/components/ui/empty.tsx +0 -100
- package/dev/components/ui/input-group.tsx +0 -155
- package/dev/components/ui/input.tsx +0 -19
- package/dev/components/ui/progress.tsx +0 -80
- package/dev/components/ui/textarea.tsx +0 -17
- package/dev/components/ui/toast.tsx +0 -227
- package/dev/feed.ts +0 -69
- package/dev/files.tsx +0 -191
- package/dev/index.html +0 -21
- package/dev/lib/utils.ts +0 -7
- package/dev/main.tsx +0 -22
- package/dev/markdown.test.tsx +0 -133
- package/dev/markdown.tsx +0 -124
- package/dev/mentions.tsx +0 -319
- package/dev/shadcn.css +0 -649
- package/dev/styles.css +0 -107
- package/dev/tags.tsx +0 -44
- package/dev/tasks.tsx +0 -151
- package/dev/todos.tsx +0 -759
- package/dev.test.ts +0 -439
- package/dev.ts +0 -386
- package/e2e.test.ts +0 -256
- package/escalate.test.ts +0 -76
- package/escalate.ts +0 -112
- package/exit.test.ts +0 -71
- package/exit.ts +0 -74
- package/git.ts +0 -294
- package/index.ts +0 -9
- package/journal.ts +0 -89
- package/list.test.ts +0 -82
- package/list.ts +0 -137
- package/load-config.test.ts +0 -121
- package/load-config.ts +0 -62
- package/log.test.ts +0 -65
- package/log.ts +0 -40
- package/merge.test.ts +0 -332
- package/merge.ts +0 -283
- package/path.test.ts +0 -35
- package/path.ts +0 -38
- package/plan.test.ts +0 -318
- package/plan.ts +0 -302
- package/progress.test.ts +0 -54
- package/progress.ts +0 -31
- package/remove.test.ts +0 -68
- package/remove.ts +0 -88
- package/repository.ts +0 -83
- package/retry.test.ts +0 -43
- package/retry.ts +0 -48
- package/shell.ts +0 -44
- package/show.test.ts +0 -91
- package/show.ts +0 -151
- package/snapshot.test.ts +0 -33
- package/snapshot.ts +0 -84
- package/table.ts +0 -20
- package/testing.ts +0 -229
- package/todo.test.ts +0 -490
- package/todo.ts +0 -834
- package/wait.test.ts +0 -61
- package/wait.ts +0 -77
- package/worktree-service.test.ts +0 -158
- package/worktree-service.ts +0 -193
- package/worktree.ts +0 -239
package/snapshot.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
2
|
-
import type { Fs } from "webappwiz/system";
|
|
3
|
-
import { type Details, TaskDetails } from "./show";
|
|
4
|
-
import type { TodoState, Todos } from "./todo";
|
|
5
|
-
import type { WorktreeService } from "./worktree-service";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Everything one page shows: `todo list`, and `list` and `show` for each
|
|
9
|
-
* task.
|
|
10
|
-
*/
|
|
11
|
-
export interface Snapshot {
|
|
12
|
-
/** The repository's directory name, so a page among many says whose it is. */
|
|
13
|
-
repo: string;
|
|
14
|
-
/** Where the repository sits, with the home directory as `~`. */
|
|
15
|
-
path: string;
|
|
16
|
-
/** Past this age a todo is offered for removal rather than recommended. */
|
|
17
|
-
todoStalenessMs: number;
|
|
18
|
-
todos: TodoState[];
|
|
19
|
-
tasks: Details[];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Reads the whole repo the way the CLI's read commands do. Takes no lease, so
|
|
24
|
-
* the page can sit open on a live tree without disturbing the agent in it.
|
|
25
|
-
*/
|
|
26
|
-
export async function snapshot({
|
|
27
|
-
service,
|
|
28
|
-
todos,
|
|
29
|
-
fs,
|
|
30
|
-
home,
|
|
31
|
-
}: {
|
|
32
|
-
service: WorktreeService;
|
|
33
|
-
todos: Todos;
|
|
34
|
-
fs: Fs;
|
|
35
|
-
/** The home directory to show as `~`, when there is one. */
|
|
36
|
-
home?: string;
|
|
37
|
-
}): Promise<Snapshot> {
|
|
38
|
-
const details = new TaskDetails({ fs });
|
|
39
|
-
const tasks: Details[] = [];
|
|
40
|
-
for (const worktree of await service.list()) {
|
|
41
|
-
tasks.push(await details.get(worktree));
|
|
42
|
-
}
|
|
43
|
-
return {
|
|
44
|
-
repo: basename(service.git.root),
|
|
45
|
-
path: tilde(service.git.root, home),
|
|
46
|
-
todoStalenessMs: service.config.todoStalenessMs,
|
|
47
|
-
todos: (await todos.all()).map((todo) => todo.state),
|
|
48
|
-
tasks,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* What the page is actually showing, minus the fields that move on their own.
|
|
54
|
-
* `age` ticks every minute, and hashing it would push to every open page for
|
|
55
|
-
* nothing.
|
|
56
|
-
*/
|
|
57
|
-
export function fingerprint({ todos, tasks }: Snapshot): string {
|
|
58
|
-
return JSON.stringify([
|
|
59
|
-
todos,
|
|
60
|
-
tasks.map((task) => [
|
|
61
|
-
task.task,
|
|
62
|
-
task.status,
|
|
63
|
-
task.lease,
|
|
64
|
-
task.ahead,
|
|
65
|
-
task.added,
|
|
66
|
-
task.removed,
|
|
67
|
-
task.escalation,
|
|
68
|
-
task.review,
|
|
69
|
-
task.plan,
|
|
70
|
-
task.planProblems,
|
|
71
|
-
]),
|
|
72
|
-
]);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** `root` with a leading `home` written as `~`, the way a shell prompt shows it. */
|
|
76
|
-
function tilde(root: string, home: string | undefined): string {
|
|
77
|
-
if (!home) {
|
|
78
|
-
return root;
|
|
79
|
-
}
|
|
80
|
-
if (root === home) {
|
|
81
|
-
return "~";
|
|
82
|
-
}
|
|
83
|
-
return root.startsWith(`${home}/`) ? `~${root.slice(home.length)}` : root;
|
|
84
|
-
}
|
package/table.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { color } from "webappwiz/log";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Columns padded to their widest cell. Padding goes by visible width, not
|
|
5
|
-
* string length: cells carry color codes.
|
|
6
|
-
*/
|
|
7
|
-
export function table(header: string[], rows: string[][]): string {
|
|
8
|
-
const width = (cell: string): number => color.strip(cell).length;
|
|
9
|
-
const widths = header.map((heading, i) =>
|
|
10
|
-
Math.max(heading.length, ...rows.map((row) => width(row[i] ?? ""))),
|
|
11
|
-
);
|
|
12
|
-
const line = (cells: string[]): string =>
|
|
13
|
-
cells
|
|
14
|
-
.map((cell, i) =>
|
|
15
|
-
cell.padEnd((widths[i] ?? 0) + cell.length - width(cell)),
|
|
16
|
-
)
|
|
17
|
-
.join(" ")
|
|
18
|
-
.trimEnd();
|
|
19
|
-
return [color.dim(line(header)), ...rows.map(line)].join("\n");
|
|
20
|
-
}
|
package/testing.ts
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
import { expect } from "bun:test";
|
|
2
|
-
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { CounterIdProvider } from "webappwiz/id";
|
|
6
|
-
import { color, MemoryLogger } from "webappwiz/log";
|
|
7
|
-
import { FileLock, NodeFs, NodePs } from "webappwiz/system";
|
|
8
|
-
import { FakeProcess } from "webappwiz/system/testing";
|
|
9
|
-
import type { Config } from "./config";
|
|
10
|
-
import { assets } from "./dev/assets";
|
|
11
|
-
import { Exit, type Reason } from "./exit";
|
|
12
|
-
import { Git } from "./git";
|
|
13
|
-
import { Journal } from "./journal";
|
|
14
|
-
import { Shell } from "./shell";
|
|
15
|
-
import { Todos } from "./todo";
|
|
16
|
-
import { WorktreeService } from "./worktree-service";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* A throwaway git repo with one commit on `main`, holding every dependency an
|
|
20
|
-
* arbor command runs against, wired to it. A command takes the whole thing as
|
|
21
|
-
* its deps: `await add(deps, "alpha")`.
|
|
22
|
-
*
|
|
23
|
-
* It coordinates the dependencies and nothing else. What a test is about, the
|
|
24
|
-
* tasks, the commits, the config it needs to differ, the test does itself.
|
|
25
|
-
*/
|
|
26
|
-
export class Testing implements AsyncDisposable {
|
|
27
|
-
readonly root: string;
|
|
28
|
-
readonly arborDir: string;
|
|
29
|
-
readonly fs = new NodeFs();
|
|
30
|
-
/** Spawns for real; its exits and signals are the fake process's to keep. */
|
|
31
|
-
readonly proc = new FakeProcess();
|
|
32
|
-
readonly ps: NodePs;
|
|
33
|
-
readonly log = new MemoryLogger();
|
|
34
|
-
/** Stored file names count up from 0, so a test can name them. */
|
|
35
|
-
readonly ids = new CounterIdProvider();
|
|
36
|
-
// The real ones, which publish: `dev` is tested by serving a page and
|
|
37
|
-
// fetching it, and asserting on what comes back is the point.
|
|
38
|
-
readonly assets = assets;
|
|
39
|
-
/** Arbitrary but valid settings; a test changes what it cares about. */
|
|
40
|
-
readonly config: Config;
|
|
41
|
-
readonly git: Git;
|
|
42
|
-
readonly service: WorktreeService;
|
|
43
|
-
readonly shell: Shell;
|
|
44
|
-
readonly journal: Journal;
|
|
45
|
-
readonly lockPath: string;
|
|
46
|
-
readonly lock: FileLock;
|
|
47
|
-
readonly todos: Todos;
|
|
48
|
-
|
|
49
|
-
private constructor(private readonly base: string) {
|
|
50
|
-
this.root = join(base, "repo");
|
|
51
|
-
this.arborDir = join(this.root, ".git", "arbor");
|
|
52
|
-
this.ps = new NodePs({ proc: this.proc });
|
|
53
|
-
this.config = {
|
|
54
|
-
trunk: "main",
|
|
55
|
-
worktreeRoot: `${this.root}-arbor`,
|
|
56
|
-
postCheckout: null,
|
|
57
|
-
postRewrite: null,
|
|
58
|
-
// Keeps merge's gate green unless a test asks otherwise.
|
|
59
|
-
preMerge: "true",
|
|
60
|
-
postMerge: null,
|
|
61
|
-
leaseStalenessMs: 90_000,
|
|
62
|
-
removedCapacity: 50,
|
|
63
|
-
mergeRetryCount: 2,
|
|
64
|
-
logCapacity: 200,
|
|
65
|
-
todoStalenessMs: 30 * 24 * 60 * 60 * 1000,
|
|
66
|
-
};
|
|
67
|
-
this.git = new Git(this.root, { ps: this.ps, fs: this.fs });
|
|
68
|
-
this.service = new WorktreeService(this.git, this.config, this.arborDir, {
|
|
69
|
-
fs: this.fs,
|
|
70
|
-
ps: this.ps,
|
|
71
|
-
});
|
|
72
|
-
this.shell = new Shell({ ps: this.ps });
|
|
73
|
-
this.journal = new Journal(
|
|
74
|
-
join(this.arborDir, "log.jsonl"),
|
|
75
|
-
this.config.logCapacity,
|
|
76
|
-
{ fs: this.fs },
|
|
77
|
-
);
|
|
78
|
-
this.lockPath = join(this.arborDir, "merge.lock");
|
|
79
|
-
this.lock = new FileLock(this.lockPath, {
|
|
80
|
-
fs: this.fs,
|
|
81
|
-
ps: this.ps,
|
|
82
|
-
log: this.log,
|
|
83
|
-
stalenessMs: this.config.leaseStalenessMs,
|
|
84
|
-
});
|
|
85
|
-
this.todos = new Todos(
|
|
86
|
-
join(this.arborDir, "todos"),
|
|
87
|
-
new FileLock(join(this.arborDir, "todos.lock"), {
|
|
88
|
-
fs: this.fs,
|
|
89
|
-
ps: this.ps,
|
|
90
|
-
log: this.log,
|
|
91
|
-
}),
|
|
92
|
-
{ fs: this.fs, ids: this.ids },
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** A repo of its own, seeded and ready for a command. */
|
|
97
|
-
static async open(): Promise<Testing> {
|
|
98
|
-
const base = await realpath(await mkdtemp(join(tmpdir(), "arbor-")));
|
|
99
|
-
const testing = new Testing(base);
|
|
100
|
-
await testing.seed();
|
|
101
|
-
return testing;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Runs git in `cwd`, named apart from the `git` a command is handed. */
|
|
105
|
-
async gitCli(cwd: string, ...args: string[]): Promise<string> {
|
|
106
|
-
const { exitCode, stdout, stderr } = await this.ps.spawnCapture([
|
|
107
|
-
"git",
|
|
108
|
-
"-C",
|
|
109
|
-
cwd,
|
|
110
|
-
...args,
|
|
111
|
-
]);
|
|
112
|
-
if (exitCode !== 0) {
|
|
113
|
-
throw new Error(`git ${args.join(" ")}: ${stderr}`);
|
|
114
|
-
}
|
|
115
|
-
return stdout.trim();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Writes a file in `cwd` and commits it. */
|
|
119
|
-
async commit(
|
|
120
|
-
cwd: string,
|
|
121
|
-
file: string,
|
|
122
|
-
content: string,
|
|
123
|
-
message: string,
|
|
124
|
-
): Promise<void> {
|
|
125
|
-
await this.fs.write(join(cwd, file), content);
|
|
126
|
-
await this.gitCli(cwd, "add", file);
|
|
127
|
-
await this.gitCli(cwd, "commit", "-m", message);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Everything logged so far, one line each, without colour. */
|
|
131
|
-
out(): string {
|
|
132
|
-
return color.strip(
|
|
133
|
-
this.log.entries.map((entry) => String(entry.message)).join("\n"),
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Deletes the repo, its worktrees and the temporary directory holding them. */
|
|
138
|
-
disposeAsync(): Promise<void> {
|
|
139
|
-
return rm(this.base, { recursive: true, force: true });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// so a concurrent test can own its repo with `await using`, instead of
|
|
143
|
-
// afterEach handing every test in the file the same one
|
|
144
|
-
[Symbol.asyncDispose](): Promise<void> {
|
|
145
|
-
return this.disposeAsync();
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
private async seed(): Promise<void> {
|
|
149
|
-
await this.fs.mkdir(this.root);
|
|
150
|
-
await this.gitCli(this.root, "init", "-b", "main");
|
|
151
|
-
await this.gitCli(this.root, "config", "user.email", "arbor@example.com");
|
|
152
|
-
await this.gitCli(this.root, "config", "user.name", "arbor");
|
|
153
|
-
await this.gitCli(this.root, "config", "commit.gpgsign", "false");
|
|
154
|
-
await this.commit(this.root, "README.md", "seed\n", "seed");
|
|
155
|
-
await this.service.init();
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** pid 1 always exists, so it stands in for another agent that is still running. */
|
|
160
|
-
export const LIVE_PID = 1;
|
|
161
|
-
|
|
162
|
-
/** What a refusal carries beyond its reason, when a test says. */
|
|
163
|
-
export interface BailOptions {
|
|
164
|
-
/** A fragment of the message the person reading it gets, or every one. */
|
|
165
|
-
message?: string | string[];
|
|
166
|
-
/** Everything the refusal reports alongside the reason. */
|
|
167
|
-
data?: Record<string, unknown>;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
declare module "bun:test" {
|
|
171
|
-
interface Matchers<T> {
|
|
172
|
-
/** Asserts the work refuses: `await expect(add(t, "x")).toBail("usage")`. */
|
|
173
|
-
toBail(reason: Reason, expected?: BailOptions): Promise<void>;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Registered here because every test file that asserts a refusal already
|
|
178
|
-
// imports `Testing`, and `expect.extend` has to have run before the first one.
|
|
179
|
-
expect.extend({
|
|
180
|
-
async toBail(work, reason, expected = {}) {
|
|
181
|
-
const exit = await refusal(work);
|
|
182
|
-
if (exit === null) {
|
|
183
|
-
return {
|
|
184
|
-
pass: false,
|
|
185
|
-
message: () => `expected a ${reason} refusal, but it succeeded`,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
if (exit.reason !== reason) {
|
|
189
|
-
return {
|
|
190
|
-
pass: false,
|
|
191
|
-
message: () =>
|
|
192
|
-
`expected a ${reason} refusal, got ${exit.reason}: ${exit.message}`,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
const fragments = [expected.message ?? []].flat();
|
|
196
|
-
const missing = fragments.filter((text) => !exit.message.includes(text));
|
|
197
|
-
if (missing.length > 0) {
|
|
198
|
-
return {
|
|
199
|
-
pass: false,
|
|
200
|
-
message: () =>
|
|
201
|
-
`expected the ${reason} message to contain ${this.utils.printExpected(missing)}, got ${this.utils.printReceived(exit.message)}`,
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
if (expected.data !== undefined && !this.equals(exit.data, expected.data)) {
|
|
205
|
-
return {
|
|
206
|
-
pass: false,
|
|
207
|
-
message: () =>
|
|
208
|
-
`expected the ${reason} data to be ${this.utils.printExpected(expected.data)}, got ${this.utils.printReceived(exit.data)}`,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
return { pass: true, message: () => `expected no ${reason} refusal` };
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* The `Exit` some work refused with, or null when it did not refuse. Anything
|
|
217
|
-
* else it threw is a real failure and is left to blow up the test as itself.
|
|
218
|
-
*/
|
|
219
|
-
async function refusal(work: unknown): Promise<Exit | null> {
|
|
220
|
-
try {
|
|
221
|
-
await work;
|
|
222
|
-
return null;
|
|
223
|
-
} catch (entry) {
|
|
224
|
-
if (entry instanceof Exit) {
|
|
225
|
-
return entry;
|
|
226
|
-
}
|
|
227
|
-
throw entry;
|
|
228
|
-
}
|
|
229
|
-
}
|