@agentswf/engine 0.1.0-beta.4 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +13 -9
- package/src/accounting/format.ts +4 -3
- package/src/accounting/spent.ts +128 -0
- package/src/accounting/summary.ts +40 -32
- package/src/agent-cli/cli.ts +35 -22
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +5 -7
- package/src/attempt-output.ts +15 -32
- package/src/attempt-view.ts +34 -33
- package/src/author-typebox.ts +13 -0
- package/src/client/checks.ts +94 -0
- package/src/client/exported.ts +17 -0
- package/src/client/index.ts +122 -0
- package/src/client/locate.ts +34 -0
- package/src/client/published.ts +3 -0
- package/src/client/runs.ts +337 -0
- package/src/command-line.ts +64 -0
- package/src/commands.ts +7 -1
- package/src/control-plane.ts +41 -11
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +41 -33
- package/src/deadlines.ts +40 -7
- package/src/decisions/directory.ts +51 -40
- package/src/decisions/fake.ts +1 -1
- package/src/decisions/openrouter.ts +4 -2
- package/src/decisions/seam.ts +4 -4
- package/src/display-path.ts +3 -3
- package/src/duration.ts +43 -27
- package/src/emit-command.ts +88 -58
- package/src/emit.ts +254 -56
- package/src/event-ids.ts +31 -0
- package/src/files.ts +44 -4
- package/src/hand-over.ts +150 -0
- package/src/help/allowance.txt +4 -3
- package/src/help/emit.txt +18 -6
- package/src/help/inbox.txt +6 -4
- package/src/help/logs.txt +9 -6
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +30 -11
- package/src/help/runs.txt +4 -2
- package/src/help/tick.txt +9 -4
- package/src/herdr-placement.ts +3 -33
- package/src/herdr-run-session.ts +7 -15
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +56 -133
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +40 -57
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.test.ts.template +3 -3
- package/src/init-template/hello/workflow.ts.template +5 -4
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +15 -12
- package/src/logs-command.ts +128 -47
- package/src/message-operation.ts +6 -3
- package/src/messaging/limits.ts +4 -4
- package/src/messaging/messaging.ts +31 -28
- package/src/messaging/routes.ts +1 -1
- package/src/messaging/text.ts +9 -7
- package/src/open-command.ts +770 -0
- package/src/operation-events.ts +4 -6
- package/src/operation-liveness.ts +19 -4
- package/src/operator-cli.ts +224 -94
- package/src/operator-runtime.ts +29 -7
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +105 -59
- package/src/progress-view.ts +10 -10
- package/src/project-engine.ts +79 -0
- package/src/projects.ts +13 -2
- package/src/refusals.ts +119 -0
- package/src/result-slots.ts +11 -31
- package/src/result-validation.ts +2 -2
- package/src/run-command.ts +45 -28
- package/src/run-commands.ts +43 -115
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +205 -43
- package/src/run-read.ts +109 -41
- package/src/run-state.ts +170 -50
- package/src/run-text.ts +54 -0
- package/src/run-unreachable.ts +18 -0
- package/src/run-usage.ts +18 -12
- package/src/run-view.ts +81 -17
- package/src/runs-command.ts +83 -63
- package/src/runs.ts +310 -212
- package/src/sandbox-homes.ts +65 -13
- package/src/sandboxes.ts +182 -28
- package/src/session-lines.ts +93 -0
- package/src/skills/run-skills.ts +8 -1
- package/src/sources.ts +28 -29
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +119 -131
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +36 -14
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +73 -26
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +25 -16
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +54 -4
- package/src/workflow-runner.ts +805 -580
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +3 -3
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +121 -53
- package/src/workflow-testing/index.ts +33 -842
- package/src/workflow-testing/run-test.ts +1128 -0
- package/src/workflow-testing/script.ts +74 -45
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -990
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
|
@@ -0,0 +1,1128 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
type EventLine,
|
|
7
|
+
type RunLine,
|
|
8
|
+
type RunPanes,
|
|
9
|
+
readRun,
|
|
10
|
+
type SandboxRecord,
|
|
11
|
+
type StageLine,
|
|
12
|
+
} from "@agentswf/contract/records";
|
|
13
|
+
import {
|
|
14
|
+
AWF_ID_PREFIX,
|
|
15
|
+
awfEventOf,
|
|
16
|
+
type Duration,
|
|
17
|
+
type Ending,
|
|
18
|
+
type EventSchemas,
|
|
19
|
+
type HarnessAllowance,
|
|
20
|
+
isEventWorkflow,
|
|
21
|
+
type JsonObject,
|
|
22
|
+
type JsonValue,
|
|
23
|
+
type KeepPane,
|
|
24
|
+
type OutputOf,
|
|
25
|
+
positiveDurationMilliseconds,
|
|
26
|
+
type RuntimeAliases,
|
|
27
|
+
WORKFLOW_KIND,
|
|
28
|
+
type Workflow,
|
|
29
|
+
} from "@agentswf/contract/workflow";
|
|
30
|
+
import type { Harness } from "@agentswf/harness";
|
|
31
|
+
import { SCRIPTED_HOMES } from "@agentswf/harness/testing";
|
|
32
|
+
import { createFakeSandboxProvider } from "@agentswf/sandbox/testing/fake";
|
|
33
|
+
import { decideEnding, type Finished } from "../attempt-ending";
|
|
34
|
+
import { continueCommand } from "../attempt-view";
|
|
35
|
+
import { checkEvent, eventIdProblem, replyRecord } from "../emit";
|
|
36
|
+
import { messageOf } from "../errors";
|
|
37
|
+
import { isInside } from "../files";
|
|
38
|
+
import { appendIn, readIn } from "../in-lines";
|
|
39
|
+
import { OPERATOR_ALIASES } from "../operator-aliases";
|
|
40
|
+
import { type EarlierOps, listOps } from "../ops";
|
|
41
|
+
import { KEEP_PANES } from "../pane-layout";
|
|
42
|
+
import { claimRunLines, type RunLines, readRunLines, runLinesFile } from "../run-lines";
|
|
43
|
+
import type { TimerState } from "../run-policy/waits";
|
|
44
|
+
import { createRun, idProblem, readEvents, readTurns } from "../runs";
|
|
45
|
+
import { primaryFailure, type WorkflowStopped } from "../stopped";
|
|
46
|
+
import { RunParked, readWaits } from "../waits";
|
|
47
|
+
import { assertJsonValue, type EventWorkflow } from "../workflow-loader";
|
|
48
|
+
import type { RunnableWorkflow } from "../workflow-runner";
|
|
49
|
+
import { runWorkflow, type SettledRun, WorkflowRunError } from "../workflow-runner";
|
|
50
|
+
import { ParticipantScripts } from "./coordinators";
|
|
51
|
+
import { createScriptedDecisions, type DecisionRequest, type DecisionScript } from "./decisions";
|
|
52
|
+
import {
|
|
53
|
+
type AgentSandbox,
|
|
54
|
+
type CompactionRecord,
|
|
55
|
+
createScriptedHost,
|
|
56
|
+
type OpenedAgent,
|
|
57
|
+
type SetRecord,
|
|
58
|
+
type TestTurn,
|
|
59
|
+
} from "./host";
|
|
60
|
+
import {
|
|
61
|
+
type MessageScript,
|
|
62
|
+
MessageScripts,
|
|
63
|
+
type ParticipantScript,
|
|
64
|
+
type Script,
|
|
65
|
+
Scripts,
|
|
66
|
+
} from "./script";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a test gives `testWorkflow`, for a workflow taking `Args`, and with `Events` when it declares
|
|
70
|
+
* them. A key not listed here is refused when the test runs. Bare, `TestOptions` is what any test
|
|
71
|
+
* may share, with no args or events: spread into a test's own, it gives them.
|
|
72
|
+
*/
|
|
73
|
+
export type TestOptions<
|
|
74
|
+
Args extends JsonValue = never,
|
|
75
|
+
Events extends EventSchemas = never,
|
|
76
|
+
> = CommonTestOptions & ArgsOption<Args, Events> & EventOptions<Events>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `args` is required where the workflow has no events and `null` is not among its args. Absent, the
|
|
80
|
+
* run gets what `prepare` makes of no argv, as `awf run` given none, or a run an event starts, has.
|
|
81
|
+
*/
|
|
82
|
+
type ArgsOption<Args extends JsonValue, Events extends EventSchemas> = [Args] extends [never]
|
|
83
|
+
? { args?: undefined }
|
|
84
|
+
: [Events] extends [never]
|
|
85
|
+
? null extends Args
|
|
86
|
+
? {
|
|
87
|
+
/** The args `run` is given, as they are: `prepare` isn't called for them. */
|
|
88
|
+
args?: Args;
|
|
89
|
+
}
|
|
90
|
+
: {
|
|
91
|
+
/** The args `run` is given, as they are: `prepare` isn't called for them. */
|
|
92
|
+
args: Args;
|
|
93
|
+
}
|
|
94
|
+
: {
|
|
95
|
+
/**
|
|
96
|
+
* Its args, as `awf run --id {id} -- {args}` would prepare them; absent, what `prepare` makes
|
|
97
|
+
* of no argv, as a run an event starts has.
|
|
98
|
+
*/
|
|
99
|
+
args?: Args;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** What only a test of a workflow with events gives: what its run starts with, and is sent. */
|
|
103
|
+
type EventOptions<Events extends EventSchemas> = [Events] extends [never]
|
|
104
|
+
? unknown
|
|
105
|
+
: {
|
|
106
|
+
/** The event the run starts with, as `awf emit` would send it; absent, it starts with none. */
|
|
107
|
+
event?: {
|
|
108
|
+
[Name in keyof Events & string]: { name: Name; data: OutputOf<Events[Name]> } & EventIds;
|
|
109
|
+
}[keyof Events & string];
|
|
110
|
+
/** Sent at points in the run, each once its point is reached. */
|
|
111
|
+
events?: readonly ScriptedEvent<Events>[];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What an earlier attempt left: given, the test's attempt is that run's continue, the attempt after
|
|
116
|
+
* the latest its lines name, attempt 2 when they name none later than 1.
|
|
117
|
+
*/
|
|
118
|
+
export type EarlierAttempt = {
|
|
119
|
+
/**
|
|
120
|
+
* The stages it recorded, by name: each value, or `undefined` for a stage that returns nothing,
|
|
121
|
+
* reused without calling their work, as `awf run --continue` would. Written as attempt 1's
|
|
122
|
+
* `stage` lines.
|
|
123
|
+
*/
|
|
124
|
+
stages?: Readonly<Record<string, JsonValue | undefined>>;
|
|
125
|
+
/**
|
|
126
|
+
* The events senders gave the run, its `in.jsonl`, in order. A starting `event` is appended
|
|
127
|
+
* after them, as the event that woke it.
|
|
128
|
+
*/
|
|
129
|
+
events?: readonly EventLine[];
|
|
130
|
+
/**
|
|
131
|
+
* What the run settled, its `run.jsonl` lines, in order, after `stages`: its timers, what its
|
|
132
|
+
* waits and races took, and the turns it began, as `run.run` gives them.
|
|
133
|
+
*/
|
|
134
|
+
run?: readonly RunLine[];
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type CommonTestOptions = {
|
|
138
|
+
/** The run's id, as `awf run --id` or a sender names it; `"test"`. */
|
|
139
|
+
id?: string;
|
|
140
|
+
/** Laid out before the run, by path under its directory, which the test removes. */
|
|
141
|
+
files?: Readonly<Record<string, FileTree>>;
|
|
142
|
+
/** Each agent's script, by its key or a pattern such as `"review:*"`. */
|
|
143
|
+
agents?: Readonly<Record<string, Script>>;
|
|
144
|
+
/**
|
|
145
|
+
* Each agent's compactions, apart from its turns: `answer("summary")` or a `noAnswer`, a list one
|
|
146
|
+
* entry per compaction. An agent with none here has every compaction answer `""`.
|
|
147
|
+
*/
|
|
148
|
+
compactions?: Readonly<Record<string, Script>>;
|
|
149
|
+
/**
|
|
150
|
+
* Each agent's message operations, apart from its turns, by its key or a pattern: a script for
|
|
151
|
+
* all of them, or a list, one per operation. An agent a message wakes with none here fails the
|
|
152
|
+
* test.
|
|
153
|
+
*/
|
|
154
|
+
messages?: Readonly<Record<string, MessageScript | readonly MessageScript[]>>;
|
|
155
|
+
/**
|
|
156
|
+
* Each participant's script, by the key the workflow connects it under. When no turn runs, no
|
|
157
|
+
* script is still working, and everything left waits on a participant with no `until`, the attempt
|
|
158
|
+
* ends: its open questions expire `attempt-ended` and, unless the workflow returned, `run.value`
|
|
159
|
+
* throws. While a question has an `until`, the test waits for the earliest, and `stall` does not
|
|
160
|
+
* count that wait. A route
|
|
161
|
+
* or message reaching a participant with none here fails the test.
|
|
162
|
+
*/
|
|
163
|
+
participants?: Readonly<Record<string, ParticipantScript>>;
|
|
164
|
+
/** Each decision's answers, by its key or a pattern. */
|
|
165
|
+
decisions?: Readonly<Record<string, DecisionScript>>;
|
|
166
|
+
/** What `wf.allowance` reads, one per harness; a harness with none here reads `none`. */
|
|
167
|
+
allowance?: readonly HarnessAllowance[];
|
|
168
|
+
/** Runtime aliases beside the ones `awf run` installs, `claude` and `codex`; the same name replaces one. */
|
|
169
|
+
runtimes?: RuntimeAliases;
|
|
170
|
+
/** The run's deadline; none unless given, as `awf run` has none unless `--timeout` gives one. */
|
|
171
|
+
timeout?: Duration;
|
|
172
|
+
/** Real time with no turn or decision starting or ending before the test fails; `"2s"`. */
|
|
173
|
+
stall?: Duration;
|
|
174
|
+
/**
|
|
175
|
+
* The run's clock is virtual: where a run would park with a timer to come, the clock moves to it
|
|
176
|
+
* and the timer fires, so a two-day reminder takes no time. After this many, the run parks
|
|
177
|
+
* instead, as a loop of reminders nobody answers would; 20. Zero parks at the first.
|
|
178
|
+
*/
|
|
179
|
+
maxTimers?: number;
|
|
180
|
+
/** Where the agents work; by default a temporary directory, removed afterwards. */
|
|
181
|
+
cwd?: string;
|
|
182
|
+
/**
|
|
183
|
+
* The session the run is started from, by its harness; absent, `agents.caller` and
|
|
184
|
+
* `agents.forkCaller` answer `null`. It is handed over, as `awf run --here` hands one over, and its
|
|
185
|
+
* turns are answered from the script under the key the workflow gives it; with `here: false` it
|
|
186
|
+
* waits on the run, and `agents.caller` answers `null`. A fork of it runs on `model`, the model its
|
|
187
|
+
* files show; without one, `forkCaller` answers `null`, as it does where they name none.
|
|
188
|
+
*/
|
|
189
|
+
caller?: { harness: Harness; model?: string; here?: boolean };
|
|
190
|
+
/**
|
|
191
|
+
* More turns started than this fails the test, so a loop that never ends doesn't hang it; 20.
|
|
192
|
+
* Nudges and wakes for a message don't count.
|
|
193
|
+
*/
|
|
194
|
+
maxTurns?: number;
|
|
195
|
+
/** What an earlier attempt left; given, the test's attempt is its continue. */
|
|
196
|
+
earlier?: EarlierAttempt;
|
|
197
|
+
/**
|
|
198
|
+
* The stage the attempt starts at, as `--from-stage`: a continue of `earlier`, or a new run
|
|
199
|
+
* started there. A workflow with events takes it only with `earlier`, as `awf run` takes it only
|
|
200
|
+
* with `--continue`.
|
|
201
|
+
*/
|
|
202
|
+
fromStage?: string;
|
|
203
|
+
/**
|
|
204
|
+
* Values of stages before `fromStage` with no record to reuse, as `awf run --values` gives them;
|
|
205
|
+
* a workflow with events takes none.
|
|
206
|
+
*/
|
|
207
|
+
values?: Readonly<Record<string, JsonValue>>;
|
|
208
|
+
/**
|
|
209
|
+
* Where the run's TUI agents' panes go and whether they stay, as `awf run --workspace` and
|
|
210
|
+
* `--keep-panes` give them: `run.agents` shows what each agent's host was asked. Absent, the
|
|
211
|
+
* run's workspace, and no pane kept.
|
|
212
|
+
*/
|
|
213
|
+
panes?: RunPanes;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** Every option a test may give, whatever its workflow's args and events: what the run reads. */
|
|
217
|
+
type AnyTestOptions = CommonTestOptions & {
|
|
218
|
+
args?: JsonValue;
|
|
219
|
+
event?: { name: string; data: JsonValue } & EventIds;
|
|
220
|
+
events?: readonly ScriptedEvent<EventSchemas>[];
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const OPTION_KEYS: readonly (keyof AnyTestOptions)[] = [
|
|
224
|
+
"args",
|
|
225
|
+
"id",
|
|
226
|
+
"files",
|
|
227
|
+
"agents",
|
|
228
|
+
"compactions",
|
|
229
|
+
"messages",
|
|
230
|
+
"participants",
|
|
231
|
+
"decisions",
|
|
232
|
+
"allowance",
|
|
233
|
+
"runtimes",
|
|
234
|
+
"caller",
|
|
235
|
+
"timeout",
|
|
236
|
+
"stall",
|
|
237
|
+
"maxTimers",
|
|
238
|
+
"maxTurns",
|
|
239
|
+
"cwd",
|
|
240
|
+
"earlier",
|
|
241
|
+
"fromStage",
|
|
242
|
+
"values",
|
|
243
|
+
"panes",
|
|
244
|
+
"event",
|
|
245
|
+
"events",
|
|
246
|
+
];
|
|
247
|
+
const EARLIER_KEYS: readonly (keyof EarlierAttempt)[] = ["stages", "events", "run"];
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A test's options as `testWorkflow` was called, `(workflow, options?)`: a workflow's args given
|
|
251
|
+
* second, or options third, are refused, naming `{ args }`; object args given second are named by
|
|
252
|
+
* their first key.
|
|
253
|
+
*/
|
|
254
|
+
export function testCall(workflow: unknown, given: readonly unknown[]): AnyTestOptions {
|
|
255
|
+
if (given.length > 1)
|
|
256
|
+
throw new Error(
|
|
257
|
+
"testWorkflow takes (workflow, options?): give args among the options, testWorkflow(workflow, { args, … })",
|
|
258
|
+
);
|
|
259
|
+
const options = plainOptions(given[0] === undefined ? {} : given[0]);
|
|
260
|
+
const problem = optionsProblem(
|
|
261
|
+
options,
|
|
262
|
+
isEventWorkflow(workflow),
|
|
263
|
+
(workflow as { meta?: { name?: string } } | null)?.meta?.name ?? "the workflow",
|
|
264
|
+
);
|
|
265
|
+
if (problem) throw new Error(problem);
|
|
266
|
+
if (!isWorkflow(workflow))
|
|
267
|
+
throw new Error(
|
|
268
|
+
"testWorkflow(workflow, options?) tests a workflow defineWorkflow made: define it with defineWorkflow",
|
|
269
|
+
);
|
|
270
|
+
return options;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isObject(value: unknown): value is AnyTestOptions {
|
|
274
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `value` as a test's options: an object literal, never a `Date`, an array or a class's instance. */
|
|
278
|
+
function plainOptions(value: unknown): AnyTestOptions {
|
|
279
|
+
const prototype = isObject(value) ? Object.getPrototypeOf(value) : undefined;
|
|
280
|
+
if (prototype === Object.prototype || prototype === null) return value as AnyTestOptions;
|
|
281
|
+
const kind =
|
|
282
|
+
value === null
|
|
283
|
+
? "null"
|
|
284
|
+
: Array.isArray(value)
|
|
285
|
+
? "an array"
|
|
286
|
+
: typeof value === "object"
|
|
287
|
+
? `a ${(prototype as { constructor?: { name?: string } }).constructor?.name || "object"}`
|
|
288
|
+
: `a ${typeof value}`;
|
|
289
|
+
throw new Error(
|
|
290
|
+
`testWorkflow's options are an object literal, not ${kind}: testWorkflow(workflow, { args, … })`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isWorkflow(workflow: unknown): workflow is Workflow {
|
|
295
|
+
return (
|
|
296
|
+
typeof workflow === "object" &&
|
|
297
|
+
workflow !== null &&
|
|
298
|
+
(workflow as { kind?: unknown }).kind === WORKFLOW_KIND
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Why a test's options aren't ones `testWorkflow` takes; undefined when they are. */
|
|
303
|
+
function optionsProblem(
|
|
304
|
+
options: AnyTestOptions,
|
|
305
|
+
withEvents: boolean,
|
|
306
|
+
name: string,
|
|
307
|
+
): string | undefined {
|
|
308
|
+
for (const key of Object.keys(options) as (keyof AnyTestOptions)[]) {
|
|
309
|
+
if (!OPTION_KEYS.includes(key))
|
|
310
|
+
return `testWorkflow has no option ${JSON.stringify(key)}: its options are ${OPTION_KEYS.join(", ")}${
|
|
311
|
+
"args" in options ? "" : "; a workflow's args are given as { args }"
|
|
312
|
+
}`;
|
|
313
|
+
if (!withEvents && (key === "event" || key === "events"))
|
|
314
|
+
return `${key} is an option for a workflow with events, and ${name} declares none`;
|
|
315
|
+
}
|
|
316
|
+
const panes = panesProblem(options.panes);
|
|
317
|
+
if (panes) return panes;
|
|
318
|
+
const { earlier } = options;
|
|
319
|
+
if (earlier === undefined) return undefined;
|
|
320
|
+
if (!isObject(earlier)) return "earlier is an object: { stages?, events?, run? }";
|
|
321
|
+
const unknown = Object.keys(earlier).find(
|
|
322
|
+
(key) => !EARLIER_KEYS.includes(key as keyof EarlierAttempt),
|
|
323
|
+
);
|
|
324
|
+
return unknown === undefined
|
|
325
|
+
? undefined
|
|
326
|
+
: `earlier has no ${JSON.stringify(unknown)}: it gives ${EARLIER_KEYS.join(", ")}`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Why `panes` isn't what `awf run --workspace` and `--keep-panes` could give; undefined when it is. */
|
|
330
|
+
function panesProblem(panes: unknown): string | undefined {
|
|
331
|
+
if (panes === undefined) return undefined;
|
|
332
|
+
if (!isObject(panes)) return "panes is an object: { workspace?, keep? }";
|
|
333
|
+
const { workspace, keep, ...rest } = panes as Record<string, unknown>;
|
|
334
|
+
const other = Object.keys(rest)[0];
|
|
335
|
+
if (other !== undefined) return `panes has no ${JSON.stringify(other)}: it gives workspace, keep`;
|
|
336
|
+
if (keep !== undefined && !KEEP_PANES.includes(keep as KeepPane))
|
|
337
|
+
return `panes.keep is ${KEEP_PANES.join(", ")}, not ${JSON.stringify(keep)}`;
|
|
338
|
+
const named =
|
|
339
|
+
isObject(workspace) &&
|
|
340
|
+
Object.keys(workspace).join() === "name" &&
|
|
341
|
+
typeof (workspace as { name?: unknown }).name === "string" &&
|
|
342
|
+
(workspace as { name: string }).name.trim() !== "";
|
|
343
|
+
if (workspace !== undefined && workspace !== "run" && workspace !== "origin" && !named)
|
|
344
|
+
return `panes.workspace is "run", "origin" or { name }, not ${JSON.stringify(workspace)}`;
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** What a test of the engine, not of a workflow, may give `runTest`. */
|
|
349
|
+
type EngineTestOptions = {
|
|
350
|
+
/**
|
|
351
|
+
* Where an agent may `--await-reply`: `listed`, where the engine's list allows it for the
|
|
352
|
+
* agent's harness and placement, as a run would; `anywhere`, on any harness, the fake's included.
|
|
353
|
+
*/
|
|
354
|
+
awaitReply?: "listed" | "anywhere";
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/** A test's length of time in milliseconds, a `Duration` checked; undefined when not given. */
|
|
358
|
+
function milliseconds(options: AnyTestOptions, field: "timeout" | "stall"): number | undefined {
|
|
359
|
+
const given = options[field];
|
|
360
|
+
return given == null ? undefined : positiveDurationMilliseconds(given, `a test's ${field}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** A file's contents, or a directory of them by name. */
|
|
364
|
+
export type FileTree = string | { readonly [name: string]: FileTree };
|
|
365
|
+
|
|
366
|
+
/** A `keepAlive` that gives no `window`, in a test: the hour it would be stalls it. */
|
|
367
|
+
const TEST_WINDOW_MS = 100;
|
|
368
|
+
|
|
369
|
+
const WAITED_OUT = "the test ended the attempt: everything left waited on a participant";
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* An event a test sends while the run goes on, at an agent's `n`th turn, `"{agent}#{n}"`: `during`
|
|
373
|
+
* it, or `after` it has ended, once the run waits live in an agent's `keepAlive` window. `n` counts
|
|
374
|
+
* the agent's turns in the test, a nudge's included, and not its compactions: not the `n` of its
|
|
375
|
+
* operation's id, `{agent}.{n}`, which counts each operation once and its compactions too.
|
|
376
|
+
*/
|
|
377
|
+
export type ScriptedEvent<Events extends EventSchemas> = (
|
|
378
|
+
| { during: string; after?: undefined }
|
|
379
|
+
| { after: string; during?: undefined }
|
|
380
|
+
) &
|
|
381
|
+
EventIds &
|
|
382
|
+
{
|
|
383
|
+
[Name in Exclude<keyof Events & string, `${string}:ended`>]: {
|
|
384
|
+
name: Name;
|
|
385
|
+
data: OutputOf<Events[Name]>;
|
|
386
|
+
};
|
|
387
|
+
}[Exclude<keyof Events & string, `${string}:ended`>];
|
|
388
|
+
|
|
389
|
+
/** An event's id in the run's `in.jsonl`, as `awf emit --event-id` gives it. */
|
|
390
|
+
type EventIds = { eventId?: string };
|
|
391
|
+
|
|
392
|
+
/** An event the run sent, to the run whose ref is `ref`, which a test doesn't start. */
|
|
393
|
+
export type EmittedEvent = { ref: string; name: string; data: JsonValue; eventId?: string };
|
|
394
|
+
|
|
395
|
+
/** What the run did. Under `parallel`, what started first is scheduling: read by key. */
|
|
396
|
+
export type TestRun<Result> = {
|
|
397
|
+
/**
|
|
398
|
+
* What the workflow returned. When it threw instead, reading this throws, with the workflow's
|
|
399
|
+
* error as the cause: `expect(() => run.value).toThrow(…)`. So does spreading or serialising the
|
|
400
|
+
* run.
|
|
401
|
+
*/
|
|
402
|
+
readonly value: Result;
|
|
403
|
+
/** Every turn, nudges included, in the order they started. */
|
|
404
|
+
turns: TestTurn[];
|
|
405
|
+
/** One agent's turns, in its session's order. */
|
|
406
|
+
turnsOf(agent: string): TestTurn[];
|
|
407
|
+
/** Every compaction, in the order they started; not among the turns. */
|
|
408
|
+
compactions: CompactionRecord[];
|
|
409
|
+
/** One agent's compactions, in order. */
|
|
410
|
+
compactionsOf(agent: string): CompactionRecord[];
|
|
411
|
+
/** Every switch of an agent's model or effort its host was asked for, in order; not among the turns. */
|
|
412
|
+
sets: SetRecord[];
|
|
413
|
+
/** One agent's switches, in order. */
|
|
414
|
+
setsOf(agent: string): SetRecord[];
|
|
415
|
+
/** The messages admitted for one agent, in the order sent: who sent each, whether it asked, and how it ended. */
|
|
416
|
+
messagesOf(recipient: string): MessageRecord[];
|
|
417
|
+
/** Every agent opened, in the order opened. */
|
|
418
|
+
agents: OpenedAgent[];
|
|
419
|
+
/** The agent opened under `key`; throws, naming the keys opened, when there is none. */
|
|
420
|
+
agentOf(key: string): OpenedAgent;
|
|
421
|
+
decisions: DecisionRequest[];
|
|
422
|
+
logs: { message: string; fields?: JsonObject }[];
|
|
423
|
+
/**
|
|
424
|
+
* Each stage the attempt entered, in order, by its line in `run.jsonl`: one it ran or was given
|
|
425
|
+
* its own, times and reason included, which the run's own summaries leave out; one it reused the
|
|
426
|
+
* line it reused, an earlier attempt's. A stage the plan stopped as it was entered wrote none, and
|
|
427
|
+
* is absent.
|
|
428
|
+
*/
|
|
429
|
+
stages: StageLine[];
|
|
430
|
+
/**
|
|
431
|
+
* How the attempt ended, as the workflow's `present` and `report` are given it: `completed` with
|
|
432
|
+
* its value, `parked` at its last wait, or `stopped`, `failed`, `timed-out` or `cancelled` with
|
|
433
|
+
* the stage it ended in and why.
|
|
434
|
+
*/
|
|
435
|
+
ending: Ending<Result>;
|
|
436
|
+
/** Every event the run sent to another workflow, in order. */
|
|
437
|
+
emits: EmittedEvent[];
|
|
438
|
+
/** The run's timers in the order set: when each was due, and whether it fired or was cancelled. */
|
|
439
|
+
timers: { name: string; due: string; state: "fired" | "cancelled" | "pending" }[];
|
|
440
|
+
/** The events senders gave the run, its `in.jsonl` as the run left it, in order. */
|
|
441
|
+
events: EventLine[];
|
|
442
|
+
/**
|
|
443
|
+
* What the run settled, its `run.jsonl` as the run left it, in order: each attempt's `claimed`,
|
|
444
|
+
* and its stages, timers, sessions, what its waits and races took, and the turns it began.
|
|
445
|
+
*/
|
|
446
|
+
run: RunLine[];
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* `testWorkflow`, and what only the engine's own tests give it: a workflow may be its `meta` and
|
|
451
|
+
* `run` alone, which the engine's tests build and a workflow's never does.
|
|
452
|
+
*/
|
|
453
|
+
export async function runTest<Result extends JsonValue>(
|
|
454
|
+
// biome-ignore lint/suspicious/noExplicitAny: args are checked by the call that typed them.
|
|
455
|
+
workflow: RunnableWorkflow<any, Result> | Workflow<any, Result, EventSchemas>,
|
|
456
|
+
options: AnyTestOptions = {},
|
|
457
|
+
engine: EngineTestOptions = {},
|
|
458
|
+
): Promise<TestRun<Result>> {
|
|
459
|
+
const withEvents = isEventWorkflow(workflow) ? (workflow as EventWorkflow) : undefined;
|
|
460
|
+
const problem = optionsProblem(options, withEvents !== undefined, workflow.meta.name);
|
|
461
|
+
if (problem) throw new Error(problem);
|
|
462
|
+
const logs: TestRun<Result>["logs"] = [];
|
|
463
|
+
const earlier = earlierOf(options);
|
|
464
|
+
if (withEvents && options.values !== undefined)
|
|
465
|
+
throw new Error(
|
|
466
|
+
"a run of a workflow with events takes no values: before fromStage, a stage with no record runs",
|
|
467
|
+
);
|
|
468
|
+
if (withEvents && options.fromStage !== undefined && earlier.continues === false)
|
|
469
|
+
throw new Error(
|
|
470
|
+
"a run of a workflow with events takes fromStage only with earlier, as awf run takes --from-stage only with --continue",
|
|
471
|
+
);
|
|
472
|
+
const started = startOf(withEvents, options);
|
|
473
|
+
const definition = workflow as RunnableWorkflow<JsonValue, Result>;
|
|
474
|
+
const scripts = new Scripts(options.agents);
|
|
475
|
+
const compactionScripts = new Scripts(options.compactions, {
|
|
476
|
+
noun: "compaction",
|
|
477
|
+
option: "compactions",
|
|
478
|
+
});
|
|
479
|
+
const messageScripts = new MessageScripts(options.messages);
|
|
480
|
+
if (options.values !== undefined && options.fromStage === undefined) {
|
|
481
|
+
throw new Error("values give the stages before fromStage: give fromStage too");
|
|
482
|
+
}
|
|
483
|
+
const stopping = new AbortController();
|
|
484
|
+
const problems: string[] = [];
|
|
485
|
+
const stallMs = milliseconds(options, "stall") ?? 2_000;
|
|
486
|
+
const timeoutMs = milliseconds(options, "timeout");
|
|
487
|
+
let stall: ReturnType<typeof setTimeout> | undefined;
|
|
488
|
+
/** The test ended the attempt: everything left waited on a coordinator's script. */
|
|
489
|
+
let waitedOut = false;
|
|
490
|
+
const arm = (ms: number) => {
|
|
491
|
+
clearTimeout(stall);
|
|
492
|
+
stall = setTimeout(stalled, ms);
|
|
493
|
+
};
|
|
494
|
+
const stalled = () => {
|
|
495
|
+
const open = coordinators.open();
|
|
496
|
+
if (host.inFlight().length === 0 && coordinators.idle && open.length > 0) {
|
|
497
|
+
const closes = open.flatMap((each) => (each.closesAt === undefined ? [] : [each.closesAt]));
|
|
498
|
+
// A question that closes on its own is waited for; that wait is not a stall.
|
|
499
|
+
if (closes.length > 0) return arm(Math.max(0, Math.min(...closes) - Date.now()) + stallMs);
|
|
500
|
+
waitedOut = true;
|
|
501
|
+
stopping.abort(new Error("everything left waits on a participant"));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (host.inFlight().length === 0 && liveUntil !== undefined && liveUntil > now())
|
|
505
|
+
return stopWith(
|
|
506
|
+
`the run stalled: it waits live in an agent's keepAlive window for ${liveUntil - now()} ms more; a test gives the agent a short window, or none`,
|
|
507
|
+
);
|
|
508
|
+
const waiting = host
|
|
509
|
+
.inFlight()
|
|
510
|
+
.map((turn) => `"${turn.agent}" turn ${turn.n}${turn.nudge ? " (nudge)" : ""}`);
|
|
511
|
+
stopWith(
|
|
512
|
+
`the run stalled: nothing started or ended for ${stallMs} ms; ${
|
|
513
|
+
waiting.length > 0 ? `in flight: ${waiting.join(", ")}` : "no turn in flight"
|
|
514
|
+
}`,
|
|
515
|
+
);
|
|
516
|
+
};
|
|
517
|
+
/** How far the virtual clock is ahead of the real one. */
|
|
518
|
+
let ahead = 0;
|
|
519
|
+
const now = () => Date.now() + ahead;
|
|
520
|
+
const maxTimers = options.maxTimers ?? 20;
|
|
521
|
+
if (!Number.isInteger(maxTimers) || maxTimers < 0)
|
|
522
|
+
throw new Error(`maxTimers is a whole number of timers, 0 or more, not ${options.maxTimers}`);
|
|
523
|
+
let skipped = 0;
|
|
524
|
+
/** The clock stopped at maxTimers, and the run parked there. */
|
|
525
|
+
let stoppedSkipping = false;
|
|
526
|
+
const skip = async (until: number) => {
|
|
527
|
+
if (skipped >= maxTimers) {
|
|
528
|
+
stoppedSkipping = true;
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
skipped += 1;
|
|
532
|
+
ahead += Math.max(0, until - now());
|
|
533
|
+
liveUntil = undefined;
|
|
534
|
+
events.onActivity();
|
|
535
|
+
return true;
|
|
536
|
+
};
|
|
537
|
+
const scripted = withEvents && new ScriptedEvents(withEvents, options, earlier.events ?? [], now);
|
|
538
|
+
const maxTurns = options.maxTurns ?? 20;
|
|
539
|
+
if (!Number.isInteger(maxTurns) || maxTurns < 1)
|
|
540
|
+
throw new Error(`maxTurns is a whole number of turns, at least 1, not ${options.maxTurns}`);
|
|
541
|
+
let turnsStarted = 0;
|
|
542
|
+
/** The end of the window the run last waited live to. */
|
|
543
|
+
let liveUntil: number | undefined;
|
|
544
|
+
let nudge: (() => void) | undefined;
|
|
545
|
+
const events = {
|
|
546
|
+
onActivity: () => arm(stallMs),
|
|
547
|
+
onScriptError: (message: string) => stopWith(message),
|
|
548
|
+
onTurnStarted: async (turn: TestTurn) => {
|
|
549
|
+
turnsStarted += 1;
|
|
550
|
+
if (turnsStarted > maxTurns)
|
|
551
|
+
return stopWith(
|
|
552
|
+
`the run started more than ${maxTurns} turns (maxTurns): a loop that never ends, or a run that needs a larger maxTurns?`,
|
|
553
|
+
);
|
|
554
|
+
const due = scripted ? scripted.due(turn) : [];
|
|
555
|
+
if (due.length === 0) return;
|
|
556
|
+
for (const line of due) await appendIn(run.dir, line);
|
|
557
|
+
nudge?.();
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
const attempt = earlier.continues
|
|
561
|
+
? Math.max(1, ...(earlier.run ?? []).map((line) => line.attempt)) + 1
|
|
562
|
+
: 1;
|
|
563
|
+
const coordinators = new ParticipantScripts(options.participants, attempt, events);
|
|
564
|
+
const stopWith = (message: string) => {
|
|
565
|
+
problems.push(message);
|
|
566
|
+
stopping.abort(new Error(message));
|
|
567
|
+
};
|
|
568
|
+
const temporary: string[] = [];
|
|
569
|
+
const directory = () => {
|
|
570
|
+
const path = mkdtempSync(join(tmpdir(), "awf-test-"));
|
|
571
|
+
temporary.push(path);
|
|
572
|
+
return path;
|
|
573
|
+
};
|
|
574
|
+
// Laid out under a directory of the test's own, which it removes: never into one it was given.
|
|
575
|
+
if (options.files && options.cwd !== undefined)
|
|
576
|
+
throw new Error("files are laid out in the test's own directory: give files or cwd, not both");
|
|
577
|
+
const cwd = options.cwd ?? directory();
|
|
578
|
+
let args: JsonValue;
|
|
579
|
+
try {
|
|
580
|
+
args = argsOf(workflow, options, cwd);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
for (const path of temporary) rmSync(path, { recursive: true, force: true });
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
const host = createScriptedHost(
|
|
586
|
+
scripts,
|
|
587
|
+
compactionScripts,
|
|
588
|
+
messageScripts,
|
|
589
|
+
events,
|
|
590
|
+
options.caller ? { ...options.caller, cwd } : undefined,
|
|
591
|
+
);
|
|
592
|
+
const decisions = createScriptedDecisions(options.decisions ?? {}, events);
|
|
593
|
+
// Sandboxes are opened by a provider that confines nothing and hosts panes.
|
|
594
|
+
const sandboxes = createFakeSandboxProvider({ panes: { prelude: "true", ready: "ready" } });
|
|
595
|
+
|
|
596
|
+
let settled: { value: Result } | { error: unknown };
|
|
597
|
+
/** What the run came to, as `awf run` decides its ending from. */
|
|
598
|
+
let finished: Finished;
|
|
599
|
+
/** What the workflow returned, before the run settled: a test that waits out a question keeps it. */
|
|
600
|
+
let returned: { value: Result } | undefined;
|
|
601
|
+
const watched: RunnableWorkflow<JsonValue, Result> = {
|
|
602
|
+
...definition,
|
|
603
|
+
run: async (context, given) => {
|
|
604
|
+
const value = await definition.run(context, given);
|
|
605
|
+
if (!waitedOut) returned = { value };
|
|
606
|
+
return value;
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
let sandboxOf = new Map<string, AgentSandbox>();
|
|
610
|
+
const emits: EmittedEvent[] = [];
|
|
611
|
+
let inLines: EventLine[] = [];
|
|
612
|
+
let runLines: RunLine[] = [];
|
|
613
|
+
let timers: TestRun<Result>["timers"] = [];
|
|
614
|
+
let stages: StageLine[] = [];
|
|
615
|
+
let messages: (MessageRecord & { to: string })[] = [];
|
|
616
|
+
const runRoot = directory();
|
|
617
|
+
const deadline =
|
|
618
|
+
timeoutMs === undefined ? undefined : { unixMilliseconds: Date.now() + timeoutMs };
|
|
619
|
+
const run = await createRun(runRoot, {
|
|
620
|
+
id: started.id,
|
|
621
|
+
workflow: definition.meta.name,
|
|
622
|
+
argv: [],
|
|
623
|
+
cwd,
|
|
624
|
+
sandbox: null,
|
|
625
|
+
}).catch((error: unknown) => {
|
|
626
|
+
for (const path of temporary) rmSync(path, { recursive: true, force: true });
|
|
627
|
+
throw error;
|
|
628
|
+
});
|
|
629
|
+
let lines: RunLines;
|
|
630
|
+
let ops: EarlierOps;
|
|
631
|
+
try {
|
|
632
|
+
if (earlier.stages) await recordEarlierAttempt(run.dir, earlier.stages);
|
|
633
|
+
if (earlier.run) await writeEarlierLines(run.dir, earlier.run);
|
|
634
|
+
for (const line of earlier.events ?? []) await appendIn(run.dir, line);
|
|
635
|
+
if (started.record) await appendIn(run.dir, started.record);
|
|
636
|
+
ops = await listOps(run.dir);
|
|
637
|
+
lines = await claimRunLines(run.dir, attempt, new Date(now()).toISOString());
|
|
638
|
+
} catch (error) {
|
|
639
|
+
for (const path of temporary) rmSync(path, { recursive: true, force: true });
|
|
640
|
+
throw error;
|
|
641
|
+
}
|
|
642
|
+
const recordedStages = async ({ stages: entered = [] }: SettledRun) => {
|
|
643
|
+
const { view } = await readRunLines(run.dir);
|
|
644
|
+
// The run's summaries hold one entry per stage, as it last stood: a stage by content that
|
|
645
|
+
// failed and ran again in this attempt is one entry, its latest line. So each entry's line is
|
|
646
|
+
// the latest its attempt wrote: a stage this attempt ran or was given has its own, a reused one
|
|
647
|
+
// the line it reused. A stage the plan stopped wrote none.
|
|
648
|
+
return entered.flatMap(({ stage, attempt: wrote }) => {
|
|
649
|
+
const line = view.stages[stage]?.history.findLast((each) => each.attempt === wrote);
|
|
650
|
+
return line ? [line] : [];
|
|
651
|
+
});
|
|
652
|
+
};
|
|
653
|
+
events.onActivity();
|
|
654
|
+
try {
|
|
655
|
+
// A path it can't lay out is the test's mistake, never the workflow's.
|
|
656
|
+
await layOut(options.files, cwd).catch((error: unknown) => {
|
|
657
|
+
stopWith(messageOf(error));
|
|
658
|
+
throw error;
|
|
659
|
+
});
|
|
660
|
+
const result = await runWorkflow(watched, args, {
|
|
661
|
+
runRoot,
|
|
662
|
+
livenessPolicy: { quietMs: 1, responseMs: 25, deliveryMs: 100, releaseMs: 1000 },
|
|
663
|
+
// A workflow's own default hour would stall its test; one it gives is kept.
|
|
664
|
+
defaultWindowMs: TEST_WINDOW_MS,
|
|
665
|
+
now,
|
|
666
|
+
...(engine.awaitReply === "anywhere" ? { awaitReply: () => true } : {}),
|
|
667
|
+
run: {
|
|
668
|
+
dir: run.dir,
|
|
669
|
+
id: run.record.id,
|
|
670
|
+
lines,
|
|
671
|
+
ops,
|
|
672
|
+
...(options.fromStage === undefined ? {} : { fromStage: options.fromStage }),
|
|
673
|
+
...(options.panes === undefined ? {} : { panes: options.panes }),
|
|
674
|
+
...(options.values === undefined
|
|
675
|
+
? {}
|
|
676
|
+
: { values: new Map(Object.entries(options.values)) }),
|
|
677
|
+
...(scripted
|
|
678
|
+
? {
|
|
679
|
+
events: true as const,
|
|
680
|
+
park: async () => undefined,
|
|
681
|
+
skip,
|
|
682
|
+
live: async (until: number) => {
|
|
683
|
+
liveUntil = until;
|
|
684
|
+
const due = scripted.ended(host.inFlight());
|
|
685
|
+
for (const line of due) await appendIn(run.dir, line);
|
|
686
|
+
if (due.length > 0) nudge?.();
|
|
687
|
+
},
|
|
688
|
+
listenNudges: (given: () => void) => {
|
|
689
|
+
nudge = given;
|
|
690
|
+
},
|
|
691
|
+
}
|
|
692
|
+
: // A plain run waits for a timer in its process; a test moves its clock, or parks it.
|
|
693
|
+
{ park: async () => undefined, skip }),
|
|
694
|
+
},
|
|
695
|
+
cwd,
|
|
696
|
+
...(deadline === undefined ? {} : { deadline }),
|
|
697
|
+
signal: stopping.signal,
|
|
698
|
+
// A test starts no other workflow's run: what it sends is recorded.
|
|
699
|
+
emit: async (target, event) => {
|
|
700
|
+
emits.push({
|
|
701
|
+
ref: `${target.meta.name}/${event.run}`,
|
|
702
|
+
name: event.name,
|
|
703
|
+
data: event.data,
|
|
704
|
+
...(event.id === undefined ? {} : { eventId: event.id }),
|
|
705
|
+
});
|
|
706
|
+
return "accepted";
|
|
707
|
+
},
|
|
708
|
+
runtime: { aliases: { ...OPERATOR_ALIASES, ...options.runtimes }, host: host.factory },
|
|
709
|
+
sandboxes: {
|
|
710
|
+
providers: {
|
|
711
|
+
installed: { srt: sandboxes.provider, docker: sandboxes.provider },
|
|
712
|
+
},
|
|
713
|
+
sandboxesDir: directory(),
|
|
714
|
+
machineRoot: directory(),
|
|
715
|
+
// An agent here launches nothing, so it needs no harness's executable or login.
|
|
716
|
+
homes: SCRIPTED_HOMES,
|
|
717
|
+
},
|
|
718
|
+
decisions: {
|
|
719
|
+
providers: { scripted: decisions.provider },
|
|
720
|
+
aliases: { jev: { provider: "scripted", model: "jev" } },
|
|
721
|
+
},
|
|
722
|
+
allowance: async (harness) => {
|
|
723
|
+
return (
|
|
724
|
+
options.allowance?.find((given) => given.harness === harness) ?? {
|
|
725
|
+
harness,
|
|
726
|
+
read: "none",
|
|
727
|
+
reason: "the test gave no allowance for it",
|
|
728
|
+
}
|
|
729
|
+
);
|
|
730
|
+
},
|
|
731
|
+
onLog: (message, fields) => logs.push({ message, ...(fields ? { fields } : {}) }),
|
|
732
|
+
coordinators: [coordinators],
|
|
733
|
+
});
|
|
734
|
+
settled = { value: result.value };
|
|
735
|
+
finished = { result };
|
|
736
|
+
sandboxOf = sandboxesOf(result.sandboxes);
|
|
737
|
+
stages = await recordedStages(result);
|
|
738
|
+
} catch (caught) {
|
|
739
|
+
finished = { error: caught };
|
|
740
|
+
settled = { error: caught instanceof WorkflowRunError ? caught.cause : caught };
|
|
741
|
+
if (caught instanceof WorkflowRunError) {
|
|
742
|
+
sandboxOf = sandboxesOf(caught.sandboxes);
|
|
743
|
+
stages = await recordedStages(caught);
|
|
744
|
+
}
|
|
745
|
+
} finally {
|
|
746
|
+
clearTimeout(stall);
|
|
747
|
+
if (waitedOut) {
|
|
748
|
+
settled = returned ?? { error: new Error(WAITED_OUT) };
|
|
749
|
+
}
|
|
750
|
+
host.settle(await readTurns(run.dir));
|
|
751
|
+
messages = await messagesIn(run.dir);
|
|
752
|
+
inLines = await readIn(run.dir);
|
|
753
|
+
runLines = (await readRunLines(run.dir)).lines;
|
|
754
|
+
timers = timersOf((await readWaits(run.dir, now())).timers);
|
|
755
|
+
for (const path of temporary) rmSync(path, { recursive: true, force: true });
|
|
756
|
+
}
|
|
757
|
+
if (problems.length === 0 && scripted) problems.push(...scripted.unsent(host.inFlight()));
|
|
758
|
+
if (problems.length === 0) {
|
|
759
|
+
const leftovers = [
|
|
760
|
+
...scripts.leftovers(),
|
|
761
|
+
...compactionScripts.leftovers(),
|
|
762
|
+
...messageScripts.leftovers(),
|
|
763
|
+
];
|
|
764
|
+
// What the workflow threw may be why a list went unfinished.
|
|
765
|
+
if (leftovers.length > 0 && "error" in settled) {
|
|
766
|
+
leftovers.push(`the workflow threw: ${messageOf(settled.error)}`);
|
|
767
|
+
}
|
|
768
|
+
problems.push(...leftovers);
|
|
769
|
+
}
|
|
770
|
+
if (problems.length > 0) throw new Error(problems.join("\n"));
|
|
771
|
+
const agents = host.agents.map((agent) => {
|
|
772
|
+
const sandbox = sandboxOf.get(agent.key);
|
|
773
|
+
return sandbox === undefined ? agent : { ...agent, sandbox };
|
|
774
|
+
});
|
|
775
|
+
const goOn = (stop: WorkflowStopped | undefined, entered: readonly { stage: string }[]) =>
|
|
776
|
+
continueCommand(
|
|
777
|
+
{
|
|
778
|
+
shellCwd: cwd,
|
|
779
|
+
cwd,
|
|
780
|
+
cwdGiven: false,
|
|
781
|
+
runRoot,
|
|
782
|
+
runRootGiven: false,
|
|
783
|
+
workflowFile: withEvents?.file ?? definition.meta.name,
|
|
784
|
+
...(options.fromStage === undefined ? {} : { fromStage: options.fromStage }),
|
|
785
|
+
},
|
|
786
|
+
run.record.id,
|
|
787
|
+
stop,
|
|
788
|
+
entered,
|
|
789
|
+
);
|
|
790
|
+
const decided = decideEnding(finished, deadline, goOn).ending;
|
|
791
|
+
// What waited on a participant the test cancelled, not an operator; after a return, nothing.
|
|
792
|
+
const ending: Ending<Result> =
|
|
793
|
+
"value" in settled
|
|
794
|
+
? { kind: "completed", value: settled.value, stages: decided.stages }
|
|
795
|
+
: waitedOut || decided.kind === "completed"
|
|
796
|
+
? {
|
|
797
|
+
kind: "cancelled",
|
|
798
|
+
...("stage" in decided && decided.stage !== undefined ? { stage: decided.stage } : {}),
|
|
799
|
+
reason: WAITED_OUT,
|
|
800
|
+
stages: decided.stages,
|
|
801
|
+
continue: goOn(undefined, decided.stages),
|
|
802
|
+
}
|
|
803
|
+
: decided;
|
|
804
|
+
return {
|
|
805
|
+
get value() {
|
|
806
|
+
if ("value" in settled) return settled.value;
|
|
807
|
+
if (primaryFailure(settled.error) instanceof RunParked)
|
|
808
|
+
throw new Error(
|
|
809
|
+
stoppedSkipping && maxTimers > 0
|
|
810
|
+
? `the run parked after the clock skipped to ${maxTimers} timers (maxTimers): a loop of timers that nothing answers?`
|
|
811
|
+
: "the run parked, and returned nothing: run.ending says at which wait",
|
|
812
|
+
);
|
|
813
|
+
throw new Error(`the workflow threw: ${messageOf(settled.error)}`, { cause: settled.error });
|
|
814
|
+
},
|
|
815
|
+
turns: host.turns,
|
|
816
|
+
emits,
|
|
817
|
+
events: inLines,
|
|
818
|
+
run: runLines,
|
|
819
|
+
turnsOf: (agent) => host.turns.filter((turn) => turn.agent === agent),
|
|
820
|
+
compactions: host.compactions,
|
|
821
|
+
compactionsOf: (agent) => host.compactions.filter((compaction) => compaction.agent === agent),
|
|
822
|
+
sets: host.sets,
|
|
823
|
+
setsOf: (agent) => host.sets.filter((set) => set.agent === agent),
|
|
824
|
+
messagesOf: (recipient) =>
|
|
825
|
+
messages.flatMap(({ to, ...each }) => (to === recipient ? [each] : [])),
|
|
826
|
+
agents,
|
|
827
|
+
agentOf(key) {
|
|
828
|
+
const agent = agents.find((opened) => opened.key === key);
|
|
829
|
+
if (agent) return agent;
|
|
830
|
+
const opened = agents.map((known) => `"${known.key}"`).join(", ") || "none";
|
|
831
|
+
throw new Error(`no agent "${key}" was opened; opened: ${opened}`);
|
|
832
|
+
},
|
|
833
|
+
decisions: decisions.asked,
|
|
834
|
+
logs,
|
|
835
|
+
stages,
|
|
836
|
+
timers,
|
|
837
|
+
ending,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Each timer: taken, or due by the run's clock and not cancelled, is `fired`. */
|
|
842
|
+
function timersOf(timers: ReadonlyMap<string, TimerState>): TestRun<unknown>["timers"] {
|
|
843
|
+
return [...timers.values()].map(({ name, due, taken, fired, cancelled }) => ({
|
|
844
|
+
name,
|
|
845
|
+
due,
|
|
846
|
+
state: taken ? "fired" : cancelled ? "cancelled" : fired ? "fired" : "pending",
|
|
847
|
+
}));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* A message as `messagesOf` gives it. A question ends `replied` or `failed`, `reason` saying why it
|
|
852
|
+
* failed (`timed-out`, `withdrawn`, `released`, `cancelled`, `attempt-ended`, or how its peer
|
|
853
|
+
* failed); any other message ends `delivered` once shown, or `undelivered` with the reason.
|
|
854
|
+
* `pending` until then. A message to a coordinator is shown once recorded.
|
|
855
|
+
*/
|
|
856
|
+
export type MessageRecord = {
|
|
857
|
+
from: string;
|
|
858
|
+
awaitReply: boolean;
|
|
859
|
+
end: "replied" | "failed" | "delivered" | "undelivered" | "pending";
|
|
860
|
+
reason?: string;
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
/** The attempt's messages, read from the run's event log. */
|
|
864
|
+
async function messagesIn(dir: string): Promise<(MessageRecord & { to: string })[]> {
|
|
865
|
+
const found = new Map<number, MessageRecord & { to: string }>();
|
|
866
|
+
const outside = new Set<string>();
|
|
867
|
+
for (const event of await readEvents(dir)) {
|
|
868
|
+
if (event.kind === "participant-connected") outside.add(event.participant);
|
|
869
|
+
if (event.kind === "message-sent") {
|
|
870
|
+
const awaitReply = event.awaitsReply !== undefined;
|
|
871
|
+
found.set(event.message, {
|
|
872
|
+
from: event.from,
|
|
873
|
+
to: event.to,
|
|
874
|
+
awaitReply,
|
|
875
|
+
// A coordinator reads the run log: what it is sent is shown once recorded.
|
|
876
|
+
end: outside.has(event.to) && !awaitReply ? "delivered" : "pending",
|
|
877
|
+
});
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
const message =
|
|
881
|
+
"message" in event && typeof event.message === "number"
|
|
882
|
+
? found.get(event.message)
|
|
883
|
+
: undefined;
|
|
884
|
+
if (!message) continue;
|
|
885
|
+
if (event.kind === "request-ended") {
|
|
886
|
+
message.end = event.end === "replied" ? "replied" : "failed";
|
|
887
|
+
const reason =
|
|
888
|
+
event.end === "failed" ? event.reason : event.end === "replied" ? undefined : event.end;
|
|
889
|
+
if (reason !== undefined) message.reason = reason;
|
|
890
|
+
} else if (!message.awaitReply && event.kind === "message-delivery") {
|
|
891
|
+
message.end = "delivered";
|
|
892
|
+
} else if (!message.awaitReply && event.kind === "message-undelivered") {
|
|
893
|
+
message.end = "undelivered";
|
|
894
|
+
message.reason = event.reason;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return [...found.values()];
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Writes `recorded` as attempt 1's succeeded stage lines into a run's `run.jsonl`, after attempt 1's
|
|
902
|
+
* claim, a second apart in the order given: a continue orders what is recorded by when each stage
|
|
903
|
+
* started.
|
|
904
|
+
*/
|
|
905
|
+
async function recordEarlierAttempt(
|
|
906
|
+
dir: string,
|
|
907
|
+
recorded: Readonly<Record<string, JsonValue | undefined>>,
|
|
908
|
+
): Promise<void> {
|
|
909
|
+
let at = Date.now() - Object.keys(recorded).length * 1_000;
|
|
910
|
+
const lines = await claimRunLines(dir, 1, new Date(at - 1_000).toISOString());
|
|
911
|
+
for (const [name, value] of Object.entries(recorded)) {
|
|
912
|
+
const time = new Date(at).toISOString();
|
|
913
|
+
at += 1_000;
|
|
914
|
+
await lines.append({
|
|
915
|
+
kind: "stage",
|
|
916
|
+
attempt: 1,
|
|
917
|
+
at: time,
|
|
918
|
+
name,
|
|
919
|
+
outcome: "succeeded",
|
|
920
|
+
started: time,
|
|
921
|
+
...(value === undefined ? {} : { value }),
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Writes `lines`, what an earlier attempt settled, into the run's `run.jsonl` as given, once
|
|
928
|
+
* `readRun` takes them: the test's claim comes after them.
|
|
929
|
+
*/
|
|
930
|
+
async function writeEarlierLines(dir: string, lines: readonly RunLine[]): Promise<void> {
|
|
931
|
+
try {
|
|
932
|
+
readRun(lines);
|
|
933
|
+
} catch (error) {
|
|
934
|
+
throw new Error(`earlier.run: ${messageOf(error)}`);
|
|
935
|
+
}
|
|
936
|
+
await appendFile(runLinesFile(dir), lines.map((line) => `${JSON.stringify(line)}\n`).join(""), {
|
|
937
|
+
mode: 0o600,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** An event a test sends while its run goes on, once its turn begins. */
|
|
942
|
+
type PendingEvent = {
|
|
943
|
+
at: string;
|
|
944
|
+
after: boolean;
|
|
945
|
+
name: string;
|
|
946
|
+
record: EventLine;
|
|
947
|
+
/** Its turn, once begun: an `after` event waits for it to end. */
|
|
948
|
+
turn?: TestTurn;
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
/** The events a test sends while its run goes on, each once its turn begins. */
|
|
952
|
+
class ScriptedEvents {
|
|
953
|
+
readonly #pending: PendingEvent[];
|
|
954
|
+
|
|
955
|
+
constructor(
|
|
956
|
+
workflow: EventWorkflow,
|
|
957
|
+
options: AnyTestOptions,
|
|
958
|
+
/** The events the earlier attempt left, whose ids are taken. */
|
|
959
|
+
earlier: readonly EventLine[],
|
|
960
|
+
/** The run's clock, which stamps what is sent. */
|
|
961
|
+
private readonly now: () => number,
|
|
962
|
+
) {
|
|
963
|
+
const ids = new Set<string>();
|
|
964
|
+
const idOf = (given: string | undefined, made: string) => {
|
|
965
|
+
const id = given ?? made;
|
|
966
|
+
const problem = given === undefined ? undefined : eventIdProblem(given);
|
|
967
|
+
if (problem) throw new Error(`the eventId ${JSON.stringify(given)}: ${problem}`);
|
|
968
|
+
// A repeated id would be appended and then dropped: the test says so instead.
|
|
969
|
+
if (ids.has(id))
|
|
970
|
+
throw new Error(`the eventId ${id} is given twice; an eventId names one event`);
|
|
971
|
+
ids.add(id);
|
|
972
|
+
return id;
|
|
973
|
+
};
|
|
974
|
+
for (const line of earlier) ids.add(line.id);
|
|
975
|
+
if (options.event) idOf(options.event.eventId, `${AWF_ID_PREFIX}start`);
|
|
976
|
+
this.#pending = (options.events ?? []).map((event, index) => {
|
|
977
|
+
if ((event.during === undefined) === (event.after === undefined))
|
|
978
|
+
throw new Error("a scripted event gives during or after, one of them");
|
|
979
|
+
const after = event.after !== undefined;
|
|
980
|
+
const at = event.after ?? event.during;
|
|
981
|
+
if (typeof at !== "string" || !/^[^#]+#[1-9]\d*$/.test(at))
|
|
982
|
+
throw new Error(
|
|
983
|
+
`a scripted event's ${after ? "after" : "during"} is "{agent}#{n}", an agent's nth turn, not ${at}`,
|
|
984
|
+
);
|
|
985
|
+
const id = idOf(event.eventId, `${AWF_ID_PREFIX}test.${index + 1}`);
|
|
986
|
+
// A participant's reply is recorded as `awf emit` records one, whatever name it is sent by.
|
|
987
|
+
const stamped = new Date().toISOString();
|
|
988
|
+
let sent = {
|
|
989
|
+
id,
|
|
990
|
+
name: event.name,
|
|
991
|
+
data: event.data as JsonValue,
|
|
992
|
+
from: "operator",
|
|
993
|
+
at: stamped,
|
|
994
|
+
};
|
|
995
|
+
const reply = awfEventOf(event.name);
|
|
996
|
+
if (reply?.kind === "reply") {
|
|
997
|
+
checkEvent(workflow, event, true);
|
|
998
|
+
sent = replyRecord(reply.key, id, event.data, stamped);
|
|
999
|
+
} else checkEvent(workflow, event);
|
|
1000
|
+
return {
|
|
1001
|
+
at,
|
|
1002
|
+
after,
|
|
1003
|
+
name: event.name,
|
|
1004
|
+
record: sent,
|
|
1005
|
+
};
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** The events due during `turn`, taken off the list, stamped now; those after it wait for it. */
|
|
1010
|
+
due(turn: TestTurn): EventLine[] {
|
|
1011
|
+
const at = `${turn.agent}#${turn.n}`;
|
|
1012
|
+
for (const each of this.#pending) if (each.after && each.at === at) each.turn = turn;
|
|
1013
|
+
return this.#take((each) => !each.after && each.at === at);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** The run waits live: the events after a turn that has ended, taken off the list. */
|
|
1017
|
+
ended(inFlight: readonly TestTurn[]): EventLine[] {
|
|
1018
|
+
return this.#take((each) => each.turn !== undefined && !inFlight.includes(each.turn));
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
#take(which: (each: PendingEvent) => boolean): EventLine[] {
|
|
1022
|
+
const due = this.#pending.filter(which);
|
|
1023
|
+
for (const each of due) this.#pending.splice(this.#pending.indexOf(each), 1);
|
|
1024
|
+
return due.map((each) => ({ ...each.record, at: new Date(this.now()).toISOString() }));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** What was never sent: the test fails. */
|
|
1028
|
+
unsent(inFlight: readonly TestTurn[]): string[] {
|
|
1029
|
+
return this.#pending.map(
|
|
1030
|
+
(each) =>
|
|
1031
|
+
`the scripted ${each.name} ${each.after ? "after" : "during"} ${each.at} was never sent: ${
|
|
1032
|
+
each.turn !== undefined && !inFlight.includes(each.turn)
|
|
1033
|
+
? "the run never waited live after that turn, in an agent's keepAlive window"
|
|
1034
|
+
: "that turn never ran"
|
|
1035
|
+
}`,
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** Lays out `files` before the run, each path under `cwd`. */
|
|
1041
|
+
async function layOut(
|
|
1042
|
+
files: Readonly<Record<string, FileTree>> | undefined,
|
|
1043
|
+
cwd: string,
|
|
1044
|
+
): Promise<void> {
|
|
1045
|
+
const write = async (path: string, tree: FileTree): Promise<void> => {
|
|
1046
|
+
if (typeof tree === "string") {
|
|
1047
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1048
|
+
await Bun.write(path, tree);
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
await mkdir(path, { recursive: true });
|
|
1052
|
+
for (const [name, inner] of Object.entries(tree)) await write(join(path, name), inner);
|
|
1053
|
+
};
|
|
1054
|
+
for (const [path, tree] of Object.entries(files ?? {})) {
|
|
1055
|
+
// Under the test's own directory, which it removes: a path outside would be left behind.
|
|
1056
|
+
const target = resolve(cwd, path);
|
|
1057
|
+
if (isAbsolute(path) || (relative(cwd, target) !== "" && !isInside(cwd, target)))
|
|
1058
|
+
throw new Error(`files lays out under the run's directory; ${path} is outside it`);
|
|
1059
|
+
await write(target, tree);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* The run a test starts: its id, and the event it starts with, as `awf emit` would append it, or
|
|
1065
|
+
* none, as `awf run --id` starts one.
|
|
1066
|
+
*/
|
|
1067
|
+
function startOf(
|
|
1068
|
+
workflow: EventWorkflow | undefined,
|
|
1069
|
+
options: AnyTestOptions,
|
|
1070
|
+
): { id: string; record?: EventLine } {
|
|
1071
|
+
const { event, id = "test" } = options;
|
|
1072
|
+
if (typeof id !== "string") throw new Error(`a run's id is a string, not ${JSON.stringify(id)}`);
|
|
1073
|
+
const problem = idProblem(id);
|
|
1074
|
+
if (problem) throw new Error(problem);
|
|
1075
|
+
if (workflow === undefined || event === undefined) return { id };
|
|
1076
|
+
checkEvent(workflow, { name: event.name, data: event.data });
|
|
1077
|
+
const { starts } = workflow;
|
|
1078
|
+
if (starts && !starts.includes(event.name))
|
|
1079
|
+
throw new Error(
|
|
1080
|
+
`${event.name} doesn't start a run of ${workflow.meta.name}: its starts are ${starts.join(", ")}`,
|
|
1081
|
+
);
|
|
1082
|
+
const record: EventLine = {
|
|
1083
|
+
at: new Date().toISOString(),
|
|
1084
|
+
id: event.eventId ?? `${AWF_ID_PREFIX}start`,
|
|
1085
|
+
name: event.name,
|
|
1086
|
+
data: event.data,
|
|
1087
|
+
from: "operator",
|
|
1088
|
+
starts: true as const,
|
|
1089
|
+
};
|
|
1090
|
+
return { id, record };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** What the earlier attempt left; `continues` when `earlier` is given: the test's is a continue. */
|
|
1094
|
+
function earlierOf(options: AnyTestOptions): EarlierAttempt & { continues: boolean } {
|
|
1095
|
+
return { ...options.earlier, continues: options.earlier !== undefined };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* The args the run is given: the test's, or what `prepare` makes of no argv, as `awf run` given
|
|
1100
|
+
* none, or a run an event starts, has. A workflow of the engine's own tests, without `prepare`,
|
|
1101
|
+
* gets `null`.
|
|
1102
|
+
*/
|
|
1103
|
+
function argsOf(
|
|
1104
|
+
workflow: { readonly meta: { name: string }; prepare?: Workflow["prepare"] },
|
|
1105
|
+
options: AnyTestOptions,
|
|
1106
|
+
cwd: string,
|
|
1107
|
+
): JsonValue {
|
|
1108
|
+
if (options.args !== undefined) return options.args;
|
|
1109
|
+
if (workflow.prepare === undefined) return null;
|
|
1110
|
+
try {
|
|
1111
|
+
const args: unknown = workflow.prepare({ argv: [], cwd });
|
|
1112
|
+
assertJsonValue(args, `${workflow.meta.name} arguments`);
|
|
1113
|
+
return args;
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
throw new Error(
|
|
1116
|
+
`${workflow.meta.name} was given no args, and its prepare of none failed: ${messageOf(error)}; give args`,
|
|
1117
|
+
{ cause: error },
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function sandboxesOf(sandboxes: readonly SandboxRecord[] | undefined): Map<string, AgentSandbox> {
|
|
1123
|
+
return new Map(
|
|
1124
|
+
(sandboxes ?? []).flatMap(({ key, provider, spec, domains, agents }) =>
|
|
1125
|
+
agents.map(({ agent }) => [agent, { key, provider, spec, domains }] as const),
|
|
1126
|
+
),
|
|
1127
|
+
);
|
|
1128
|
+
}
|