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

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 (82) 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/init-command.ts +240 -0
  29. package/src/init-template/AGENTS.md.template +16 -0
  30. package/src/init-template/hello/workflow.test.ts.template +17 -0
  31. package/src/init-template/hello/workflow.ts.template +26 -0
  32. package/src/init-template/tsconfig.json.template +11 -0
  33. package/src/jsonl.ts +74 -0
  34. package/src/logs-command.ts +605 -0
  35. package/src/machine.ts +27 -0
  36. package/src/message-operation.ts +245 -0
  37. package/src/messaging/limits.ts +35 -0
  38. package/src/messaging/messaging.ts +1303 -0
  39. package/src/messaging/routes.ts +79 -0
  40. package/src/messaging/text.ts +251 -0
  41. package/src/operation-events.ts +182 -0
  42. package/src/operation-liveness.ts +674 -0
  43. package/src/operator-aliases.ts +10 -0
  44. package/src/operator-cli.ts +600 -0
  45. package/src/operator-runtime.ts +503 -0
  46. package/src/pane-layout.ts +125 -0
  47. package/src/progress-view.ts +568 -0
  48. package/src/result-slots.ts +325 -0
  49. package/src/result-validation.ts +47 -0
  50. package/src/run-command.ts +242 -0
  51. package/src/run-commands.ts +215 -0
  52. package/src/run-event-log.ts +72 -0
  53. package/src/run-prepare.ts +178 -0
  54. package/src/run-progress.ts +137 -0
  55. package/src/run-read.ts +425 -0
  56. package/src/run-state.ts +120 -0
  57. package/src/run-usage.ts +393 -0
  58. package/src/run-view.ts +225 -0
  59. package/src/runs-command.ts +356 -0
  60. package/src/runs.ts +645 -0
  61. package/src/sandbox-homes.ts +211 -0
  62. package/src/sandboxes.ts +521 -0
  63. package/src/skills/fetch.ts +305 -0
  64. package/src/skills/run-skills.ts +181 -0
  65. package/src/skills/sources.ts +87 -0
  66. package/src/skills/tree.ts +60 -0
  67. package/src/stage-ledger.ts +535 -0
  68. package/src/stage-plan.ts +127 -0
  69. package/src/stage-schema.ts +88 -0
  70. package/src/stopped.ts +36 -0
  71. package/src/test-command.ts +110 -0
  72. package/src/testing/operation-events.ts +89 -0
  73. package/src/testing.ts +164 -0
  74. package/src/workflow-loader.ts +113 -0
  75. package/src/workflow-runner.ts +3666 -0
  76. package/src/workflow-test-preload.ts +13 -0
  77. package/src/workflow-testing/decisions.ts +129 -0
  78. package/src/workflow-testing/host.ts +811 -0
  79. package/src/workflow-testing/index.ts +420 -0
  80. package/src/workflow-testing/script.ts +447 -0
  81. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  82. package/README.md +0 -3
@@ -0,0 +1,325 @@
1
+ import { acceptAny, type SemanticCheck } from "@agentswf/contract";
2
+ import type { CandidateSource } from "@agentswf/contract/records";
3
+ import type { JsonSchema } from "@agentswf/contract/schema";
4
+ import type { ResultSubmitCode } from "@agentswf/contract/wire";
5
+ import type { AbsoluteDeadline } from "@agentswf/contract/workflow";
6
+ import { scheduleAt } from "./deadlines";
7
+ import { evaluateResult } from "./result-validation";
8
+ import { recordCandidate, writeAcceptedExclusive, writeCall } from "./runs";
9
+
10
+ export type ResultRejectionCode = ResultSubmitCode;
11
+ export type ResultSlotAccepted = {
12
+ kind: "accepted";
13
+ value: unknown;
14
+ candidateRecorded: boolean;
15
+ /** Eligibility was reserved at this time, before persistence began. */
16
+ admittedAt: number;
17
+ /** The exclusive result write completed at this time. */
18
+ acceptedAt: number;
19
+ };
20
+ type Rejected = { kind: "rejected"; code: ResultRejectionCode; error: string };
21
+ export type ResultSubmission = ResultSlotAccepted | Rejected;
22
+ export type WaitingGrant = { kind: "waiting"; waitUntil: number; deadline: number };
23
+ export type ResultSlotSettlement =
24
+ | ResultSlotAccepted
25
+ | { kind: "closed" }
26
+ | { kind: "expired" }
27
+ | { kind: "failed"; reason: string };
28
+ export type ResultSlotEvent =
29
+ | { kind: "waiting"; reason: string; waitUntil: number; at: number; revision: number }
30
+ | { kind: "admitted"; admittedAt: number; revision: number }
31
+ | { kind: "persisted"; accepted: ResultSlotAccepted; revision: number };
32
+ export type ResultSlotBinding = { operationId: string; settled: Promise<ResultSlotSettlement> };
33
+ export type ResultSlotSpec = {
34
+ operationId: string;
35
+ agentId: string;
36
+ question: string;
37
+ schema?: JsonSchema;
38
+ semantic?: SemanticCheck;
39
+ deadline: AbsoluteDeadline;
40
+ allowWaiting?: boolean;
41
+ onEvent?(event: ResultSlotEvent): void;
42
+ };
43
+ export type ResultSlotRegistryOptions = {
44
+ runDir: string;
45
+ now?: () => number;
46
+ schedule?: (delayMilliseconds: number, expire: () => void) => () => void;
47
+ persistence?: ResultSlotPersistence;
48
+ persistenceMs?: number;
49
+ auditMs?: number;
50
+ waitDefaultMs?: number;
51
+ waitMinimumMs?: number;
52
+ };
53
+ interface ResultSlotPersistence {
54
+ writeCall: typeof writeCall;
55
+ recordCandidate: typeof recordCandidate;
56
+ writeAcceptedExclusive: typeof writeAcceptedExclusive;
57
+ }
58
+ type SubmissionInput = {
59
+ operationId: string;
60
+ agentId: string;
61
+ raw: string;
62
+ source: CandidateSource;
63
+ };
64
+ export interface ResultSlotRegistry {
65
+ open(spec: ResultSlotSpec): Promise<ResultSlotBinding>;
66
+ submit(input: SubmissionInput): Promise<ResultSubmission>;
67
+ waiting(input: {
68
+ operationId: string;
69
+ agentId: string;
70
+ reason: string;
71
+ timeoutMs?: number;
72
+ }): Promise<WaitingGrant | Rejected>;
73
+ close(operationId: string): Promise<boolean>;
74
+ }
75
+ type Slot = ResultSlotSpec & {
76
+ state: "open" | "committing" | "accepted" | "closed" | "expired" | "failed";
77
+ settle(value: ResultSlotSettlement): void;
78
+ cancelExpiry(): void;
79
+ revision: number;
80
+ };
81
+
82
+ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): ResultSlotRegistry {
83
+ const now = options.now ?? Date.now;
84
+ const schedule = options.schedule ?? scheduleExpiry;
85
+ const persistence = options.persistence ?? { writeCall, recordCandidate, writeAcceptedExclusive };
86
+ const persistenceMs = duration(options.persistenceMs, 5_000);
87
+ const auditMs = duration(options.auditMs, 1_000);
88
+ const waitDefaultMs = duration(options.waitDefaultMs, 120_000);
89
+ const waitMinimumMs = duration(options.waitMinimumMs, 30_000);
90
+ const slots = new Map<string, Slot>();
91
+ const bounded = <T>(pending: Promise<T>, milliseconds: number, reason: string): Promise<T> => {
92
+ let cancel = () => {};
93
+ const expiry = new Promise<never>((_resolve, reject) => {
94
+ cancel = schedule(milliseconds, () => reject(new SlotTimeoutError(reason)));
95
+ });
96
+ return Promise.race([pending, expiry]).finally(() => cancel());
97
+ };
98
+ const audit = async (
99
+ slot: Slot,
100
+ input: SubmissionInput,
101
+ accepted: boolean,
102
+ error?: string,
103
+ ): Promise<boolean> => {
104
+ try {
105
+ await bounded(
106
+ Promise.resolve().then(() =>
107
+ persistence.recordCandidate(options.runDir, slot.operationId, {
108
+ at: new Date(now()).toISOString(),
109
+ source: input.source,
110
+ raw: input.raw,
111
+ accepted,
112
+ ...(error === undefined ? {} : { error }),
113
+ }),
114
+ ),
115
+ auditMs,
116
+ "candidate recording timed out",
117
+ );
118
+ return true;
119
+ } catch {
120
+ return false;
121
+ }
122
+ };
123
+ const rejectKnown = async (
124
+ slot: Slot,
125
+ input: SubmissionInput,
126
+ code: ResultRejectionCode,
127
+ error: string = code,
128
+ ): Promise<Rejected> => {
129
+ await audit(slot, input, false, error);
130
+ return rejected(code, error);
131
+ };
132
+
133
+ return {
134
+ async open(spec) {
135
+ if (slots.has(spec.operationId))
136
+ throw new Error(`result slot already exists for operation ${spec.operationId}`);
137
+ let settle!: (value: ResultSlotSettlement) => void;
138
+ const settled = new Promise<ResultSlotSettlement>((resolve) => {
139
+ settle = resolve;
140
+ });
141
+ const slot: Slot = { ...spec, state: "open", settle, cancelExpiry: () => {}, revision: 0 };
142
+ slots.set(spec.operationId, slot);
143
+ try {
144
+ await bounded(
145
+ Promise.resolve().then(() =>
146
+ persistence.writeCall(options.runDir, {
147
+ callId: spec.operationId,
148
+ question: spec.question,
149
+ ...(spec.schema ? { schema: spec.schema } : {}),
150
+ }),
151
+ ),
152
+ persistenceMs,
153
+ "call persistence timed out",
154
+ );
155
+ } catch (error) {
156
+ if (error instanceof SlotTimeoutError) {
157
+ // The old write may still publish; its operation ID must never be reused.
158
+ slot.state = "failed";
159
+ slot.settle({ kind: "failed", reason: error.message });
160
+ } else slots.delete(spec.operationId);
161
+ throw error;
162
+ }
163
+ if (slot.state === "open") {
164
+ if (now() >= spec.deadline.unixMilliseconds) closeSlot(slot, "expired");
165
+ else
166
+ slot.cancelExpiry = schedule(spec.deadline.unixMilliseconds - now(), () => {
167
+ if (slot.state === "open") closeSlot(slot, "expired");
168
+ });
169
+ }
170
+ return { operationId: spec.operationId, settled };
171
+ },
172
+
173
+ async waiting(input) {
174
+ const slot = slots.get(input.operationId);
175
+ if (!slot) return rejected("unknown-operation");
176
+ if (input.agentId !== slot.agentId) return rejected("wrong-agent");
177
+ const unavailable = unavailableCode(slot, now());
178
+ if (unavailable) return rejected(unavailable);
179
+ if (!slot.allowWaiting)
180
+ return rejected("unsupported-command", "waiting is unavailable for this operation");
181
+ if (
182
+ typeof input.reason !== "string" ||
183
+ input.reason.trim() === "" ||
184
+ new TextEncoder().encode(input.reason).byteLength > 2048
185
+ )
186
+ return rejected("invalid-request", "waiting reason must contain 1 to 2048 UTF-8 bytes");
187
+ if (
188
+ input.timeoutMs !== undefined &&
189
+ (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs <= 0)
190
+ )
191
+ return rejected("invalid-request", "waiting timeout must be a positive safe integer");
192
+ const at = now();
193
+ const remaining = slot.deadline.unixMilliseconds - at;
194
+ if (remaining <= 0) {
195
+ closeSlot(slot, "expired");
196
+ return rejected("expired-operation");
197
+ }
198
+ const waitUntil =
199
+ at + Math.min(Math.max(input.timeoutMs ?? waitDefaultMs, waitMinimumMs), remaining);
200
+ notify(slot, {
201
+ kind: "waiting",
202
+ reason: input.reason,
203
+ waitUntil,
204
+ at,
205
+ revision: ++slot.revision,
206
+ });
207
+ return { kind: "waiting", waitUntil, deadline: slot.deadline.unixMilliseconds };
208
+ },
209
+
210
+ async submit(input) {
211
+ const slot = slots.get(input.operationId);
212
+ if (!slot) return rejected("unknown-operation");
213
+ if (input.agentId !== slot.agentId) return rejected("wrong-agent");
214
+ const unavailable = unavailableCode(slot, now());
215
+ if (unavailable) return rejectKnown(slot, input, unavailable);
216
+ const evaluated = await evaluateResult(slot, input.raw, slot.semantic ?? acceptAny);
217
+ const admittedAt = now();
218
+ const late = unavailableCode(slot, admittedAt);
219
+ if (late) return rejectKnown(slot, input, late);
220
+ if (evaluated.kind === "rejected")
221
+ return rejectKnown(slot, input, "invalid-result", evaluated.error);
222
+
223
+ // No await crosses admission: other commands observe the reservation before I/O starts.
224
+ slot.state = "committing";
225
+ slot.cancelExpiry();
226
+ notify(slot, { kind: "admitted", admittedAt, revision: ++slot.revision });
227
+ const writing = Promise.resolve().then(() =>
228
+ persistence.writeAcceptedExclusive(options.runDir, slot.operationId, evaluated.value),
229
+ );
230
+ let acceptedAt: number | undefined;
231
+ void writing.then(
232
+ (won) => {
233
+ if (won) acceptedAt = now();
234
+ },
235
+ () => {},
236
+ );
237
+ let won: boolean;
238
+ try {
239
+ won = await bounded(writing, persistenceMs, "result persistence timed out");
240
+ } catch (error) {
241
+ void writing.then(
242
+ async (saved) => {
243
+ if (!saved) return;
244
+ const accepted: ResultSlotAccepted = {
245
+ kind: "accepted",
246
+ value: evaluated.value,
247
+ admittedAt,
248
+ acceptedAt: acceptedAt ?? now(),
249
+ candidateRecorded: await audit(slot, input, true),
250
+ };
251
+ notify(slot, { kind: "persisted", accepted, revision: ++slot.revision });
252
+ },
253
+ () => {},
254
+ );
255
+ const reason = error instanceof Error ? error.message : String(error);
256
+ if (slot.state === "committing") {
257
+ slot.state = "failed";
258
+ slot.settle({ kind: "failed", reason });
259
+ }
260
+ throw error;
261
+ }
262
+ if (!won) {
263
+ closeSlot(slot, "closed");
264
+ return rejectKnown(slot, input, "closed-operation");
265
+ }
266
+ const accepted: ResultSlotAccepted = {
267
+ kind: "accepted",
268
+ value: evaluated.value,
269
+ admittedAt,
270
+ acceptedAt: acceptedAt ?? now(),
271
+ candidateRecorded: await audit(slot, input, true),
272
+ };
273
+ if (slot.state === "committing") {
274
+ slot.state = "accepted";
275
+ slot.settle(accepted);
276
+ }
277
+ notify(slot, { kind: "persisted", accepted, revision: ++slot.revision });
278
+ return accepted;
279
+ },
280
+
281
+ async close(operationId) {
282
+ const slot = slots.get(operationId);
283
+ if (!slot || (slot.state !== "open" && slot.state !== "committing")) return false;
284
+ closeSlot(slot, now() >= slot.deadline.unixMilliseconds ? "expired" : "closed");
285
+ return true;
286
+ },
287
+ };
288
+ }
289
+
290
+ function unavailableCode(slot: Slot, now: number): ResultRejectionCode | null {
291
+ if (slot.state === "expired") return "expired-operation";
292
+ if (slot.state !== "open") return "closed-operation";
293
+ if (now >= slot.deadline.unixMilliseconds) {
294
+ closeSlot(slot, "expired");
295
+ return "expired-operation";
296
+ }
297
+ return null;
298
+ }
299
+ function closeSlot(slot: Slot, state: "closed" | "expired"): void {
300
+ if (slot.state !== "open" && slot.state !== "committing") return;
301
+ slot.state = state;
302
+ slot.cancelExpiry();
303
+ slot.settle({ kind: state });
304
+ }
305
+ function notify(slot: Slot, event: ResultSlotEvent): void {
306
+ try {
307
+ slot.onEvent?.(event);
308
+ } catch {
309
+ /* An observer cannot undo admission. */
310
+ }
311
+ }
312
+ function rejected(code: ResultRejectionCode, error: string = code): Rejected {
313
+ return { kind: "rejected", code, error };
314
+ }
315
+ function duration(value: number | undefined, fallback: number): number {
316
+ const actual = value ?? fallback;
317
+ if (!Number.isSafeInteger(actual) || actual <= 0)
318
+ throw new Error("slot durations must be positive safe integers");
319
+ return actual;
320
+ }
321
+ function scheduleExpiry(delayMilliseconds: number, expire: () => void): () => void {
322
+ return scheduleAt({ unixMilliseconds: Date.now() + delayMilliseconds }, expire);
323
+ }
324
+
325
+ class SlotTimeoutError extends Error {}
@@ -0,0 +1,47 @@
1
+ import { acceptAny, type SemanticCheck } from "@agentswf/contract";
2
+ import type { CallSpec } from "@agentswf/contract/records";
3
+ import { formatErrors, validate } from "@agentswf/contract/schema";
4
+ import { messageOf } from "./errors";
5
+
6
+ export type CandidateEvaluation =
7
+ | { kind: "accepted"; value: unknown }
8
+ | { kind: "rejected"; error: string };
9
+
10
+ /** Validates without repairing or persisting; the result-slot transition owns acceptance. */
11
+ export async function evaluateResult(
12
+ call: Pick<CallSpec, "question" | "schema">,
13
+ raw: string,
14
+ semantic: SemanticCheck = acceptAny,
15
+ ): Promise<CandidateEvaluation> {
16
+ let value: unknown;
17
+ try {
18
+ value = JSON.parse(raw);
19
+ } catch (error) {
20
+ const reason = messageOf(error);
21
+ return {
22
+ kind: "rejected",
23
+ error: [
24
+ `the value is not valid JSON: ${reason}`,
25
+ `expected: ${expectedShape(call)}`,
26
+ "send one JSON value, quoted as a single shell argument.",
27
+ ].join("\n"),
28
+ };
29
+ }
30
+
31
+ if (call.schema) {
32
+ const errors = validate(call.schema, value);
33
+ if (errors.length > 0) return { kind: "rejected", error: formatErrors(errors) };
34
+ }
35
+
36
+ const verdict = await semantic({ question: call.question, value: structuredClone(value) });
37
+ return verdict.kind === "accepted"
38
+ ? { kind: "accepted", value }
39
+ : {
40
+ kind: "rejected",
41
+ error: `the value does not answer what was asked: ${verdict.reason}`,
42
+ };
43
+ }
44
+
45
+ function expectedShape(call: Pick<CallSpec, "schema">): string {
46
+ return call.schema ? JSON.stringify(call.schema) : "any JSON value";
47
+ }
@@ -0,0 +1,242 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { JsonValue } from "@agentswf/contract/workflow";
4
+ import { parseDuration } from "./duration";
5
+ import { messageOf } from "./errors";
6
+ import { SESSION_CODE } from "./here";
7
+ import { idProblem, runRootOf, stageNameProblem } from "./runs";
8
+
9
+ const DEFAULT_TIMEOUT = "1h";
10
+
11
+ export const usage = [
12
+ "usage: awf run [options] <workflow-file> [options] [-- workflow arguments...]",
13
+ " awf run [options] <workflow-file> --continue <id>",
14
+ " awf init [dir] [--no-install]",
15
+ " awf test [paths...] [-t <pattern>] [--watch] [--timeout <duration>]",
16
+ " awf allowance [harness...] [--json]",
17
+ " awf runs [<run>] [--json] (awf runs -h)",
18
+ " awf logs [<run>] [--after <cursor>] [--wait <seconds>] [--json] (awf logs -h)",
19
+ " awf --version",
20
+ "run options: --id <id>, --continue <id>, --from-stage <stage>, --values <file>,",
21
+ " --timeout <duration>, --run-root <directory>, --cwd <directory>, --sandbox <file>,",
22
+ " --json, --no-watch, --here",
23
+ "",
24
+ "Each awf run is an attempt of a run: a new run, with --id's id or a generated one, or the run",
25
+ "--continue names, with the arguments, --cwd and --sandbox it was started with. A continue reuses",
26
+ "the stages that succeeded, up to the first with no record or to --from-stage, and runs the rest.",
27
+ "A new run with --from-stage starts there. A stage before it with no record to reuse takes its",
28
+ "value from --values, a JSON object by stage name, checked by its result; one that returns",
29
+ "nothing is passed. Without one the attempt stops, naming the stage and its schema.",
30
+ "A stop exits 3. A run is kept in .awf/runs/<workflow>/<id> under --cwd, unless --run-root names",
31
+ "another folder for .awf/runs.",
32
+ "The deadline defaults to 1h, per attempt.",
33
+ "A workflow that knows how to present its result prints that; --json prints the full result instead.",
34
+ "Either way the full result is kept as output.json in the run's folder, beside report.md",
35
+ "when the workflow writes one. A run that fails or is cancelled once its agents have started keeps",
36
+ "output.json too, with what it spent and why it ended; --json prints it. A second Ctrl-C stops",
37
+ "awf at once, without it.",
38
+ "--cwd sets the directory the workflow and its agents work in; it defaults to the current one.",
39
+ "--sandbox puts every agent of the run in one sandbox, working in --cwd; the file is a JSON spec",
40
+ 'such as {"read": ["/data/request.md"], "srt": {}}, whose relative paths are from --cwd. A',
41
+ "workflow that opens a sandbox of its own is refused.",
42
+ "A sandbox with its own Herdr, as a docker box has, gets a tab in the run's workspace showing its",
43
+ "panes; --no-watch leaves it out, and awf still prints the command that shows them.",
44
+ "--here, run by an agent in a Herdr pane, starts the run in a new tab and has it take that agent's",
45
+ "session over as one of its agents, from its next turn until the run ends. It prints a line for",
46
+ "the agent to end its turn with, which is how the run finds the session.",
47
+ "",
48
+ "Examples:",
49
+ " awf run examples/minimum-review/review-loop.ts",
50
+ " awf run --timeout 20m examples/minimum-review/review-loop.ts",
51
+ " awf run examples/minimum-review/review-loop.ts -- packages/engine/src",
52
+ " awf run examples/minimum-review/review-loop.ts --cwd ../other-repo",
53
+ "",
54
+ "Workflow files are trusted code and run with your filesystem and process authority.",
55
+ ].join("\n");
56
+
57
+ export type RunCommand = {
58
+ /** Where paths typed on the command line resolve. */
59
+ shellCwd: string;
60
+ /** Where the workflow and its agents work. */
61
+ cwd: string;
62
+ /** Whether `--cwd` gave it, which a continue checks against its run's. */
63
+ cwdGiven: boolean;
64
+ /** Whether `--run-root` gave the run root, which the command that goes on repeats. */
65
+ runRootGiven: boolean;
66
+ workflowFile: string;
67
+ workflowArgs: string[];
68
+ /** As typed, for the attempt's record. */
69
+ timeout: string;
70
+ timeoutMilliseconds: number;
71
+ runRoot: string;
72
+ /** A new run's id, as `--id` gave it. */
73
+ id?: string;
74
+ /** The run `--continue` adds an attempt to. */
75
+ continueId?: string;
76
+ /** The stage the attempt starts at, reusing those before it. */
77
+ fromStage?: string;
78
+ /** `--values`: its file, and each stage's value in it. */
79
+ values?: { file: string; stages: ReadonlyMap<string, JsonValue> };
80
+ json: boolean;
81
+ /** Whether each sandbox with its own Herdr gets a tab attached to it in the run's workspace. */
82
+ watch: boolean;
83
+ /** The sandbox every agent runs in, as `--sandbox`'s file gave it. */
84
+ sandbox?: unknown;
85
+ /** Started from an agent's shell: start the run in a tab and take that session over. */
86
+ here: boolean;
87
+ /** The code the calling session replies with, which the run finds it by. */
88
+ session?: string;
89
+ };
90
+
91
+ export function parseCommand(argv: readonly string[], cwd: string): RunCommand {
92
+ if (argv[0] !== "run") throw new Error("expected the run command");
93
+ let timeout = DEFAULT_TIMEOUT;
94
+ let timeoutMilliseconds = parseDuration(DEFAULT_TIMEOUT);
95
+ let runRoot: string | undefined;
96
+ let id: string | undefined;
97
+ let continueId: string | undefined;
98
+ let fromStage: string | undefined;
99
+ let values: RunCommand["values"];
100
+ let cwdGiven = false;
101
+ let json = false;
102
+ let watch = true;
103
+ let workCwd = cwd;
104
+ let sandbox: unknown;
105
+ let here = false;
106
+ let session: string | undefined;
107
+ let workflowFile: string | undefined;
108
+ // awf's own options may come before or after the workflow file; only `--` ends them.
109
+ let index = 1;
110
+ for (; index < argv.length && argv[index] !== "--"; index += 1) {
111
+ const option = argv[index]!;
112
+ if (!option.startsWith("--")) {
113
+ if (workflowFile !== undefined) throw new Error("put -- before workflow arguments");
114
+ workflowFile = option;
115
+ continue;
116
+ }
117
+ if (option === "--json") {
118
+ json = true;
119
+ continue;
120
+ }
121
+ if (option === "--no-watch") {
122
+ watch = false;
123
+ continue;
124
+ }
125
+ if (option === "--here") {
126
+ here = true;
127
+ continue;
128
+ }
129
+ const value = argv[index + 1];
130
+ if (option === "--timeout") {
131
+ if (!value) throw new Error("--timeout needs a duration such as 30m");
132
+ timeoutMilliseconds = parseDuration(value);
133
+ timeout = value;
134
+ } else if (option === "--run-root") {
135
+ if (!value) throw new Error("--run-root needs a directory");
136
+ runRoot = resolve(cwd, value);
137
+ } else if (option === "--cwd") {
138
+ if (!value) throw new Error("--cwd needs a directory");
139
+ workCwd = resolve(cwd, value);
140
+ cwdGiven = true;
141
+ if (!statSync(workCwd, { throwIfNoEntry: false })?.isDirectory()) {
142
+ throw new Error(`--cwd: not a directory: ${workCwd}`);
143
+ }
144
+ } else if (option === "--id" || option === "--continue") {
145
+ if (!value) throw new Error(`${option} needs a run's id`);
146
+ const problem = idProblem(value);
147
+ if (problem) throw new Error(`${option}: ${problem}`);
148
+ if (option === "--id") id = value;
149
+ else continueId = value;
150
+ } else if (option === "--from-stage") {
151
+ if (!value) throw new Error("--from-stage needs a stage's name");
152
+ const problem = stageNameProblem(value);
153
+ if (problem) throw new Error(`--from-stage: ${problem}`);
154
+ fromStage = value;
155
+ } else if (option === "--values") {
156
+ if (!value) throw new Error("--values needs a JSON file");
157
+ if (values !== undefined) throw new Error("--values given twice");
158
+ values = readValues(resolve(cwd, value));
159
+ } else if (option === "--session") {
160
+ if (!value || !SESSION_CODE.test(value))
161
+ throw new Error("--session needs the code --here printed");
162
+ session = value;
163
+ } else if (option === "--sandbox") {
164
+ if (!value) throw new Error("--sandbox needs a JSON file");
165
+ if (sandbox !== undefined) throw new Error("--sandbox given twice");
166
+ sandbox = readSandboxSpec(resolve(cwd, value));
167
+ } else {
168
+ throw new Error(`unknown option: ${option}; put workflow arguments after --`);
169
+ }
170
+ index += 1;
171
+ }
172
+ if (!workflowFile) throw new Error("run needs one workflow file");
173
+ if (here && session !== undefined) throw new Error("--here and --session do not go together");
174
+ if (id !== undefined && continueId !== undefined) {
175
+ throw new Error("--id names a new run and --continue an existing one; give one");
176
+ }
177
+ if (values !== undefined && fromStage === undefined) {
178
+ throw new Error("--values goes with --from-stage: it gives the stages before it");
179
+ }
180
+ const workflowArgs = argv.slice(index + 1);
181
+ return {
182
+ shellCwd: cwd,
183
+ cwd: workCwd,
184
+ cwdGiven,
185
+ runRootGiven: runRoot !== undefined,
186
+ workflowFile,
187
+ workflowArgs,
188
+ timeout,
189
+ timeoutMilliseconds,
190
+ runRoot: runRoot ?? runRootOf(workCwd),
191
+ ...(id === undefined ? {} : { id }),
192
+ ...(continueId === undefined ? {} : { continueId }),
193
+ ...(fromStage === undefined ? {} : { fromStage }),
194
+ ...(values === undefined ? {} : { values }),
195
+ json,
196
+ watch,
197
+ here,
198
+ ...(session === undefined ? {} : { session }),
199
+ ...(sandbox === undefined ? {} : { sandbox }),
200
+ };
201
+ }
202
+
203
+ /** `--values`'s file: a JSON object of stage values, by stage name. */
204
+ function readValues(file: string): NonNullable<RunCommand["values"]> {
205
+ let parsed: unknown;
206
+ try {
207
+ parsed = JSON.parse(readFileSync(file, "utf8"));
208
+ } catch (error) {
209
+ throw new Error(`--values: ${messageOf(error)}`);
210
+ }
211
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
212
+ throw new Error(`--values: ${file} must hold a JSON object, each stage's value by its name`);
213
+ }
214
+ for (const stage of Object.keys(parsed)) {
215
+ const problem = stageNameProblem(stage);
216
+ if (problem) throw new Error(`--values: ${problem}`);
217
+ }
218
+ return { file, stages: new Map(Object.entries(parsed as Record<string, JsonValue>)) };
219
+ }
220
+
221
+ /** `--sandbox`'s file: an inline sandbox spec, whose working directory is the run's. */
222
+ function readSandboxSpec(file: string): object {
223
+ let spec: unknown;
224
+ try {
225
+ spec = JSON.parse(readFileSync(file, "utf8"));
226
+ } catch (error) {
227
+ throw new Error(`--sandbox: ${messageOf(error)}`);
228
+ }
229
+ if (typeof spec !== "object" || spec === null || Array.isArray(spec)) {
230
+ throw new Error(`--sandbox: ${file} must hold a JSON object, a sandbox spec`);
231
+ }
232
+ for (const [field, why] of Object.entries(NOT_IN_A_RUN_SANDBOX)) {
233
+ if (field in spec) throw new Error(`--sandbox: the run's sandbox names no ${field}; ${why}`);
234
+ }
235
+ return spec;
236
+ }
237
+
238
+ const NOT_IN_A_RUN_SANDBOX = {
239
+ cwd: "it works in --cwd",
240
+ key: 'awf keys it "run"',
241
+ provider: 'its provider is its setting, such as "srt": {}',
242
+ };