@webappwiz/arbor 0.0.7 → 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 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 { Fs } from "webappwiz/system";
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
- }, { port }?: {
30
- port?: number | undefined;
31
- }): Promise<DevServer>;
43
+ }, { ports }?: DevOptions): Promise<DevServer>;
package/exit.d.ts CHANGED
@@ -14,6 +14,7 @@ export declare const EXIT: {
14
14
  readonly orphaned: 11;
15
15
  readonly merge_failed: 12;
16
16
  readonly already_removed: 13;
17
+ readonly timeout: 14;
17
18
  };
18
19
  export type Reason = keyof typeof EXIT;
19
20
  /**
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
@@ -219,7 +225,7 @@ import { NodeFs } from "webappwiz/system";
219
225
  // plan.ts
220
226
  import { Markdown } from "webappwiz/md";
221
227
  var SECTIONS = ["Goal", "Files", "Done", "Next", "Notes", "Blocked"];
222
- var REQUIRED = ["Goal", "Next"];
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
- }, { port = DEFAULT_PORT } = {}) {
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, { port: opts.port }));
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.7",
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.7"
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>;