@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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
@@ -0,0 +1,79 @@
1
+ import { dirname } from "node:path";
2
+ import manifest from "../package.json" with { type: "json" };
3
+ import {
4
+ type InstalledEngine,
5
+ installedEngine,
6
+ type ProjectEngine,
7
+ projectEngine,
8
+ } from "./hand-over";
9
+
10
+ /** This engine's version, which every package of a release shares. */
11
+ export const ENGINE_VERSION: string = manifest.version;
12
+
13
+ /** Why an engine won't run a project's runs: `other-engine` or `no-engine`, as a client says it. */
14
+ export class EngineRefused extends Error {
15
+ constructor(
16
+ readonly code: "other-engine" | "no-engine",
17
+ message: string,
18
+ ) {
19
+ super(message);
20
+ this.name = "EngineRefused";
21
+ }
22
+ }
23
+
24
+ /**
25
+ * The engine a project's runs run on (ADR 0015): the `agentswf` installed above it; else, for a
26
+ * project with none above it, the one its workflow files resolve, as in a workspace whose root
27
+ * holds `.awf/` and whose packages each have a `node_modules`. Files that resolve two versions, or
28
+ * one other than the project's, are refused: one project runs on one engine.
29
+ */
30
+ export function engineOf(project: string, files: readonly string[] = []): ProjectEngine {
31
+ const engine = projectEngine(project);
32
+ let fromFiles: (InstalledEngine & { file: string }) | undefined;
33
+ for (const file of files) {
34
+ const found = installedEngine(dirname(file));
35
+ if (!found) continue;
36
+ const other = engine.kind === "installed" ? engine : fromFiles;
37
+ if (other && other.version !== found.version)
38
+ throw new EngineRefused(
39
+ "other-engine",
40
+ `${file} resolves agentswf ${found.version}, from ${found.root}, and ${engine.kind === "installed" ? `its project ${project}` : (fromFiles?.file ?? project)} ${other.version}: one project runs on one engine`,
41
+ );
42
+ fromFiles ??= { ...found, file };
43
+ }
44
+ if (engine.kind === "installed" || !fromFiles) return engine;
45
+ const { file: _file, ...installed } = fromFiles;
46
+ return { kind: "installed", ...installed };
47
+ }
48
+
49
+ /**
50
+ * Refuses `project` unless this engine is the one its runs run on, as `engineOf` finds it with
51
+ * `file`: at this version, or, for a project with none, any engine but a client's.
52
+ */
53
+ export function checkProjectEngine(
54
+ project: string,
55
+ options: { file?: string; files?: readonly string[]; client?: boolean; version?: string } = {},
56
+ ): void {
57
+ const own = options.version ?? ENGINE_VERSION;
58
+ const engine = engineOf(project, [
59
+ ...(options.file === undefined ? [] : [options.file]),
60
+ ...(options.files ?? []),
61
+ ]);
62
+ if (engine.kind === "declared")
63
+ throw new EngineRefused(
64
+ "no-engine",
65
+ `${project} declares agentswf in ${engine.manifest} and has none installed: run bun install there`,
66
+ );
67
+ if (engine.kind === "installed" && engine.version !== own)
68
+ throw new EngineRefused(
69
+ "other-engine",
70
+ options.client
71
+ ? `${project} runs on its agentswf ${engine.version}, and this program's agentswf/client is ${own}: install one version in both`
72
+ : `${project} runs on its agentswf ${engine.version}, and this awf is ${own}: run the project's, bunx awf, from ${engine.root}`,
73
+ );
74
+ if (engine.kind === "none" && options.client)
75
+ throw new EngineRefused(
76
+ "no-engine",
77
+ `${project} has no agentswf installed, so it runs on whichever global awf is installed: bun add -d agentswf there`,
78
+ );
79
+ }
package/src/projects.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { stat } from "node:fs/promises";
2
- import { dirname, join, resolve } from "node:path";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
3
  import { realPath } from "./files";
4
4
  import { appendLine } from "./jsonl";
5
5
  import { withRunLock } from "./run-lock";
@@ -34,7 +34,7 @@ export function projectRunRoot(project: string): string {
34
34
  }
35
35
 
36
36
  /** `~/.awf/projects`: each project awf has started an event run in, one absolute path a line. */
37
- export function projectsFile(home: string): string {
37
+ function projectsFile(home: string): string {
38
38
  return join(home, ".awf", "projects");
39
39
  }
40
40
 
@@ -55,3 +55,14 @@ export async function recordProject(home: string, project: string): Promise<void
55
55
  await appendLine(file, project);
56
56
  });
57
57
  }
58
+
59
+ /**
60
+ * The project a run root's runs belong to: the folder holding it as its `.awf/runs`, else, under a
61
+ * `--run-root` of the operator's, the directory the run works in.
62
+ */
63
+ export function rootProject(runRoot: string, cwd: string): string {
64
+ const root = resolve(runRoot);
65
+ return basename(root) === "runs" && basename(dirname(root)) === ".awf"
66
+ ? dirname(dirname(root))
67
+ : cwd;
68
+ }
@@ -0,0 +1,119 @@
1
+ import { ClientError, type ClientErrorCode } from "@agentswf/contract/client";
2
+ import { REFUSAL_CODES, type RefusalCode, type RefusalJson } from "@agentswf/contract/records";
3
+ import type { InboxCode, ParticipantCode } from "@agentswf/contract/wire";
4
+ import { EmitError } from "./emit";
5
+ import { messageOf } from "./errors";
6
+ import { EngineRefused } from "./project-engine";
7
+ import { RunNotFound } from "./run-state";
8
+ import { isSystemError, RunUnreachable } from "./run-unreachable";
9
+ import { RunRefused } from "./runs";
10
+ import { WorkflowFileError } from "./workflow-loader";
11
+
12
+ /** Every code a command refuses with. */
13
+ type CommandCode = RefusalCode | ParticipantCode | InboxCode;
14
+
15
+ /**
16
+ * The codes only a command prints: a program's call has no argv and reads no run's socket, and
17
+ * `internal-error` is the command's word for an error with no code, which the client throws as it
18
+ * is.
19
+ */
20
+ const COMMAND_ONLY = new Set<RefusalCode>(
21
+ Object.keys({
22
+ usage: true,
23
+ ambiguous: true,
24
+ "not-started": true,
25
+ unreachable: true,
26
+ "internal-error": true,
27
+ "no-session": true,
28
+ live: true,
29
+ opened: true,
30
+ // Every code a client doesn't throw, so a new one is either a client's or listed here.
31
+ } satisfies Record<Exclude<RefusalCode, ClientErrorCode>, true>) as RefusalCode[],
32
+ );
33
+
34
+ /**
35
+ * The code an engine error is refused with, by a `--json` command and as a `ClientError` alike, so
36
+ * the two can't drift; none for an error that isn't the caller's.
37
+ */
38
+ function refusalCode(error: unknown): RefusalCode | undefined {
39
+ return error instanceof EmitError ||
40
+ error instanceof EngineRefused ||
41
+ error instanceof RunNotFound ||
42
+ error instanceof WorkflowFileError ||
43
+ error instanceof RunUnreachable ||
44
+ error instanceof RunRefused
45
+ ? error.code
46
+ : undefined;
47
+ }
48
+
49
+ /** `code` as a client's word, when it is one: what a command refused with, read back. */
50
+ export function clientCode(code: unknown): ClientErrorCode | undefined {
51
+ const known = (REFUSAL_CODES as readonly unknown[]).includes(code);
52
+ return known && !COMMAND_ONLY.has(code as RefusalCode) ? (code as ClientErrorCode) : undefined;
53
+ }
54
+
55
+ /**
56
+ * A failure while reading a run: the error's own code; else a system error, a file that couldn't
57
+ * be read, `unreachable`; else `internal-error`.
58
+ */
59
+ export function readFailureCode(error: unknown): RefusalCode {
60
+ const cause = error instanceof Error ? error.cause : undefined;
61
+ return (
62
+ refusalCode(error) ??
63
+ (cause === undefined ? undefined : refusalCode(cause)) ??
64
+ (isSystemError(error) ? "unreachable" : "internal-error")
65
+ );
66
+ }
67
+
68
+ /** An engine error that is the caller's mistake as a `ClientError`; anything else as it is. */
69
+ export function asClientError(error: unknown, ref?: string): unknown {
70
+ const code = refusalCode(error);
71
+ if (code === undefined || COMMAND_ONLY.has(code)) return error;
72
+ return new ClientError(code as ClientErrorCode, messageOf(error), ref);
73
+ }
74
+
75
+ /**
76
+ * A failure as a `--json` command prints it: the sentence on stderr, for people, and with `json`
77
+ * the refusal as stdout's last document. The code is the error's own, as `refusalCode` gives it;
78
+ * `otherwise` is the command's word for an error that has none. A caller's mistake never reaches a
79
+ * host as `internal-error`: it gets an error class `refusalCode` reads. `usage` follows the
80
+ * sentence.
81
+ */
82
+ export function printRefusal(
83
+ out: { stdout(text: string): void; stderr(text: string): void },
84
+ json: { json: boolean; v: number },
85
+ error: unknown,
86
+ otherwise: CommandCode,
87
+ usage?: string,
88
+ ): void {
89
+ const sentence = messageOf(error);
90
+ if (json.json) out.stdout(refusalLine(json.v, refusalCode(error) ?? otherwise, sentence));
91
+ out.stderr(`awf: ${sentence}${usage === undefined ? "" : `\n\n${usage}`}`);
92
+ }
93
+
94
+ /** A refusal as its one line on stdout, for a command whose stderr says it in its own words. */
95
+ export function refusalLine(
96
+ v: number,
97
+ code: CommandCode,
98
+ error: string,
99
+ choices?: string[],
100
+ ): string {
101
+ return JSON.stringify({
102
+ v,
103
+ code,
104
+ error,
105
+ ...(choices ? { choices } : {}),
106
+ } satisfies RefusalJson<CommandCode>);
107
+ }
108
+
109
+ /**
110
+ * Whether `argv` asks for `--json`, anywhere before `--`: what follows is a message's text or a
111
+ * workflow's arguments, which can't ask for it, and a usage error counts it before any parse.
112
+ */
113
+ export function asksJson(argv: readonly string[]): boolean {
114
+ const end = argv.indexOf("--");
115
+ return (end < 0 ? argv : argv.slice(0, end)).includes("--json");
116
+ }
117
+
118
+ /** No `--json`: the failure on stderr only. */
119
+ export const NO_JSON = { json: false, v: 0 };
@@ -4,11 +4,12 @@ import type { JsonSchema } from "@agentswf/contract/schema";
4
4
  import type { GivenInput, ResultSubmitCode } from "@agentswf/contract/wire";
5
5
  import type { AbsoluteDeadline } from "@agentswf/contract/workflow";
6
6
  import { scheduleAt } from "./deadlines";
7
+ import { messageOf } from "./errors";
8
+ import { recordAnswer, writeAcceptedExclusive } from "./ops";
7
9
  import { evaluateGiven } from "./result-validation";
8
- import { recordCandidate, writeAcceptedExclusive, writeCall } from "./runs";
9
10
 
10
- export type ResultRejectionCode = ResultSubmitCode;
11
- export type ResultSlotAccepted = {
11
+ type ResultRejectionCode = ResultSubmitCode;
12
+ type ResultSlotAccepted = {
12
13
  kind: "accepted";
13
14
  value: unknown;
14
15
  candidateRecorded: boolean;
@@ -18,8 +19,8 @@ export type ResultSlotAccepted = {
18
19
  acceptedAt: number;
19
20
  };
20
21
  type Rejected = { kind: "rejected"; code: ResultRejectionCode; error: string };
21
- export type ResultSubmission = ResultSlotAccepted | Rejected;
22
- export type WaitingGrant = { kind: "waiting"; waitUntil: number; deadline?: number };
22
+ type ResultSubmission = ResultSlotAccepted | Rejected;
23
+ type WaitingGrant = { kind: "waiting"; waitUntil: number; deadline?: number };
23
24
  export type ResultSlotSettlement =
24
25
  | ResultSlotAccepted
25
26
  | { kind: "closed" }
@@ -29,7 +30,7 @@ export type ResultSlotEvent =
29
30
  | { kind: "waiting"; reason: string; waitUntil: number; at: number; revision: number }
30
31
  | { kind: "admitted"; admittedAt: number; revision: number }
31
32
  | { kind: "persisted"; accepted: ResultSlotAccepted; revision: number };
32
- export type ResultSlotBinding = { operationId: string; settled: Promise<ResultSlotSettlement> };
33
+ type ResultSlotBinding = { operationId: string; settled: Promise<ResultSlotSettlement> };
33
34
  export type ResultSlotSpec = {
34
35
  operationId: string;
35
36
  agentId: string;
@@ -52,8 +53,7 @@ export type ResultSlotRegistryOptions = {
52
53
  waitMinimumMs?: number;
53
54
  };
54
55
  interface ResultSlotPersistence {
55
- writeCall: typeof writeCall;
56
- recordCandidate: typeof recordCandidate;
56
+ recordAnswer: typeof recordAnswer;
57
57
  writeAcceptedExclusive: typeof writeAcceptedExclusive;
58
58
  }
59
59
  type SubmissionInput = {
@@ -84,7 +84,7 @@ type Slot = ResultSlotSpec & {
84
84
  export function createResultSlotRegistry(options: ResultSlotRegistryOptions): ResultSlotRegistry {
85
85
  const now = options.now ?? Date.now;
86
86
  const schedule = options.schedule ?? scheduleExpiry;
87
- const persistence = options.persistence ?? { writeCall, recordCandidate, writeAcceptedExclusive };
87
+ const persistence = options.persistence ?? { recordAnswer, writeAcceptedExclusive };
88
88
  const persistenceMs = duration(options.persistenceMs, 5_000);
89
89
  const auditMs = duration(options.auditMs, 1_000);
90
90
  const waitDefaultMs = duration(options.waitDefaultMs, 120_000);
@@ -106,7 +106,7 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
106
106
  try {
107
107
  await bounded(
108
108
  Promise.resolve().then(() =>
109
- persistence.recordCandidate(options.runDir, slot.operationId, {
109
+ persistence.recordAnswer(options.runDir, slot.operationId, {
110
110
  at: new Date(now()).toISOString(),
111
111
  source: input.source,
112
112
  // Flags as given, whether or not they convert, so the record shows what the agent typed.
@@ -143,26 +143,6 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
143
143
  });
144
144
  const slot: Slot = { ...spec, state: "open", settle, cancelExpiry: () => {}, revision: 0 };
145
145
  slots.set(spec.operationId, slot);
146
- try {
147
- await bounded(
148
- Promise.resolve().then(() =>
149
- persistence.writeCall(options.runDir, {
150
- callId: spec.operationId,
151
- question: spec.question,
152
- ...(spec.schema ? { schema: spec.schema } : {}),
153
- }),
154
- ),
155
- persistenceMs,
156
- "call persistence timed out",
157
- );
158
- } catch (error) {
159
- if (error instanceof SlotTimeoutError) {
160
- // The old write may still publish; its operation ID must never be reused.
161
- slot.state = "failed";
162
- slot.settle({ kind: "failed", reason: error.message });
163
- } else slots.delete(spec.operationId);
164
- throw error;
165
- }
166
146
  if (slot.state === "open") expireAt(slot);
167
147
  return { operationId: spec.operationId, settled };
168
148
  },
@@ -253,7 +233,7 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
253
233
  },
254
234
  () => {},
255
235
  );
256
- const reason = error instanceof Error ? error.message : String(error);
236
+ const reason = messageOf(error);
257
237
  if (slot.state === "committing") {
258
238
  slot.state = "failed";
259
239
  slot.settle({ kind: "failed", reason });
@@ -1,5 +1,5 @@
1
1
  import { acceptAny, type SemanticCheck } from "@agentswf/contract";
2
- import type { CallSpec } from "@agentswf/contract/records";
2
+ import type { JsonSchema } from "@agentswf/contract/schema";
3
3
  import type { GivenInput } from "@agentswf/contract/wire";
4
4
  import { checkGiven, readGiven } from "./simple-input";
5
5
 
@@ -12,7 +12,7 @@ export type CandidateEvaluation =
12
12
  * owns acceptance.
13
13
  */
14
14
  export async function evaluateGiven(
15
- call: Pick<CallSpec, "question" | "schema">,
15
+ call: { question: string; schema?: JsonSchema },
16
16
  given: GivenInput,
17
17
  semantic: SemanticCheck = acceptAny,
18
18
  ): Promise<CandidateEvaluation> {
@@ -1,11 +1,16 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import type { JsonValue } from "@agentswf/contract/workflow";
3
+ import type { RunPanes } from "@agentswf/contract/records";
4
+ import type { JsonValue, KeepPane } from "@agentswf/contract/workflow";
4
5
  import { parseDuration } from "./duration";
5
6
  import { messageOf } from "./errors";
6
7
  import help from "./help/run.txt" with { type: "text" };
7
8
  import { SESSION_CODE } from "./here";
8
- import { idProblem, runRootOf, stageNameProblem } from "./runs";
9
+ import { KEEP_PANES, parseWorkspace } from "./pane-layout";
10
+ import { idProblem, RunRefused, runRootOf, stageNameProblem } from "./runs";
11
+
12
+ /** `awf run --json`'s `"v"`, which its refusals carry too. */
13
+ export const RUN_V = 1;
9
14
 
10
15
  export const usage = help.trimEnd();
11
16
 
@@ -37,18 +42,16 @@ export type RunCommand = {
37
42
  watch: boolean;
38
43
  /** The sandbox every agent runs in, as `--sandbox`'s file gave it. */
39
44
  sandbox?: unknown;
45
+ /** `--workspace` and `--keep-panes`, which the run records; absent, neither was given. */
46
+ panes?: RunPanes;
40
47
  /** Started from an agent's shell: start the run in a tab and take that session over. */
41
48
  here: boolean;
42
49
  /** The code the calling session replies with, which the run finds it by. */
43
50
  session?: string;
44
51
  };
45
52
 
46
- /** `awf run`'s options and workflow; `"help"` for a `-h` or `--help` anywhere before `--`. */
47
- export function parseCommand(argv: readonly string[], cwd: string): RunCommand | "help" {
48
- if (argv[0] !== "run") throw new Error("expected the run command");
49
- const end = argv.indexOf("--");
50
- const own = end < 0 ? argv : argv.slice(0, end);
51
- if (own.includes("-h") || own.includes("--help")) return "help";
53
+ /** `awf run`'s options and workflow. */
54
+ export function parseCommand(argv: readonly string[], cwd: string): RunCommand {
52
55
  let timeout: string | undefined;
53
56
  let timeoutMilliseconds: number | undefined;
54
57
  let runRoot: string | undefined;
@@ -63,6 +66,7 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand |
63
66
  let sandbox: unknown;
64
67
  let here = false;
65
68
  let session: string | undefined;
69
+ const panes: RunPanes = {};
66
70
  let workflowFile: string | undefined;
67
71
  // awf's own options may come before or after the workflow file; only `--` ends them.
68
72
  let index = 1;
@@ -91,8 +95,8 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand |
91
95
  }
92
96
  const value = argv[index + 1];
93
97
  if (option === "--timeout") {
94
- if (!value) throw new Error("--timeout needs a duration such as 30m");
95
- timeoutMilliseconds = parseDuration(value);
98
+ if (!value) throw new Error("--timeout needs a duration such as 30m or 1d");
99
+ timeoutMilliseconds = parseDuration(value, "--timeout");
96
100
  timeout = value;
97
101
  } else if (option === "--run-root") {
98
102
  if (!value) throw new Error("--run-root needs a directory");
@@ -107,7 +111,8 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand |
107
111
  } else if (option === "--id") {
108
112
  if (!value) throw new Error("--id needs a run's id");
109
113
  const problem = idProblem(value);
110
- if (problem) throw new Error(`--id: ${problem}`);
114
+ // Refused as any id the grammar refuses is, `id`, still a usage error's exit.
115
+ if (problem) throw new RunRefused(`--id: ${problem}`, "id");
111
116
  named = value;
112
117
  } else if (option === "--from-stage") {
113
118
  if (!value) throw new Error("--from-stage needs a stage's name");
@@ -122,6 +127,16 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand |
122
127
  if (!value || !SESSION_CODE.test(value))
123
128
  throw new Error("--session needs the code --here printed");
124
129
  session = value;
130
+ } else if (option === "--workspace") {
131
+ if (!value?.trim() || value.startsWith("-"))
132
+ throw new Error("--workspace needs run, origin or a workspace's name");
133
+ if (panes.workspace !== undefined) throw new Error("--workspace given twice");
134
+ panes.workspace = parseWorkspace(value);
135
+ } else if (option === "--keep-panes") {
136
+ if (!KEEP_PANES.includes(value as KeepPane))
137
+ throw new Error("--keep-panes needs never, on-failure or always");
138
+ if (panes.keep !== undefined) throw new Error("--keep-panes given twice");
139
+ panes.keep = value as KeepPane;
125
140
  } else if (option === "--sandbox") {
126
141
  if (!value) throw new Error("--sandbox needs a JSON file");
127
142
  if (sandbox !== undefined) throw new Error("--sandbox given twice");
@@ -133,6 +148,10 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand |
133
148
  }
134
149
  if (!workflowFile) throw new Error("run needs one workflow file");
135
150
  if (here && session !== undefined) throw new Error("--here and --session do not go together");
151
+ if (here && json)
152
+ throw new Error(
153
+ "--here prints what the calling agent does next, no document: leave out --json",
154
+ );
136
155
  if (continuing && named === undefined)
137
156
  throw new Error("--continue goes on with the run --id names");
138
157
  const id = continuing ? undefined : named;
@@ -160,20 +179,13 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand |
160
179
  here,
161
180
  ...(session === undefined ? {} : { session }),
162
181
  ...(sandbox === undefined ? {} : { sandbox }),
182
+ ...(Object.keys(panes).length === 0 ? {} : { panes }),
163
183
  };
164
184
  }
165
185
 
166
186
  /** `--values`'s file: a JSON object of stage values, by stage name. */
167
187
  function readValues(file: string): NonNullable<RunCommand["values"]> {
168
- let parsed: unknown;
169
- try {
170
- parsed = JSON.parse(readFileSync(file, "utf8"));
171
- } catch (error) {
172
- throw new Error(`--values: ${messageOf(error)}`);
173
- }
174
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
175
- throw new Error(`--values: ${file} must hold a JSON object, each stage's value by its name`);
176
- }
188
+ const parsed = readJsonObject(file, "--values", "each stage's value by its name");
177
189
  for (const stage of Object.keys(parsed)) {
178
190
  const problem = stageNameProblem(stage);
179
191
  if (problem) throw new Error(`--values: ${problem}`);
@@ -181,17 +193,22 @@ function readValues(file: string): NonNullable<RunCommand["values"]> {
181
193
  return { file, stages: new Map(Object.entries(parsed as Record<string, JsonValue>)) };
182
194
  }
183
195
 
184
- /** `--sandbox`'s file: an inline sandbox spec, whose working directory is the run's. */
185
- function readSandboxSpec(file: string): object {
186
- let spec: unknown;
196
+ /** `flag`'s file, which must hold a JSON object: `what`, as its refusal says. */
197
+ function readJsonObject(file: string, flag: string, what: string): object {
198
+ let parsed: unknown;
187
199
  try {
188
- spec = JSON.parse(readFileSync(file, "utf8"));
200
+ parsed = JSON.parse(readFileSync(file, "utf8"));
189
201
  } catch (error) {
190
- throw new Error(`--sandbox: ${messageOf(error)}`);
191
- }
192
- if (typeof spec !== "object" || spec === null || Array.isArray(spec)) {
193
- throw new Error(`--sandbox: ${file} must hold a JSON object, a sandbox spec`);
202
+ throw new Error(`${flag}: ${messageOf(error)}`);
194
203
  }
204
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
205
+ throw new Error(`${flag}: ${file} must hold a JSON object, ${what}`);
206
+ return parsed;
207
+ }
208
+
209
+ /** `--sandbox`'s file: an inline sandbox spec, whose working directory is the run's. */
210
+ function readSandboxSpec(file: string): object {
211
+ const spec = readJsonObject(file, "--sandbox", "a sandbox spec");
195
212
  for (const [field, why] of Object.entries(NOT_IN_A_RUN_SANDBOX)) {
196
213
  if (field in spec) throw new Error(`--sandbox: the run's sandbox names no ${field}; ${why}`);
197
214
  }