@webappwiz/arbor 0.0.6 → 0.0.8
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/README.md +18 -0
- package/dev.d.ts +16 -4
- package/exit.d.ts +1 -0
- package/index.js +69 -8
- package/package.json +2 -2
- package/wait.d.ts +22 -0
package/README.md
CHANGED
|
@@ -131,6 +131,23 @@ prescribes (`# <task>`, `## Goal`, `## Next` with something unchecked in it, a
|
|
|
131
131
|
Warnings only, never a refusal: the agent that wrote the file is the one that
|
|
132
132
|
runs `show` on it, and a rough plan still beats none.
|
|
133
133
|
|
|
134
|
+
### `arbor wait <task> [--timeout-secs 300]`
|
|
135
|
+
|
|
136
|
+
Blocks until a task stops moving, then prints where it stopped.
|
|
137
|
+
|
|
138
|
+
Moving means `working` or `merging`. Everything else is somewhere it stays
|
|
139
|
+
without a person: `escalated` (printed with its reason), `removed` (merged or
|
|
140
|
+
discarded, and `arbor log` says which), or one of the broken statuses.
|
|
141
|
+
|
|
142
|
+
This is for the agent whose own work overlaps a task already in flight and
|
|
143
|
+
would rather rebase onto its result than against it. The timeout is short by
|
|
144
|
+
design: five minutes, and then a `timeout` refusal that hands the decision
|
|
145
|
+
back, rather than a session that blocks all afternoon on a tree nobody is
|
|
146
|
+
driving. Wait again, work alongside it, or ask the human.
|
|
147
|
+
|
|
148
|
+
Like `show` and `path`, it takes no lease, so watching a task cannot knock its
|
|
149
|
+
agent off it.
|
|
150
|
+
|
|
134
151
|
### `arbor log [--count 20] [--json]`
|
|
135
152
|
|
|
136
153
|
The last N things done here (`add`, `claim`, `merge`, `rm`, `escalate`,
|
|
@@ -221,6 +238,7 @@ The agent's control flow runs on these.
|
|
|
221
238
|
| 11 | `orphaned` | Record with no worktree. `arbor rm` it. |
|
|
222
239
|
| 12 | `merge_failed` | Trunk could not be fast-forwarded (usually a dirty main worktree). |
|
|
223
240
|
| 13 | `already_removed` | This task was removed earlier; nothing left to remove. |
|
|
241
|
+
| 14 | `timeout` | `arbor wait` gave up: the task is still working or merging. |
|
|
224
242
|
|
|
225
243
|
Every failure prints a one-line JSON object on **stdout** (`{"reason": ...}`,
|
|
226
244
|
plus fields like `paths` for conflicts) and the human explanation on **stderr**.
|
package/dev.d.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import type { HttpServer } from "webappwiz/http";
|
|
2
2
|
import type { Logger } from "webappwiz/log";
|
|
3
|
-
import type
|
|
3
|
+
import { type Fs, type PortProvider } from "webappwiz/system";
|
|
4
4
|
import type { Assets } from "./dev/assets.js";
|
|
5
5
|
import type { Journal } from "./journal.js";
|
|
6
6
|
import type { WorktreeService } from "./worktree-service.js";
|
|
7
|
+
/** Preferred, not required: `dev` moves up from here when it is taken. */
|
|
7
8
|
export declare const DEFAULT_PORT = 4269;
|
|
9
|
+
/** How far above the port asked for `dev` will look before giving up. */
|
|
10
|
+
export declare const PORT_SPAN = 20;
|
|
11
|
+
/**
|
|
12
|
+
* Where `dev` will listen, given the port asked for. A flag is outside input,
|
|
13
|
+
* so a port that cannot exist is a refusal with an exit code rather than the
|
|
14
|
+
* assertion `OpenPortProvider` would raise for a bug in here.
|
|
15
|
+
*/
|
|
16
|
+
export declare function devPorts(from: number): PortProvider;
|
|
17
|
+
/** What `dev` lets a caller choose. */
|
|
18
|
+
export interface DevOptions {
|
|
19
|
+
/** Where to listen; the port `--port` asked for, and the span above it. */
|
|
20
|
+
ports?: PortProvider;
|
|
21
|
+
}
|
|
8
22
|
/** A running server, and the one thing a caller ever wants to do with it. */
|
|
9
23
|
export interface DevServer {
|
|
10
24
|
port: number;
|
|
@@ -26,6 +40,4 @@ export declare function dev({ service, fs, journal, log, http, assets, }: {
|
|
|
26
40
|
log: Logger;
|
|
27
41
|
http: HttpServer;
|
|
28
42
|
assets: Assets;
|
|
29
|
-
}, {
|
|
30
|
-
port?: number | undefined;
|
|
31
|
-
}): Promise<DevServer>;
|
|
43
|
+
}, { ports }?: DevOptions): Promise<DevServer>;
|
package/exit.d.ts
CHANGED
package/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { NodeFs as NodeFs7 } from "webappwiz/system";
|
|
|
8
8
|
// arbor.ts
|
|
9
9
|
import { cli } from "webappwiz/cmd";
|
|
10
10
|
import { t } from "webappwiz/t";
|
|
11
|
+
import { Duration as Duration3 } from "webappwiz/time";
|
|
11
12
|
|
|
12
13
|
// add.ts
|
|
13
14
|
import { color } from "webappwiz/log";
|
|
@@ -27,7 +28,8 @@ var EXIT = {
|
|
|
27
28
|
exists: 10,
|
|
28
29
|
orphaned: 11,
|
|
29
30
|
merge_failed: 12,
|
|
30
|
-
already_removed: 13
|
|
31
|
+
already_removed: 13,
|
|
32
|
+
timeout: 14
|
|
31
33
|
};
|
|
32
34
|
|
|
33
35
|
class Exit extends Error {
|
|
@@ -60,7 +62,7 @@ function exits() {
|
|
|
60
62
|
// add.ts
|
|
61
63
|
var NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
62
64
|
var PLAN_FILE = "ARBOR.md";
|
|
63
|
-
var PLAN = (task) => new MarkdownWriter().heading(1, task).heading(2, "Goal").heading(2, "Next").checklist("fill in Goal and list the steps here as `- [ ]` items").toString();
|
|
65
|
+
var PLAN = (task) => new MarkdownWriter().heading(1, task).heading(2, "Goal").heading(2, "Files").heading(2, "Next").checklist("fill in Goal and list the steps here as `- [ ]` items").toString();
|
|
64
66
|
async function add({
|
|
65
67
|
service,
|
|
66
68
|
shell,
|
|
@@ -159,6 +161,10 @@ ${changes.map((change) => ` ${change}`).join(`
|
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
// dev.ts
|
|
164
|
+
import {
|
|
165
|
+
MAX_PORT,
|
|
166
|
+
OpenPortProvider
|
|
167
|
+
} from "webappwiz/system";
|
|
162
168
|
import { Duration } from "webappwiz/time";
|
|
163
169
|
|
|
164
170
|
// snapshot.ts
|
|
@@ -218,8 +224,8 @@ import { NodeFs } from "webappwiz/system";
|
|
|
218
224
|
|
|
219
225
|
// plan.ts
|
|
220
226
|
import { Markdown } from "webappwiz/md";
|
|
221
|
-
var SECTIONS = ["Goal", "Done", "Next", "Notes", "Blocked"];
|
|
222
|
-
var REQUIRED = ["Goal", "Next"];
|
|
227
|
+
var SECTIONS = ["Goal", "Files", "Done", "Next", "Notes", "Blocked"];
|
|
228
|
+
var REQUIRED = ["Goal", "Files", "Next"];
|
|
223
229
|
var UNCHECKED = /^[ \t]*- \[ \]/m;
|
|
224
230
|
function checkPlan(text, { task, escalated = false }) {
|
|
225
231
|
const md = Markdown.parse(text);
|
|
@@ -236,6 +242,9 @@ function checkPlan(text, { task, escalated = false }) {
|
|
|
236
242
|
if (section("Goal") === "") {
|
|
237
243
|
problems.push("## Goal is empty: say in a line or two what done means");
|
|
238
244
|
}
|
|
245
|
+
if (section("Files") === "") {
|
|
246
|
+
problems.push("## Files is empty: list the file paths you plan to touch");
|
|
247
|
+
}
|
|
239
248
|
const next = section("Next");
|
|
240
249
|
if (next !== null && !UNCHECKED.test(next)) {
|
|
241
250
|
problems.push("## Next has no `- [ ]` item: say what the next step is");
|
|
@@ -358,6 +367,15 @@ function fingerprint({ tasks, entries }) {
|
|
|
358
367
|
|
|
359
368
|
// dev.ts
|
|
360
369
|
var DEFAULT_PORT = 4269;
|
|
370
|
+
var PORT_SPAN = 20;
|
|
371
|
+
function devPorts(from) {
|
|
372
|
+
if (!Number.isInteger(from) || from < 0 || from > MAX_PORT) {
|
|
373
|
+
fail("usage", `invalid port '${from}': use a whole number 0 to ${MAX_PORT}`, {
|
|
374
|
+
port: from
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return OpenPortProvider.span({ from, span: PORT_SPAN });
|
|
378
|
+
}
|
|
361
379
|
var POLL_MS = 2000;
|
|
362
380
|
async function dev({
|
|
363
381
|
service,
|
|
@@ -366,7 +384,7 @@ async function dev({
|
|
|
366
384
|
log: log2,
|
|
367
385
|
http,
|
|
368
386
|
assets
|
|
369
|
-
}, {
|
|
387
|
+
}, { ports = devPorts(DEFAULT_PORT) } = {}) {
|
|
370
388
|
const open = new Set;
|
|
371
389
|
const encoder = new TextEncoder;
|
|
372
390
|
let last = fingerprint(await snapshot(service, journal, { fs }));
|
|
@@ -428,7 +446,7 @@ async function dev({
|
|
|
428
446
|
default:
|
|
429
447
|
return new Response("not found", { status: 404 });
|
|
430
448
|
}
|
|
431
|
-
}, { port, idleTimeout: Duration.zero() });
|
|
449
|
+
}, { port: await ports.get(), idleTimeout: Duration.zero() });
|
|
432
450
|
log2.info(`arbor dev on http://localhost:${listening.port}`);
|
|
433
451
|
return {
|
|
434
452
|
port: listening.port,
|
|
@@ -1255,6 +1273,43 @@ async function rm({ service, log: log2 }, task, { force = false } = {}) {
|
|
|
1255
1273
|
`));
|
|
1256
1274
|
}
|
|
1257
1275
|
|
|
1276
|
+
// wait.ts
|
|
1277
|
+
import { color as color11 } from "webappwiz/log";
|
|
1278
|
+
import { Duration as Duration2, sleep } from "webappwiz/time";
|
|
1279
|
+
var DEFAULT_TIMEOUT = Duration2.mins(5);
|
|
1280
|
+
var POLL = Duration2.secs(2);
|
|
1281
|
+
var RUNNING = ["working", "merging"];
|
|
1282
|
+
async function wait({ service, log: log2 }, task, { timeout = DEFAULT_TIMEOUT, poll = POLL } = {}) {
|
|
1283
|
+
const deadline = Date.now() + timeout.ms;
|
|
1284
|
+
for (;; ) {
|
|
1285
|
+
const worktree = await service.find(task);
|
|
1286
|
+
if (worktree.status === "absent") {
|
|
1287
|
+
fail("not_found", `no task '${task}': run \`arbor ls\` to see what there is`, { task });
|
|
1288
|
+
}
|
|
1289
|
+
if (!RUNNING.includes(worktree.status)) {
|
|
1290
|
+
log2.info(report2(worktree));
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const left = deadline - Date.now();
|
|
1294
|
+
if (left <= 0) {
|
|
1295
|
+
fail("timeout", `'${task}' is still ${worktree.status} after ${timeout.secs}s: wait again, work alongside it and accept the rebase, or ask the human`, { task, status: worktree.status });
|
|
1296
|
+
}
|
|
1297
|
+
await sleep(Duration2.min(poll, Duration2.ms(left)));
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
function report2(worktree) {
|
|
1301
|
+
const lines2 = [`${color11.bold(worktree.task)} ${worktree.status}`];
|
|
1302
|
+
const escalation = worktree.state?.escalations?.at(-1)?.reason;
|
|
1303
|
+
if (escalation) {
|
|
1304
|
+
lines2.push(` ${color11.yellow(`escalated: ${escalation}`)}`);
|
|
1305
|
+
}
|
|
1306
|
+
if (worktree.status === "removed") {
|
|
1307
|
+
lines2.push("", `Nothing left of it here: \`arbor log\` says whether it landed.`);
|
|
1308
|
+
}
|
|
1309
|
+
return lines2.join(`
|
|
1310
|
+
`);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1258
1313
|
// arbor.ts
|
|
1259
1314
|
var here = async ({
|
|
1260
1315
|
service,
|
|
@@ -1274,14 +1329,20 @@ arbor.command("rm").description("discard a task: worktree, branch and state file
|
|
|
1274
1329
|
}).action((opts, ctx) => ctx.journal.record("rm", opts.task, () => rm(ctx, opts.task, { force: opts.force })));
|
|
1275
1330
|
arbor.command("ls").description("list every task: name, status, lease (held: an agent is on it now; stale: gone quiet, normal for a task mid-edit; none), commits ahead of trunk, age").option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => ls(ctx, { json: opts.json }));
|
|
1276
1331
|
arbor.command("show").description("read one task without touching it: everything `ls` lists for it, plus the ARBOR.md its agent left at the worktree root; takes no lease, so it cannot knock that agent off its own tree").arg("task", t.string(), { description: "task name" }).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
|
|
1332
|
+
arbor.command("wait").description("block until a task stops moving: escalated, or gone (merged or removed), or broken; takes no lease, and gives up with `timeout` rather than waiting forever").arg("task", t.string(), { description: "task name" }).option("timeout-secs", t.number(), {
|
|
1333
|
+
default: DEFAULT_TIMEOUT.secs,
|
|
1334
|
+
description: "how long to wait before giving up"
|
|
1335
|
+
}).action((opts, ctx) => wait(ctx, opts.task, {
|
|
1336
|
+
timeout: Duration3.secs(opts["timeout-secs"])
|
|
1337
|
+
}));
|
|
1277
1338
|
arbor.command("log").description("show what has been done here recently: one line per add, claim, merge, rm, escalate and retry, with how it ended; outlives the tasks themselves").option("count", t.number(), {
|
|
1278
1339
|
default: DEFAULT_COUNT,
|
|
1279
1340
|
description: "how many entries to show"
|
|
1280
1341
|
}).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => log(ctx, { count: opts.count, json: opts.json }));
|
|
1281
1342
|
arbor.command("dev").description("serve `ls`, `show` and `log` as a web page; read-only").option("port", t.number(), {
|
|
1282
1343
|
default: DEFAULT_PORT,
|
|
1283
|
-
description: "port to listen on"
|
|
1284
|
-
}).action((opts, ctx) => dev(ctx, {
|
|
1344
|
+
description: "port to listen on, or the next open one above it"
|
|
1345
|
+
}).action((opts, ctx) => dev(ctx, { ports: devPorts(opts.port) }));
|
|
1285
1346
|
arbor.command("path").description("print a task's worktree path, or the main tree with no task; names another agent's tree without taking its lease").arg("task", t.string(), {
|
|
1286
1347
|
default: "",
|
|
1287
1348
|
description: "task name; omit for the main tree"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webappwiz/arbor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "Runs several AI coding agents on one repository at once, each in its own git worktree",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jared Johnson",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"webappwiz": "^0.0.
|
|
20
|
+
"webappwiz": "^0.0.8"
|
|
21
21
|
},
|
|
22
22
|
"main": "./index.js",
|
|
23
23
|
"types": "./index.d.ts",
|
package/wait.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Logger } from "webappwiz/log";
|
|
2
|
+
import { Duration } from "webappwiz/time";
|
|
3
|
+
import type { WorktreeService } from "./worktree-service.js";
|
|
4
|
+
/** How long `wait` gives a task before handing the wait back to its caller. */
|
|
5
|
+
export declare const DEFAULT_TIMEOUT: Duration;
|
|
6
|
+
export interface WaitOptions {
|
|
7
|
+
/** How long to wait before giving up. */
|
|
8
|
+
timeout?: Duration;
|
|
9
|
+
/** How long between reads of the record; the default suits a human's patience. */
|
|
10
|
+
poll?: Duration;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Blocks until a task stops moving: merged or removed (both leave the name
|
|
14
|
+
* `removed`), escalated to a human, or broken. Waiting is for the agent whose
|
|
15
|
+
* own work overlaps this one's and would rather rebase onto the result than
|
|
16
|
+
* against it, which is why the timeout is short enough to come back and think
|
|
17
|
+
* again rather than block a session for an afternoon.
|
|
18
|
+
*/
|
|
19
|
+
export declare function wait({ service, log }: {
|
|
20
|
+
service: WorktreeService;
|
|
21
|
+
log: Logger;
|
|
22
|
+
}, task: string, { timeout, poll }?: WaitOptions): Promise<void>;
|