@agentswf/engine 0.1.0-beta.4 → 0.1.0

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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
@@ -0,0 +1,337 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ ClientError,
5
+ type Ending,
6
+ type Run as RunHandle,
7
+ type RunStatus,
8
+ type StartResult,
9
+ } from "@agentswf/contract/client";
10
+ import type { AttemptRecord, ClaimedAttempt, OutputRecord } from "@agentswf/contract/records";
11
+ import { isEventWorkflow, type JsonValue } from "@agentswf/contract/workflow";
12
+ import { canonical } from "../canonical-json";
13
+ import { shellLine } from "../command-line";
14
+ import { messageOf } from "../errors";
15
+ import { exists } from "../files";
16
+ import { recordProject } from "../projects";
17
+ import { lockHeld, runLockFile, withRunLock } from "../run-lock";
18
+ import { type RunState, runStatus, statusOf, stuckOf } from "../run-state";
19
+ import {
20
+ claimedOf,
21
+ createRun,
22
+ generateId,
23
+ openRun,
24
+ outFile,
25
+ type Run,
26
+ RunRefused,
27
+ RunTaken,
28
+ readAttemptFiles,
29
+ takenInCase,
30
+ } from "../runs";
31
+ import { wake } from "../wake";
32
+ import type { AnyWorkflow } from "../workflow-loader";
33
+ import { checkArgs, checkRunId, derivedId } from "./checks";
34
+ import type { ClientContext } from "./index";
35
+ import { locate } from "./locate";
36
+
37
+ /** How often a wait reads the run's files. */
38
+ const POLL_MILLISECONDS = 500;
39
+
40
+ /**
41
+ * `start`: makes sure the run with these args exists and has been launched. A run there with the
42
+ * same args is `exists`, launched only if it never had an attempt; with other args, or none
43
+ * recorded, `other-args`.
44
+ */
45
+ export async function start(
46
+ workflow: AnyWorkflow,
47
+ args: JsonValue,
48
+ given: { id?: string },
49
+ context: ClientContext,
50
+ ): Promise<StartResult<JsonValue>> {
51
+ const found = await locate(workflow, context.home);
52
+ const name = workflow.meta.name;
53
+ checkArgs(name, args);
54
+ const named = given.id ?? derivedId(workflow, args);
55
+ if (named !== undefined) checkRunId(named, `${name}/${named}`);
56
+ // Without one, a new id, as awf run makes one: no sender names this run.
57
+ const id = named ?? (await unusedId(found.runRoot, name));
58
+ const create = async (id: string): Promise<StartResult<JsonValue> | undefined> => {
59
+ let run: Run;
60
+ try {
61
+ run = await createRun(found.runRoot, {
62
+ id,
63
+ workflow: name,
64
+ argv: [],
65
+ args,
66
+ cwd: found.project,
67
+ sandbox: null,
68
+ ...(isEventWorkflow(workflow) ? { events: true as const } : {}),
69
+ env: { PATH: context.environment.PATH ?? "" },
70
+ file: found.file,
71
+ });
72
+ } catch (error) {
73
+ // A generated id an `awf run` took meanwhile, which takes no lock: drawn again.
74
+ if (named === undefined && error instanceof RunTaken) return undefined;
75
+ // A named id an `awf run` took meanwhile: begun with no args recorded, as a moment later.
76
+ if (error instanceof RunTaken && (await exists(join(found.runRoot, name, id, "run.json"))))
77
+ throw new ClientError(
78
+ "other-args",
79
+ `${name}/${id} is there already, begun by awf run or an event, with no args recorded`,
80
+ `${name}/${id}`,
81
+ );
82
+ // An id taken in another case is the caller's; a disk that fails is not.
83
+ if (error instanceof RunRefused)
84
+ throw new ClientError("id", messageOf(error), `${name}/${id}`);
85
+ throw error;
86
+ }
87
+ await recordProject(context.home, found.project);
88
+ return launched(run, "started");
89
+ };
90
+ const launched = async (run: Run, did: "started" | "exists"): Promise<StartResult<JsonValue>> => {
91
+ const woken = await wake(run, { ...context, file: found.file });
92
+ const handle = runHandle(run, context);
93
+ if (woken.why === "launch-failed")
94
+ return {
95
+ ok: false,
96
+ run: handle,
97
+ did: "launch-failed",
98
+ ...(woken.error ? { error: woken.error } : {}),
99
+ };
100
+ if (woken.why === "stuck") return { ok: false, run: handle, did: "stuck" };
101
+ return { ok: true, run: handle, did };
102
+ };
103
+ const ref = `${name}/${id}`;
104
+ if (named === undefined)
105
+ // A generated id another caller drew the same minute is drawn again, never taken as theirs.
106
+ for (let candidate = id; ; candidate = await unusedId(found.runRoot, name)) {
107
+ const made = await withRunLock(runLockFile(found.runRoot, name, candidate), async () =>
108
+ (await exists(join(found.runRoot, name, candidate))) ? undefined : create(candidate),
109
+ );
110
+ if (made) return made;
111
+ }
112
+ return withRunLock(runLockFile(found.runRoot, name, id), async () => {
113
+ const run = await openExact(found.runRoot, name, id, "id");
114
+ // Named, so never drawn again: create returns a result or throws.
115
+ if (!run) return (await create(id))!;
116
+ if (run.record.args === undefined)
117
+ throw new ClientError(
118
+ "other-args",
119
+ `${ref} is there already, started by an event or by awf run, with no args recorded; get reads it`,
120
+ ref,
121
+ );
122
+ if (JSON.stringify(canonical(run.record.args)) !== JSON.stringify(canonical(args)))
123
+ throw new ClientError(
124
+ "other-args",
125
+ `${ref} is there already, started with other args; another id starts these`,
126
+ ref,
127
+ );
128
+ const files = await readAttemptFiles(run);
129
+ if (claimedOf(files).length > 0)
130
+ return { ok: true, run: runHandle(run, context), did: "exists" };
131
+ // Never launched, or its launch failed: launched now, as a wake would, unless it is stuck.
132
+ if (stuckOf(files)) return { ok: false, run: runHandle(run, context), did: "stuck" };
133
+ return launched(run, "exists");
134
+ });
135
+ }
136
+
137
+ async function unusedId(runRoot: string, workflow: string): Promise<string> {
138
+ for (;;) {
139
+ const id = generateId();
140
+ if (!(await exists(join(runRoot, workflow, id)))) return id;
141
+ }
142
+ }
143
+
144
+ /** `get`: the run `id` of `workflow`, or `no-run`. */
145
+ export async function get(
146
+ workflow: AnyWorkflow,
147
+ id: string,
148
+ context: ClientContext,
149
+ ): Promise<RunHandle<JsonValue>> {
150
+ const found = await locate(workflow, context.home);
151
+ const ref = `${workflow.meta.name}/${id}`;
152
+ checkRunId(id, ref);
153
+ const run = await openExact(found.runRoot, workflow.meta.name, id, "no-run");
154
+ if (!run) throw new ClientError("no-run", `no run ${ref} in ${found.project}`, ref);
155
+ return runHandle(run, context);
156
+ }
157
+
158
+ /**
159
+ * The run `{workflow}/{id}` in `runRoot`, or undefined when there is none; one there whose id
160
+ * differs from `id` only in case is refused with `differing`.
161
+ */
162
+ export async function openExact(
163
+ runRoot: string,
164
+ workflow: string,
165
+ id: string,
166
+ differing: "id" | "no-run",
167
+ ): Promise<Run | undefined> {
168
+ const ref = `${workflow}/${id}`;
169
+ const refuse = (other: string) =>
170
+ differing === "id"
171
+ ? new ClientError("id", `${ref} differs only in case from ${other}`, ref)
172
+ : new ClientError("no-run", `no run ${ref}; ${other} differs only in case`, ref);
173
+ if (!(await exists(join(runRoot, workflow, id, "run.json")))) {
174
+ // A file system that tells case apart, as Linux's does, finds no folder at all.
175
+ const other = await takenInCase(join(runRoot, workflow), id);
176
+ if (other === undefined) return undefined;
177
+ throw refuse(other);
178
+ }
179
+ const run = await openRun(runRoot, workflow, id);
180
+ if (run.record.id === id) return run;
181
+ throw refuse(run.record.id);
182
+ }
183
+
184
+ /** A run, read from its files whenever asked. */
185
+ function runHandle(run: Run, context: Pick<ClientContext, "pollMs">): RunHandle<JsonValue> {
186
+ const { id, workflow } = run.record;
187
+ return {
188
+ id,
189
+ ref: `${workflow}/${id}`,
190
+ status: () => statusNow(run),
191
+ wait: (options = {}) =>
192
+ waitFor(run, options.final === true, options.signal, context.pollMs ?? POLL_MILLISECONDS),
193
+ };
194
+ }
195
+
196
+ /** Whether nothing continues a run in `state` on its own. */
197
+ export function isFinal(state: RunState, events: boolean): boolean {
198
+ switch (state) {
199
+ case "completed":
200
+ case "stopped":
201
+ case "cancelled":
202
+ case "stuck":
203
+ return true;
204
+ case "failed":
205
+ case "timed-out":
206
+ case "interrupted":
207
+ // An event run's next event, or awf tick, continues it.
208
+ return !events;
209
+ case "parked":
210
+ case "running":
211
+ return false;
212
+ }
213
+ }
214
+
215
+ async function statusNow(run: Run): Promise<RunStatus<JsonValue>> {
216
+ const status = await runStatus(run);
217
+ const last = status.attempts.at(-1);
218
+ const ending = last?.outcome === undefined ? undefined : await endingOf(run, last);
219
+ return {
220
+ state: status.state,
221
+ final: isFinal(status.state, run.record.events === true),
222
+ ...(ending ? { ending } : {}),
223
+ };
224
+ }
225
+
226
+ /** An ended attempt's ending, from its record and, for the last one, `out.json`. */
227
+ async function endingOf(run: Run, attempt: ClaimedAttempt): Promise<Ending<JsonValue> | undefined> {
228
+ const output = await readFile(outFile(run.dir), "utf8")
229
+ .then((text) => JSON.parse(text) as OutputRecord)
230
+ .catch(() => undefined);
231
+ const own = output?.attempt === attempt.attempt ? output : undefined;
232
+ const stages = own?.stages ?? [];
233
+ switch (attempt.outcome) {
234
+ case undefined:
235
+ return undefined;
236
+ case "completed":
237
+ if (attempt.value !== undefined) return { kind: "completed", value: attempt.value, stages };
238
+ return own?.outcome === "completed"
239
+ ? { kind: "completed", value: own.value, stages }
240
+ : undefined;
241
+ case "parked":
242
+ return {
243
+ kind: "parked",
244
+ stages,
245
+ ...(own?.outcome === "parked" && own.label !== undefined ? { label: own.label } : {}),
246
+ ...(own?.outcome === "parked" && own.until !== undefined ? { until: own.until } : {}),
247
+ };
248
+ default:
249
+ return {
250
+ kind: attempt.outcome,
251
+ ...(attempt.stage === undefined ? {} : { stage: attempt.stage }),
252
+ reason: attempt.reason ?? "",
253
+ stages,
254
+ continue: shellLine(["awf", "run", attempt.file, "--id", run.record.id, "--continue"]),
255
+ ...(own && "needs" in own && own.needs ? { needs: own.needs } : {}),
256
+ };
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Waits for the run's next rest after the call, or, with `final`, the first rest nothing will
262
+ * continue on its own. A rest is read from the attempt records, not by sampling the state: an
263
+ * attempt current at the call, or any later one, ending or found dead; or a launch that failed
264
+ * after the call, with the run's lock free.
265
+ */
266
+ async function waitFor(
267
+ run: Run,
268
+ final: boolean,
269
+ signal: AbortSignal | undefined,
270
+ pollMs: number,
271
+ ): Promise<RunStatus<JsonValue>> {
272
+ signal?.throwIfAborted();
273
+ const failedLaunches = (files: readonly AttemptRecord[]) =>
274
+ files.filter((file) => file.kind === "failed-launch").length;
275
+ // Read before the status: an attempt that rests and is continued between the two is still seen.
276
+ const files = await readAttemptFiles(run);
277
+ const at = claimedOf(files);
278
+ let launches = failedLaunches(files);
279
+ const now = await statusNow(run);
280
+ if (final ? now.final : now.state !== "running") return now;
281
+ const lockFile = runLockFile(run.root, run.record.workflow, run.record.id);
282
+ // The attempt current at the call, or the first one after it.
283
+ let watching =
284
+ at.at(-1)?.outcome === undefined && at.length > 0
285
+ ? at.at(-1)!.attempt
286
+ : (files.at(-1)?.attempt ?? 0) + 1;
287
+ for (;;) {
288
+ await pause(pollMs, signal);
289
+ const all = await readAttemptFiles(run);
290
+ const attempts = claimedOf(all);
291
+ let rested: ClaimedAttempt | undefined;
292
+ for (let i = 0; i < attempts.length; i++) {
293
+ const attempt = attempts[i]!;
294
+ if (attempt.attempt < watching) continue;
295
+ const state = statusOf(run, attempts.slice(0, i + 1)).state;
296
+ if (state === "running") break;
297
+ rested = attempt;
298
+ watching = attempt.attempt + 1;
299
+ }
300
+ // A launch that failed is a rest once its sender has let the run go; a sender holding the lock
301
+ // is launching it again.
302
+ const launchFailed = failedLaunches(all) > launches && !(await lockHeld(lockFile));
303
+ if (launchFailed) launches = failedLaunches(all);
304
+ const current = await statusNow(run);
305
+ if (final) {
306
+ if (current.final) return current;
307
+ continue;
308
+ }
309
+ if (current.state !== "running") return current;
310
+ const events = run.record.events === true;
311
+ if (rested) {
312
+ // Rested, and continued since: the rest as it was.
313
+ const state: RunState = rested.outcome ?? "interrupted";
314
+ const ending = await endingOf(run, rested);
315
+ return { state, final: isFinal(state, events), ...(ending ? { ending } : {}) };
316
+ }
317
+ // Unless an attempt has started since, by a sender that launched it again.
318
+ if (launchFailed && !attempts.some((attempt) => attempt.attempt >= watching))
319
+ return { state: "interrupted", final: isFinal("interrupted", events) };
320
+ }
321
+ }
322
+
323
+ function pause(ms: number, signal: AbortSignal | undefined): Promise<void> {
324
+ return new Promise((resolve, reject) => {
325
+ // Aborted meanwhile: a listener added now would never hear it.
326
+ if (signal?.aborted) return reject(signal.reason);
327
+ const done = () => {
328
+ clearTimeout(timer);
329
+ reject(signal?.reason);
330
+ };
331
+ const timer = setTimeout(() => {
332
+ signal?.removeEventListener("abort", done);
333
+ resolve();
334
+ }, ms);
335
+ signal?.addEventListener("abort", done, { once: true });
336
+ });
337
+ }
@@ -0,0 +1,64 @@
1
+ // The operator commands' words: where the commands that read runs look, and the commands they
2
+ // suggest, as text to paste into a shell.
3
+ import { resolve } from "node:path";
4
+ import type { RunCommand } from "./run-command";
5
+ import { runRootOf } from "./runs";
6
+
7
+ /** Where the commands that read runs look, and the options their suggested commands repeat. */
8
+ export type ReadPlace = { shellCwd: string; runRoot: string; repeat: string[] };
9
+
10
+ export type Out = { stdout(text: string): void; stderr(text: string): void };
11
+
12
+ /** `--cwd`, `--run-root` and `--json`, taken out of `argv`; the rest is left for the command. */
13
+ export function parsePlace(
14
+ argv: readonly string[],
15
+ shellCwd: string,
16
+ ): { place: ReadPlace; json: boolean; rest: string[] } {
17
+ let cwd = shellCwd;
18
+ let runRoot: string | undefined;
19
+ let json = false;
20
+ const repeat: string[] = [];
21
+ const rest: string[] = [];
22
+ for (let index = 0; index < argv.length; index += 1) {
23
+ const arg = argv[index]!;
24
+ // What follows is the command's own, a message's text included.
25
+ if (arg === "--") {
26
+ rest.push(...argv.slice(index));
27
+ break;
28
+ }
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, `words` and then the place repeated, before any options added after. */
43
+ export function command(place: ReadPlace, ...words: string[]): string[] {
44
+ return [...words, ...place.repeat];
45
+ }
46
+
47
+ /** The flags that find this run again from another command: the folder or run root given. */
48
+ export function placeFlags(
49
+ run: Pick<RunCommand, "cwd" | "cwdGiven" | "runRoot" | "runRootGiven">,
50
+ ): string[] {
51
+ return [
52
+ ...(run.cwdGiven ? ["--cwd", run.cwd] : []),
53
+ ...(run.runRootGiven ? ["--run-root", run.runRoot] : []),
54
+ ];
55
+ }
56
+
57
+ /** A suggested command as text to paste into a shell. */
58
+ export function shellLine(words: readonly string[]): string {
59
+ return words.map(shellWord).join(" ");
60
+ }
61
+
62
+ export function shellWord(word: string): string {
63
+ return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`;
64
+ }
package/src/commands.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { allowanceUsage } from "./allowance-command";
2
+ import { shellLine } from "./command-line";
2
3
  import { emitUsage } from "./emit-command";
3
4
  import { replyUsage } from "./holder-command";
4
5
  import { inboxUsage } from "./inbox-command";
5
6
  import { initUsage } from "./init-command";
6
7
  import { logsUsage } from "./logs-command";
8
+ import { openUsage } from "./open-command";
7
9
  import { usage as runUsage } from "./run-command";
8
- import { shellLine } from "./run-commands";
9
10
  import { runsUsage } from "./runs-command";
10
11
  import { testUsage } from "./test-command";
11
12
  import { tickUsage } from "./tick-command";
@@ -46,6 +47,11 @@ export const COMMANDS = [
46
47
  commands: { logs: "What a run did and is doing, merged by time" },
47
48
  help: logsUsage,
48
49
  },
50
+ {
51
+ group: "Look at runs",
52
+ commands: { open: "Open a copy of an agent's session in its harness, or ask it one question" },
53
+ help: openUsage,
54
+ },
49
55
  {
50
56
  group: "Coordinate a run from outside",
51
57
  commands: { inbox: "What agents said to a participant outside the run, and what it said back" },
@@ -6,6 +6,7 @@ import {
6
6
  type GivenInput,
7
7
  WIRE_VERSION,
8
8
  } from "@agentswf/contract/wire";
9
+ import { DOORS_PREFIX } from "@agentswf/sandbox";
9
10
  import { isCode } from "./files";
10
11
  import type { Messaging } from "./messaging/messaging";
11
12
  import { UNRECORDED } from "./messaging/text";
@@ -13,13 +14,10 @@ import type { ResultSlotRegistry } from "./result-slots";
13
14
  import type { ToolCalls } from "./tools";
14
15
 
15
16
  /** What the control plane asks of the run's tools, by the agent whose socket asked. */
16
- export type ToolHandler = Pick<ToolCalls<unknown>, "call" | "running" | "accepted">;
17
+ type ToolHandler = Pick<ToolCalls<unknown>, "call" | "running" | "accepted">;
17
18
 
18
19
  /** What the control plane asks of the run's messaging, by the agent whose socket asked. */
19
- export type MessagingHandler = Pick<
20
- Messaging,
21
- "checkFinish" | "peers" | "send" | "reply" | "owing"
22
- >;
20
+ type MessagingHandler = Pick<Messaging, "checkFinish" | "peers" | "send" | "reply" | "owing">;
23
21
 
24
22
  /** `wfa` accepts a 1 MiB value; escaped into the request's JSON it can double, plus the envelope. */
25
23
  const MAX_RESULT_REQUEST_BYTES = 2 * 1024 * 1024 + 4 * 1024;
@@ -97,7 +95,7 @@ export async function startResultControlPlane(options: {
97
95
  );
98
96
  const root = options.socketRoot ?? CONTROL_PLANE_ROOT;
99
97
  await mkdir(root, { recursive: true });
100
- const directory = await mkdtemp(join(root, "awf-"));
98
+ const directory = await mkdtemp(join(root, DOORS_PREFIX));
101
99
  // Traversable but not listable: an agent reaches the one path it was given and cannot read the
102
100
  // directory to discover its siblings'. Close puts the read bit back so the tree can be removed.
103
101
  await chmod(directory, 0o300);
@@ -321,16 +319,17 @@ type Handlers = {
321
319
  lifetime: (milliseconds: number | undefined) => void;
322
320
  };
323
321
 
324
- async function handleFrame(frame: string, handlers: Handlers): Promise<ControlResponse> {
322
+ async function handleFrame(frame: string, handlers: Handlers): Promise<Answer> {
325
323
  const { messaging, agentId } = handlers;
326
324
  const response = await answerFrame(frame, handlers);
325
+ if (inOtherVersion(response)) return response;
327
326
  // Leads every response, so what is owed comes back after a compaction; not one that already
328
327
  // says it, about the wait it began or the answer it is refused for.
329
328
  const repeats =
330
329
  response.kind === "awaiting-reply" ||
331
330
  (response.kind === "sent" && response.until !== undefined) ||
332
331
  (response.kind === "rejected" &&
333
- (response.code === "owes-reply" || response.code === "awaiting"));
332
+ (response.code === "owes-reply" || response.code === "already-awaiting"));
334
333
  const owing = repeats ? undefined : messaging?.owing(agentId);
335
334
  return owing ? { ...response, owing } : response;
336
335
  }
@@ -338,7 +337,7 @@ async function handleFrame(frame: string, handlers: Handlers): Promise<ControlRe
338
337
  async function answerFrame(
339
338
  frame: string,
340
339
  { slots, messaging, tools, agentId, onSession, lifetime }: Handlers,
341
- ): Promise<ControlResponse> {
340
+ ): Promise<Answer> {
342
341
  if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
343
342
  return rejected("invalid-request", "expected exactly one newline-delimited JSON request");
344
343
  }
@@ -350,7 +349,10 @@ async function answerFrame(
350
349
  return rejected("invalid-request", "request is not valid JSON");
351
350
  }
352
351
  const decoded = decodeControlRequest(parsed);
353
- if (!decoded.ok) return rejected(decoded.code, decoded.error);
352
+ if (!decoded.ok)
353
+ return decoded.code === "unsupported-version"
354
+ ? otherVersion((parsed as { version: number }).version)
355
+ : rejected(decoded.code, decoded.error);
354
356
  // Before the result is judged: a rejected submission still proves which session sent it.
355
357
  if (decoded.value.session) onSession?.(decoded.value.session);
356
358
 
@@ -447,7 +449,35 @@ function rejected(
447
449
  return { version: WIRE_VERSION, kind: "rejected", code, error };
448
450
  }
449
451
 
450
- function queueResponse(socket: Bun.Socket<ConnectionState>, response: ControlResponse): void {
452
+ /** A refusal in another wire version than this engine's: the one answer not a `ControlResponse`. */
453
+ type OtherVersion = {
454
+ version: number;
455
+ kind: "rejected";
456
+ code: "unsupported-version";
457
+ error: string;
458
+ };
459
+ type Answer = ControlResponse | OtherVersion;
460
+
461
+ function inOtherVersion(answer: Answer): answer is OtherVersion {
462
+ return answer.version !== WIRE_VERSION;
463
+ }
464
+
465
+ /**
466
+ * Refused in the version the request gave: a `wfa` of version 4 or 5 decodes a rejection of that
467
+ * shape and prints its error, where it would refuse this engine's version unread. They meet when
468
+ * awf is installed anew in place while a run is live: a launcher runs the `wfa` on disk, which is
469
+ * no longer the one the engine in memory shipped with.
470
+ */
471
+ function otherVersion(version: number): OtherVersion {
472
+ return {
473
+ version,
474
+ kind: "rejected",
475
+ code: "unsupported-version",
476
+ error: `this wfa speaks wire version ${version} and the workflow engine ${WIRE_VERSION}: awf was installed anew while the run was live. Nothing was done; the run must go on under one version of awf.`,
477
+ };
478
+ }
479
+
480
+ function queueResponse(socket: Bun.Socket<ConnectionState>, response: Answer): void {
451
481
  socket.data.outgoing = Buffer.from(`${JSON.stringify(response)}\n`);
452
482
  socket.data.written = 0;
453
483
  flushResponse(socket);
@@ -1,7 +1,8 @@
1
1
  import { createInterface, type Interface } from "node:readline/promises";
2
+ import { shellLine } from "./command-line";
2
3
  import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
4
+ import { messageOf } from "./errors";
3
5
  import type { MessagingEventBody } from "./messaging/messaging";
4
- import { shellLine } from "./run-commands";
5
6
 
6
7
  /** Where the prompt reads a line: `awf run`'s terminal, or a test's script. */
7
8
  export type LineReader = {
@@ -13,7 +14,7 @@ export type LineReader = {
13
14
  };
14
15
 
15
16
  /** What the prompt shows beside the progress view, which holds its block while a prompt is open. */
16
- export type PromptOutput = {
17
+ type PromptOutput = {
17
18
  /** A line in the run's log, above the progress block. */
18
19
  log(line: string): void;
19
20
  /** The progress block stops redrawing while a prompt waits for a line, and lines go above it. */
@@ -93,7 +94,7 @@ export function promptCoordinator(
93
94
  if (!question) return;
94
95
  // One that fails is said and passed over; the others are still asked.
95
96
  await ask(question).catch((error: unknown) =>
96
- output.log(`awf: the prompt failed: ${error instanceof Error ? error.message : error}`),
97
+ output.log(`awf: the prompt failed: ${messageOf(error)}`),
97
98
  );
98
99
  }
99
100
  };