@agentswf/engine 0.0.0-stage → 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/package.json +39 -4
  2. package/src/accounting/format.ts +252 -0
  3. package/src/accounting/plans.ts +38 -0
  4. package/src/accounting/prices.ts +98 -0
  5. package/src/accounting/summary.ts +385 -0
  6. package/src/accounting/tokens.ts +34 -0
  7. package/src/agent-launcher.ts +127 -0
  8. package/src/allowance-command.ts +105 -0
  9. package/src/attempt-ending.ts +198 -0
  10. package/src/attempt-output.ts +132 -0
  11. package/src/attempt-view.ts +362 -0
  12. package/src/canonical-json.ts +12 -0
  13. package/src/control-plane.ts +424 -0
  14. package/src/deadlines.ts +106 -0
  15. package/src/decisions/directory.ts +432 -0
  16. package/src/decisions/fake.ts +60 -0
  17. package/src/decisions/openrouter.ts +161 -0
  18. package/src/decisions/seam.ts +70 -0
  19. package/src/display-path.ts +16 -0
  20. package/src/duration.ts +12 -0
  21. package/src/errors.ts +4 -0
  22. package/src/files.ts +78 -0
  23. package/src/herdr-placement.ts +182 -0
  24. package/src/herdr-run-session.ts +530 -0
  25. package/src/herdr-workspace-marks.ts +194 -0
  26. package/src/here.ts +289 -0
  27. package/src/index.ts +5 -0
  28. package/src/jsonl.ts +74 -0
  29. package/src/logs-command.ts +605 -0
  30. package/src/machine.ts +27 -0
  31. package/src/message-operation.ts +245 -0
  32. package/src/messaging/limits.ts +35 -0
  33. package/src/messaging/messaging.ts +1303 -0
  34. package/src/messaging/routes.ts +79 -0
  35. package/src/messaging/text.ts +251 -0
  36. package/src/operation-events.ts +182 -0
  37. package/src/operation-liveness.ts +674 -0
  38. package/src/operator-aliases.ts +10 -0
  39. package/src/operator-cli.ts +569 -0
  40. package/src/operator-runtime.ts +503 -0
  41. package/src/pane-layout.ts +125 -0
  42. package/src/progress-view.ts +568 -0
  43. package/src/result-slots.ts +325 -0
  44. package/src/result-validation.ts +47 -0
  45. package/src/run-command.ts +241 -0
  46. package/src/run-commands.ts +215 -0
  47. package/src/run-event-log.ts +72 -0
  48. package/src/run-prepare.ts +178 -0
  49. package/src/run-progress.ts +137 -0
  50. package/src/run-read.ts +425 -0
  51. package/src/run-state.ts +120 -0
  52. package/src/run-usage.ts +393 -0
  53. package/src/run-view.ts +225 -0
  54. package/src/runs-command.ts +356 -0
  55. package/src/runs.ts +645 -0
  56. package/src/sandbox-homes.ts +211 -0
  57. package/src/sandboxes.ts +516 -0
  58. package/src/skills/fetch.ts +305 -0
  59. package/src/skills/run-skills.ts +181 -0
  60. package/src/skills/sources.ts +87 -0
  61. package/src/skills/tree.ts +60 -0
  62. package/src/stage-ledger.ts +535 -0
  63. package/src/stage-plan.ts +127 -0
  64. package/src/stage-schema.ts +88 -0
  65. package/src/stopped.ts +36 -0
  66. package/src/test-command.ts +110 -0
  67. package/src/testing/operation-events.ts +89 -0
  68. package/src/testing.ts +164 -0
  69. package/src/workflow-loader.ts +113 -0
  70. package/src/workflow-runner.ts +3665 -0
  71. package/src/workflow-test-preload.ts +13 -0
  72. package/src/workflow-testing/decisions.ts +129 -0
  73. package/src/workflow-testing/host.ts +811 -0
  74. package/src/workflow-testing/index.ts +420 -0
  75. package/src/workflow-testing/script.ts +447 -0
  76. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  77. package/README.md +0 -3
@@ -0,0 +1,569 @@
1
+ #!/usr/bin/env -S bun --no-env-file
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, relative, resolve } from "node:path";
5
+ import type { HarnessAllowance } from "@agentswf/contract/records";
6
+ import type { JsonObject, JsonValue } from "@agentswf/contract/workflow";
7
+ import { type Harness, handBack, type RunProcess } from "@agentswf/harness";
8
+ import manifest from "../package.json" with { type: "json" };
9
+ import { stageFigures } from "./accounting/format";
10
+ import {
11
+ type AllowanceCommand,
12
+ allowanceUsage,
13
+ describeReport,
14
+ parseAllowanceCommand,
15
+ readReport,
16
+ } from "./allowance-command";
17
+ import {
18
+ type AttemptEnd,
19
+ cleanupFailed,
20
+ decideEnding,
21
+ endedBeforeStart,
22
+ type Finished,
23
+ type GoOn,
24
+ signalExitCode,
25
+ } from "./attempt-ending";
26
+ import { type Kept, keepRecords } from "./attempt-output";
27
+ import { continueCommand, printEnding, toldOf } from "./attempt-view";
28
+ import { messageOf } from "./errors";
29
+ import { type Caller, showOwnTab, startHere, takeCaller } from "./here";
30
+ import { logsCommand } from "./logs-command";
31
+ import { machinePaths, sandboxesDirOf } from "./machine";
32
+ import {
33
+ allowanceReader,
34
+ herdrConfig,
35
+ installOperatorRuntime,
36
+ type OperatorRuntimeInstallation,
37
+ type RunSession,
38
+ } from "./operator-runtime";
39
+ import { ANSI, describeRunSession, type Terminal, watchProgress } from "./progress-view";
40
+ import { parseCommand, type RunCommand, usage } from "./run-command";
41
+ import { shellLine, startedLine } from "./run-commands";
42
+ import { claimNext, loadAndPrepare, type PreparedRun, workspaceLabel } from "./run-prepare";
43
+ import { findRun } from "./run-state";
44
+ import { type Attempt, createRun, discardRun, type Run, RunRefused } from "./runs";
45
+ import { runsCommand } from "./runs-command";
46
+ import { parseTestCommand, runWorkflowTests, type TestCommand, testUsage } from "./test-command";
47
+ import type { LoadedWorkflow } from "./workflow-loader";
48
+ import {
49
+ type RunWorkflowOptions,
50
+ startWorkflow,
51
+ WorkflowCancelledError,
52
+ type WorkflowRunHandle,
53
+ } from "./workflow-runner";
54
+
55
+ /** What the CLI reaches the machine through; each is the real one when absent. */
56
+ export type OperatorEnvironment = {
57
+ cwd?: string;
58
+ home?: string;
59
+ stdout?: (text: string) => void;
60
+ stderr?: (text: string) => void;
61
+ installRuntime?: (
62
+ timeoutMilliseconds: number,
63
+ options: {
64
+ watchSandboxes: boolean;
65
+ caller?: Caller;
66
+ home?: string;
67
+ onRunSession?: (session: RunSession) => void;
68
+ },
69
+ ) => Promise<OperatorRuntimeInstallation>;
70
+ /** Given, progress is redrawn in place on it; otherwise each change is a line on stderr. */
71
+ terminal?: Terminal;
72
+ bunVersion?: string;
73
+ /** Runs `herdr` for `--here` and `--session`. */
74
+ herdr?: RunProcess;
75
+ environment?: Readonly<Record<string, string | undefined>>;
76
+ /** The command that runs this awf, which `--here` types into the run's tab. */
77
+ self?: readonly string[];
78
+ /** How long `--session` looks for the pane showing its code. */
79
+ callerSearchMs?: number;
80
+ signal?: AbortSignal;
81
+ now?: () => number;
82
+ /** How `awf allowance` reads a harness's plan. */
83
+ readAllowance?: (harness: Harness) => Promise<HarnessAllowance>;
84
+ /**
85
+ * Where agents may `--await-reply`, past the engine's list: for the live checks that decide
86
+ * what goes on it (story 029's M2 gate).
87
+ */
88
+ awaitReply?: RunWorkflowOptions["awaitReply"];
89
+ };
90
+
91
+ type Output = {
92
+ stdout: (text: string) => void;
93
+ stderr: (text: string) => void;
94
+ /** For what awf says beside a problem rather than about one, which is never painted. */
95
+ notice: (text: string) => void;
96
+ terminal: Terminal | undefined;
97
+ now: () => number;
98
+ };
99
+
100
+ export async function runOperatorCli(
101
+ argv: readonly string[],
102
+ environment: OperatorEnvironment = {},
103
+ ): Promise<number> {
104
+ const stdout = environment.stdout ?? ((text) => console.log(text));
105
+ const write = (text: string) => void process.stderr.write(text);
106
+ const stderr =
107
+ environment.stderr ??
108
+ stderrLines(write, { color: process.stderr.isTTY && !process.env.NO_COLOR });
109
+ const notice = environment.stderr ?? ((text: string) => write(`${text}\n`));
110
+ const terminal =
111
+ environment.terminal ??
112
+ (!environment.stderr && process.stderr.isTTY
113
+ ? {
114
+ write: (text: string) => void process.stderr.write(text),
115
+ color: !process.env.NO_COLOR,
116
+ }
117
+ : undefined);
118
+ const now = environment.now ?? Date.now;
119
+ const tooOld = bunProblem(environment.bunVersion ?? Bun.version);
120
+ if (tooOld) {
121
+ stderr(`awf: ${tooOld}`);
122
+ return 1;
123
+ }
124
+ if (argv.length === 1 && argv[0] === "--version") {
125
+ stdout(describeVersion());
126
+ return 0;
127
+ }
128
+ if (argv[0] === "test") return testCommand(argv.slice(1), environment, stdout, stderr);
129
+ if (argv[0] === "logs") {
130
+ return logsCommand(argv.slice(1), environment.cwd ?? process.cwd(), { stdout, stderr });
131
+ }
132
+ if (argv[0] === "runs") {
133
+ return runsCommand(argv.slice(1), environment.cwd ?? process.cwd(), { stdout, stderr });
134
+ }
135
+ if (argv[0] === "allowance") {
136
+ return allowanceCommand(argv.slice(1), environment, stdout, stderr);
137
+ }
138
+ let command: RunCommand;
139
+ try {
140
+ command = parseCommand(argv, environment.cwd ?? process.cwd());
141
+ } catch (error) {
142
+ stderr(`awf: ${messageOf(error)}\n\n${usage}`);
143
+ return 2;
144
+ }
145
+ const meant = await runNamed(command);
146
+ if (meant) {
147
+ const look = shellLine([
148
+ "awf",
149
+ "runs",
150
+ meant,
151
+ ...(command.cwdGiven ? ["--cwd", command.cwd] : []),
152
+ ...(command.runRootGiven ? ["--run-root", command.runRoot] : []),
153
+ ]);
154
+ stderr(`awf: ${command.workflowFile} is not a workflow file; did you mean: ${look}`);
155
+ return 2;
156
+ }
157
+ if (command.here) return startHere(argv, command, environment, stdout, stderr, notice);
158
+ // The session that started a `--session` run waits on it, and its tab opened unfocused: until the
159
+ // run takes the session over, a refusal is said where the operator will look.
160
+ const unstarted = async (exitCode: number) => {
161
+ if (command.session !== undefined) await showOwnTab(environment);
162
+ return exitCode;
163
+ };
164
+ const home = environment.home ?? homedir();
165
+ const { sandboxes, callers } = machinePaths(home);
166
+ if (!relative(command.runRoot, sandboxes).startsWith("..")) {
167
+ stderr(
168
+ `awf: --run-root ${command.runRoot} holds ${sandboxes}, which every sandbox must reach and none may reach the run root`,
169
+ );
170
+ return unstarted(2);
171
+ }
172
+ const ready = await loadAndPrepare(command, now());
173
+ if ("refused" in ready) {
174
+ stderr(`awf: ${ready.refused}`);
175
+ return unstarted(2);
176
+ }
177
+ for (const line of ready.prepared.continued?.warnings ?? []) notice(line);
178
+
179
+ // Loading the workflow imports operator-supplied code before anything listens to the signal, so
180
+ // a Ctrl-C in it would otherwise be swallowed and have to be pressed again.
181
+ if (environment.signal?.aborted) {
182
+ return unstarted(cancelledBeforeStart(environment.signal, stderr));
183
+ }
184
+ let calling: { caller: Caller; release: () => void } | undefined;
185
+ if (command.session !== undefined) {
186
+ const taken = await takeCaller(command.session, callers, environment);
187
+ if (typeof taken === "string") {
188
+ stderr(`awf: --session: ${taken}`);
189
+ return 1;
190
+ }
191
+ calling = taken;
192
+ if (environment.signal?.aborted) {
193
+ calling.release();
194
+ return cancelledBeforeStart(environment.signal, stderr);
195
+ }
196
+ }
197
+ return runAttempt(command, ready, calling, environment, home, {
198
+ stdout,
199
+ stderr,
200
+ notice,
201
+ terminal,
202
+ now,
203
+ });
204
+ }
205
+
206
+ /** The run `awf run {ref}` names where `{ref}` is no file: one meant for `awf runs`. */
207
+ async function runNamed(command: RunCommand): Promise<string | undefined> {
208
+ if (existsSync(resolve(command.shellCwd, command.workflowFile))) return undefined;
209
+ const found = await findRun(command.runRoot, command.workflowFile).catch(() => undefined);
210
+ return found?.status.ref;
211
+ }
212
+
213
+ /**
214
+ * Claims the run, new or continued, and its next attempt, runs it, and keeps its records; then
215
+ * hands the calling session, if one was taken, back.
216
+ */
217
+ async function runAttempt(
218
+ command: RunCommand,
219
+ { loaded, prepared }: { loaded: LoadedWorkflow; prepared: PreparedRun },
220
+ calling: { caller: Caller; release: () => void } | undefined,
221
+ environment: OperatorEnvironment,
222
+ home: string,
223
+ output: Output,
224
+ ): Promise<number> {
225
+ const { stdout, stderr, notice, terminal, now } = output;
226
+ const { executable } = loaded;
227
+ const { meta } = executable.definition;
228
+ /** The run, once it is claimed. */
229
+ let run: Run | undefined;
230
+ // Once the run has taken the session over, however it ends, the session gets it back.
231
+ let handedBack = false;
232
+ const handOver = async (ended: string, stopped = false) => {
233
+ if (!calling || handedBack) return;
234
+ handedBack = true;
235
+ calling.release();
236
+ const failed = await handBack(
237
+ herdrConfig(calling.caller.session),
238
+ calling.caller.pane.paneId,
239
+ `[awf] The workflow ${meta.name}${run ? `, run ${run.record.id},` : ""} ${ended}. ${stopped ? "This session was released from the run" : "The run is over"} and this session is yours; nothing here needs an answer.`,
240
+ environment.herdr,
241
+ );
242
+ if (failed) stderr(`awf: the calling session was not told the run ended: ${failed}`);
243
+ };
244
+
245
+ const refuse = async (why: string) => {
246
+ stderr(`awf: ${why}`);
247
+ await handOver(`did not start: ${why}`);
248
+ return 2;
249
+ };
250
+ try {
251
+ run =
252
+ prepared.continued?.run ??
253
+ (await createRun(command.runRoot, {
254
+ ...(prepared.id === undefined ? {} : { id: prepared.id }),
255
+ workflow: meta.name,
256
+ argv: command.workflowArgs,
257
+ cwd: command.cwd,
258
+ sandbox: (command.sandbox ?? null) as JsonValue,
259
+ }));
260
+ } catch (error) {
261
+ return refuse(
262
+ `${error instanceof RunRefused ? "" : `${command.runRoot}: `}${messageOf(error)}`,
263
+ );
264
+ }
265
+ let attempt: Attempt;
266
+ try {
267
+ attempt = await claimNext(run, loaded, command, notice, now());
268
+ } catch (error) {
269
+ // A run this call created and never ran is not left to hold its id.
270
+ if (!prepared.continued) await discardRun(run).catch(() => undefined);
271
+ return refuse(messageOf(error));
272
+ }
273
+ const { id, cwd } = run.record;
274
+ const sandbox = run.record.sandbox ?? undefined;
275
+ const n = attempt.record.attempt;
276
+ const goOn: GoOn = (stop, entered) => continueCommand(command, id, stop, entered);
277
+ const records = { attempt, executable, workflow: { name: meta.name, file: loaded.file }, stderr };
278
+ const close = (end: AttemptEnd, kept: Kept, cleanupAlso?: string) =>
279
+ printEnding(end, kept, {
280
+ executable,
281
+ run: { id, dir: run.dir },
282
+ n,
283
+ earlier: prepared.continued?.attempts ?? [],
284
+ json: command.json,
285
+ fromStage: command.fromStage !== undefined,
286
+ now: now(),
287
+ shellCwd: command.shellCwd,
288
+ home,
289
+ ...(cleanupAlso === undefined ? {} : { cleanupAlso }),
290
+ ...(terminal ? { terminal } : {}),
291
+ stdout,
292
+ stderr,
293
+ notice,
294
+ });
295
+ // Timed from here: finding the calling session can take minutes the run itself never had.
296
+ const startedAt = now();
297
+ // Every way out from here writes the attempt's ending, before the hand-back: a session told the
298
+ // run is over may continue it at once. One that never does is interrupted. A new run that never
299
+ // started is not kept at all, so the same command starts it again, unless another attempt has
300
+ // claimed it since: then its records are kept as a continued run's are.
301
+ const deadline = { unixMilliseconds: startedAt + command.timeoutMilliseconds };
302
+ const endUnstarted = async (error: unknown, cleanupAlso?: string) => {
303
+ const end = endedBeforeStart(error, deadline, { runId: id, startedAt, endedAt: now() }, goOn);
304
+ const cancelled = end.ending.kind === "cancelled";
305
+ if (!prepared.continued && (await discardRun(run, attempt).catch(() => false))) {
306
+ if (cleanupAlso) stderr(`awf: runtime cleanup also failed: ${cleanupAlso}`);
307
+ const said = cancelled
308
+ ? "run cancelled before it started"
309
+ : `${end.ending.reason}; the run did not start`;
310
+ stderr(`awf: ${said}, and nothing of it is kept`);
311
+ await handOver(`did not start: ${cancelled ? "cancelled" : end.ending.reason}`);
312
+ return end.exitCode;
313
+ }
314
+ const kept = await keepRecords(end, records);
315
+ await handOver(toldOf(end, kept));
316
+ return close(end, kept, cleanupAlso);
317
+ };
318
+ // Relied on: the session is told only from a pane agent's `openRun`, inside `startWorkflow`, by
319
+ // when `placed` says it on the progress.
320
+ let placed: (session: RunSession) => void = () => undefined;
321
+ let installed: OperatorRuntimeInstallation;
322
+ try {
323
+ installed = await (environment.installRuntime ?? installOperatorRuntime)(
324
+ command.timeoutMilliseconds,
325
+ {
326
+ watchSandboxes: command.watch,
327
+ home,
328
+ onRunSession: (session) => placed(session),
329
+ ...(calling ? { caller: calling.caller } : { shellCwd: command.shellCwd }),
330
+ },
331
+ );
332
+ } catch (error) {
333
+ return endUnstarted(new Error(`runtime: ${messageOf(error)}`));
334
+ }
335
+ const cleanUp = () =>
336
+ installed.cleanup().then(
337
+ () => undefined,
338
+ (error: unknown) => messageOf(error),
339
+ );
340
+ // Installing the runtime probes two subscription logins, with nothing listening to the signal
341
+ // either.
342
+ if (environment.signal?.aborted) {
343
+ return endUnstarted(new WorkflowCancelledError(environment.signal.reason), await cleanUp());
344
+ }
345
+
346
+ // Before any agent starts; in a terminal, the progress block's header names the run instead.
347
+ if (!terminal) notice(startedLine(meta.name, id, command));
348
+ const progress = watchProgress(`${meta.name}/${id}${n > 1 ? ` · attempt ${n}` : ""}`, startedAt, {
349
+ stderr,
350
+ terminal,
351
+ now,
352
+ });
353
+ placed = (session) => {
354
+ progress.placedIn(session.name);
355
+ const label = workspaceLabel(meta.name, id, n);
356
+ const insideHerdr = (environment.environment ?? process.env).HERDR_ENV === "1";
357
+ for (const line of describeRunSession(session, label, insideHerdr)) progress.log(line);
358
+ };
359
+ let handle: WorkflowRunHandle<JsonValue>;
360
+ try {
361
+ handle = await startWorkflow(executable.definition, prepared.args, {
362
+ runRoot: run.root,
363
+ run: {
364
+ dir: run.dir,
365
+ id,
366
+ attempt: n,
367
+ label: workspaceLabel(meta.name, id, n),
368
+ ...(command.fromStage === undefined ? {} : { fromStage: command.fromStage }),
369
+ ...(command.values === undefined ? {} : { values: command.values.stages }),
370
+ },
371
+ runtime: installed.config,
372
+ sandboxes: {
373
+ providers: installed.sandboxes ?? { installed: {} },
374
+ sandboxesDir: sandboxesDirOf(home, run.record),
375
+ machineRoot: machinePaths(home).root,
376
+ ...(sandbox === undefined ? {} : { run: sandbox }),
377
+ },
378
+ ...(installed.decisions ? { decisions: installed.decisions } : {}),
379
+ deadline,
380
+ cwd,
381
+ ...(environment.signal ? { signal: environment.signal } : {}),
382
+ ...(environment.awaitReply ? { awaitReply: environment.awaitReply } : {}),
383
+ onCallerStopped: () => handOver("stopped using this session", true),
384
+ onLog: (logMessage, fields?: JsonObject) =>
385
+ progress.log(fields ? `${logMessage} ${JSON.stringify(fields)}` : logMessage),
386
+ });
387
+ } catch (error) {
388
+ progress.stop();
389
+ return endUnstarted(error, await cleanUp());
390
+ }
391
+ let finished: Finished;
392
+ progress.watch(handle);
393
+ try {
394
+ finished = { result: await handle.result };
395
+ } catch (error) {
396
+ finished = { error };
397
+ }
398
+
399
+ let end = decideEnding(finished, deadline, goOn);
400
+ progress.stop(end.settled ? stageFigures(end.settled.accounting) : undefined);
401
+ let kept: Kept;
402
+ let cleanupAlso: string | undefined;
403
+ if (end.ending.kind === "completed") {
404
+ // Cleaned up before it is ended, as a failed cleanup fails it: ended, it lets the next attempt
405
+ // start, whose records this one's would otherwise overwrite.
406
+ try {
407
+ await installed.cleanup();
408
+ } catch (error) {
409
+ end = cleanupFailed(end, error, goOn(undefined, end.ending.stages));
410
+ }
411
+ kept = await keepRecords(end, records);
412
+ } else {
413
+ // Ended before cleanup, so a second Ctrl-C during it leaves the attempt ended.
414
+ kept = await keepRecords(end, records);
415
+ try {
416
+ await installed.cleanup();
417
+ } catch (error) {
418
+ cleanupAlso = messageOf(error);
419
+ }
420
+ }
421
+ await handOver(toldOf(end, kept));
422
+ return close(end, kept, cleanupAlso);
423
+ }
424
+
425
+ /** `awf allowance`: what is left of each harness's plan. */
426
+ async function allowanceCommand(
427
+ argv: readonly string[],
428
+ environment: OperatorEnvironment,
429
+ stdout: (text: string) => void,
430
+ stderr: (text: string) => void,
431
+ ): Promise<number> {
432
+ let command: AllowanceCommand;
433
+ try {
434
+ command = parseAllowanceCommand(argv);
435
+ } catch (error) {
436
+ stderr(`awf: ${messageOf(error)}\n\n${allowanceUsage}`);
437
+ return 2;
438
+ }
439
+ if (command === "help") {
440
+ stdout(allowanceUsage);
441
+ return 0;
442
+ }
443
+ const read =
444
+ environment.readAllowance ??
445
+ allowanceReader(
446
+ environment.environment ?? process.env,
447
+ undefined,
448
+ environment.signal,
449
+ environment.home ?? homedir(),
450
+ );
451
+ const report = await readReport(command.harnesses, read, (environment.now ?? Date.now)());
452
+ stdout(command.json ? JSON.stringify(report, null, 2) : describeReport(report));
453
+ return 0;
454
+ }
455
+
456
+ /** `awf test`: the workflow tests under the given paths, with Bun's test runner. */
457
+ async function testCommand(
458
+ argv: readonly string[],
459
+ environment: OperatorEnvironment,
460
+ stdout: (text: string) => void,
461
+ stderr: (text: string) => void,
462
+ ): Promise<number> {
463
+ let tests: TestCommand | "help";
464
+ try {
465
+ tests = parseTestCommand(argv, environment.cwd ?? process.cwd());
466
+ } catch (error) {
467
+ stderr(`awf: ${messageOf(error)}\n\n${testUsage}`);
468
+ return 2;
469
+ }
470
+ if (tests === "help") {
471
+ stdout(testUsage);
472
+ return 0;
473
+ }
474
+ const captured = environment.stdout || environment.stderr ? { stdout, stderr } : undefined;
475
+ return runWorkflowTests(tests, environment.cwd ?? process.cwd(), {
476
+ ...(environment.signal ? { signal: environment.signal } : {}),
477
+ ...(captured ? { output: captured } : {}),
478
+ });
479
+ }
480
+
481
+ /**
482
+ * Not `console.error`, which Bun paints red on a terminal: a workflow's log and the accounting
483
+ * are ordinary lines. Only awf's own problems, which it prefixes `awf: `, are red: their first line.
484
+ */
485
+ export function stderrLines(
486
+ write: (text: string) => void,
487
+ options: { color: boolean },
488
+ ): (line: string) => void {
489
+ return (text) => {
490
+ if (!options.color || !text.startsWith("awf: ")) return write(`${text}\n`);
491
+ const end = text.indexOf("\n");
492
+ write(end < 0 ? `${ANSI.bad(text)}\n` : `${ANSI.bad(text.slice(0, end))}${text.slice(end)}\n`);
493
+ };
494
+ }
495
+
496
+ function cancelledBeforeStart(signal: AbortSignal, stderr: (line: string) => void): number {
497
+ stderr("awf: run cancelled before it started");
498
+ return signalExitCode(signal.reason);
499
+ }
500
+
501
+ /** Why this Bun can't run awf, against the engine's `engines.bun`; undefined when it can. */
502
+ function bunProblem(running: string, required: string = manifest.engines.bun) {
503
+ if (Bun.semver.satisfies(running, required)) return undefined;
504
+ return `awf needs Bun ${required}, and this is Bun ${running}; bun upgrade installs a newer one`;
505
+ }
506
+
507
+ /** The engine's version, and the commit when it runs from a clone of this repository. */
508
+ function describeVersion(): string {
509
+ const engine = resolve(import.meta.dir, "..");
510
+ const git = (...args: string[]): string | undefined => {
511
+ try {
512
+ const done = Bun.spawnSync(["git", "-C", engine, ...args], { stderr: "ignore" });
513
+ return done.success ? done.stdout.toString().trim() : undefined;
514
+ } catch {
515
+ return undefined;
516
+ }
517
+ };
518
+ const top = git("rev-parse", "--show-toplevel");
519
+ // Installed inside someone else's repository, its commit would be theirs, not awf's.
520
+ const inTop = top === undefined ? undefined : join(top, "packages/engine");
521
+ const clone =
522
+ inTop !== undefined && existsSync(inTop) && realpathSync(inTop) === realpathSync(engine);
523
+ const commit = clone ? git("rev-parse", "--short", "HEAD") : undefined;
524
+ return commit ? `awf ${manifest.version} (${commit})` : `awf ${manifest.version}`;
525
+ }
526
+
527
+ /** How soon a repeated signal is the copy `bun awf` forwards, not the operator pressing again. */
528
+ const REPEAT_MS = 1_000;
529
+
530
+ /**
531
+ * Cancels `controller` at the first signal that asks the run to stop, and stops the process at
532
+ * once at a second the operator sends, without the run's cleanup. Ctrl-C reaches the whole
533
+ * process group and `bun awf` forwards it once more, so a repeat within `REPEAT_MS` is ignored:
534
+ * taken as the second, it would kill the run mid-cleanup and leave its sandboxes behind. Returns
535
+ * what removes the handlers.
536
+ */
537
+ export function cancelOnSignals(
538
+ controller: AbortController,
539
+ exit: (code: number) => void = (code) => process.exit(code),
540
+ now: () => number = Date.now,
541
+ ): () => void {
542
+ let first: number | undefined;
543
+ const cancel = (signal: NodeJS.Signals) => {
544
+ if (first === undefined) {
545
+ first = now();
546
+ controller.abort(signal);
547
+ } else if (now() - first > REPEAT_MS) {
548
+ exit(signalExitCode(signal));
549
+ }
550
+ };
551
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
552
+ for (const signal of signals) process.on(signal, cancel);
553
+ return () => {
554
+ for (const signal of signals) process.off(signal, cancel);
555
+ };
556
+ }
557
+
558
+ /** Runs `awf` with this process's arguments and signals; `agentswf`'s bin calls it too. */
559
+ export async function main(): Promise<void> {
560
+ const controller = new AbortController();
561
+ const stop = cancelOnSignals(controller);
562
+ try {
563
+ process.exitCode = await runOperatorCli(process.argv.slice(2), { signal: controller.signal });
564
+ } finally {
565
+ stop();
566
+ }
567
+ }
568
+
569
+ if (import.meta.main) await main();