@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.3

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 (68) hide show
  1. package/package.json +5 -5
  2. package/src/allowance-command.ts +29 -7
  3. package/src/attempt-ending.ts +23 -5
  4. package/src/attempt-output.ts +48 -18
  5. package/src/attempt-view.ts +25 -15
  6. package/src/commands.ts +176 -0
  7. package/src/control-plane.ts +72 -21
  8. package/src/coordinator-port.ts +29 -0
  9. package/src/coordinator-prompt.ts +237 -0
  10. package/src/coordinator-socket.ts +236 -0
  11. package/src/deadlines.ts +47 -10
  12. package/src/decisions/directory.ts +8 -6
  13. package/src/duration.ts +29 -0
  14. package/src/emit-command.ts +130 -0
  15. package/src/emit.ts +246 -0
  16. package/src/files.ts +11 -1
  17. package/src/here.ts +8 -2
  18. package/src/hold-run-lock.fixture.ts +8 -0
  19. package/src/holder-command.ts +297 -0
  20. package/src/inbox-command.ts +727 -0
  21. package/src/init-command.ts +27 -0
  22. package/src/init-template/AGENTS.md.template +1 -1
  23. package/src/init-template/hello/workflow.test.ts.template +2 -2
  24. package/src/init-template/hello/workflow.ts.template +16 -20
  25. package/src/keep-alive.ts +40 -0
  26. package/src/logs-command.ts +104 -20
  27. package/src/machine.ts +12 -1
  28. package/src/mailbox.ts +977 -0
  29. package/src/message-operation.ts +26 -3
  30. package/src/messaging/messaging.ts +458 -65
  31. package/src/messaging/routes.ts +10 -3
  32. package/src/messaging/text.ts +8 -5
  33. package/src/operation-liveness.ts +54 -8
  34. package/src/operator-cli.ts +290 -119
  35. package/src/operator-runtime.ts +5 -2
  36. package/src/progress-view.ts +49 -7
  37. package/src/projects.ts +57 -0
  38. package/src/result-slots.ts +43 -19
  39. package/src/result-validation.ts +18 -27
  40. package/src/run-command.ts +36 -32
  41. package/src/run-commands.ts +13 -2
  42. package/src/run-lock.ts +123 -0
  43. package/src/run-prepare.ts +94 -22
  44. package/src/run-state.ts +125 -6
  45. package/src/run-usage.ts +1 -1
  46. package/src/run-view.ts +0 -9
  47. package/src/runs-command.ts +46 -17
  48. package/src/runs.ts +142 -23
  49. package/src/sandboxes.ts +8 -2
  50. package/src/simple-input.ts +258 -0
  51. package/src/socket-exchange.ts +26 -0
  52. package/src/sources.ts +70 -0
  53. package/src/stage-ledger.ts +88 -13
  54. package/src/stage-plan.ts +23 -0
  55. package/src/stop-crashed.ts +39 -0
  56. package/src/test-command.ts +32 -0
  57. package/src/testing.ts +71 -3
  58. package/src/tick-command.ts +87 -0
  59. package/src/tools.ts +636 -0
  60. package/src/wake.ts +264 -0
  61. package/src/workflow-loader.ts +32 -22
  62. package/src/workflow-runner.ts +1210 -77
  63. package/src/workflow-testing/coordinator.typecheck.ts +55 -0
  64. package/src/workflow-testing/coordinators.ts +137 -0
  65. package/src/workflow-testing/host.ts +61 -1
  66. package/src/workflow-testing/index.ts +488 -33
  67. package/src/workflow-testing/script.ts +72 -2
  68. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -0,0 +1,727 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import type {
4
+ AttemptRecord,
5
+ InboxErrorJson,
6
+ InboxExpiry,
7
+ InboxItemJson,
8
+ RequestEnd,
9
+ RunLogLine,
10
+ } from "@agentswf/contract/records";
11
+ import type { InboxCode } from "@agentswf/contract/wire";
12
+ import type { JsonObject } from "@agentswf/contract/workflow";
13
+ import { maskCredentials } from "@agentswf/harness";
14
+ import { duration } from "./duration";
15
+ import { messageOf } from "./errors";
16
+ import { exists } from "./files";
17
+ import { readMailbox } from "./mailbox";
18
+ import {
19
+ clockTime,
20
+ command,
21
+ failed,
22
+ type Out,
23
+ parsePlace,
24
+ type ReadPlace,
25
+ shellLine,
26
+ } from "./run-commands";
27
+ import { findRunByRef, listRuns, RunNotFound, statusOf } from "./run-state";
28
+ import {
29
+ attemptsToken,
30
+ type LogMark,
31
+ logSize,
32
+ type Run,
33
+ readAttempts,
34
+ readEventsFrom,
35
+ } from "./runs";
36
+
37
+ export const inboxUsage = [
38
+ "usage: awf inbox [<run>] [--open] [--workflow <name>] [--as <key>] [--after <cursor>] [--wait <seconds>]",
39
+ " [--cwd <directory>] [--run-root <directory>] [--json]",
40
+ "",
41
+ "What agents said to a participant outside the run, and what it said back: routes, messages,",
42
+ "answers, questions that closed unanswered, and each attempt's end, oldest first. Without a run,",
43
+ "every run here that connected one; --workflow narrows that. A run is <workflow>/<id>, or an <id>",
44
+ "only one workflow has. --after reads on from a cursor the last answer gave; --wait",
45
+ "waits up to that many seconds, at most 300, when nothing is new. --open lists the questions",
46
+ "still waiting for an answer, a snapshot. --json prints one item per line.",
47
+ "",
48
+ "Exits 0 on a read that worked, 1 when there is no such run, it has no participant, or the",
49
+ "cursor is another's, 2 on a usage error, 3 when the run's files could not be read.",
50
+ ].join("\n");
51
+
52
+ const MAX_WAIT_SECONDS = 300;
53
+ const POLL_MS = 1_000;
54
+
55
+ /** The clock and the pause between polls, which tests drive. */
56
+ export type InboxClock = { now(): number; sleep(ms: number): Promise<void> };
57
+ const REAL_CLOCK: InboxClock = { now: Date.now, sleep: (ms) => Bun.sleep(ms) };
58
+
59
+ type Options = {
60
+ ref?: string;
61
+ all: boolean;
62
+ open: boolean;
63
+ workflow?: string;
64
+ as?: string;
65
+ after?: string;
66
+ waitSeconds: number;
67
+ };
68
+
69
+ /** An item's place in its run's log: its line, the attempts ended before it, and its turn among those after the line. */
70
+ type Position = [line: number, ended: number, index: number];
71
+
72
+ type Body<T> = T extends unknown ? Omit<T, "v" | "ref" | "cursor"> : never;
73
+ /** An item without what the command adds to it: its run, and the cursor that reads on after it. */
74
+ type Item = Body<InboxItemJson> & { position: Position };
75
+
76
+ /** The cursor: who it was made for, and each run's last position read. */
77
+ type CursorState = { f: string; r: Record<string, Position> };
78
+
79
+ class Refused extends Error {
80
+ constructor(
81
+ readonly code: InboxCode,
82
+ message: string,
83
+ ) {
84
+ super(message);
85
+ }
86
+ }
87
+
88
+ export async function inboxCommand(
89
+ argv: readonly string[],
90
+ shellCwd: string,
91
+ out: Out,
92
+ {
93
+ clock = REAL_CLOCK,
94
+ home = homedir(),
95
+ signal,
96
+ }: {
97
+ clock?: InboxClock;
98
+ /** Where `~/.awf/projects` is, through which an event run is found from anywhere. */
99
+ home?: string;
100
+ /** A stop, as a signal to `awf`: a wait ends at the next poll, as at its deadline. */
101
+ signal?: AbortSignal;
102
+ } = {},
103
+ ): Promise<number> {
104
+ if (argv.includes("-h") || argv.includes("--help")) {
105
+ out.stdout(inboxUsage);
106
+ return 0;
107
+ }
108
+ let place: ReadPlace;
109
+ let json: boolean;
110
+ let options: Options;
111
+ try {
112
+ const parsed = parsePlace(argv, shellCwd);
113
+ place = parsed.place;
114
+ json = parsed.json;
115
+ options = parseInbox(parsed.rest);
116
+ } catch (error) {
117
+ out.stderr(`awf: ${messageOf(error)}\n\n${inboxUsage}`);
118
+ return 2;
119
+ }
120
+ const refuse = (code: InboxCode, message: string) => {
121
+ if (json) out.stdout(JSON.stringify({ v: 1, code, error: message } satisfies InboxErrorJson));
122
+ else out.stderr(`awf: ${message}`);
123
+ return 1;
124
+ };
125
+ try {
126
+ const filter = options.all
127
+ ? `all|${options.workflow ?? ""}|${options.as ?? ""}`
128
+ : `run|${options.ref}|${options.as ?? ""}`;
129
+ let state: CursorState = { f: filter, r: {} };
130
+ // Reading every run here, none is missing or unconnected, so a cursor is checked first, sparing a read.
131
+ let checked = options.after === undefined;
132
+ if (!checked && options.all) {
133
+ const given = decodeCursor(options.after!);
134
+ if (!given || given.f !== filter)
135
+ throw new Refused(
136
+ "bad-cursor",
137
+ `cursor ${options.after} was made with another run or filter; read again without --after`,
138
+ );
139
+ state = given;
140
+ checked = true;
141
+ }
142
+ const deadline = clock.now() + options.waitSeconds * 1000;
143
+ // An event run lives in its project, found through ~/.awf/projects from anywhere (Q15).
144
+ const found =
145
+ options.ref === undefined ? undefined : await findRunByRef(options.ref, place, home);
146
+ const watch = new Watch(found?.run.root ?? place.runRoot, options.as);
147
+ const one = found === undefined ? undefined : watch.entry(found.run);
148
+ for (;;) {
149
+ const runs = await readInbox(options, state, watch, one);
150
+ // After a run that is not there, or never connected one: those come first.
151
+ if (!checked) {
152
+ const given = decodeCursor(options.after!);
153
+ if (!given || given.f !== filter)
154
+ throw new Refused(
155
+ "bad-cursor",
156
+ `cursor ${options.after} was made with another run or filter; read again without --after`,
157
+ );
158
+ state = given;
159
+ checked = true;
160
+ continue;
161
+ }
162
+ if (options.open) {
163
+ const open = openQuestions(runs);
164
+ if (json) for (const item of open) out.stdout(JSON.stringify(item));
165
+ else
166
+ out.stdout(
167
+ open.length
168
+ ? open.map((item) => safe(textOf(item, options.all))).join("\n")
169
+ : "No open questions.",
170
+ );
171
+ return 0;
172
+ }
173
+ const fresh = newItems(runs, state);
174
+ if (fresh.length > 0 || clock.now() >= deadline || signal?.aborted) {
175
+ const lines: string[] = [];
176
+ for (const { ref, item } of fresh) {
177
+ state = { f: filter, r: { ...state.r, [ref]: item.position } };
178
+ const { position: _position, ...shown } = item;
179
+ const published = {
180
+ v: 1,
181
+ ref,
182
+ ...shown,
183
+ cursor: encodeCursor(state),
184
+ } as InboxItemJson;
185
+ lines.push(json ? JSON.stringify(published) : safe(textOf(published, options.all)));
186
+ }
187
+ if (json) {
188
+ for (const line of lines) out.stdout(line);
189
+ } else {
190
+ const words = options.all
191
+ ? ["awf", "inbox", ...(options.workflow ? ["--workflow", options.workflow] : [])]
192
+ : ["awf", "inbox", options.ref!];
193
+ const again = command(
194
+ place,
195
+ ...words,
196
+ ...(options.as ? ["--as", options.as] : []),
197
+ "--after",
198
+ encodeCursor(state),
199
+ );
200
+ out.stdout([...lines, `next: ${shellLine(again)}`].join("\n"));
201
+ }
202
+ return 0;
203
+ }
204
+ await clock.sleep(Math.min(POLL_MS, Math.max(0, deadline - clock.now())));
205
+ }
206
+ } catch (error) {
207
+ if (error instanceof Refused) return refuse(error.code, error.message);
208
+ if (error instanceof RunNotFound) return refuse("not-found", error.message);
209
+ return failed(error, place, out, messageOf(error));
210
+ }
211
+ }
212
+
213
+ function parseInbox(rest: readonly string[]): Options {
214
+ const options: Options = { all: false, open: false, waitSeconds: 0 };
215
+ for (let index = 0; index < rest.length; index += 1) {
216
+ const arg = rest[index]!;
217
+ const value = () => {
218
+ const given = rest[index + 1];
219
+ if (given === undefined || given.startsWith("--")) throw new Error(`${arg} needs a value`);
220
+ index += 1;
221
+ return given;
222
+ };
223
+ if (arg === "--open") options.open = true;
224
+ else if (arg === "--workflow") options.workflow = value();
225
+ else if (arg === "--as") options.as = value();
226
+ else if (arg === "--after") options.after = value();
227
+ else if (arg === "--wait") {
228
+ const seconds = Number(value());
229
+ if (!Number.isFinite(seconds) || seconds < 0 || seconds > MAX_WAIT_SECONDS)
230
+ throw new Error(`--wait needs seconds from 0 to ${MAX_WAIT_SECONDS}`);
231
+ options.waitSeconds = seconds;
232
+ } else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
233
+ else if (options.ref === undefined) options.ref = arg;
234
+ else throw new Error(`one run at a time: ${options.ref} and ${arg}`);
235
+ }
236
+ options.all = options.ref === undefined;
237
+ if (options.workflow !== undefined && !options.all)
238
+ throw new Error("--workflow narrows every run here; a run names its workflow already");
239
+ if (options.open && (options.after !== undefined || options.waitSeconds > 0))
240
+ throw new Error("--open is a snapshot: it takes no --after or --wait");
241
+ return options;
242
+ }
243
+
244
+ type RunItems = { ref: string; items: Item[] };
245
+
246
+ /** The runs to read, each with its items; a run that never connected a participant has none. */
247
+ async function readInbox(
248
+ options: Options,
249
+ state: CursorState,
250
+ watch: Watch,
251
+ one: Watched | undefined,
252
+ ): Promise<RunItems[]> {
253
+ if (one) {
254
+ const ref = `${one.run.record.workflow}/${one.run.record.id}`;
255
+ // Removed while waited on: gone, as it would be to a read started now.
256
+ if (!(await exists(join(one.run.dir, "run.json")))) throw new RunNotFound(`no run ${ref}`);
257
+ await watch.read(one);
258
+ if (one.items === undefined && !one.live)
259
+ throw new Refused(
260
+ "no-participant",
261
+ `${ref} ended without connecting ${options.as ? `participant ${options.as}` : "a participant"}`,
262
+ );
263
+ return [{ ref, items: one.items ?? [] }];
264
+ }
265
+ const found: RunItems[] = [];
266
+ for (const each of await watch.list()) {
267
+ const { workflow, id } = each.run.record;
268
+ if (options.workflow !== undefined && workflow !== options.workflow) continue;
269
+ const ref = `${workflow}/${id}`;
270
+ // One that cannot be read is no reason to fail the others.
271
+ const read = await watch.read(each, state.r[ref]).then(
272
+ () => true,
273
+ () => false,
274
+ );
275
+ if (read && each.items) found.push({ ref, items: each.items });
276
+ }
277
+ return found;
278
+ }
279
+
280
+ /** One run as this command last read it, kept while it waits. */
281
+ type Watched = {
282
+ run: Run;
283
+ /** The attempt files when `attempts` was read; undefined before the first read. */
284
+ token?: string;
285
+ attempts: AttemptRecord[];
286
+ /** Each attempt's last wait label, from the mailbox, read with `attempts`. */
287
+ labels: ReadonlyMap<number, string>;
288
+ fold: ItemFold;
289
+ mark: LogMark;
290
+ /** Undefined when it never connected a participant, or none under `as`. */
291
+ items: Item[] | undefined;
292
+ live: boolean;
293
+ /**
294
+ * Its last attempt had ended, or its process was gone, when its log was read: nothing comes
295
+ * until another attempt.
296
+ */
297
+ settled: boolean;
298
+ };
299
+
300
+ /**
301
+ * The runs a command reads, kept while it waits, so a poll reads only what changed since the last:
302
+ * a run's record once, its attempts when their files change, and its log on from where the last
303
+ * read stopped. A settled run is checked for another attempt only.
304
+ */
305
+ class Watch {
306
+ #runs = new Map<string, Watched>();
307
+
308
+ constructor(
309
+ readonly runRoot: string,
310
+ readonly as: string | undefined,
311
+ ) {}
312
+
313
+ /** Every run here, as `listRuns` has them, opening only those new to this command. */
314
+ async list(): Promise<Watched[]> {
315
+ const runs = await listRuns(this.runRoot, (dir) => this.#runs.get(dir)?.run);
316
+ this.#runs = new Map(runs.map((run) => [run.dir, this.#runs.get(run.dir) ?? this.entry(run)]));
317
+ return [...this.#runs.values()];
318
+ }
319
+
320
+ entry(run: Run): Watched {
321
+ return {
322
+ run,
323
+ attempts: [],
324
+ labels: new Map(),
325
+ fold: new ItemFold(),
326
+ mark: { offset: 0, count: 0 },
327
+ items: undefined,
328
+ live: false,
329
+ settled: false,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Brings `entry` up to date. `seen`, the cursor's last position in it, spares reading the log of
335
+ * a run the cursor read to its end.
336
+ */
337
+ async read(entry: Watched, seen?: Position): Promise<void> {
338
+ const { run } = entry;
339
+ const token = await attemptsToken(run);
340
+ if (token === entry.token && entry.settled) return;
341
+ if (token !== entry.token) {
342
+ const first = entry.token === undefined;
343
+ entry.attempts = await readAttempts(run);
344
+ entry.labels = await waitLabels(run.dir);
345
+ entry.token = token;
346
+ entry.settled = false;
347
+ const last = entry.attempts.at(-1);
348
+ if (first && seen && last?.outcome !== undefined && seen[1] >= last.attempt) {
349
+ entry.items = [];
350
+ entry.live = false;
351
+ entry.settled = true;
352
+ return;
353
+ }
354
+ }
355
+ let status = statusOf(run, entry.attempts);
356
+ // An attempt writes its end before its process exits: one gone with no end may have just ended.
357
+ if (status.state === "interrupted" && entry.attempts.length > 0) {
358
+ const again = await attemptsToken(run);
359
+ if (again !== entry.token) {
360
+ entry.attempts = await readAttempts(run);
361
+ entry.labels = await waitLabels(run.dir);
362
+ entry.token = again;
363
+ status = statusOf(run, entry.attempts);
364
+ }
365
+ }
366
+ entry.live = status.state === "running";
367
+ // After the attempts: one that has ended has written its whole log.
368
+ if ((await logSize(run.dir)) > entry.mark.offset) {
369
+ const read = await readEventsFrom(run.dir, entry.mark);
370
+ entry.fold.add(read.lines);
371
+ entry.mark = read.mark;
372
+ }
373
+ entry.items = entry.fold.items(entry.attempts, entry.live, this.as, entry.labels);
374
+ entry.settled = entry.attempts.length > 0 && status.checked && !entry.live;
375
+ }
376
+ }
377
+
378
+ const EXPIRY: Partial<Record<RequestEnd, InboxExpiry>> = {
379
+ "timed-out": "timeout",
380
+ released: "released",
381
+ cancelled: "cancelled",
382
+ "attempt-ended": "attempt-ended",
383
+ };
384
+
385
+ type Numbered = { number: number; line: RunLogLine };
386
+
387
+ /**
388
+ * A run's log folded into its items as lines are added, so a log is read once however often its
389
+ * items are asked for. An attempt's `ended` item, and the questions it left open, are made when
390
+ * asked for, from the attempts as read then: an attempt with no end whose process is gone, or
391
+ * that a later attempt followed, was interrupted, and its open questions expire `attempt-ended`.
392
+ */
393
+ export class ItemFold {
394
+ readonly #items: Item[] = [];
395
+ readonly #connected = new Set<string>();
396
+ /** By attempt: the participants connected and their labels. */
397
+ readonly #outside = new Map<number, Map<string, JsonObject | undefined>>();
398
+ readonly #open = new Map<string, { participant: string; attempt: number }>();
399
+ /** Each route as opened, by its ends, for the item that says it closed. */
400
+ readonly #purposes = new Map<
401
+ string,
402
+ { purpose: string; timeoutMs?: number; labels?: JsonObject }
403
+ >();
404
+ /**
405
+ * Where the log went on to a later attempt: the attempts that ended there, and the line before.
406
+ * Attempt numbers only go up in a log, as only the live attempt writes it.
407
+ */
408
+ readonly #moves: { from: number; to: number; last: number; lastAt: string }[] = [];
409
+ #attempt = 0;
410
+ #last = 0;
411
+ #lastAt = "";
412
+
413
+ add(lines: readonly Numbered[]): this {
414
+ for (const { number, line } of lines) this.#add(number, line);
415
+ return this;
416
+ }
417
+
418
+ /** Its items in the log's order; undefined when it never connected a participant, or none under `as`. */
419
+ items(
420
+ attempts: readonly AttemptRecord[],
421
+ live: boolean,
422
+ as?: string,
423
+ labels: ReadonlyMap<number, string> = new Map(),
424
+ ): Item[] | undefined {
425
+ if (as === undefined ? this.#connected.size === 0 : !this.#connected.has(as)) return undefined;
426
+ const ends: Item[] = [];
427
+ const lastAttempt = attempts.at(-1)?.attempt ?? 0;
428
+ const end = (ended: number, last: number, lastAt: string) => {
429
+ const record = attempts.find((each) => each.attempt === ended);
430
+ const interrupted = !record?.outcome && (ended < lastAttempt || !live);
431
+ if (!record?.outcome && !interrupted) return;
432
+ let index = 0;
433
+ if (interrupted)
434
+ for (const [id, question] of this.#open)
435
+ if (question.attempt === ended)
436
+ ends.push({
437
+ kind: "expired",
438
+ participant: question.participant,
439
+ id,
440
+ reason: "attempt-ended",
441
+ at: lastAt,
442
+ position: [last, ended - 1, ++index],
443
+ });
444
+ const label = record?.outcome === "parked" ? labels.get(ended) : undefined;
445
+ ends.push({
446
+ kind: "ended",
447
+ attempt: ended,
448
+ outcome: record?.outcome ?? "interrupted",
449
+ ...(label === undefined ? {} : { label }),
450
+ at: record?.ended ?? lastAt,
451
+ position: [last, ended, 0],
452
+ });
453
+ };
454
+ for (const { from, to, last, lastAt } of this.#moves)
455
+ for (let ended = from; ended < to; ended += 1) end(ended, last, lastAt);
456
+ for (let ended = Math.max(this.#attempt, 1); ended <= lastAttempt; ended += 1)
457
+ end(ended, this.#last, this.#lastAt);
458
+ const items = [...this.#items, ...ends].sort((a, b) =>
459
+ after(a.position, b.position) ? 1 : -1,
460
+ );
461
+ return as === undefined
462
+ ? items
463
+ : items.filter((item) => !("participant" in item) || item.participant === as);
464
+ }
465
+
466
+ #add(number: number, line: RunLogLine): void {
467
+ const at = "at" in line && typeof line.at === "string" ? line.at : this.#lastAt;
468
+ if (line.attempt !== this.#attempt) {
469
+ if (this.#attempt >= 1 && line.attempt > this.#attempt)
470
+ this.#moves.push({
471
+ from: this.#attempt,
472
+ to: line.attempt,
473
+ last: this.#last,
474
+ lastAt: this.#lastAt,
475
+ });
476
+ this.#attempt = line.attempt;
477
+ }
478
+ const attempt = this.#attempt;
479
+ this.#last = number;
480
+ this.#lastAt = at;
481
+ const position: Position = [number, attempt - 1, 0];
482
+ const here = this.#outside.get(attempt) ?? new Map<string, JsonObject | undefined>();
483
+ this.#outside.set(attempt, here);
484
+ const id = (n: number) => `${attempt}.${n}`;
485
+ const items = this.#items;
486
+ if (line.kind === "participant-connected") {
487
+ here.set(line.participant, line.labels);
488
+ this.#connected.add(line.participant);
489
+ return;
490
+ }
491
+ if (line.kind === "participant-post") {
492
+ items.push({
493
+ kind: "post",
494
+ participant: line.participant,
495
+ id: `${attempt}.${line.id}`,
496
+ text: line.text,
497
+ ...(here.get(line.participant) === undefined ? {} : { labels: here.get(line.participant) }),
498
+ at,
499
+ position,
500
+ });
501
+ return;
502
+ }
503
+ if (line.kind === "route-opened" || line.kind === "route-closed") {
504
+ const [a, b] = line.between ?? [line.from, line.to];
505
+ const participant = here.has(a) ? a : here.has(b) ? b : undefined;
506
+ if (participant === undefined) return;
507
+ const agent = participant === a ? b : a;
508
+ const direction = line.between
509
+ ? "both"
510
+ : line.from === participant
511
+ ? "from-participant"
512
+ : "to-participant";
513
+ const labels = here.get(participant);
514
+ const ends = JSON.stringify([attempt, participant, agent, direction]);
515
+ if (line.kind === "route-opened")
516
+ this.#purposes.set(ends, {
517
+ purpose: line.purpose,
518
+ ...(line.timeoutMs === undefined ? {} : { timeoutMs: line.timeoutMs }),
519
+ ...(labels === undefined ? {} : { labels }),
520
+ });
521
+ // A closed route is said as it was opened, and why it closed.
522
+ const opening = this.#purposes.get(ends) ?? { purpose: "" };
523
+ items.push({
524
+ kind: "route",
525
+ state: line.kind === "route-opened" ? "opened" : "closed",
526
+ participant,
527
+ agent: address(line.callPath, agent),
528
+ direction,
529
+ ...opening,
530
+ ...(line.kind === "route-closed" ? { reason: line.reason } : {}),
531
+ at,
532
+ position,
533
+ });
534
+ return;
535
+ }
536
+ if (line.kind === "message-sent") {
537
+ if (here.has(line.from)) {
538
+ if (line.replyTo !== undefined) {
539
+ items.push({
540
+ kind: "answered",
541
+ participant: line.from,
542
+ id: id(line.replyTo),
543
+ answer: line.text,
544
+ via: line.via ?? "command",
545
+ at,
546
+ position,
547
+ });
548
+ this.#open.delete(id(line.replyTo));
549
+ } else
550
+ items.push({
551
+ kind: "message",
552
+ participant: line.from,
553
+ id: id(line.message),
554
+ from: line.from,
555
+ to: address(line.callPath, line.to),
556
+ text: line.text,
557
+ ...(line.via ? { via: line.via } : {}),
558
+ at,
559
+ position,
560
+ });
561
+ return;
562
+ }
563
+ if (!here.has(line.to)) return;
564
+ const asks = line.awaitsReply !== undefined;
565
+ items.push({
566
+ kind: "message",
567
+ participant: line.to,
568
+ id: id(line.message),
569
+ from: address(line.callPath, line.from),
570
+ to: line.to,
571
+ text: line.text,
572
+ ...(asks ? { asks: true as const } : {}),
573
+ ...(line.awaitsReply?.until ? { until: line.awaitsReply.until } : {}),
574
+ at,
575
+ position,
576
+ });
577
+ if (asks) this.#open.set(id(line.message), { participant: line.to, attempt });
578
+ return;
579
+ }
580
+ if (line.kind === "request-ended" && here.has(line.owedBy) && line.end !== "replied") {
581
+ items.push({
582
+ kind: "expired",
583
+ participant: line.owedBy,
584
+ id: id(line.message),
585
+ reason: EXPIRY[line.end] ?? "attempt-ended",
586
+ at,
587
+ position,
588
+ });
589
+ this.#open.delete(id(line.message));
590
+ }
591
+ }
592
+ }
593
+
594
+ function address(callPath: readonly string[], key: string): string {
595
+ return [...callPath, key].join("/");
596
+ }
597
+
598
+ /** What is past the cursor in each run, oldest first across runs, each run's in its log's order. */
599
+ function newItems(runs: readonly RunItems[], state: CursorState): { ref: string; item: Item }[] {
600
+ return merged(
601
+ runs.map(({ ref, items }) => {
602
+ const seen = state.r[ref];
603
+ return { ref, items: items.filter((item) => !seen || after(item.position, seen)) };
604
+ }),
605
+ );
606
+ }
607
+
608
+ /** Oldest first across runs, by each run's next item: a run's own order holds whatever its times say. */
609
+ function merged<T extends { at: string }>(
610
+ runs: readonly { ref: string; items: readonly T[] }[],
611
+ ): { ref: string; item: T }[] {
612
+ const queues = runs.map(({ ref, items }) => ({ ref, items: [...items] }));
613
+ const out: { ref: string; item: T }[] = [];
614
+ for (;;) {
615
+ let next: (typeof queues)[number] | undefined;
616
+ for (const queue of queues) {
617
+ if (queue.items.length > 0 && (!next || queue.items[0]!.at < next.items[0]!.at)) next = queue;
618
+ }
619
+ if (!next) return out;
620
+ out.push({ ref: next.ref, item: next.items.shift()! });
621
+ }
622
+ }
623
+
624
+ function after(position: Position, seen: Position): boolean {
625
+ for (let index = 0; index < 3; index += 1) {
626
+ if (position[index]! !== seen[index]!) return position[index]! > seen[index]!;
627
+ }
628
+ return false;
629
+ }
630
+
631
+ /** The questions open now, as their `message` items, with no cursor: a snapshot. */
632
+ function openQuestions(runs: readonly RunItems[]): InboxItemJson[] {
633
+ const open = runs.map(({ ref, items }) => {
634
+ const closed = new Set(
635
+ items.flatMap((item) =>
636
+ item.kind === "answered" || item.kind === "expired" ? [item.id] : [],
637
+ ),
638
+ );
639
+ return {
640
+ ref,
641
+ items: items.filter((item) => item.kind === "message" && item.asks && !closed.has(item.id)),
642
+ };
643
+ });
644
+ return merged(open).map(({ ref, item }) => {
645
+ const { position: _position, ...shown } = item;
646
+ return { v: 1, ref, ...shown } as InboxItemJson;
647
+ });
648
+ }
649
+
650
+ /** What a run's agents wrote, safe on a terminal: credentials masked, no control characters. */
651
+ function safe(text: string): string {
652
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it strips.
653
+ return maskCredentials(text).replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, "");
654
+ }
655
+
656
+ function textOf(item: InboxItemJson, all: boolean): string {
657
+ const time = item.at ? clockTime(item.at).slice(0, 5) : " ";
658
+ const head = `${time} ${all ? `${item.ref} ` : ""}`;
659
+ const body = (text: string) =>
660
+ safe(text)
661
+ .split("\n")
662
+ .map((line) => ` ${line}`)
663
+ .join("\n");
664
+ switch (item.kind) {
665
+ case "route": {
666
+ const arrow =
667
+ item.direction === "both"
668
+ ? `${item.agent} ↔ ${item.participant}`
669
+ : item.direction === "to-participant"
670
+ ? `${item.agent} → ${item.participant}`
671
+ : `${item.participant} → ${item.agent}`;
672
+ return item.state === "opened"
673
+ ? `${head}route ${arrow} opened “${item.purpose}”${item.timeoutMs === undefined ? "" : ` ${duration(item.timeoutMs)}`}`
674
+ : `${head}route ${arrow} closed ${item.reason}`;
675
+ }
676
+ case "message": {
677
+ const until = item.until ? `, until ${clockTime(item.until).slice(0, 5)}` : "";
678
+ const via = item.via ? ` (${item.via})` : "";
679
+ const what = item.asks
680
+ ? `${item.from} asks ${item.to}${until}`
681
+ : `${item.from} → ${item.to}${via}`;
682
+ return `${head}${item.id} ${what}\n${body(item.text)}`;
683
+ }
684
+ case "answered":
685
+ return `${head}${item.id} answered (${item.via})\n${body(item.answer)}`;
686
+ case "post":
687
+ return `${head}${item.id} run → ${item.participant}\n${body(item.text)}`;
688
+ case "expired":
689
+ return `${head}${item.id} closed unanswered: ${item.reason}`;
690
+ case "ended":
691
+ return `${head}attempt ${item.attempt} ended: ${item.outcome}`;
692
+ }
693
+ }
694
+
695
+ function encodeCursor(state: CursorState): string {
696
+ return `i1.${Buffer.from(JSON.stringify(state)).toString("base64url")}`;
697
+ }
698
+
699
+ function decodeCursor(text: string): CursorState | undefined {
700
+ try {
701
+ if (!text.startsWith("i1.")) return undefined;
702
+ const value: unknown = JSON.parse(Buffer.from(text.slice(3), "base64url").toString("utf8"));
703
+ if (typeof value !== "object" || value === null) return undefined;
704
+ const { f, r } = value as { f?: unknown; r?: unknown };
705
+ if (typeof f !== "string" || typeof r !== "object" || r === null) return undefined;
706
+ for (const position of Object.values(r)) {
707
+ if (
708
+ !Array.isArray(position) ||
709
+ position.length !== 3 ||
710
+ !position.every((n) => typeof n === "number")
711
+ )
712
+ return undefined;
713
+ }
714
+ return { f, r: r as Record<string, Position> };
715
+ } catch {
716
+ return undefined;
717
+ }
718
+ }
719
+
720
+ /** Each attempt's last wait label: what a parked attempt is waiting for. */
721
+ async function waitLabels(runDir: string): Promise<Map<number, string>> {
722
+ const labels = new Map<number, string>();
723
+ for (const record of await readMailbox(runDir))
724
+ if (record.kind === "wait" && record.label !== undefined)
725
+ labels.set(record.attempt, record.label);
726
+ return labels;
727
+ }