@agentswf/engine 0.1.0-beta.5 → 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 (122) 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 +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  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 +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  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 +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  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 +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  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 +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
@@ -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);
@@ -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
  };
@@ -1,10 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { chmod, mkdir, realpath, rm, stat } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
4
- import type { ParticipantResultJson } from "@agentswf/contract/records";
5
- import { decodeHolderRequest, type NudgeWireResult, WIRE_VERSION } from "@agentswf/contract/wire";
3
+ import { basename, dirname, isAbsolute, join } from "node:path";
4
+
5
+ import {
6
+ decodeHolderRequest,
7
+ type HolderWireAnswer,
8
+ type NudgeWireResult,
9
+ WIRE_VERSION,
10
+ } from "@agentswf/contract/wire";
6
11
  import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
7
- import { realPath } from "./files";
12
+ import { messageOf } from "./errors";
13
+ import { isInside, realPath } from "./files";
8
14
  import type { MessagingEventBody } from "./messaging/messaging";
9
15
  import type { RunStatus } from "./run-state";
10
16
 
@@ -51,9 +57,8 @@ export async function prepareCoordinatorDir(
51
57
  return parent === path ? path : join(await real(parent), basename(path));
52
58
  }
53
59
  };
54
- const inside = relative(await real(workflowDir), await real(dir));
55
- const within =
56
- inside === "" || !(inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside));
60
+ const [root, realDir] = [await real(workflowDir), await real(dir)];
61
+ const within = realDir === root || isInside(root, realDir);
57
62
  if (moved && within)
58
63
  throw new Error(
59
64
  `AWF_COORDINATOR_DIR ${dir} is inside the workflow's directory, where its agents work; move it out`,
@@ -65,7 +70,7 @@ export async function prepareCoordinatorDir(
65
70
  /**
66
71
  * The socket `awf reply` and `awf send` reach a live run through. It opens once the workflow
67
72
  * connects an outside participant and goes when the run ends; each connection is one request
68
- * line and one `ParticipantResultJson` line back.
73
+ * line and one `HolderWireAnswer` line back.
69
74
  */
70
75
  export function coordinatorSocket(options: {
71
76
  dir: string;
@@ -73,7 +78,7 @@ export function coordinatorSocket(options: {
73
78
  attempt: number;
74
79
  /** Why the socket did not open: the run goes on, and no holder's command reaches it. */
75
80
  failed(reason: string): void;
76
- /** A nudge: the run's mailbox has a new event. Given, the socket opens at once, for awf's nudge. */
81
+ /** A nudge: the run has a new event. Given, the socket opens at once, for awf's nudge. */
77
82
  nudged?(): void;
78
83
  }): CoordinatorPort & { close(): Promise<void>; readonly path: string } {
79
84
  const path = coordinatorSocketPath(options.dir, options.runDir);
@@ -87,9 +92,7 @@ export function coordinatorSocket(options: {
87
92
  * Undefined for a request no `awf` command makes: the connection closes unanswered. One of
88
93
  * another wire version is refused naming both, which an older `awf send` or `awf reply` prints.
89
94
  */
90
- const answer = async (
91
- frame: string,
92
- ): Promise<ParticipantResultJson | NudgeWireResult | undefined> => {
95
+ const answer = async (frame: string): Promise<HolderWireAnswer | NudgeWireResult | undefined> => {
93
96
  let parsed: unknown;
94
97
  try {
95
98
  parsed = JSON.parse(frame);
@@ -111,13 +114,14 @@ export function coordinatorSocket(options: {
111
114
  return { v: 1, code: "nudged" };
112
115
  }
113
116
  if (!holder) return refused("no-participant", "the run has no participant connected");
114
- // Another attempt's question is never open in this one: a number no message has.
115
- const to =
116
- request.to === undefined
117
- ? undefined
118
- : request.to.split(".")[0] === String(options.attempt)
119
- ? Number(request.to.split(".")[1])
120
- : 0;
117
+ const [attempt, n] = request.to?.split(".") ?? [];
118
+ // Another attempt's question ended with it.
119
+ if (request.to !== undefined && attempt !== String(options.attempt))
120
+ return refused(
121
+ "expired",
122
+ `${request.to} is a question of attempt ${attempt}, which has ended; the run is on attempt ${options.attempt}.`,
123
+ );
124
+ const to = n === undefined ? undefined : Number(n);
121
125
  const asked = {
122
126
  ...(request.participant === undefined ? {} : { coordinator: request.participant }),
123
127
  agent: request.agent,
@@ -194,10 +198,7 @@ export function coordinatorSocket(options: {
194
198
  .then((result) => socket.end(result ? `${JSON.stringify(result)}\n` : undefined));
195
199
  };
196
200
 
197
- if (options.nudged)
198
- opening = open().catch((error: unknown) =>
199
- options.failed(error instanceof Error ? error.message : String(error)),
200
- );
201
+ if (options.nudged) opening = open().catch((error: unknown) => options.failed(messageOf(error)));
201
202
  return {
202
203
  path,
203
204
  attach(given) {
@@ -205,9 +206,7 @@ export function coordinatorSocket(options: {
205
206
  },
206
207
  recorded(event: MessagingEventBody) {
207
208
  if (event.kind === "participant-connected" && !opening) {
208
- opening = open().catch((error: unknown) =>
209
- options.failed(error instanceof Error ? error.message : String(error)),
210
- );
209
+ opening = open().catch((error: unknown) => options.failed(messageOf(error)));
211
210
  }
212
211
  },
213
212
  async close() {
@@ -220,9 +219,9 @@ export function coordinatorSocket(options: {
220
219
  }
221
220
 
222
221
  function refused(
223
- code: Exclude<ParticipantResultJson["code"], "delivered">,
222
+ code: Exclude<HolderWireAnswer["code"], "delivered">,
224
223
  error: string,
225
- ): ParticipantResultJson {
224
+ ): HolderWireAnswer {
226
225
  return { v: 2, code, error };
227
226
  }
228
227
 
package/src/deadlines.ts CHANGED
@@ -92,10 +92,6 @@ export function earlierDeadline(
92
92
  return left.unixMilliseconds <= right.unixMilliseconds ? left : right;
93
93
  }
94
94
 
95
- export function laterDeadline(left: AbsoluteDeadline, right: AbsoluteDeadline): AbsoluteDeadline {
96
- return left.unixMilliseconds >= right.unixMilliseconds ? left : right;
97
- }
98
-
99
95
  export function deadlineWithin(
100
96
  milliseconds: number,
101
97
  ceiling: AbsoluteDeadline | undefined,
@@ -106,49 +102,19 @@ export function deadlineWithin(
106
102
  return earlierDeadline({ unixMilliseconds: Date.now() + milliseconds }, ceiling);
107
103
  }
108
104
 
109
- /** A call's bound as a workflow gives it: `timeout`, or the deprecated `deadline` or `timeoutMs`. */
110
- export type GivenBound = {
111
- timeout?: Timeout;
112
- deadline?: AbsoluteDeadline;
113
- timeoutMs?: number;
114
- };
115
-
116
105
  /**
117
- * The deadline a call's bound sets, never past `ceiling`, the deadlines around it; `ceiling` when it
118
- * sets none of its own. A deprecated form is checked, then reported under `call`, the call it was
119
- * given to, and refused beside `timeout`. `null` from an untyped caller is no bound, as it was.
106
+ * The deadline a call's `timeout` sets, never past `ceiling`, the deadlines around it; `ceiling`
107
+ * when it sets none of its own. `null` from an untyped caller is no bound.
120
108
  */
121
109
  export function boundedDeadline(
122
- given: GivenBound,
110
+ timeout: Timeout | null | undefined,
123
111
  ceiling: AbsoluteDeadline | undefined,
124
- deprecated: (name: "timeout-ms" | "deadline-option", call: string) => void,
125
- call: string,
126
112
  ): AbsoluteDeadline | undefined {
127
- const { timeout, deadline, timeoutMs } = given;
128
- if (timeout != null) {
129
- if (deadline != null || timeoutMs != null)
130
- throw new Error(
131
- "give timeout alone: deadline and timeoutMs are its deprecated forms, not limits beside it",
132
- );
133
- return timeoutDeadline(timeout, ceiling);
134
- }
135
- if (deadline != null && timeoutMs != null)
136
- throw new Error("an operation cannot specify both deadline and timeoutMs");
137
- if (timeoutMs != null) {
138
- const bounded = deadlineWithin(timeoutMs, ceiling);
139
- deprecated("timeout-ms", call);
140
- return bounded;
141
- }
142
- if (deadline == null) return ceiling;
143
- // A copy: the object given may be `wf.deadline`, whose `unixMilliseconds` reports each read.
144
- const own = { unixMilliseconds: deadline.unixMilliseconds };
145
- assertDeadlineValue(own);
146
- deprecated("deadline-option", call);
147
- return earlierDeadline(own, ceiling);
113
+ return timeout == null ? ceiling : timeoutDeadline(timeout, ceiling);
148
114
  }
149
115
 
150
116
  /** The deadline `timeout` sets from now, never past `ceiling`; `ceiling` for `false`. */
151
- export function timeoutDeadline(
117
+ function timeoutDeadline(
152
118
  timeout: Timeout,
153
119
  ceiling: AbsoluteDeadline | undefined,
154
120
  ): AbsoluteDeadline | undefined {
@@ -176,7 +142,7 @@ export function assertDeadline(deadline: AbsoluteDeadline | undefined): void {
176
142
  }
177
143
  }
178
144
 
179
- export function assertDeadlineValue(deadline: AbsoluteDeadline): void {
145
+ function assertDeadlineValue(deadline: AbsoluteDeadline): void {
180
146
  if (!Number.isSafeInteger(deadline.unixMilliseconds) || deadline.unixMilliseconds < 0) {
181
147
  throw new Error("deadline.unixMilliseconds must be a non-negative safe integer");
182
148
  }