@agentswf/engine 0.0.0-stage → 0.1.0-beta.1

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 (82) hide show
  1. package/package.json +39 -4
  2. package/src/accounting/format.ts +252 -0
  3. package/src/accounting/plans.ts +38 -0
  4. package/src/accounting/prices.ts +98 -0
  5. package/src/accounting/summary.ts +385 -0
  6. package/src/accounting/tokens.ts +34 -0
  7. package/src/agent-launcher.ts +127 -0
  8. package/src/allowance-command.ts +105 -0
  9. package/src/attempt-ending.ts +198 -0
  10. package/src/attempt-output.ts +132 -0
  11. package/src/attempt-view.ts +362 -0
  12. package/src/canonical-json.ts +12 -0
  13. package/src/control-plane.ts +424 -0
  14. package/src/deadlines.ts +106 -0
  15. package/src/decisions/directory.ts +432 -0
  16. package/src/decisions/fake.ts +60 -0
  17. package/src/decisions/openrouter.ts +161 -0
  18. package/src/decisions/seam.ts +70 -0
  19. package/src/display-path.ts +16 -0
  20. package/src/duration.ts +12 -0
  21. package/src/errors.ts +4 -0
  22. package/src/files.ts +78 -0
  23. package/src/herdr-placement.ts +182 -0
  24. package/src/herdr-run-session.ts +530 -0
  25. package/src/herdr-workspace-marks.ts +194 -0
  26. package/src/here.ts +289 -0
  27. package/src/index.ts +5 -0
  28. package/src/init-command.ts +240 -0
  29. package/src/init-template/AGENTS.md.template +16 -0
  30. package/src/init-template/hello/workflow.test.ts.template +17 -0
  31. package/src/init-template/hello/workflow.ts.template +26 -0
  32. package/src/init-template/tsconfig.json.template +11 -0
  33. package/src/jsonl.ts +74 -0
  34. package/src/logs-command.ts +605 -0
  35. package/src/machine.ts +27 -0
  36. package/src/message-operation.ts +245 -0
  37. package/src/messaging/limits.ts +35 -0
  38. package/src/messaging/messaging.ts +1303 -0
  39. package/src/messaging/routes.ts +79 -0
  40. package/src/messaging/text.ts +251 -0
  41. package/src/operation-events.ts +182 -0
  42. package/src/operation-liveness.ts +674 -0
  43. package/src/operator-aliases.ts +10 -0
  44. package/src/operator-cli.ts +600 -0
  45. package/src/operator-runtime.ts +503 -0
  46. package/src/pane-layout.ts +125 -0
  47. package/src/progress-view.ts +568 -0
  48. package/src/result-slots.ts +325 -0
  49. package/src/result-validation.ts +47 -0
  50. package/src/run-command.ts +242 -0
  51. package/src/run-commands.ts +215 -0
  52. package/src/run-event-log.ts +72 -0
  53. package/src/run-prepare.ts +178 -0
  54. package/src/run-progress.ts +137 -0
  55. package/src/run-read.ts +425 -0
  56. package/src/run-state.ts +120 -0
  57. package/src/run-usage.ts +393 -0
  58. package/src/run-view.ts +225 -0
  59. package/src/runs-command.ts +356 -0
  60. package/src/runs.ts +645 -0
  61. package/src/sandbox-homes.ts +211 -0
  62. package/src/sandboxes.ts +521 -0
  63. package/src/skills/fetch.ts +305 -0
  64. package/src/skills/run-skills.ts +181 -0
  65. package/src/skills/sources.ts +87 -0
  66. package/src/skills/tree.ts +60 -0
  67. package/src/stage-ledger.ts +535 -0
  68. package/src/stage-plan.ts +127 -0
  69. package/src/stage-schema.ts +88 -0
  70. package/src/stopped.ts +36 -0
  71. package/src/test-command.ts +110 -0
  72. package/src/testing/operation-events.ts +89 -0
  73. package/src/testing.ts +164 -0
  74. package/src/workflow-loader.ts +113 -0
  75. package/src/workflow-runner.ts +3666 -0
  76. package/src/workflow-test-preload.ts +13 -0
  77. package/src/workflow-testing/decisions.ts +129 -0
  78. package/src/workflow-testing/host.ts +811 -0
  79. package/src/workflow-testing/index.ts +420 -0
  80. package/src/workflow-testing/script.ts +447 -0
  81. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  82. package/README.md +0 -3
@@ -0,0 +1,215 @@
1
+ import { resolve } from "node:path";
2
+ import type { AttemptRecord, RunLogLine } from "@agentswf/contract/records";
3
+ import { maskCredentials } from "@agentswf/harness";
4
+ import { continueCommand, shellWord } from "./attempt-view";
5
+ import type { RunCommand } from "./run-command";
6
+ import { type Cursor, encodeCursor } from "./run-read";
7
+ import { RunNotFound, type RunStatus } from "./run-state";
8
+ import { runRootOf } from "./runs";
9
+
10
+ /** What `awf runs` and `awf logs` share: where they look, and how they say a run. */
11
+
12
+ /** Where the commands that read runs look, and the options their suggested commands repeat. */
13
+ export type ReadPlace = { shellCwd: string; runRoot: string; repeat: string[] };
14
+
15
+ export type Out = { stdout(text: string): void; stderr(text: string): void };
16
+
17
+ /** `--cwd`, `--run-root` and `--json`, taken out of `argv`; the rest is left for the command. */
18
+ export function parsePlace(
19
+ argv: readonly string[],
20
+ shellCwd: string,
21
+ ): { place: ReadPlace; json: boolean; rest: string[] } {
22
+ let cwd = shellCwd;
23
+ let runRoot: string | undefined;
24
+ let json = false;
25
+ const repeat: string[] = [];
26
+ const rest: string[] = [];
27
+ for (let index = 0; index < argv.length; index += 1) {
28
+ const arg = argv[index]!;
29
+ if (arg === "--json") json = true;
30
+ else if (arg === "--cwd" || arg === "--run-root") {
31
+ const value = argv[index + 1];
32
+ if (!value || value.startsWith("--")) throw new Error(`${arg} needs a directory`);
33
+ index += 1;
34
+ if (arg === "--cwd") cwd = resolve(shellCwd, value);
35
+ else runRoot = resolve(shellCwd, value);
36
+ repeat.push(arg, value);
37
+ } else rest.push(arg);
38
+ }
39
+ return { place: { shellCwd, runRoot: runRoot ?? runRootOf(cwd), repeat }, json, rest };
40
+ }
41
+
42
+ /** A suggested command, with the place repeated. */
43
+ export function command(place: ReadPlace, ...words: string[]): string[] {
44
+ const [first, second, ...rest] = words;
45
+ return [first!, second!, ...rest.slice(0, 1), ...place.repeat, ...rest.slice(1)];
46
+ }
47
+
48
+ /** A field whose name says it holds a credential, whatever its value looks like. */
49
+ const SECRET_NAME =
50
+ /password|passwd|passphrase|passcode|secret|token|credential|cookie|bearer|authorization|(?:api|access|private|session|ssh|signing|encryption)[_-]?key|^(?:pwd|pass|pin|auth)$/i;
51
+
52
+ /**
53
+ * Every string in `value` masked, and everything under a credential's name hidden: a run's text,
54
+ * errors and fields are its workflow's, its agents' and its tools'. Past `limit`, a string is cut.
55
+ */
56
+ export function masked(value: unknown, limit = Number.POSITIVE_INFINITY, hidden = false): unknown {
57
+ if (typeof value === "string") {
58
+ if (hidden) return "***";
59
+ const shown = maskCredentials(value);
60
+ return shown.length > limit ? `${shown.slice(0, limit - 1)}…` : shown;
61
+ }
62
+ if (hidden && value !== null && typeof value !== "object") return "***";
63
+ if (Array.isArray(value)) return value.map((each) => masked(each, limit, hidden));
64
+ if (value && typeof value === "object") {
65
+ return Object.fromEntries(
66
+ Object.entries(value).map(([key, each]) => [
67
+ key,
68
+ masked(each, limit, hidden || SECRET_NAME.test(key)),
69
+ ]),
70
+ );
71
+ }
72
+ return value;
73
+ }
74
+
75
+ /**
76
+ * Text a run wrote, as one masked line of about `limit` characters, so it can never pass for a
77
+ * line of its own or fill a page. `@{handle} --full` prints it whole.
78
+ */
79
+ export function said(text: string, limit = 120): string {
80
+ const line = maskCredentials(text).replace(/\s*[\r\n]+\s*/g, " ⏎ ");
81
+ return line.length > limit ? `${line.slice(0, limit - 1)}…` : line;
82
+ }
83
+
84
+ /** A suggested command as text to paste into a shell. */
85
+ export function shellLine(words: readonly string[]): string {
86
+ return words.map(shellWord).join(" ");
87
+ }
88
+
89
+ /** Says a run that is not there, and exits 1; anything else is the run's files, exit 3. */
90
+ export function failed(error: unknown, place: ReadPlace, out: Out, message: string): number {
91
+ if (error instanceof RunNotFound) {
92
+ out.stderr(`${error.message}; runs here: ${shellLine(command(place, "awf", "runs"))}`);
93
+ return 1;
94
+ }
95
+ out.stderr(`awf: ${message}`);
96
+ return 3;
97
+ }
98
+
99
+ /**
100
+ * What `awf run` says first where no person watches its progress: the run's reference, the one
101
+ * route a calling agent has to its own run. Its prefix, `awf: {workflow}/{id} started`, is stable.
102
+ */
103
+ export function startedLine(
104
+ workflow: string,
105
+ id: string,
106
+ run: Pick<RunCommand, "cwd" | "cwdGiven" | "runRoot" | "runRootGiven">,
107
+ ): string {
108
+ const ref = `${workflow}/${id}`;
109
+ const repeat = [
110
+ ...(run.cwdGiven ? ["--cwd", run.cwd] : []),
111
+ ...(run.runRootGiven ? ["--run-root", run.runRoot] : []),
112
+ ];
113
+ return `awf: ${ref} started · watch: ${shellLine(["awf", "logs", ref, ...repeat])}`;
114
+ }
115
+
116
+ /** `awf logs` from `cursor`: with `--wait` unless the page stopped short of the newest. */
117
+ export function nextLogs(
118
+ status: RunStatus,
119
+ place: ReadPlace,
120
+ cursor: Cursor,
121
+ filters: string[] = [],
122
+ wait = true,
123
+ ): string[] {
124
+ return [
125
+ ...command(place, "awf", "logs", status.ref),
126
+ ...filters,
127
+ "--after",
128
+ encodeCursor(cursor),
129
+ ...(wait ? ["--wait", "90"] : []),
130
+ ];
131
+ }
132
+
133
+ /** The command an interrupted run goes on with, as `awf run` would print it. */
134
+ export function continueOf(
135
+ status: RunStatus,
136
+ lines: readonly { line: RunLogLine }[],
137
+ place: ReadPlace,
138
+ ): string | undefined {
139
+ const { run } = status;
140
+ const last = status.attempts.at(-1);
141
+ if (!last) return undefined;
142
+ const entered = lines.flatMap(({ line }) =>
143
+ line.attempt === last.attempt &&
144
+ (line.kind === "stage-started" || (line.kind === "stage-ended" && line.source))
145
+ ? [{ stage: line.stage }]
146
+ : [],
147
+ );
148
+ return continueCommand(
149
+ {
150
+ shellCwd: place.shellCwd,
151
+ cwd: run.record.cwd,
152
+ cwdGiven: resolve(run.record.cwd) !== resolve(place.shellCwd),
153
+ runRoot: run.root,
154
+ runRootGiven: resolve(run.root) !== resolve(runRootOf(run.record.cwd)),
155
+ workflowFile: last.file,
156
+ ...(last.flags.fromStage === undefined ? {} : { fromStage: last.flags.fromStage }),
157
+ },
158
+ run.record.id,
159
+ undefined,
160
+ entered,
161
+ );
162
+ }
163
+
164
+ /** The stage the last attempt is in: open, or the one it ended in. */
165
+ export function currentStage(
166
+ status: RunStatus,
167
+ lines: readonly { line: RunLogLine }[],
168
+ ): string | undefined {
169
+ const last = status.attempts.at(-1);
170
+ if (!last) return undefined;
171
+ const open = new Set<string>();
172
+ for (const { line } of lines) {
173
+ if (line.attempt !== last.attempt) continue;
174
+ if (line.kind === "stage-started") open.add(line.stage);
175
+ if (line.kind === "stage-ended") open.delete(line.stage);
176
+ }
177
+ return [...open].at(-1) ?? last.stage;
178
+ }
179
+
180
+ /** `running`, or `running (unchecked, last event 09:41:07)` where its process could not be asked. */
181
+ export function stateWord(status: RunStatus, lastEventAt?: number): string {
182
+ if (status.state !== "running" || status.checked) return status.state;
183
+ return `running (unchecked${lastEventAt === undefined ? "" : `, last event ${clockTime(lastEventAt)}`})`;
184
+ }
185
+
186
+ /** From the first attempt's start to the last's end, or to now while it runs. */
187
+ export function span(
188
+ status: RunStatus,
189
+ now: number,
190
+ ): { started: number; ended?: number; ms: number } {
191
+ const started = Date.parse(status.attempts[0]?.started ?? status.run.record.created);
192
+ const last = status.attempts.at(-1);
193
+ const ended = last?.ended ? Date.parse(last.ended) : undefined;
194
+ return { started, ...(ended === undefined ? {} : { ended }), ms: (ended ?? now) - started };
195
+ }
196
+
197
+ /** At list prices, over the attempts that kept one: never what was billed. */
198
+ export function estimateOf(attempts: readonly AttemptRecord[]): number | undefined {
199
+ const known = attempts.flatMap((each) =>
200
+ each.accounting?.totals.estimate === undefined ? [] : [each.accounting.totals.estimate],
201
+ );
202
+ return known.length > 0 ? known.reduce((total, each) => total + each, 0) : undefined;
203
+ }
204
+
205
+ /** `$6.10`; `<$0.01` for less, and nothing for none. */
206
+ export function money(estimate: number | undefined): string[] {
207
+ if (!estimate) return [];
208
+ return [estimate < 0.005 ? "<$0.01" : `$${estimate.toFixed(2)}`];
209
+ }
210
+
211
+ export function clockTime(at: number | string): string {
212
+ const date = new Date(at);
213
+ const two = (n: number) => String(n).padStart(2, "0");
214
+ return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`;
215
+ }
@@ -0,0 +1,72 @@
1
+ import { RUN_EVENT_VERSIONS, type RunEvent, type TurnRecord } from "@agentswf/contract/records";
2
+ import { messageOf } from "./errors";
3
+ import { appendEvent } from "./runs";
4
+
5
+ type Body<E> = E extends unknown ? Omit<E, "version" | "attempt" | "at"> : never;
6
+ /** An event as the engine writes it: the log fills in its version, attempt and time. */
7
+ export type RunEventBody = Body<RunEvent>;
8
+
9
+ /**
10
+ * The attempt's writer of the run's event log. Lines go out in call order, and a write that fails
11
+ * costs the run nothing: it is said through `failed`, which must not write to this log, for each
12
+ * turn and once for the other lines.
13
+ */
14
+ export class RunEventLog {
15
+ readonly #writes: Promise<void>[] = [];
16
+ #failed = false;
17
+ #closed = false;
18
+
19
+ constructor(
20
+ private readonly options: {
21
+ runDir: string;
22
+ attempt: number;
23
+ failed(message: string): void;
24
+ now?: () => number;
25
+ },
26
+ ) {}
27
+
28
+ /** Whether the line was written, once it is; a caller may wait for it, and none has to. */
29
+ event(body: RunEventBody, at?: number): Promise<boolean> {
30
+ if (this.#closed) return Promise.resolve(false);
31
+ const time = new Date(at ?? this.options.now?.() ?? Date.now()).toISOString();
32
+ return this.#append({
33
+ version: RUN_EVENT_VERSIONS[body.kind],
34
+ attempt: this.options.attempt,
35
+ at: time,
36
+ ...body,
37
+ } as RunEvent);
38
+ }
39
+
40
+ turn(record: TurnRecord): void {
41
+ if (this.#closed) return;
42
+ this.#writes.push(
43
+ appendEvent(this.options.runDir, record).catch((error) =>
44
+ this.options.failed(`awf: a turn was not recorded in turns.jsonl: ${messageOf(error)}`),
45
+ ),
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Once every line asked for so far is written, or has failed. Nothing is written after: the
51
+ * attempt's end follows, and then the next attempt's lines.
52
+ */
53
+ async close(): Promise<void> {
54
+ this.#closed = true;
55
+ await Promise.all(this.#writes);
56
+ }
57
+
58
+ #append(line: RunEvent): Promise<boolean> {
59
+ const written = appendEvent(this.options.runDir, line).then(
60
+ () => true,
61
+ (error) => {
62
+ if (!this.#failed) {
63
+ this.#failed = true;
64
+ this.options.failed(`awf: the run's event log was not written: ${messageOf(error)}`);
65
+ }
66
+ return false;
67
+ },
68
+ );
69
+ this.#writes.push(written.then(() => undefined));
70
+ return written;
71
+ }
72
+ }
@@ -0,0 +1,178 @@
1
+ import { statSync } from "node:fs";
2
+ import { type AttemptRecord, RUN_EVENT_VERSIONS } from "@agentswf/contract/records";
3
+ import type { ExecutableWorkflow, JsonValue } from "@agentswf/contract/workflow";
4
+ import { refusalMessage } from "./attempt-view";
5
+ import { messageOf } from "./errors";
6
+ import type { RunCommand } from "./run-command";
7
+ import {
8
+ type Attempt,
9
+ appendEvent,
10
+ attemptRefusal,
11
+ checkContinue,
12
+ checkFree,
13
+ claimAttempt,
14
+ endTurnsLine,
15
+ idProblem,
16
+ openRun,
17
+ type Run,
18
+ RunRefused,
19
+ readAttempts,
20
+ readStageRecords,
21
+ readTurns,
22
+ } from "./runs";
23
+ import { assertJsonValue, type LoadedWorkflow, loadWorkflowFile } from "./workflow-loader";
24
+
25
+ export type PreparedRun = {
26
+ args: JsonValue;
27
+ /** A new run's id, from `--id` or the workflow's `id(args)`; absent for one to generate. */
28
+ id?: string;
29
+ /** The run `--continue` names, its earlier attempts, and what to warn of before it goes on. */
30
+ continued?: { run: Run; attempts: AttemptRecord[]; warnings: string[] };
31
+ };
32
+
33
+ /**
34
+ * The command's workflow loaded and its run prepared, or why not, as awf says it: in the shell that
35
+ * typed the command, before anything starts.
36
+ */
37
+ export async function loadAndPrepare(
38
+ command: RunCommand,
39
+ now: number,
40
+ ): Promise<{ loaded: LoadedWorkflow; prepared: PreparedRun } | { refused: string }> {
41
+ let loaded: LoadedWorkflow;
42
+ try {
43
+ loaded = await loadWorkflowFile(command.workflowFile, command.shellCwd);
44
+ } catch (error) {
45
+ return { refused: `load: ${messageOf(error)}` };
46
+ }
47
+ try {
48
+ return { loaded, prepared: await prepareRun(command, loaded, now) };
49
+ } catch (error) {
50
+ return { refused: `${error instanceof RunRefused ? "" : "prepare: "}${messageOf(error)}` };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The workflow's arguments, and the run `--continue` names, whose recorded argv is prepared again
56
+ * by the code as it is now. A new run is claimed after, by its caller.
57
+ */
58
+ async function prepareRun(
59
+ command: RunCommand,
60
+ loaded: LoadedWorkflow,
61
+ now: number,
62
+ ): Promise<PreparedRun> {
63
+ const { executable, file } = loaded;
64
+ const { meta } = executable.definition;
65
+ const prepare = (argv: readonly string[], cwd: string) => {
66
+ const args = executable.prepare({ argv, cwd });
67
+ assertJsonValue(args, `${meta.name} arguments`);
68
+ return args;
69
+ };
70
+ if (command.continueId === undefined) {
71
+ const args = prepare(command.workflowArgs, command.cwd);
72
+ const id = command.id ?? derivedId(executable, args);
73
+ if (id !== undefined) await checkFree(command.runRoot, meta.name, id);
74
+ return { args, ...(id === undefined ? {} : { id }) };
75
+ }
76
+ const run = await openRun(command.runRoot, meta.name, command.continueId);
77
+ checkContinue(run, {
78
+ argv: command.workflowArgs,
79
+ ...(command.cwdGiven ? { cwd: command.cwd } : {}),
80
+ ...(command.sandbox === undefined ? {} : { sandbox: command.sandbox }),
81
+ });
82
+ const { id, argv, cwd } = run.record;
83
+ if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) {
84
+ throw new RunRefused(`${id} works in ${cwd}, which is no longer a directory`);
85
+ }
86
+ let args: JsonValue;
87
+ try {
88
+ args = prepare(argv, cwd);
89
+ } catch (error) {
90
+ throw new RunRefused(
91
+ `the recorded argv of ${id} no longer parses with ${file}: ${messageOf(error)}; start a new run`,
92
+ );
93
+ }
94
+ const attempts = await readAttempts(run);
95
+ const records = await readStageRecords(run.dir);
96
+ // The claim refuses it too; this says so before `--here` opens a tab, or `--session` waits.
97
+ const refused = attemptRefusal(attempts, records, { fromStage: command.fromStage !== undefined });
98
+ if (refused) throw new RunRefused(refusalMessage(id, refused, now));
99
+ const { fromStage } = command;
100
+ // Once per start point: a run started at a stage goes on to it attempt after attempt.
101
+ const unrecorded =
102
+ fromStage !== undefined &&
103
+ !records.has(fromStage) &&
104
+ attempts.at(-1)?.flags.fromStage !== fromStage
105
+ ? [`awf: nothing is recorded for ${fromStage}; the attempt stops if it never reaches it`]
106
+ : [];
107
+ return { args, continued: { run, attempts, warnings: unrecorded } };
108
+ }
109
+
110
+ /** The id the workflow's `id(args)` gives a new run, checked; undefined when it has none. */
111
+ function derivedId(
112
+ executable: ExecutableWorkflow<JsonValue, JsonValue>,
113
+ args: JsonValue,
114
+ ): string | undefined {
115
+ if (!executable.id) return undefined;
116
+ let id: unknown;
117
+ try {
118
+ id = executable.id(args);
119
+ } catch (error) {
120
+ throw new RunRefused(`the workflow's id(args) failed: ${messageOf(error)}; --id gives one`);
121
+ }
122
+ const problem =
123
+ typeof id === "string" ? idProblem(id) : `it returned a ${typeof id}, not a string`;
124
+ if (problem) throw new RunRefused(`the workflow's id(args): ${problem}; --id gives one`);
125
+ return id as string;
126
+ }
127
+
128
+ /** Claims the run's next attempt, naming each earlier one found interrupted. */
129
+ export async function claimNext(
130
+ run: Run,
131
+ loaded: LoadedWorkflow,
132
+ command: RunCommand,
133
+ notice: (text: string) => void,
134
+ now: number,
135
+ ): Promise<Attempt> {
136
+ const { meta } = loaded.executable.definition;
137
+ const claimed = await claimAttempt(
138
+ run,
139
+ {
140
+ file: loaded.file,
141
+ ...(meta.version === undefined ? {} : { workflowVersion: meta.version }),
142
+ flags: {
143
+ timeout: command.timeout,
144
+ ...(command.fromStage === undefined ? {} : { fromStage: command.fromStage }),
145
+ },
146
+ },
147
+ { fromStage: command.fromStage !== undefined },
148
+ );
149
+ if ("refused" in claimed) {
150
+ throw new RunRefused(refusalMessage(run.record.id, claimed.refused, now));
151
+ }
152
+ // A crash may have torn the last line an earlier attempt appended; this attempt's first line
153
+ // must not be glued to it.
154
+ await endTurnsLine(run.dir)
155
+ .then(() =>
156
+ appendEvent(run.dir, {
157
+ version: RUN_EVENT_VERSIONS["attempt-started"],
158
+ attempt: claimed.attempt.record.attempt,
159
+ at: claimed.attempt.record.started,
160
+ kind: "attempt-started",
161
+ }),
162
+ )
163
+ .catch((error) => notice(`awf: the run's event log was not written: ${messageOf(error)}`));
164
+ const turns = claimed.interrupted.length > 0 ? await readTurns(run.dir).catch(() => []) : [];
165
+ for (const earlier of claimed.interrupted) {
166
+ // Its last turn says the stage it was in, as nothing else it wrote does.
167
+ const stage = turns.findLast((turn) => turn.attempt === earlier.attempt)?.stage;
168
+ notice(
169
+ `awf: attempt ${earlier.attempt} of ${run.record.id} was interrupted${stage ? ` in ${stage}` : ""}; its panes may still be open in Herdr workspace "${workspaceLabel(meta.name, run.record.id, earlier.attempt)}"`,
170
+ );
171
+ }
172
+ return claimed.attempt;
173
+ }
174
+
175
+ /** A run's Herdr workspace, which names the attempt: a dead one's may still be open. */
176
+ export function workspaceLabel(workflow: string, id: string, attempt: number): string {
177
+ return `awf ${workflow} ${id} #${attempt}`;
178
+ }
@@ -0,0 +1,137 @@
1
+ import type { TurnRecord } from "@agentswf/contract/records";
2
+ import type { AgentKey, OperationRecord } from "@agentswf/contract/workflow";
3
+
4
+ /** One labelled `parallel` call. Its label need not be unique; its position is. */
5
+ export type GroupProgress = {
6
+ label: string;
7
+ total: number;
8
+ /** Items handed to the operation so far; the rest wait for a free slot. */
9
+ started: number;
10
+ done: number;
11
+ startedAt: number;
12
+ endedAt?: number;
13
+ };
14
+
15
+ type TurnProgress = Pick<TurnRecord, "kind" | "stage" | "label"> & {
16
+ startedAt: number;
17
+ phase?: string;
18
+ /** When `phase` began. */
19
+ phaseAt?: number;
20
+ waitingReason?: string;
21
+ checkInAt?: number;
22
+ settledAt?: number;
23
+ /** Absent while the turn runs. */
24
+ outcome?: TurnRecord["outcome"];
25
+ reason?: string;
26
+ };
27
+
28
+ /** A message admitted, as the progress view lines it. */
29
+ export type MessageProgress = {
30
+ from: AgentKey;
31
+ to: AgentKey;
32
+ bytes: number;
33
+ at: number;
34
+ /** It asks: `to` owes a reply. */
35
+ asks?: true;
36
+ /** It answers a question `to` asked. */
37
+ answers?: true;
38
+ };
39
+
40
+ export type AgentProgress = {
41
+ /** Index into `groups` of the labelled `parallel` that opened the agent. */
42
+ group?: number;
43
+ turns: number;
44
+ stopped?: { at: number; reason: string; cancelledTurn: boolean };
45
+ /** The agent it was forked from. */
46
+ forkedFrom?: AgentKey;
47
+ /** The latest turn. */
48
+ turn?: TurnProgress;
49
+ };
50
+
51
+ /**
52
+ * What the engine knows about a run's progress beyond each agent's harness state and the stages,
53
+ * which the stage ledger keeps.
54
+ */
55
+ export class RunProgress {
56
+ readonly #groups: GroupProgress[] = [];
57
+ readonly #agents = new Map<AgentKey, AgentProgress>();
58
+ readonly #messages: MessageProgress[] = [];
59
+
60
+ group(label: string, total: number): GroupProgress {
61
+ const group = { label, total, started: 0, done: 0, startedAt: Date.now() };
62
+ this.#groups.push(group);
63
+ return group;
64
+ }
65
+
66
+ agentOpened(key: AgentKey, group: GroupProgress | undefined): void {
67
+ if (this.#agents.has(key)) return;
68
+ const index = group ? this.#groups.indexOf(group) : -1;
69
+ this.#agents.set(key, { turns: 0, ...(index >= 0 ? { group: index } : {}) });
70
+ }
71
+
72
+ /** A fork that was not made, whose key is free again. */
73
+ agentDropped(key: AgentKey): void {
74
+ this.#agents.delete(key);
75
+ }
76
+
77
+ /** Once its parent's session was copied, not when it was asked for. */
78
+ agentForked(key: AgentKey, from: AgentKey): void {
79
+ const agent = this.#agents.get(key);
80
+ if (agent) agent.forkedFrom = from;
81
+ }
82
+
83
+ agentStopped(key: AgentKey, reason: string): void {
84
+ const agent = this.#agents.get(key);
85
+ if (agent && !agent.stopped) {
86
+ agent.stopped = {
87
+ at: Date.now(),
88
+ reason,
89
+ cancelledTurn: agent.turn !== undefined && agent.turn.outcome === undefined,
90
+ };
91
+ }
92
+ }
93
+
94
+ turnStarted(
95
+ key: AgentKey,
96
+ tags: Pick<OperationRecord, "stage" | "label"> = {},
97
+ kind: TurnRecord["kind"] = "turn",
98
+ ): void {
99
+ const agent = this.#agents.get(key);
100
+ if (!agent) return;
101
+ agent.turns += 1;
102
+ agent.turn = { kind, startedAt: Date.now(), ...tags };
103
+ }
104
+
105
+ turnSettled(key: AgentKey, outcome: TurnProgress["outcome"], reason?: string): void {
106
+ const turn = this.#agents.get(key)?.turn;
107
+ if (!turn || turn.outcome) return;
108
+ turn.settledAt = Date.now();
109
+ turn.outcome = outcome;
110
+ if (reason !== undefined) turn.reason = reason;
111
+ }
112
+
113
+ turnPhase(key: AgentKey, phase: string, reason?: string, until?: number): void {
114
+ const turn = this.#agents.get(key)?.turn;
115
+ if (!turn || turn.outcome) return;
116
+ if (turn.phase !== phase || turn.waitingReason !== reason) turn.phaseAt = Date.now();
117
+ turn.phase = phase;
118
+ turn.waitingReason = reason;
119
+ turn.checkInAt = until;
120
+ }
121
+
122
+ messageSent(message: MessageProgress): void {
123
+ this.#messages.push(message);
124
+ }
125
+
126
+ snapshot(): {
127
+ groups: GroupProgress[];
128
+ agents: Map<AgentKey, AgentProgress>;
129
+ messages: MessageProgress[];
130
+ } {
131
+ return {
132
+ groups: structuredClone(this.#groups),
133
+ agents: structuredClone(this.#agents),
134
+ messages: structuredClone(this.#messages),
135
+ };
136
+ }
137
+ }