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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +5 -5
  2. package/src/allowance-command.ts +29 -7
  3. package/src/attempt-ending.ts +23 -5
  4. package/src/attempt-output.ts +48 -18
  5. package/src/attempt-view.ts +25 -15
  6. package/src/commands.ts +176 -0
  7. package/src/control-plane.ts +72 -21
  8. package/src/coordinator-port.ts +29 -0
  9. package/src/coordinator-prompt.ts +237 -0
  10. package/src/coordinator-socket.ts +236 -0
  11. package/src/deadlines.ts +47 -10
  12. package/src/decisions/directory.ts +8 -6
  13. package/src/duration.ts +29 -0
  14. package/src/emit-command.ts +130 -0
  15. package/src/emit.ts +246 -0
  16. package/src/files.ts +11 -1
  17. package/src/here.ts +8 -2
  18. package/src/hold-run-lock.fixture.ts +8 -0
  19. package/src/holder-command.ts +297 -0
  20. package/src/inbox-command.ts +727 -0
  21. package/src/init-command.ts +27 -0
  22. package/src/init-template/AGENTS.md.template +1 -1
  23. package/src/init-template/hello/workflow.test.ts.template +2 -2
  24. package/src/init-template/hello/workflow.ts.template +16 -20
  25. package/src/keep-alive.ts +40 -0
  26. package/src/logs-command.ts +104 -20
  27. package/src/machine.ts +12 -1
  28. package/src/mailbox.ts +977 -0
  29. package/src/message-operation.ts +26 -3
  30. package/src/messaging/messaging.ts +458 -65
  31. package/src/messaging/routes.ts +10 -3
  32. package/src/messaging/text.ts +8 -5
  33. package/src/operation-liveness.ts +54 -8
  34. package/src/operator-cli.ts +290 -119
  35. package/src/operator-runtime.ts +5 -2
  36. package/src/progress-view.ts +49 -7
  37. package/src/projects.ts +57 -0
  38. package/src/result-slots.ts +43 -19
  39. package/src/result-validation.ts +18 -27
  40. package/src/run-command.ts +36 -32
  41. package/src/run-commands.ts +13 -2
  42. package/src/run-lock.ts +123 -0
  43. package/src/run-prepare.ts +94 -22
  44. package/src/run-state.ts +125 -6
  45. package/src/run-usage.ts +1 -1
  46. package/src/run-view.ts +0 -9
  47. package/src/runs-command.ts +46 -17
  48. package/src/runs.ts +142 -23
  49. package/src/sandboxes.ts +8 -2
  50. package/src/simple-input.ts +258 -0
  51. package/src/socket-exchange.ts +26 -0
  52. package/src/sources.ts +70 -0
  53. package/src/stage-ledger.ts +88 -13
  54. package/src/stage-plan.ts +23 -0
  55. package/src/stop-crashed.ts +39 -0
  56. package/src/test-command.ts +32 -0
  57. package/src/testing.ts +71 -3
  58. package/src/tick-command.ts +87 -0
  59. package/src/tools.ts +636 -0
  60. package/src/wake.ts +264 -0
  61. package/src/workflow-loader.ts +32 -22
  62. package/src/workflow-runner.ts +1210 -77
  63. package/src/workflow-testing/coordinator.typecheck.ts +55 -0
  64. package/src/workflow-testing/coordinators.ts +137 -0
  65. package/src/workflow-testing/host.ts +61 -1
  66. package/src/workflow-testing/index.ts +488 -33
  67. package/src/workflow-testing/script.ts +72 -2
  68. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -1,27 +1,39 @@
1
1
  import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
5
  import {
6
+ MAILBOX_RECORD_VERSION,
7
+ type MailboxRecord,
5
8
  type SandboxRecord,
6
9
  STAGE_RECORD_VERSION,
7
10
  type StageNeed,
8
11
  type StageRecord,
9
12
  } from "@agentswf/contract/records";
10
13
  import {
11
- EXECUTABLE_WORKFLOW_KIND,
12
- type ExecutableWorkflow,
14
+ AWF_ID_PREFIX,
15
+ type EventSchemas,
16
+ type HarnessAllowance,
17
+ isEventWorkflow,
13
18
  type JsonObject,
14
19
  type JsonValue,
20
+ type OutputOf,
21
+ type ParticipantReply,
15
22
  type RuntimeAliases,
23
+ type Workflow,
16
24
  type WorkflowDefinition,
17
25
  } from "@agentswf/contract/workflow";
18
26
  import { type Harness, sandboxTokens } from "@agentswf/harness";
19
27
  import { createFakeSandboxProvider } from "@agentswf/sandbox/testing/fake";
28
+ import { checkEvent, eventIdProblem } from "../emit";
20
29
  import { messageOf } from "../errors";
30
+ import { appendMailbox, foldMailbox, RunParked, readMailbox } from "../mailbox";
21
31
  import { OPERATOR_ALIASES } from "../operator-aliases";
22
- import { createRun, readEvents, readStageRecords, writeStageRecord } from "../runs";
32
+ import { createRun, idProblem, readEvents, readStageRecords, writeStageRecord } from "../runs";
23
33
  import { primaryFailure, WorkflowStopped } from "../stopped";
34
+ import type { EventWorkflow } from "../workflow-loader";
24
35
  import { runWorkflow, type SettledRun, WorkflowRunError } from "../workflow-runner";
36
+ import { ParticipantScripts } from "./coordinators";
25
37
  import { createScriptedDecisions, type DecisionRequest, type DecisionScript } from "./decisions";
26
38
  import {
27
39
  type AgentSandbox,
@@ -31,7 +43,13 @@ import {
31
43
  type SetRecord,
32
44
  type TurnRecord,
33
45
  } from "./host";
34
- import { type MessageScript, MessageScripts, type Script, Scripts } from "./script";
46
+ import {
47
+ type MessageScript,
48
+ MessageScripts,
49
+ type ParticipantScript,
50
+ type Script,
51
+ Scripts,
52
+ } from "./script";
35
53
 
36
54
  export type { DecisionRequest, DecisionScript } from "./decisions";
37
55
  export {
@@ -39,14 +57,21 @@ export {
39
57
  type Message,
40
58
  type MessageOutcome,
41
59
  type MessageScript,
60
+ type ParticipantMessage,
61
+ type ParticipantOutcome,
62
+ type ParticipantRoute,
63
+ type ParticipantScript,
42
64
  type ReceivedMessage,
43
65
  reply,
44
66
  type Script,
45
67
  type SendOptions,
68
+ type ToolOutcome,
46
69
  type Turn,
47
70
  } from "./script";
48
71
 
49
72
  export type TestOptions = {
73
+ /** Laid out before the run, by path under its directory, which the test removes. */
74
+ files?: Readonly<Record<string, FileTree>>;
50
75
  /** Each agent's script, by its key or a pattern such as `"review:*"`. */
51
76
  agents?: Readonly<Record<string, Script>>;
52
77
  /**
@@ -60,6 +85,15 @@ export type TestOptions = {
60
85
  * test.
61
86
  */
62
87
  messages?: Readonly<Record<string, MessageScript | readonly MessageScript[]>>;
88
+ /**
89
+ * Each participant's script, by the key the workflow connects it under. When no turn runs, no
90
+ * script is still working, and everything left waits on a participant with no `until`, the attempt
91
+ * ends: its open questions expire `attempt-ended` and, unless the workflow returned, `run.value`
92
+ * throws. While a question has an `until`, the test waits for the earliest, and `stallMs` does not
93
+ * count that wait. A route
94
+ * or message reaching a participant with none here fails the test.
95
+ */
96
+ participants?: Readonly<Record<string, ParticipantScript>>;
63
97
  /**
64
98
  * Where an agent may `--await-reply`: `listed`, where the engine's list allows it for the
65
99
  * agent's harness and placement, as a run would; `anywhere`, to test a workflow's messaging on a
@@ -68,12 +102,20 @@ export type TestOptions = {
68
102
  awaitReply?: "listed" | "anywhere";
69
103
  /** Each decision's answers, by its key or a pattern. */
70
104
  decisions?: Readonly<Record<string, DecisionScript>>;
105
+ /** What `workflow.allowance` reads, one per harness; a harness with none here reads `none`. */
106
+ allowance?: readonly HarnessAllowance[];
71
107
  /** Runtime aliases beside the ones `awf run` installs, `claude` and `codex`; the same name replaces one. */
72
108
  runtimes?: RuntimeAliases;
73
- /** The run's deadline; an hour, as `awf run`'s. */
109
+ /** The run's deadline; none unless given, as `awf run` has none unless `--timeout` gives one. */
74
110
  timeoutMs?: number;
75
111
  /** Real time with no turn or decision starting or ending before the test fails; 2 s. */
76
112
  stallMs?: number;
113
+ /**
114
+ * The run's clock is virtual: where a run would park with a timer to come, the clock moves to it
115
+ * and the timer fires, so a two-day reminder takes no time. After this many, the run parks
116
+ * instead, as a loop of reminders nobody answers would; 20. Zero parks at the first.
117
+ */
118
+ maxTimers?: number;
77
119
  /** Where the agents work; by default a temporary directory, removed afterwards. */
78
120
  cwd?: string;
79
121
  /**
@@ -99,6 +141,60 @@ export type TestOptions = {
99
141
  values?: Readonly<Record<string, JsonValue>>;
100
142
  };
101
143
 
144
+ /** A file's contents, or a directory of them by name. */
145
+ export type FileTree = string | { readonly [name: string]: FileTree };
146
+
147
+ /** A `keepAlive` window that gives no `timeoutMs`, in a test: the hour it would be stalls it. */
148
+ const TEST_WINDOW_MS = 100;
149
+
150
+ /**
151
+ * An event a test sends while the run goes on, at an agent's `n`th turn, `"{agent}#{n}"`: `during`
152
+ * it, or `after` it has ended, once the run waits live in an agent's `keepAlive` window.
153
+ */
154
+ export type ScriptedEvent<Events extends EventSchemas> = (
155
+ | { during: string; after?: undefined }
156
+ | { after: string; during?: undefined }
157
+ ) & { id?: string } & (
158
+ | {
159
+ [Name in keyof Events & string]: { name: Name; data: OutputOf<Events[Name]> };
160
+ }[keyof Events & string]
161
+ /** A reply on a participant routed to the code; awf fills its author and id when absent. */
162
+ | { name: "participant:reply"; from: string; data: ParticipantReply }
163
+ );
164
+
165
+ /**
166
+ * A test of a workflow with `events`: what a run is started with, then sends while it goes on.
167
+ * `recorded`, `fromStage` and `values` have no meaning for it.
168
+ */
169
+ export type EventTestOptions<Events extends EventSchemas, Args = JsonValue> = Omit<
170
+ TestOptions,
171
+ "recorded" | "fromStage" | "values"
172
+ > & {
173
+ /** The run's id, as `awf run --id` or a sender names it. */
174
+ id: string;
175
+ /**
176
+ * Its args, as `awf run --id {id} -- {args}` would prepare them; absent, what `prepare` makes of no
177
+ * argv, as a run an event starts has.
178
+ */
179
+ args?: Args;
180
+ /** The event the run starts with, as `awf emit` would send it; absent, it starts with none. */
181
+ event?: {
182
+ [Name in keyof Events & string]: { name: Name; data: OutputOf<Events[Name]>; id?: string };
183
+ }[keyof Events & string];
184
+ /** Sent at points in the run, each once its point is reached. */
185
+ events?: readonly ScriptedEvent<Events>[];
186
+ /** More turns started than this fails the test, so a loop that never parks doesn't hang it; 20. */
187
+ maxTurns?: number;
188
+ /**
189
+ * What an earlier attempt left in the run's mailbox, in order: given, the test's attempt is its
190
+ * continue, attempt 2, and `event` is appended after them, as the event that woke it.
191
+ */
192
+ mailbox?: readonly MailboxRecord[];
193
+ };
194
+
195
+ /** An event the run sent, to the run whose ref is `ref`, which a test doesn't start. */
196
+ export type EmittedEvent = { ref: string; name: string; data: JsonValue; eventId?: string };
197
+
102
198
  /** What the run did. Under `parallel`, what started first is scheduling: read by key. */
103
199
  export type TestRun<Result> = {
104
200
  /**
@@ -134,6 +230,17 @@ export type TestRun<Result> = {
134
230
  stages: StageRecord[];
135
231
  /** How the attempt stopped, apart from a failure, and the value it stopped for; absent when it didn't. */
136
232
  stopped?: { reason: string; stage?: string; needs?: readonly StageNeed[] };
233
+ /** Every event the run sent to another workflow, in order. */
234
+ emits: EmittedEvent[];
235
+ /** A run that parked, and its last wait; absent when it returned. A plain run parks only at maxTimers. */
236
+ parked?: { label?: string; until?: string };
237
+ /** The run's timers in the order set: when each was due, and whether it fired or was cancelled. */
238
+ timers: { name: string; due: string; state: "fired" | "cancelled" | "pending" }[];
239
+ /**
240
+ * Its mailbox as the run left it: its events, timers, and what its waits and turns wrote, in
241
+ * order; a plain run's holds its timers alone.
242
+ */
243
+ mailbox: MailboxRecord[];
137
244
  };
138
245
 
139
246
  /**
@@ -141,12 +248,34 @@ export type TestRun<Result> = {
141
248
  * from its own. It rejects when a script cannot meet what the workflow asked, or leaves a list
142
249
  * unfinished, or the run stalls: those fail the test, never the workflow.
143
250
  */
251
+ export async function testWorkflow<
252
+ Args extends JsonValue,
253
+ Result extends JsonValue,
254
+ Events extends EventSchemas,
255
+ >(
256
+ workflow: Workflow<Args, Result, Events> & { readonly events: Events },
257
+ options: EventTestOptions<NoInfer<Events>, NoInfer<Args>>,
258
+ ): Promise<TestRun<Result>>;
144
259
  export async function testWorkflow<Args extends JsonValue, Result extends JsonValue>(
145
- workflow: WorkflowDefinition<Args, Result> | ExecutableWorkflow<Args, Result>,
260
+ workflow: WorkflowDefinition<Args, Result> & { readonly events?: undefined },
261
+ args: NoInfer<Args>,
262
+ options?: TestOptions,
263
+ ): Promise<TestRun<Result>>;
264
+ export async function testWorkflow<Args extends JsonValue, Result extends JsonValue>(
265
+ workflow: WorkflowDefinition<Args, Result> | Workflow<Args, Result, EventSchemas>,
146
266
  args: NoInfer<Args>,
147
267
  options: TestOptions = {},
148
268
  ): Promise<TestRun<Result>> {
149
- const definition = isExecutable(workflow) ? workflow.definition : workflow;
269
+ const withEvents = isEventWorkflow(workflow) ? (workflow as EventWorkflow) : undefined;
270
+ // A workflow with events takes its test's options where another takes its args.
271
+ const eventOptions = withEvents ? (args as unknown as EventTestOptions<EventSchemas>) : undefined;
272
+ if (eventOptions) {
273
+ if (typeof eventOptions !== "object" || eventOptions === null)
274
+ throw new Error("a workflow with events is tested from { id, event? }");
275
+ options = eventOptions;
276
+ }
277
+ const started = withEvents && eventOptions && startOf(withEvents, eventOptions);
278
+ const definition = workflow as WorkflowDefinition<Args, Result>;
150
279
  const scripts = new Scripts(options.agents);
151
280
  const compactionScripts = new Scripts(options.compactions, {
152
281
  noun: "compaction",
@@ -160,22 +289,78 @@ export async function testWorkflow<Args extends JsonValue, Result extends JsonVa
160
289
  const problems: string[] = [];
161
290
  const stallMs = options.stallMs ?? 2_000;
162
291
  let stall: ReturnType<typeof setTimeout> | undefined;
292
+ /** The test ended the attempt: everything left waited on a coordinator's script. */
293
+ let waitedOut = false;
294
+ const arm = (ms: number) => {
295
+ clearTimeout(stall);
296
+ stall = setTimeout(stalled, ms);
297
+ };
298
+ const stalled = () => {
299
+ const open = coordinators.open();
300
+ if (host.inFlight().length === 0 && coordinators.idle && open.length > 0) {
301
+ const closes = open.flatMap((each) => (each.closesAt === undefined ? [] : [each.closesAt]));
302
+ // A question that closes on its own is waited for; that wait is not a stall.
303
+ if (closes.length > 0) return arm(Math.max(0, Math.min(...closes) - Date.now()) + stallMs);
304
+ waitedOut = true;
305
+ stopping.abort(new Error("everything left waits on a participant"));
306
+ return;
307
+ }
308
+ if (host.inFlight().length === 0 && liveUntil !== undefined && liveUntil > now())
309
+ return stopWith(
310
+ `the run stalled: it waits live in an agent's keepAlive window for ${liveUntil - now()} ms more; a test gives the agent a short timeoutMs, or none`,
311
+ );
312
+ const waiting = host
313
+ .inFlight()
314
+ .map((turn) => `"${turn.agent}" turn ${turn.n}${turn.nudge ? " (nudge)" : ""}`);
315
+ stopWith(
316
+ `the run stalled: nothing started or ended for ${stallMs} ms; ${
317
+ waiting.length > 0 ? `in flight: ${waiting.join(", ")}` : "no turn in flight"
318
+ }`,
319
+ );
320
+ };
321
+ /** How far the virtual clock is ahead of the real one. */
322
+ let ahead = 0;
323
+ const now = () => Date.now() + ahead;
324
+ const maxTimers = options.maxTimers ?? 20;
325
+ if (!Number.isInteger(maxTimers) || maxTimers < 0)
326
+ throw new Error(`maxTimers is a whole number of timers, 0 or more, not ${options.maxTimers}`);
327
+ let skipped = 0;
328
+ /** The clock stopped at maxTimers, and the run parked there. */
329
+ let stoppedSkipping = false;
330
+ const skip = async (until: number) => {
331
+ if (skipped >= maxTimers) {
332
+ stoppedSkipping = true;
333
+ return false;
334
+ }
335
+ skipped += 1;
336
+ ahead += Math.max(0, until - now());
337
+ liveUntil = undefined;
338
+ events.onActivity();
339
+ return true;
340
+ };
341
+ const scripted = withEvents && eventOptions && new ScriptedEvents(withEvents, eventOptions, now);
342
+ /** The end of the window the run last waited live to. */
343
+ let liveUntil: number | undefined;
344
+ let nudge: (() => void) | undefined;
163
345
  const events = {
164
- onActivity() {
165
- clearTimeout(stall);
166
- stall = setTimeout(() => {
167
- const waiting = host
168
- .inFlight()
169
- .map((turn) => `"${turn.agent}" turn ${turn.n}${turn.nudge ? " (nudge)" : ""}`);
170
- stopWith(
171
- `the run stalled: nothing started or ended for ${stallMs} ms; ${
172
- waiting.length > 0 ? `in flight: ${waiting.join(", ")}` : "no turn in flight"
173
- }`,
174
- );
175
- }, stallMs);
176
- },
346
+ onActivity: () => arm(stallMs),
177
347
  onScriptError: (message: string) => stopWith(message),
348
+ ...(scripted
349
+ ? {
350
+ onTurnStarted: async (turn: TurnRecord) => {
351
+ const problem = scripted.started();
352
+ if (problem) return stopWith(problem);
353
+ const due = scripted.due(turn);
354
+ if (due.length === 0) return;
355
+ for (const record of due) await appendMailbox(run.dir, record);
356
+ nudge?.();
357
+ },
358
+ }
359
+ : {}),
178
360
  };
361
+ const seeded = eventOptions?.mailbox;
362
+ const attempt = options.recorded || seeded ? 2 : 1;
363
+ const coordinators = new ParticipantScripts(options.participants, attempt, events);
179
364
  const stopWith = (message: string) => {
180
365
  problems.push(message);
181
366
  stopping.abort(new Error(message));
@@ -186,7 +371,14 @@ export async function testWorkflow<Args extends JsonValue, Result extends JsonVa
186
371
  temporary.push(path);
187
372
  return path;
188
373
  };
374
+ // Laid out under a directory of the test's own, which it removes: never into one it was given.
375
+ if (options.files && options.cwd !== undefined)
376
+ throw new Error("files are laid out in the test's own directory: give files or cwd, not both");
189
377
  const cwd = options.cwd ?? directory();
378
+ if (withEvents && eventOptions)
379
+ args = (
380
+ "args" in eventOptions ? eventOptions.args : withEvents.prepare({ argv: [], cwd })
381
+ ) as NoInfer<Args>;
190
382
  const host = createScriptedHost(
191
383
  scripts,
192
384
  compactionScripts,
@@ -200,12 +392,24 @@ export async function testWorkflow<Args extends JsonValue, Result extends JsonVa
200
392
  const sandboxes = createFakeSandboxProvider({ panes: { prelude: "true", ready: "ready" } });
201
393
 
202
394
  let settled: { value: Result } | { error: unknown };
395
+ /** What the workflow returned, before the run settled: a test that waits out a question keeps it. */
396
+ let returned: { value: Result } | undefined;
397
+ const watched: WorkflowDefinition<Args, Result> = {
398
+ ...definition,
399
+ run: async (context, given) => {
400
+ const value = await definition.run(context, given);
401
+ if (!waitedOut) returned = { value };
402
+ return value;
403
+ },
404
+ };
203
405
  let sandboxOf = new Map<string, AgentSandbox>();
406
+ const emits: EmittedEvent[] = [];
407
+ let mailbox: MailboxRecord[] = [];
204
408
  let stages: StageRecord[] = [];
205
409
  let messages: (MessageRecord & { to: string })[] = [];
206
410
  const runRoot = directory();
207
411
  const run = await createRun(runRoot, {
208
- id: "test",
412
+ id: started ? started.id : "test",
209
413
  workflow: definition.meta.name,
210
414
  argv: [],
211
415
  cwd,
@@ -215,6 +419,8 @@ export async function testWorkflow<Args extends JsonValue, Result extends JsonVa
215
419
  throw error;
216
420
  });
217
421
  if (options.recorded) await recordEarlierAttempt(run.dir, options.recorded);
422
+ for (const record of seeded ?? []) await appendMailbox(run.dir, record);
423
+ if (started?.record) await appendMailbox(run.dir, started.record);
218
424
  const recordedStages = async ({ stages: entered = [] }: SettledRun) => {
219
425
  const records = await readStageRecords(run.dir);
220
426
  // A stage the plan stopped has no record of this attempt's: the one there is an earlier one's.
@@ -225,22 +431,59 @@ export async function testWorkflow<Args extends JsonValue, Result extends JsonVa
225
431
  };
226
432
  events.onActivity();
227
433
  try {
228
- const result = await runWorkflow(definition, args, {
434
+ // A path it can't lay out is the test's mistake, never the workflow's.
435
+ await layOut(options.files, cwd).catch((error: unknown) => {
436
+ stopWith(messageOf(error));
437
+ throw error;
438
+ });
439
+ const result = await runWorkflow(watched, args, {
229
440
  runRoot,
230
441
  livenessPolicy: { quietMs: 1, responseMs: 25, deliveryMs: 100, releaseMs: 1000 },
442
+ // A workflow's own default hour would stall its test; one it gives is kept.
443
+ defaultWindowMs: TEST_WINDOW_MS,
444
+ now,
231
445
  ...(options.awaitReply === "anywhere" ? { awaitReply: () => true } : {}),
232
446
  run: {
233
447
  dir: run.dir,
234
448
  id: run.record.id,
235
- attempt: options.recorded ? 2 : 1,
449
+ attempt,
236
450
  ...(options.fromStage === undefined ? {} : { fromStage: options.fromStage }),
237
451
  ...(options.values === undefined
238
452
  ? {}
239
453
  : { values: new Map(Object.entries(options.values)) }),
454
+ ...(started
455
+ ? {
456
+ events: true as const,
457
+ park: async () => undefined,
458
+ skip,
459
+ live: async (until: number) => {
460
+ liveUntil = until;
461
+ const due = scripted ? scripted.ended() : [];
462
+ for (const record of due) await appendMailbox(run.dir, record);
463
+ if (due.length > 0) nudge?.();
464
+ },
465
+ listenNudges: (given: () => void) => {
466
+ nudge = given;
467
+ },
468
+ }
469
+ : // A plain run waits for a timer in its process; a test moves its clock, or parks it.
470
+ { park: async () => undefined, skip }),
240
471
  },
241
472
  cwd,
242
- deadline: { unixMilliseconds: Date.now() + (options.timeoutMs ?? 60 * 60_000) },
473
+ ...(options.timeoutMs === undefined
474
+ ? {}
475
+ : { deadline: { unixMilliseconds: Date.now() + options.timeoutMs } }),
243
476
  signal: stopping.signal,
477
+ // A test starts no other workflow's run: what it sends is recorded.
478
+ emit: async (target, event) => {
479
+ emits.push({
480
+ ref: `${target.meta.name}/${event.run}`,
481
+ name: event.name,
482
+ data: event.data,
483
+ ...(event.id === undefined ? {} : { eventId: event.id }),
484
+ });
485
+ return "accepted";
486
+ },
244
487
  runtime: { aliases: { ...OPERATOR_ALIASES, ...options.runtimes }, host: host.factory },
245
488
  sandboxes: {
246
489
  providers: {
@@ -259,7 +502,17 @@ export async function testWorkflow<Args extends JsonValue, Result extends JsonVa
259
502
  providers: { scripted: decisions.provider },
260
503
  aliases: { jev: { provider: "scripted", model: "jev" } },
261
504
  },
505
+ allowance: async (harness) => {
506
+ return (
507
+ options.allowance?.find((given) => given.harness === harness) ?? {
508
+ harness,
509
+ read: "none",
510
+ reason: "the test gave no allowance for it",
511
+ }
512
+ );
513
+ },
262
514
  onLog: (message, fields) => logs.push({ message, ...(fields ? { fields } : {}) }),
515
+ coordinators: [coordinators],
263
516
  });
264
517
  settled = { value: result.value };
265
518
  sandboxOf = sandboxesOf(result.sandboxes);
@@ -272,9 +525,16 @@ export async function testWorkflow<Args extends JsonValue, Result extends JsonVa
272
525
  }
273
526
  } finally {
274
527
  clearTimeout(stall);
528
+ if (waitedOut) {
529
+ settled = returned ?? {
530
+ error: new Error("the test ended the attempt: everything left waited on a participant"),
531
+ };
532
+ }
275
533
  messages = await messagesIn(run.dir);
534
+ mailbox = await readMailbox(run.dir);
276
535
  for (const path of temporary) rmSync(path, { recursive: true, force: true });
277
536
  }
537
+ if (problems.length === 0 && scripted) problems.push(...scripted.unsent());
278
538
  if (problems.length === 0) {
279
539
  const leftovers = [
280
540
  ...scripts.leftovers(),
@@ -295,9 +555,17 @@ export async function testWorkflow<Args extends JsonValue, Result extends JsonVa
295
555
  return {
296
556
  get value() {
297
557
  if ("value" in settled) return settled.value;
558
+ if (primaryFailure(settled.error) instanceof RunParked)
559
+ throw new Error(
560
+ stoppedSkipping && maxTimers > 0
561
+ ? `the run parked after the clock skipped to ${maxTimers} timers (maxTimers): a loop of timers that nothing answers?`
562
+ : "the run parked, and returned nothing: run.parked says at which wait",
563
+ );
298
564
  throw new Error(`the workflow threw: ${messageOf(settled.error)}`, { cause: settled.error });
299
565
  },
300
566
  turns: host.turns,
567
+ emits,
568
+ mailbox,
301
569
  turnsOf: (agent) => host.turns.filter((turn) => turn.agent === agent),
302
570
  compactions: host.compactions,
303
571
  compactionsOf: (agent) => host.compactions.filter((compaction) => compaction.agent === agent),
@@ -315,14 +583,25 @@ export async function testWorkflow<Args extends JsonValue, Result extends JsonVa
315
583
  decisions: decisions.asked,
316
584
  logs,
317
585
  stages,
586
+ timers: timersOf(mailbox),
318
587
  ...stoppedOf(settled),
588
+ ...parkedOf(settled),
319
589
  };
320
590
  }
321
591
 
592
+ function timersOf(records: readonly MailboxRecord[]): TestRun<unknown>["timers"] {
593
+ return [...foldMailbox(records).timers.values()].map(({ name, due, fired, cancelled }) => ({
594
+ name,
595
+ due,
596
+ state: fired ? "fired" : cancelled ? "cancelled" : "pending",
597
+ }));
598
+ }
599
+
322
600
  /**
323
601
  * A message as `messagesOf` gives it. A question ends `replied` or `failed`, `reason` saying why it
324
- * failed (`timed-out`, `withdrawn`, `released`, or how its peer failed); any other message ends
325
- * `delivered` once shown, or `undelivered` with the reason. `pending` until then.
602
+ * failed (`timed-out`, `withdrawn`, `released`, `cancelled`, `attempt-ended`, or how its peer
603
+ * failed); any other message ends `delivered` once shown, or `undelivered` with the reason.
604
+ * `pending` until then. A message to a coordinator is shown once recorded.
326
605
  */
327
606
  export type MessageRecord = {
328
607
  from: string;
@@ -334,13 +613,17 @@ export type MessageRecord = {
334
613
  /** The attempt's messages, read from the run's event log. */
335
614
  async function messagesIn(dir: string): Promise<(MessageRecord & { to: string })[]> {
336
615
  const found = new Map<number, MessageRecord & { to: string }>();
616
+ const outside = new Set<string>();
337
617
  for (const event of await readEvents(dir)) {
618
+ if (event.kind === "participant-connected") outside.add(event.participant);
338
619
  if (event.kind === "message-sent") {
620
+ const awaitReply = event.awaitsReply !== undefined;
339
621
  found.set(event.message, {
340
622
  from: event.from,
341
623
  to: event.to,
342
- awaitReply: event.awaitsReply !== undefined,
343
- end: "pending",
624
+ awaitReply,
625
+ // A coordinator reads the run log: what it is sent is shown once recorded.
626
+ end: outside.has(event.to) && !awaitReply ? "delivered" : "pending",
344
627
  });
345
628
  continue;
346
629
  }
@@ -389,10 +672,172 @@ async function recordEarlierAttempt(
389
672
  }
390
673
  }
391
674
 
392
- function isExecutable<Args extends JsonValue, Result extends JsonValue>(
393
- workflow: WorkflowDefinition<Args, Result> | ExecutableWorkflow<Args, Result>,
394
- ): workflow is ExecutableWorkflow<Args, Result> {
395
- return (workflow as { kind?: unknown }).kind === EXECUTABLE_WORKFLOW_KIND;
675
+ /**
676
+ * The events a test sends while its run goes on, each once its turn begins, and the count of
677
+ * turns started, past `maxTurns` of which the test fails: a loop that never parks.
678
+ */
679
+ type PendingEvent = {
680
+ at: string;
681
+ after: boolean;
682
+ name: string;
683
+ record: MailboxRecord;
684
+ /** Its turn, once begun: an `after` event waits for it to end. */
685
+ turn?: TurnRecord;
686
+ };
687
+
688
+ class ScriptedEvents {
689
+ readonly #pending: PendingEvent[];
690
+ readonly #max: number;
691
+ #turns = 0;
692
+
693
+ constructor(
694
+ workflow: EventWorkflow,
695
+ options: EventTestOptions<EventSchemas>,
696
+ /** The run's clock, which stamps what is sent. */
697
+ private readonly now: () => number,
698
+ ) {
699
+ this.#max = options.maxTurns ?? 20;
700
+ if (!Number.isInteger(this.#max) || this.#max < 1)
701
+ throw new Error(`maxTurns is a whole number of turns, at least 1, not ${options.maxTurns}`);
702
+ const ids = new Set<string>();
703
+ const idOf = (given: string | undefined, made: string) => {
704
+ const id = given ?? made;
705
+ const problem = given === undefined ? undefined : eventIdProblem(given);
706
+ if (problem) throw new Error(`the id ${JSON.stringify(given)}: ${problem}`);
707
+ // A repeated id would be appended and then dropped: the test says so instead.
708
+ if (ids.has(id)) throw new Error(`the id ${id} is given twice; an id names one event`);
709
+ ids.add(id);
710
+ return id;
711
+ };
712
+ for (const record of options.mailbox ?? []) if (record.kind === "event") ids.add(record.id);
713
+ if (options.event) idOf(options.event.id, `${AWF_ID_PREFIX}start`);
714
+ this.#pending = (options.events ?? []).map((event, index) => {
715
+ if ((event.during === undefined) === (event.after === undefined))
716
+ throw new Error("a scripted event gives during or after, one of them");
717
+ const after = event.after !== undefined;
718
+ const at = event.after ?? event.during;
719
+ if (typeof at !== "string" || !/^[^#]+#[1-9]\d*$/.test(at))
720
+ throw new Error(
721
+ `a scripted event's ${after ? "after" : "during"} is "{agent}#{n}", an agent's nth turn, not ${at}`,
722
+ );
723
+ const id = idOf(event.id, `${AWF_ID_PREFIX}test.${index + 1}`);
724
+ let sent: { from: string; data: JsonValue } = { from: "operator", data: event.data };
725
+ if (event.name === "participant:reply") {
726
+ const reply = event as { from: string; data: ParticipantReply };
727
+ sent = { from: reply.from, data: { author: "engineer", ...reply.data } };
728
+ } else checkEvent(workflow, event);
729
+ return {
730
+ at,
731
+ after,
732
+ name: event.name,
733
+ record: {
734
+ version: MAILBOX_RECORD_VERSION,
735
+ at: new Date().toISOString(),
736
+ kind: "event",
737
+ id,
738
+ name: event.name,
739
+ data: sent.data,
740
+ from: sent.from,
741
+ },
742
+ };
743
+ });
744
+ }
745
+
746
+ /** A turn began: why the test fails, when it is one too many. */
747
+ started(): string | undefined {
748
+ this.#turns += 1;
749
+ return this.#turns > this.#max
750
+ ? `the run started more than ${this.#max} turns (maxTurns): a loop that never parks?`
751
+ : undefined;
752
+ }
753
+
754
+ /** The events due during `turn`, taken off the list, stamped now; those after it wait for it. */
755
+ due(turn: TurnRecord): MailboxRecord[] {
756
+ const at = `${turn.agent}#${turn.n}`;
757
+ for (const each of this.#pending) if (each.after && each.at === at) each.turn = turn;
758
+ return this.#take((each) => !each.after && each.at === at);
759
+ }
760
+
761
+ /** The run waits live: the events after a turn that has ended, taken off the list. */
762
+ ended(): MailboxRecord[] {
763
+ return this.#take((each) => each.turn !== undefined && each.turn.outcome !== "hang");
764
+ }
765
+
766
+ #take(which: (each: PendingEvent) => boolean): MailboxRecord[] {
767
+ const due = this.#pending.filter(which);
768
+ for (const each of due) this.#pending.splice(this.#pending.indexOf(each), 1);
769
+ return due.map((each) => ({ ...each.record, at: new Date(this.now()).toISOString() }));
770
+ }
771
+
772
+ /** What was never sent: the test fails. */
773
+ unsent(): string[] {
774
+ return this.#pending.map(
775
+ (each) =>
776
+ `the scripted ${each.name} ${each.after ? "after" : "during"} ${each.at} was never sent: ${
777
+ each.turn !== undefined && each.turn.outcome !== "hang"
778
+ ? "the run never waited live after that turn, in an agent's keepAlive window"
779
+ : "that turn never ran"
780
+ }`,
781
+ );
782
+ }
783
+ }
784
+
785
+ /** Lays out `files` before the run, each path under `cwd`. */
786
+ async function layOut(
787
+ files: Readonly<Record<string, FileTree>> | undefined,
788
+ cwd: string,
789
+ ): Promise<void> {
790
+ const write = async (path: string, tree: FileTree): Promise<void> => {
791
+ if (typeof tree === "string") {
792
+ await mkdir(dirname(path), { recursive: true });
793
+ await Bun.write(path, tree);
794
+ return;
795
+ }
796
+ await mkdir(path, { recursive: true });
797
+ for (const [name, inner] of Object.entries(tree)) await write(join(path, name), inner);
798
+ };
799
+ for (const [path, tree] of Object.entries(files ?? {})) {
800
+ // Under the test's own directory, which it removes: a path outside would be left behind.
801
+ const target = resolve(cwd, path);
802
+ if (isAbsolute(path) || relative(cwd, target).startsWith(".."))
803
+ throw new Error(`files lays out under the run's directory; ${path} is outside it`);
804
+ await write(target, tree);
805
+ }
806
+ }
807
+
808
+ /**
809
+ * The run a test starts: its id, and the event it starts with, as `awf emit` would append it, or
810
+ * none, as `awf run --id` starts one.
811
+ */
812
+ function startOf(
813
+ workflow: EventWorkflow,
814
+ options: EventTestOptions<EventSchemas>,
815
+ ): { id: string; record?: MailboxRecord } {
816
+ const { event, id } = options;
817
+ if (typeof id !== "string")
818
+ throw new Error(
819
+ `a run of ${workflow.meta.name} is named by its id: { id }, and an event if it starts with one`,
820
+ );
821
+ const problem = idProblem(id);
822
+ if (problem) throw new Error(problem);
823
+ if (event === undefined) return { id };
824
+ checkEvent(workflow, { name: event.name, data: event.data });
825
+ const { starts } = workflow;
826
+ if (starts && !starts.includes(event.name))
827
+ throw new Error(
828
+ `${event.name} doesn't start a run of ${workflow.meta.name}: its starts are ${starts.join(", ")}`,
829
+ );
830
+ const record = {
831
+ version: MAILBOX_RECORD_VERSION,
832
+ at: new Date().toISOString(),
833
+ kind: "event" as const,
834
+ id: event.id ?? `${AWF_ID_PREFIX}start`,
835
+ name: event.name,
836
+ data: event.data,
837
+ from: "operator",
838
+ starts: true as const,
839
+ };
840
+ return { id, record };
396
841
  }
397
842
 
398
843
  function sandboxesOf(sandboxes: readonly SandboxRecord[] | undefined): Map<string, AgentSandbox> {
@@ -403,6 +848,16 @@ function sandboxesOf(sandboxes: readonly SandboxRecord[] | undefined): Map<strin
403
848
  );
404
849
  }
405
850
 
851
+ /** A run that parked, and its last wait: what the attempt ended with. */
852
+ function parkedOf(
853
+ settled: { value: unknown } | { error: unknown },
854
+ ): Partial<Pick<TestRun<unknown>, "parked">> {
855
+ if (!("error" in settled)) return {};
856
+ const parked = primaryFailure(settled.error);
857
+ if (!(parked instanceof RunParked)) return {};
858
+ return { parked: parked.wait };
859
+ }
860
+
406
861
  /** Decided as `awf run` decides it: a stop is the run's first failure, whatever failed beside it. */
407
862
  function stoppedOf(
408
863
  settled: { value: unknown } | { error: unknown },