@agentswf/engine 0.1.0-beta.4 → 0.1.0-beta.5

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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/agent-cli/cli.ts +57 -17
  3. package/src/attempt-ending.ts +2 -4
  4. package/src/attempt-output.ts +2 -1
  5. package/src/attempt-view.ts +1 -1
  6. package/src/control-plane.ts +37 -5
  7. package/src/coordinator-socket.ts +15 -6
  8. package/src/deadlines.ts +69 -2
  9. package/src/decisions/directory.ts +9 -11
  10. package/src/deprecations.ts +177 -0
  11. package/src/duration.ts +50 -20
  12. package/src/emit-command.ts +55 -41
  13. package/src/emit.ts +124 -15
  14. package/src/help/emit.txt +12 -4
  15. package/src/help/inbox.txt +2 -2
  16. package/src/help/logs.txt +5 -4
  17. package/src/help/reply.txt +5 -5
  18. package/src/help/run.txt +21 -7
  19. package/src/herdr-run-session.ts +2 -2
  20. package/src/holder-command.ts +36 -31
  21. package/src/inbox-command.ts +7 -10
  22. package/src/init-template/hello/workflow.test.ts.template +3 -3
  23. package/src/init-template/hello/workflow.ts.template +2 -2
  24. package/src/keep-alive.ts +28 -13
  25. package/src/logs-command.ts +12 -12
  26. package/src/mailbox.ts +2 -20
  27. package/src/messaging/limits.ts +4 -4
  28. package/src/messaging/messaging.ts +26 -21
  29. package/src/messaging/routes.ts +1 -1
  30. package/src/messaging/text.ts +7 -5
  31. package/src/operator-cli.ts +23 -9
  32. package/src/operator-runtime.ts +5 -5
  33. package/src/pane-layout.ts +125 -32
  34. package/src/progress-view.ts +8 -8
  35. package/src/run-command.ts +25 -4
  36. package/src/run-commands.ts +1 -1
  37. package/src/run-prepare.ts +46 -8
  38. package/src/run-usage.ts +1 -1
  39. package/src/run-view.ts +9 -4
  40. package/src/runs-command.ts +12 -10
  41. package/src/runs.ts +44 -8
  42. package/src/sources.ts +42 -6
  43. package/src/stage-ledger.ts +42 -12
  44. package/src/stage-plan.ts +37 -1
  45. package/src/test-command.ts +7 -3
  46. package/src/testing.ts +7 -8
  47. package/src/tools.ts +46 -18
  48. package/src/workflow-loader.ts +5 -2
  49. package/src/workflow-runner.ts +553 -235
  50. package/src/workflow-testing/coordinator.typecheck.ts +7 -7
  51. package/src/workflow-testing/coordinators.ts +2 -2
  52. package/src/workflow-testing/host.ts +107 -38
  53. package/src/workflow-testing/index.ts +45 -836
  54. package/src/workflow-testing/run-test.ts +1213 -0
  55. package/src/workflow-testing/script.ts +102 -41
  56. package/src/workflow-testing/workflow-testing.typecheck.ts +11 -8
@@ -1,62 +1,25 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
2
- import { mkdir } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
- import {
6
- MAILBOX_RECORD_VERSION,
7
- type MailboxRecord,
8
- type SandboxRecord,
9
- STAGE_RECORD_VERSION,
10
- type StageNeed,
11
- type StageRecord,
12
- } from "@agentswf/contract/records";
13
- import {
14
- AWF_ID_PREFIX,
15
- type EventSchemas,
16
- type HarnessAllowance,
17
- isEventWorkflow,
18
- type JsonObject,
19
- type JsonValue,
20
- type OutputOf,
21
- type ParticipantReply,
22
- type RuntimeAliases,
23
- type Workflow,
24
- type WorkflowDefinition,
25
- } from "@agentswf/contract/workflow";
26
- import { type Harness, sandboxTokens } from "@agentswf/harness";
27
- import { createFakeSandboxProvider } from "@agentswf/sandbox/testing/fake";
28
- import { checkEvent, eventIdProblem } from "../emit";
29
- import { messageOf } from "../errors";
30
- import { appendMailbox, foldMailbox, RunParked, readMailbox } from "../mailbox";
31
- import { OPERATOR_ALIASES } from "../operator-aliases";
32
- import { createRun, idProblem, readEvents, readStageRecords, writeStageRecord } from "../runs";
33
- import { primaryFailure, WorkflowStopped } from "../stopped";
34
- import type { EventWorkflow } from "../workflow-loader";
35
- import { runWorkflow, type SettledRun, WorkflowRunError } from "../workflow-runner";
36
- import { ParticipantScripts } from "./coordinators";
37
- import { createScriptedDecisions, type DecisionRequest, type DecisionScript } from "./decisions";
38
- import {
39
- type AgentSandbox,
40
- type CompactionRecord,
41
- createScriptedHost,
42
- type OpenedAgent,
43
- type SetRecord,
44
- type TurnRecord,
45
- } from "./host";
46
- import {
47
- type MessageScript,
48
- MessageScripts,
49
- type ParticipantScript,
50
- type Script,
51
- Scripts,
52
- } from "./script";
1
+ // What `agentswf/testing` publishes, which `awf test` serves too: nothing here is the engine's own.
2
+ import type { EventSchemas, JsonValue, Workflow, WorkflowMeta } from "@agentswf/contract/workflow";
3
+ import { runTest, type TestOptions, type TestRun, testCall } from "./run-test";
53
4
 
54
5
  export type { DecisionRequest, DecisionScript } from "./decisions";
6
+ export type { TestTurn } from "./host";
7
+ export type {
8
+ EarlierAttempt,
9
+ EmittedEvent,
10
+ EventTestOptions,
11
+ FileTree,
12
+ MessageRecord,
13
+ ScriptedEvent,
14
+ TestOptions,
15
+ TestRun,
16
+ } from "./run-test";
55
17
  export {
56
18
  answer,
57
19
  type Message,
58
20
  type MessageOutcome,
59
21
  type MessageScript,
22
+ noAnswer,
60
23
  type ParticipantMessage,
61
24
  type ParticipantOutcome,
62
25
  type ParticipantRoute,
@@ -65,188 +28,19 @@ export {
65
28
  reply,
66
29
  type Script,
67
30
  type SendOptions,
31
+ type TestTurnOutcome,
68
32
  type ToolOutcome,
69
33
  type Turn,
70
34
  } from "./script";
71
35
 
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>>;
75
- /** Each agent's script, by its key or a pattern such as `"review:*"`. */
76
- agents?: Readonly<Record<string, Script>>;
77
- /**
78
- * Each agent's compactions, apart from its turns: `answer("summary")` or a `reply`, a list one
79
- * entry per compaction. An agent with none here has every compaction answer `""`.
80
- */
81
- compactions?: Readonly<Record<string, Script>>;
82
- /**
83
- * Each agent's message operations, apart from its turns, by its key or a pattern: a script for
84
- * all of them, or a list, one per operation. An agent a message wakes with none here fails the
85
- * test.
86
- */
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>>;
97
- /**
98
- * Where an agent may `--await-reply`: `listed`, where the engine's list allows it for the
99
- * agent's harness and placement, as a run would; `anywhere`, to test a workflow's messaging on a
100
- * harness not listed yet.
101
- */
102
- awaitReply?: "listed" | "anywhere";
103
- /** Each decision's answers, by its key or a pattern. */
104
- decisions?: Readonly<Record<string, DecisionScript>>;
105
- /** What `wf.allowance` reads, one per harness; a harness with none here reads `none`. */
106
- allowance?: readonly HarnessAllowance[];
107
- /** Runtime aliases beside the ones `awf run` installs, `claude` and `codex`; the same name replaces one. */
108
- runtimes?: RuntimeAliases;
109
- /** The run's deadline; none unless given, as `awf run` has none unless `--timeout` gives one. */
110
- timeoutMs?: number;
111
- /** Real time with no turn or decision starting or ending before the test fails; 2 s. */
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;
119
- /** Where the agents work; by default a temporary directory, removed afterwards. */
120
- cwd?: string;
121
- /**
122
- * The session the run is started from, by its harness; absent, `agents.caller` and
123
- * `agents.forkCaller` answer `null`. It is handed over, as `awf run --here` hands one over, and its
124
- * turns are answered from the script under the key the workflow gives it; with `here: false` it
125
- * waits on the run, and `agents.caller` answers `null`. A fork of it runs on `model`, the model its
126
- * files show; without one, `forkCaller` answers `null`, as it does where they name none.
127
- */
128
- caller?: { harness: Harness; model?: string; here?: boolean };
129
- /**
130
- * Stages an earlier attempt recorded, by name: each value, or `undefined` for a stage that
131
- * returns nothing. Given, the run is that run's continue, reusing them without calling their
132
- * work, as `awf run --continue` would.
133
- */
134
- recorded?: Readonly<Record<string, JsonValue | undefined>>;
135
- /**
136
- * The stage the attempt starts at, as `--from-stage`: a continue of `recorded`, or a new run
137
- * started there.
138
- */
139
- fromStage?: string;
140
- /** Values of stages before `fromStage` with no record to reuse, as `awf run --values` gives them. */
141
- values?: Readonly<Record<string, JsonValue>>;
142
- };
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
-
198
- /** What the run did. Under `parallel`, what started first is scheduling: read by key. */
199
- export type TestRun<Result> = {
200
- /**
201
- * What the workflow returned. When it threw instead, reading this throws, with the workflow's
202
- * error as the cause: `expect(() => run.value).toThrow(…)`. So does spreading or serialising the
203
- * run.
204
- */
205
- readonly value: Result;
206
- /** Every turn, nudges included, in the order they started. */
207
- turns: TurnRecord[];
208
- /** One agent's turns, in its session's order. */
209
- turnsOf(agent: string): TurnRecord[];
210
- /** Every compaction, in the order they started; not among the turns. */
211
- compactions: CompactionRecord[];
212
- /** One agent's compactions, in order. */
213
- compactionsOf(agent: string): CompactionRecord[];
214
- /** Every switch of an agent's model or effort its host was asked for, in order; not among the turns. */
215
- sets: SetRecord[];
216
- /** One agent's switches, in order. */
217
- setsOf(agent: string): SetRecord[];
218
- /** The messages admitted for one agent, in the order sent: who sent each, whether it asked, and how it ended. */
219
- messagesOf(recipient: string): MessageRecord[];
220
- /** Every agent opened, in the order opened. */
221
- agents: OpenedAgent[];
222
- /** The agent opened under `key`; throws, naming the keys opened, when there is none. */
223
- agentOf(key: string): OpenedAgent;
224
- decisions: DecisionRequest[];
225
- logs: { message: string; fields?: JsonObject }[];
226
- /**
227
- * Each stage's record in the order entered, as the run wrote it to disk: sessions, times and
228
- * reason included, which the run's own summaries leave out.
229
- */
230
- stages: StageRecord[];
231
- /** How the attempt stopped, apart from a failure, and the value it stopped for; absent when it didn't. */
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[];
244
- };
36
+ /** What an array, `null` or a primitive is: args the deprecated form may give second. */
37
+ type NotAnObject = null | string | number | boolean | JsonValue[];
245
38
 
246
39
  /**
247
40
  * Runs `workflow` through the real engine, answering each agent from its script and each decision
248
41
  * from its own. It rejects when a script cannot meet what the workflow asked, or leaves a list
249
- * unfinished, or the run stalls: those fail the test, never the workflow.
42
+ * unfinished, or the run stalls, or an option is one it doesn't take: those fail the test, never
43
+ * the workflow.
250
44
  */
251
45
  export async function testWorkflow<
252
46
  Args extends JsonValue,
@@ -254,621 +48,36 @@ export async function testWorkflow<
254
48
  Events extends EventSchemas,
255
49
  >(
256
50
  workflow: Workflow<Args, Result, Events> & { readonly events: Events },
257
- options: EventTestOptions<NoInfer<Events>, NoInfer<Args>>,
51
+ options?: TestOptions<NoInfer<Args>, NoInfer<Events>>,
258
52
  ): Promise<TestRun<Result>>;
259
53
  export async function testWorkflow<Args extends JsonValue, Result extends JsonValue>(
260
- workflow: WorkflowDefinition<Args, Result> & { readonly events?: undefined },
261
- args: NoInfer<Args>,
262
- options?: TestOptions,
54
+ workflow: Workflow<Args, Result>,
55
+ ...options: null extends Args
56
+ ? [options?: TestOptions<NoInfer<Args>>]
57
+ : [options: TestOptions<NoInfer<Args>>]
263
58
  ): Promise<TestRun<Result>>;
59
+ /** @deprecated Give args among the options: `testWorkflow(workflow, { args, … })`. */
264
60
  export async function testWorkflow<Args extends JsonValue, Result extends JsonValue>(
265
- workflow: WorkflowDefinition<Args, Result> | Workflow<Args, Result, EventSchemas>,
61
+ workflow: DeprecatedWorkflow<Args, Result>,
266
62
  args: NoInfer<Args>,
267
- options: TestOptions = {},
268
- ): Promise<TestRun<Result>> {
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>;
279
- const scripts = new Scripts(options.agents);
280
- const compactionScripts = new Scripts(options.compactions, {
281
- noun: "compaction",
282
- option: "compactions",
283
- });
284
- const messageScripts = new MessageScripts(options.messages);
285
- if (options.values !== undefined && options.fromStage === undefined) {
286
- throw new Error("values give the stages before fromStage: give fromStage too");
287
- }
288
- const stopping = new AbortController();
289
- const problems: string[] = [];
290
- const stallMs = options.stallMs ?? 2_000;
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;
345
- const events = {
346
- onActivity: () => arm(stallMs),
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
- : {}),
360
- };
361
- const seeded = eventOptions?.mailbox;
362
- const attempt = options.recorded || seeded ? 2 : 1;
363
- const coordinators = new ParticipantScripts(options.participants, attempt, events);
364
- const stopWith = (message: string) => {
365
- problems.push(message);
366
- stopping.abort(new Error(message));
367
- };
368
- const temporary: string[] = [];
369
- const directory = () => {
370
- const path = mkdtempSync(join(tmpdir(), "awf-test-"));
371
- temporary.push(path);
372
- return path;
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");
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>;
382
- const host = createScriptedHost(
383
- scripts,
384
- compactionScripts,
385
- messageScripts,
386
- events,
387
- options.caller ? { ...options.caller, cwd } : undefined,
388
- );
389
- const decisions = createScriptedDecisions(options.decisions ?? {}, events);
390
- const logs: TestRun<Result>["logs"] = [];
391
- // Sandboxes are opened by a provider that confines nothing and hosts panes.
392
- const sandboxes = createFakeSandboxProvider({ panes: { prelude: "true", ready: "ready" } });
393
-
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
- };
405
- let sandboxOf = new Map<string, AgentSandbox>();
406
- const emits: EmittedEvent[] = [];
407
- let mailbox: MailboxRecord[] = [];
408
- let stages: StageRecord[] = [];
409
- let messages: (MessageRecord & { to: string })[] = [];
410
- const runRoot = directory();
411
- const run = await createRun(runRoot, {
412
- id: started ? started.id : "test",
413
- workflow: definition.meta.name,
414
- argv: [],
415
- cwd,
416
- sandbox: null,
417
- }).catch((error: unknown) => {
418
- for (const path of temporary) rmSync(path, { recursive: true, force: true });
419
- throw error;
420
- });
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);
424
- const recordedStages = async ({ stages: entered = [] }: SettledRun) => {
425
- const records = await readStageRecords(run.dir);
426
- // A stage the plan stopped has no record of this attempt's: the one there is an earlier one's.
427
- return entered.flatMap(({ stage, attempt }) => {
428
- const record = records.get(stage);
429
- return record?.attempt === attempt ? [record] : [];
430
- });
431
- };
432
- events.onActivity();
433
- try {
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, {
440
- runRoot,
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,
445
- ...(options.awaitReply === "anywhere" ? { awaitReply: () => true } : {}),
446
- run: {
447
- dir: run.dir,
448
- id: run.record.id,
449
- attempt,
450
- ...(options.fromStage === undefined ? {} : { fromStage: options.fromStage }),
451
- ...(options.values === undefined
452
- ? {}
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 }),
471
- },
472
- cwd,
473
- ...(options.timeoutMs === undefined
474
- ? {}
475
- : { deadline: { unixMilliseconds: Date.now() + options.timeoutMs } }),
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
- },
487
- runtime: { aliases: { ...OPERATOR_ALIASES, ...options.runtimes }, host: host.factory },
488
- sandboxes: {
489
- providers: {
490
- installed: { srt: sandboxes.provider, docker: sandboxes.provider },
491
- },
492
- sandboxesDir: directory(),
493
- machineRoot: directory(),
494
- // A sandbox here launches nothing, so no harness's login is needed: a stand-in for each.
495
- environment: {
496
- ...process.env,
497
- ...Object.fromEntries(sandboxTokens().map((name) => [name, "workflow-test"])),
498
- },
499
- },
500
- decisions: {
501
- providers: { scripted: decisions.provider },
502
- aliases: { jev: { provider: "scripted", model: "jev" } },
503
- },
504
- allowance: async (harness) => {
505
- return (
506
- options.allowance?.find((given) => given.harness === harness) ?? {
507
- harness,
508
- read: "none",
509
- reason: "the test gave no allowance for it",
510
- }
511
- );
512
- },
513
- onLog: (message, fields) => logs.push({ message, ...(fields ? { fields } : {}) }),
514
- coordinators: [coordinators],
515
- });
516
- settled = { value: result.value };
517
- sandboxOf = sandboxesOf(result.sandboxes);
518
- stages = await recordedStages(result);
519
- } catch (caught) {
520
- settled = { error: caught instanceof WorkflowRunError ? caught.cause : caught };
521
- if (caught instanceof WorkflowRunError) {
522
- sandboxOf = sandboxesOf(caught.sandboxes);
523
- stages = await recordedStages(caught);
524
- }
525
- } finally {
526
- clearTimeout(stall);
527
- if (waitedOut) {
528
- settled = returned ?? {
529
- error: new Error("the test ended the attempt: everything left waited on a participant"),
530
- };
531
- }
532
- messages = await messagesIn(run.dir);
533
- mailbox = await readMailbox(run.dir);
534
- for (const path of temporary) rmSync(path, { recursive: true, force: true });
535
- }
536
- if (problems.length === 0 && scripted) problems.push(...scripted.unsent());
537
- if (problems.length === 0) {
538
- const leftovers = [
539
- ...scripts.leftovers(),
540
- ...compactionScripts.leftovers(),
541
- ...messageScripts.leftovers(),
542
- ];
543
- // What the workflow threw may be why a list went unfinished.
544
- if (leftovers.length > 0 && "error" in settled) {
545
- leftovers.push(`the workflow threw: ${messageOf(settled.error)}`);
546
- }
547
- problems.push(...leftovers);
548
- }
549
- if (problems.length > 0) throw new Error(problems.join("\n"));
550
- const agents = host.agents.map((agent) => {
551
- const sandbox = sandboxOf.get(agent.key);
552
- return sandbox === undefined ? agent : { ...agent, sandbox };
553
- });
554
- return {
555
- get value() {
556
- if ("value" in settled) return settled.value;
557
- if (primaryFailure(settled.error) instanceof RunParked)
558
- throw new Error(
559
- stoppedSkipping && maxTimers > 0
560
- ? `the run parked after the clock skipped to ${maxTimers} timers (maxTimers): a loop of timers that nothing answers?`
561
- : "the run parked, and returned nothing: run.parked says at which wait",
562
- );
563
- throw new Error(`the workflow threw: ${messageOf(settled.error)}`, { cause: settled.error });
564
- },
565
- turns: host.turns,
566
- emits,
567
- mailbox,
568
- turnsOf: (agent) => host.turns.filter((turn) => turn.agent === agent),
569
- compactions: host.compactions,
570
- compactionsOf: (agent) => host.compactions.filter((compaction) => compaction.agent === agent),
571
- sets: host.sets,
572
- setsOf: (agent) => host.sets.filter((set) => set.agent === agent),
573
- messagesOf: (recipient) =>
574
- messages.flatMap(({ to, ...each }) => (to === recipient ? [each] : [])),
575
- agents,
576
- agentOf(key) {
577
- const agent = agents.find((opened) => opened.key === key);
578
- if (agent) return agent;
579
- const opened = agents.map((known) => `"${known.key}"`).join(", ") || "none";
580
- throw new Error(`no agent "${key}" was opened; opened: ${opened}`);
581
- },
582
- decisions: decisions.asked,
583
- logs,
584
- stages,
585
- timers: timersOf(mailbox),
586
- ...stoppedOf(settled),
587
- ...parkedOf(settled),
588
- };
589
- }
590
-
591
- function timersOf(records: readonly MailboxRecord[]): TestRun<unknown>["timers"] {
592
- return [...foldMailbox(records).timers.values()].map(({ name, due, fired, cancelled }) => ({
593
- name,
594
- due,
595
- state: fired ? "fired" : cancelled ? "cancelled" : "pending",
596
- }));
597
- }
598
-
599
- /**
600
- * A message as `messagesOf` gives it. A question ends `replied` or `failed`, `reason` saying why it
601
- * failed (`timed-out`, `withdrawn`, `released`, `cancelled`, `attempt-ended`, or how its peer
602
- * failed); any other message ends `delivered` once shown, or `undelivered` with the reason.
603
- * `pending` until then. A message to a coordinator is shown once recorded.
604
- */
605
- export type MessageRecord = {
606
- from: string;
607
- awaitReply: boolean;
608
- end: "replied" | "failed" | "delivered" | "undelivered" | "pending";
609
- reason?: string;
610
- };
611
-
612
- /** The attempt's messages, read from the run's event log. */
613
- async function messagesIn(dir: string): Promise<(MessageRecord & { to: string })[]> {
614
- const found = new Map<number, MessageRecord & { to: string }>();
615
- const outside = new Set<string>();
616
- for (const event of await readEvents(dir)) {
617
- if (event.kind === "participant-connected") outside.add(event.participant);
618
- if (event.kind === "message-sent") {
619
- const awaitReply = event.awaitsReply !== undefined;
620
- found.set(event.message, {
621
- from: event.from,
622
- to: event.to,
623
- awaitReply,
624
- // A coordinator reads the run log: what it is sent is shown once recorded.
625
- end: outside.has(event.to) && !awaitReply ? "delivered" : "pending",
626
- });
627
- continue;
628
- }
629
- const message =
630
- "message" in event && typeof event.message === "number"
631
- ? found.get(event.message)
632
- : undefined;
633
- if (!message) continue;
634
- if (event.kind === "request-ended") {
635
- message.end = event.end === "replied" ? "replied" : "failed";
636
- const reason =
637
- event.end === "failed" ? event.reason : event.end === "replied" ? undefined : event.end;
638
- if (reason !== undefined) message.reason = reason;
639
- } else if (!message.awaitReply && event.kind === "message-delivery") {
640
- message.end = "delivered";
641
- } else if (!message.awaitReply && event.kind === "message-undelivered") {
642
- message.end = "undelivered";
643
- message.reason = event.reason;
644
- }
645
- }
646
- return [...found.values()];
647
- }
648
-
649
- /**
650
- * Writes `recorded` as attempt 1's succeeded stages into a run's folder, a second apart in the order
651
- * given: a continue orders what is recorded by when each stage started.
652
- */
653
- async function recordEarlierAttempt(
654
- dir: string,
655
- recorded: Readonly<Record<string, JsonValue | undefined>>,
656
- ): Promise<void> {
657
- let at = Date.now() - Object.keys(recorded).length * 1_000;
658
- for (const [stage, value] of Object.entries(recorded)) {
659
- const time = new Date(at).toISOString();
660
- at += 1_000;
661
- await writeStageRecord(dir, {
662
- version: STAGE_RECORD_VERSION,
663
- stage,
664
- attempt: 1,
665
- outcome: "succeeded",
666
- started: time,
667
- ended: time,
668
- sessions: [],
669
- ...(value === undefined ? {} : { value }),
670
- });
671
- }
63
+ options: Omit<TestOptions<NoInfer<Args>>, "args"> | undefined,
64
+ ): Promise<TestRun<Result>>;
65
+ /** @deprecated Give args among the options: `testWorkflow(workflow, { args })`. */
66
+ export async function testWorkflow<Args extends NotAnObject, Result extends JsonValue>(
67
+ workflow: DeprecatedWorkflow<Args, Result>,
68
+ args: Args,
69
+ ): Promise<TestRun<Result>>;
70
+ export async function testWorkflow(
71
+ workflow: DeprecatedWorkflow<JsonValue, JsonValue>,
72
+ ...given: unknown[]
73
+ ): Promise<TestRun<JsonValue>> {
74
+ const { options, used } = testCall(workflow, given);
75
+ return runTest(workflow, options, { used });
672
76
  }
673
77
 
674
- /**
675
- * The events a test sends while its run goes on, each once its turn begins, and the count of
676
- * turns started, past `maxTurns` of which the test fails: a loop that never parks.
677
- */
678
- type PendingEvent = {
679
- at: string;
680
- after: boolean;
681
- name: string;
682
- record: MailboxRecord;
683
- /** Its turn, once begun: an `after` event waits for it to end. */
684
- turn?: TurnRecord;
78
+ /** What the deprecated form still takes: a workflow, or its `meta` and `run` alone. */
79
+ type DeprecatedWorkflow<Args extends JsonValue, Result extends JsonValue> = {
80
+ readonly meta: WorkflowMeta;
81
+ readonly events?: undefined;
82
+ run: Workflow<Args, Result>["run"];
685
83
  };
686
-
687
- class ScriptedEvents {
688
- readonly #pending: PendingEvent[];
689
- readonly #max: number;
690
- #turns = 0;
691
-
692
- constructor(
693
- workflow: EventWorkflow,
694
- options: EventTestOptions<EventSchemas>,
695
- /** The run's clock, which stamps what is sent. */
696
- private readonly now: () => number,
697
- ) {
698
- this.#max = options.maxTurns ?? 20;
699
- if (!Number.isInteger(this.#max) || this.#max < 1)
700
- throw new Error(`maxTurns is a whole number of turns, at least 1, not ${options.maxTurns}`);
701
- const ids = new Set<string>();
702
- const idOf = (given: string | undefined, made: string) => {
703
- const id = given ?? made;
704
- const problem = given === undefined ? undefined : eventIdProblem(given);
705
- if (problem) throw new Error(`the id ${JSON.stringify(given)}: ${problem}`);
706
- // A repeated id would be appended and then dropped: the test says so instead.
707
- if (ids.has(id)) throw new Error(`the id ${id} is given twice; an id names one event`);
708
- ids.add(id);
709
- return id;
710
- };
711
- for (const record of options.mailbox ?? []) if (record.kind === "event") ids.add(record.id);
712
- if (options.event) idOf(options.event.id, `${AWF_ID_PREFIX}start`);
713
- this.#pending = (options.events ?? []).map((event, index) => {
714
- if ((event.during === undefined) === (event.after === undefined))
715
- throw new Error("a scripted event gives during or after, one of them");
716
- const after = event.after !== undefined;
717
- const at = event.after ?? event.during;
718
- if (typeof at !== "string" || !/^[^#]+#[1-9]\d*$/.test(at))
719
- throw new Error(
720
- `a scripted event's ${after ? "after" : "during"} is "{agent}#{n}", an agent's nth turn, not ${at}`,
721
- );
722
- const id = idOf(event.id, `${AWF_ID_PREFIX}test.${index + 1}`);
723
- let sent: { from: string; data: JsonValue } = { from: "operator", data: event.data };
724
- if (event.name === "participant:reply") {
725
- const reply = event as { from: string; data: ParticipantReply };
726
- sent = { from: reply.from, data: { author: "engineer", ...reply.data } };
727
- } else checkEvent(workflow, event);
728
- return {
729
- at,
730
- after,
731
- name: event.name,
732
- record: {
733
- version: MAILBOX_RECORD_VERSION,
734
- at: new Date().toISOString(),
735
- kind: "event",
736
- id,
737
- name: event.name,
738
- data: sent.data,
739
- from: sent.from,
740
- },
741
- };
742
- });
743
- }
744
-
745
- /** A turn began: why the test fails, when it is one too many. */
746
- started(): string | undefined {
747
- this.#turns += 1;
748
- return this.#turns > this.#max
749
- ? `the run started more than ${this.#max} turns (maxTurns): a loop that never parks?`
750
- : undefined;
751
- }
752
-
753
- /** The events due during `turn`, taken off the list, stamped now; those after it wait for it. */
754
- due(turn: TurnRecord): MailboxRecord[] {
755
- const at = `${turn.agent}#${turn.n}`;
756
- for (const each of this.#pending) if (each.after && each.at === at) each.turn = turn;
757
- return this.#take((each) => !each.after && each.at === at);
758
- }
759
-
760
- /** The run waits live: the events after a turn that has ended, taken off the list. */
761
- ended(): MailboxRecord[] {
762
- return this.#take((each) => each.turn !== undefined && each.turn.outcome !== "hang");
763
- }
764
-
765
- #take(which: (each: PendingEvent) => boolean): MailboxRecord[] {
766
- const due = this.#pending.filter(which);
767
- for (const each of due) this.#pending.splice(this.#pending.indexOf(each), 1);
768
- return due.map((each) => ({ ...each.record, at: new Date(this.now()).toISOString() }));
769
- }
770
-
771
- /** What was never sent: the test fails. */
772
- unsent(): string[] {
773
- return this.#pending.map(
774
- (each) =>
775
- `the scripted ${each.name} ${each.after ? "after" : "during"} ${each.at} was never sent: ${
776
- each.turn !== undefined && each.turn.outcome !== "hang"
777
- ? "the run never waited live after that turn, in an agent's keepAlive window"
778
- : "that turn never ran"
779
- }`,
780
- );
781
- }
782
- }
783
-
784
- /** Lays out `files` before the run, each path under `cwd`. */
785
- async function layOut(
786
- files: Readonly<Record<string, FileTree>> | undefined,
787
- cwd: string,
788
- ): Promise<void> {
789
- const write = async (path: string, tree: FileTree): Promise<void> => {
790
- if (typeof tree === "string") {
791
- await mkdir(dirname(path), { recursive: true });
792
- await Bun.write(path, tree);
793
- return;
794
- }
795
- await mkdir(path, { recursive: true });
796
- for (const [name, inner] of Object.entries(tree)) await write(join(path, name), inner);
797
- };
798
- for (const [path, tree] of Object.entries(files ?? {})) {
799
- // Under the test's own directory, which it removes: a path outside would be left behind.
800
- const target = resolve(cwd, path);
801
- if (isAbsolute(path) || relative(cwd, target).startsWith(".."))
802
- throw new Error(`files lays out under the run's directory; ${path} is outside it`);
803
- await write(target, tree);
804
- }
805
- }
806
-
807
- /**
808
- * The run a test starts: its id, and the event it starts with, as `awf emit` would append it, or
809
- * none, as `awf run --id` starts one.
810
- */
811
- function startOf(
812
- workflow: EventWorkflow,
813
- options: EventTestOptions<EventSchemas>,
814
- ): { id: string; record?: MailboxRecord } {
815
- const { event, id } = options;
816
- if (typeof id !== "string")
817
- throw new Error(
818
- `a run of ${workflow.meta.name} is named by its id: { id }, and an event if it starts with one`,
819
- );
820
- const problem = idProblem(id);
821
- if (problem) throw new Error(problem);
822
- if (event === undefined) return { id };
823
- checkEvent(workflow, { name: event.name, data: event.data });
824
- const { starts } = workflow;
825
- if (starts && !starts.includes(event.name))
826
- throw new Error(
827
- `${event.name} doesn't start a run of ${workflow.meta.name}: its starts are ${starts.join(", ")}`,
828
- );
829
- const record = {
830
- version: MAILBOX_RECORD_VERSION,
831
- at: new Date().toISOString(),
832
- kind: "event" as const,
833
- id: event.id ?? `${AWF_ID_PREFIX}start`,
834
- name: event.name,
835
- data: event.data,
836
- from: "operator",
837
- starts: true as const,
838
- };
839
- return { id, record };
840
- }
841
-
842
- function sandboxesOf(sandboxes: readonly SandboxRecord[] | undefined): Map<string, AgentSandbox> {
843
- return new Map(
844
- (sandboxes ?? []).flatMap(({ key, provider, spec, domains, agents }) =>
845
- agents.map(({ agent }) => [agent, { key, provider, spec, domains }] as const),
846
- ),
847
- );
848
- }
849
-
850
- /** A run that parked, and its last wait: what the attempt ended with. */
851
- function parkedOf(
852
- settled: { value: unknown } | { error: unknown },
853
- ): Partial<Pick<TestRun<unknown>, "parked">> {
854
- if (!("error" in settled)) return {};
855
- const parked = primaryFailure(settled.error);
856
- if (!(parked instanceof RunParked)) return {};
857
- return { parked: parked.wait };
858
- }
859
-
860
- /** Decided as `awf run` decides it: a stop is the run's first failure, whatever failed beside it. */
861
- function stoppedOf(
862
- settled: { value: unknown } | { error: unknown },
863
- ): Partial<Pick<TestRun<unknown>, "stopped">> {
864
- if (!("error" in settled)) return {};
865
- const stop = primaryFailure(settled.error);
866
- if (!(stop instanceof WorkflowStopped)) return {};
867
- return {
868
- stopped: {
869
- reason: stop.reason,
870
- ...(stop.stage === undefined ? {} : { stage: stop.stage }),
871
- ...(stop.needs === undefined ? {} : { needs: stop.needs }),
872
- },
873
- };
874
- }