@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.
- package/package.json +5 -5
- package/src/allowance-command.ts +29 -7
- package/src/attempt-ending.ts +23 -5
- package/src/attempt-output.ts +48 -18
- package/src/attempt-view.ts +25 -15
- package/src/commands.ts +176 -0
- package/src/control-plane.ts +72 -21
- package/src/coordinator-port.ts +29 -0
- package/src/coordinator-prompt.ts +237 -0
- package/src/coordinator-socket.ts +236 -0
- package/src/deadlines.ts +47 -10
- package/src/decisions/directory.ts +8 -6
- package/src/duration.ts +29 -0
- package/src/emit-command.ts +130 -0
- package/src/emit.ts +246 -0
- package/src/files.ts +11 -1
- package/src/here.ts +8 -2
- package/src/hold-run-lock.fixture.ts +8 -0
- package/src/holder-command.ts +297 -0
- package/src/inbox-command.ts +727 -0
- package/src/init-command.ts +27 -0
- package/src/init-template/AGENTS.md.template +1 -1
- package/src/init-template/hello/workflow.test.ts.template +2 -2
- package/src/init-template/hello/workflow.ts.template +16 -20
- package/src/keep-alive.ts +40 -0
- package/src/logs-command.ts +104 -20
- package/src/machine.ts +12 -1
- package/src/mailbox.ts +977 -0
- package/src/message-operation.ts +26 -3
- package/src/messaging/messaging.ts +458 -65
- package/src/messaging/routes.ts +10 -3
- package/src/messaging/text.ts +8 -5
- package/src/operation-liveness.ts +54 -8
- package/src/operator-cli.ts +290 -119
- package/src/operator-runtime.ts +5 -2
- package/src/progress-view.ts +49 -7
- package/src/projects.ts +57 -0
- package/src/result-slots.ts +43 -19
- package/src/result-validation.ts +18 -27
- package/src/run-command.ts +36 -32
- package/src/run-commands.ts +13 -2
- package/src/run-lock.ts +123 -0
- package/src/run-prepare.ts +94 -22
- package/src/run-state.ts +125 -6
- package/src/run-usage.ts +1 -1
- package/src/run-view.ts +0 -9
- package/src/runs-command.ts +46 -17
- package/src/runs.ts +142 -23
- package/src/sandboxes.ts +8 -2
- package/src/simple-input.ts +258 -0
- package/src/socket-exchange.ts +26 -0
- package/src/sources.ts +70 -0
- package/src/stage-ledger.ts +88 -13
- package/src/stage-plan.ts +23 -0
- package/src/stop-crashed.ts +39 -0
- package/src/test-command.ts +32 -0
- package/src/testing.ts +71 -3
- package/src/tick-command.ts +87 -0
- package/src/tools.ts +636 -0
- package/src/wake.ts +264 -0
- package/src/workflow-loader.ts +32 -22
- package/src/workflow-runner.ts +1210 -77
- package/src/workflow-testing/coordinator.typecheck.ts +55 -0
- package/src/workflow-testing/coordinators.ts +137 -0
- package/src/workflow-testing/host.ts +61 -1
- package/src/workflow-testing/index.ts +488 -33
- package/src/workflow-testing/script.ts +72 -2
- 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
|
-
|
|
12
|
-
type
|
|
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 {
|
|
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;
|
|
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>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
325
|
-
* `delivered` once shown, or `undelivered` with the reason.
|
|
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
|
|
343
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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 },
|