@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +5 -5
  2. package/src/allowance-command.ts +29 -7
  3. package/src/attempt-ending.ts +23 -5
  4. package/src/attempt-output.ts +48 -18
  5. package/src/attempt-view.ts +25 -15
  6. package/src/commands.ts +176 -0
  7. package/src/control-plane.ts +72 -21
  8. package/src/coordinator-port.ts +29 -0
  9. package/src/coordinator-prompt.ts +237 -0
  10. package/src/coordinator-socket.ts +236 -0
  11. package/src/deadlines.ts +47 -10
  12. package/src/decisions/directory.ts +8 -6
  13. package/src/duration.ts +29 -0
  14. package/src/emit-command.ts +130 -0
  15. package/src/emit.ts +246 -0
  16. package/src/files.ts +11 -1
  17. package/src/here.ts +8 -2
  18. package/src/hold-run-lock.fixture.ts +8 -0
  19. package/src/holder-command.ts +297 -0
  20. package/src/inbox-command.ts +727 -0
  21. package/src/init-command.ts +27 -0
  22. package/src/init-template/AGENTS.md.template +1 -1
  23. package/src/init-template/hello/workflow.test.ts.template +2 -2
  24. package/src/init-template/hello/workflow.ts.template +16 -20
  25. package/src/keep-alive.ts +40 -0
  26. package/src/logs-command.ts +104 -20
  27. package/src/machine.ts +12 -1
  28. package/src/mailbox.ts +977 -0
  29. package/src/message-operation.ts +26 -3
  30. package/src/messaging/messaging.ts +458 -65
  31. package/src/messaging/routes.ts +10 -3
  32. package/src/messaging/text.ts +8 -5
  33. package/src/operation-liveness.ts +54 -8
  34. package/src/operator-cli.ts +290 -119
  35. package/src/operator-runtime.ts +5 -2
  36. package/src/progress-view.ts +49 -7
  37. package/src/projects.ts +57 -0
  38. package/src/result-slots.ts +43 -19
  39. package/src/result-validation.ts +18 -27
  40. package/src/run-command.ts +36 -32
  41. package/src/run-commands.ts +13 -2
  42. package/src/run-lock.ts +123 -0
  43. package/src/run-prepare.ts +94 -22
  44. package/src/run-state.ts +125 -6
  45. package/src/run-usage.ts +1 -1
  46. package/src/run-view.ts +0 -9
  47. package/src/runs-command.ts +46 -17
  48. package/src/runs.ts +142 -23
  49. package/src/sandboxes.ts +8 -2
  50. package/src/simple-input.ts +258 -0
  51. package/src/socket-exchange.ts +26 -0
  52. package/src/sources.ts +70 -0
  53. package/src/stage-ledger.ts +88 -13
  54. package/src/stage-plan.ts +23 -0
  55. package/src/stop-crashed.ts +39 -0
  56. package/src/test-command.ts +32 -0
  57. package/src/testing.ts +71 -3
  58. package/src/tick-command.ts +87 -0
  59. package/src/tools.ts +636 -0
  60. package/src/wake.ts +264 -0
  61. package/src/workflow-loader.ts +32 -22
  62. package/src/workflow-runner.ts +1210 -77
  63. package/src/workflow-testing/coordinator.typecheck.ts +55 -0
  64. package/src/workflow-testing/coordinators.ts +137 -0
  65. package/src/workflow-testing/host.ts +61 -1
  66. package/src/workflow-testing/index.ts +488 -33
  67. package/src/workflow-testing/script.ts +72 -2
  68. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentswf/engine",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.3",
4
4
  "description": "agents.wf: the engine that runs workflows made of coding agents",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/romanlv/agentswf",
@@ -29,10 +29,10 @@
29
29
  "!src/**/fixtures"
30
30
  ],
31
31
  "dependencies": {
32
- "@agentswf/wf": "0.1.0-beta.2",
33
- "@agentswf/contract": "0.1.0-beta.2",
34
- "@agentswf/harness": "0.1.0-beta.2",
35
- "@agentswf/sandbox": "0.1.0-beta.2",
32
+ "@agentswf/wf": "0.1.0-beta.3",
33
+ "@agentswf/contract": "0.1.0-beta.3",
34
+ "@agentswf/harness": "0.1.0-beta.3",
35
+ "@agentswf/sandbox": "0.1.0-beta.3",
36
36
  "typebox": "^1.3.34"
37
37
  },
38
38
  "publishConfig": {
@@ -1,11 +1,12 @@
1
- import {
2
- ALLOWANCE_VERSION,
3
- type AllowanceReport,
4
- type AllowanceWindow,
5
- type HarnessAllowance,
1
+ import type {
2
+ AllowanceReport,
3
+ AllowanceWindow,
4
+ HarnessAllowance,
6
5
  } from "@agentswf/contract/records";
7
6
  import { HARNESS_NAMES, type Harness } from "@agentswf/harness";
8
7
  import { PLAN_BASIS, planPrice } from "./accounting/plans";
8
+ import { messageOf } from "./errors";
9
+ import type { Out } from "./run-commands";
9
10
 
10
11
  export const allowanceUsage = [
11
12
  "usage: awf allowance [harness...] [--json]",
@@ -17,7 +18,7 @@ export const allowanceUsage = [
17
18
  "A plan's monthly price is the vendor's list price as of the table's date, not what was",
18
19
  `charged: ${PLAN_BASIS}.`,
19
20
  "",
20
- `--json prints an ${ALLOWANCE_VERSION} record, the interface for scripts, with the plan as the`,
21
+ `--json prints one document, the interface for scripts, with the plan as the`,
21
22
  "harness names it and no price; what it prints otherwise is for reading. A harness that could",
22
23
  "not be read says why. Exits 0 once every harness was asked, 2 on a usage error.",
23
24
  ].join("\n");
@@ -37,6 +38,27 @@ export function parseAllowanceCommand(argv: readonly string[]): AllowanceCommand
37
38
  return { harnesses: harnesses.length > 0 ? harnesses : [...HARNESS_NAMES], json };
38
39
  }
39
40
 
41
+ /** `awf allowance`: what is left of each harness's plan, read with `read`. */
42
+ export async function allowanceCommand(
43
+ argv: readonly string[],
44
+ io: Out & { read: (harness: Harness) => Promise<HarnessAllowance>; now: () => number },
45
+ ): Promise<number> {
46
+ let command: AllowanceCommand;
47
+ try {
48
+ command = parseAllowanceCommand(argv);
49
+ } catch (error) {
50
+ io.stderr(`awf: ${messageOf(error)}\n\n${allowanceUsage}`);
51
+ return 2;
52
+ }
53
+ if (command === "help") {
54
+ io.stdout(allowanceUsage);
55
+ return 0;
56
+ }
57
+ const report = await readReport(command.harnesses, io.read, io.now());
58
+ io.stdout(command.json ? JSON.stringify(report, null, 2) : describeReport(report));
59
+ return 0;
60
+ }
61
+
40
62
  /** Every harness asked at once: cursor's pane takes seconds the others need not wait for. */
41
63
  export async function readReport(
42
64
  harnesses: readonly Harness[],
@@ -44,7 +66,7 @@ export async function readReport(
44
66
  now: number,
45
67
  ): Promise<AllowanceReport> {
46
68
  return {
47
- version: ALLOWANCE_VERSION,
69
+ v: 1,
48
70
  readAt: new Date(now).toISOString(),
49
71
  harnesses: await Promise.all(harnesses.map(read)),
50
72
  };
@@ -12,6 +12,7 @@ import {
12
12
  import { PUBLISHED_PRICES } from "./accounting/prices";
13
13
  import { summarizeRun } from "./accounting/summary";
14
14
  import { messageOf } from "./errors";
15
+ import { RunParked } from "./mailbox";
15
16
  import type { Paint } from "./progress-view";
16
17
  import { FromStageUnreached, primaryFailure, WorkflowStopped } from "./stopped";
17
18
  import {
@@ -53,6 +54,7 @@ export const OUTCOMES: Record<
53
54
  { word: string; told: string; mark: (paint: Paint) => string; exitCode?: number }
54
55
  > = {
55
56
  completed: { word: "completed", told: "completed", mark: (p) => p.ok("✓"), exitCode: 0 },
57
+ parked: { word: "parked", told: "parked", mark: (p) => p.ok("◇"), exitCode: 0 },
56
58
  stopped: { word: "stopped", told: "stopped", mark: (p) => p.busy("■"), exitCode: 3 },
57
59
  cancelled: { word: "cancelled", told: "was cancelled", mark: (p) => p.busy("■") },
58
60
  "timed-out": { word: "timed out", told: "timed out", mark: (p) => p.bad("✗"), exitCode: 1 },
@@ -63,7 +65,11 @@ export const OUTCOMES: Record<
63
65
  export type GoOn = (stop: WorkflowStopped | undefined, entered: readonly StageSummary[]) => string;
64
66
 
65
67
  /** The ending of a run that returned or threw. */
66
- export function decideEnding(run: Finished, deadline: AbsoluteDeadline, goOn: GoOn): AttemptEnd {
68
+ export function decideEnding(
69
+ run: Finished,
70
+ deadline: AbsoluteDeadline | undefined,
71
+ goOn: GoOn,
72
+ ): AttemptEnd {
67
73
  if ("result" in run) {
68
74
  const { result } = run;
69
75
  return {
@@ -74,6 +80,18 @@ export function decideEnding(run: Finished, deadline: AbsoluteDeadline, goOn: Go
74
80
  }
75
81
  const { error } = run;
76
82
  const settled = error instanceof WorkflowRunError ? error : undefined;
83
+ const parked = primaryFailure(unwrapped(error));
84
+ if (parked instanceof RunParked) {
85
+ return {
86
+ ending: {
87
+ kind: "parked",
88
+ stages: settled?.stages ?? [],
89
+ ...parked.wait,
90
+ },
91
+ ...(settled ? { settled } : {}),
92
+ exitCode: OUTCOMES.parked.exitCode!,
93
+ };
94
+ }
77
95
  const { kind, stop, cancellation } = runOutcome(error, deadline);
78
96
  const cause = unwrapped(error);
79
97
  const beside =
@@ -104,10 +122,10 @@ export function decideEnding(run: Finished, deadline: AbsoluteDeadline, goOn: Go
104
122
  */
105
123
  export function endedBeforeStart(
106
124
  error: unknown,
107
- deadline: AbsoluteDeadline,
125
+ deadline: AbsoluteDeadline | undefined,
108
126
  run: { runId: string; startedAt: number; endedAt: number },
109
127
  goOn: GoOn,
110
- ): AttemptEnd & { ending: Exclude<Ending<JsonValue>, { kind: "completed" }> } {
128
+ ): AttemptEnd & { ending: Exclude<Ending<JsonValue>, { kind: "completed" | "parked" }> } {
111
129
  const { kind, cancellation } = runOutcome(error, deadline);
112
130
  const times = {
113
131
  startedAt: new Date(run.startedAt).toISOString(),
@@ -156,14 +174,14 @@ export function cleanupFailed(end: AttemptEnd, error: unknown, goOn: string): At
156
174
  */
157
175
  export function runOutcome(
158
176
  error: unknown,
159
- deadline: AbsoluteDeadline,
177
+ deadline: AbsoluteDeadline | undefined,
160
178
  ): { kind: UnfinishedOutcome; stop?: WorkflowStopped; cancellation?: WorkflowCancelledError } {
161
179
  const cancellation = findCancellation(error);
162
180
  const failure = primaryFailure(unwrapped(error));
163
181
  const stop = failure instanceof WorkflowStopped ? failure : undefined;
164
182
  const timedOut =
165
183
  failure instanceof DeadlineExceededError &&
166
- failure.deadline.unixMilliseconds === deadline.unixMilliseconds;
184
+ failure.deadline.unixMilliseconds === deadline?.unixMilliseconds;
167
185
  const kind = cancellation ? "cancelled" : stop ? "stopped" : timedOut ? "timed-out" : "failed";
168
186
  return { kind, ...(stop ? { stop } : {}), ...(cancellation ? { cancellation } : {}) };
169
187
  }
@@ -4,13 +4,15 @@ import {
4
4
  type OutputRecord,
5
5
  RUN_EVENT_VERSIONS,
6
6
  } from "@agentswf/contract/records";
7
- import type { Ending, ExecutableWorkflow, JsonValue } from "@agentswf/contract/workflow";
7
+ import type { Ending, JsonValue } from "@agentswf/contract/workflow";
8
8
  import { attemptAccounting } from "./accounting/summary";
9
9
  import type { AttemptEnd } from "./attempt-ending";
10
10
  import { messageOf } from "./errors";
11
11
  import { writeJson, writeWhole } from "./files";
12
+ import { foldMailbox, readMailbox } from "./mailbox";
12
13
  import { type Attempt, appendEvent, endAttempt, outputFile, reportFile } from "./runs";
13
14
  import { withoutValue } from "./stage-ledger";
15
+ import type { AnyWorkflow, LoadedWorkflow } from "./workflow-loader";
14
16
 
15
17
  /** Where an attempt's records went, and its output record as `--json` prints it. */
16
18
  export type Kept = {
@@ -30,27 +32,34 @@ export async function keepRecords(
30
32
  end: AttemptEnd,
31
33
  context: {
32
34
  attempt: Attempt;
33
- executable: ExecutableWorkflow<JsonValue, JsonValue>;
34
- workflow: { name: string; file: string };
35
+ loaded: LoadedWorkflow;
35
36
  stderr: (text: string) => void;
37
+ /** An event run's: runs `work` holding its run's lock. */
38
+ locked?: (work: () => Promise<void>) => Promise<void>;
36
39
  },
37
40
  ): Promise<Kept> {
38
41
  const { attempt, stderr } = context;
39
42
  const { ending, settled } = end;
40
43
  const dir = attempt.run.dir;
41
- const report = await writeReport(context.executable, ending, dir, stderr);
44
+ const report = await writeReport(context.loaded.workflow, ending, dir, stderr);
42
45
  const stages = settled?.stages?.map(withoutValue);
43
- const at =
44
- ending.kind !== "completed" && ending.stage !== undefined ? { stage: ending.stage } : {};
46
+ const unfinished = ending.kind !== "completed" && ending.kind !== "parked" ? ending : undefined;
47
+ const at = unfinished?.stage !== undefined ? { stage: unfinished.stage } : {};
45
48
  const ended =
46
49
  ending.kind === "completed"
47
50
  ? { outcome: "completed" as const, value: ending.value }
48
- : {
49
- outcome: ending.kind,
50
- reason: ending.reason,
51
- ...at,
52
- ...(end.needs ? { needs: [...end.needs] } : {}),
53
- };
51
+ : ending.kind === "parked"
52
+ ? {
53
+ outcome: "parked" as const,
54
+ ...(ending.label === undefined ? {} : { label: ending.label }),
55
+ ...(ending.until === undefined ? {} : { until: ending.until }),
56
+ }
57
+ : {
58
+ outcome: ending.kind,
59
+ reason: ending.reason,
60
+ ...at,
61
+ ...(end.needs ? { needs: [...end.needs] } : {}),
62
+ };
54
63
  const kept: Kept = report ? { report } : {};
55
64
  const output = outputFile(dir);
56
65
  if (!settled) {
@@ -63,7 +72,7 @@ export async function keepRecords(
63
72
  version: OUTPUT_RECORD_VERSION,
64
73
  runId: settled.runId,
65
74
  attempt: attempt.record.attempt,
66
- workflow: context.workflow,
75
+ workflow: { name: context.loaded.workflow.meta.name, file: context.loaded.file },
67
76
  accounting: settled.accounting,
68
77
  usage: settled.usage,
69
78
  artifacts: dir,
@@ -91,14 +100,35 @@ export async function keepRecords(
91
100
  kind: "attempt-ended",
92
101
  outcome: ending.kind,
93
102
  }).catch((error) => stderr(`awf: the run's event log was not written: ${messageOf(error)}`));
94
- try {
103
+ // An event run's ending is written holding its run's lock, as parking's is: a sender after it sees
104
+ // the run returned and is refused, so what no wait took is counted whole. Its last batch was
105
+ // handled, as the code returned after it.
106
+ const writeEnding = async () => {
107
+ const unacknowledged =
108
+ ending.kind === "completed" && attempt.run.record.events
109
+ ? await readMailbox(dir).then(
110
+ // The event that started the run is `workflow.started`'s, taken or not.
111
+ (records) => {
112
+ const state = foldMailbox(records);
113
+ return state.unacknowledged.filter((id) => id !== state.started?.id).length;
114
+ },
115
+ (error: unknown) => {
116
+ stderr(`awf: what no wait took was not counted: ${messageOf(error)}`);
117
+ return undefined;
118
+ },
119
+ )
120
+ : undefined;
95
121
  await endAttempt(attempt, {
96
122
  outcome: ending.kind,
97
- ...(ending.kind === "completed" ? {} : { reason: ending.reason }),
123
+ ...(unacknowledged === undefined ? {} : { unacknowledged }),
124
+ ...(unfinished ? { reason: unfinished.reason } : {}),
98
125
  ...at,
99
126
  ...(stages ? { stages } : {}),
100
127
  ...(settled ? { accounting: attemptAccounting(settled.accounting) } : {}),
101
128
  });
129
+ };
130
+ try {
131
+ await (context.locked ?? ((work: () => Promise<void>) => work()))(writeEnding);
102
132
  } catch (error) {
103
133
  stderr(`awf: the attempt's ending was not written to ${attempt.file}: ${messageOf(error)}`);
104
134
  }
@@ -106,7 +136,7 @@ export async function keepRecords(
106
136
  }
107
137
 
108
138
  async function writeReport(
109
- executable: ExecutableWorkflow<JsonValue, JsonValue>,
139
+ workflow: AnyWorkflow,
110
140
  ending: Ending<JsonValue>,
111
141
  dir: string,
112
142
  stderr: (text: string) => void,
@@ -114,11 +144,11 @@ async function writeReport(
114
144
  const file = reportFile(dir);
115
145
  // An earlier attempt's report is gone with it: report.md is the last ended attempt's.
116
146
  const none = () => rm(file, { force: true }).then(() => undefined);
117
- if (!executable.report) return none();
147
+ if (!workflow.report) return none();
118
148
  // The result is still in output.json; a report that cannot be rendered or saved should not fail
119
149
  // the run, nor cost it its record.
120
150
  try {
121
- const markdown = executable.report(
151
+ const markdown = workflow.report(
122
152
  ending.kind === "completed" ? ending.value : undefined,
123
153
  ending,
124
154
  );
@@ -5,12 +5,7 @@ import type {
5
5
  StageNeed,
6
6
  StageRecord,
7
7
  } from "@agentswf/contract/records";
8
- import type {
9
- Ending,
10
- ExecutableWorkflow,
11
- JsonValue,
12
- StageSummary,
13
- } from "@agentswf/contract/workflow";
8
+ import type { Ending, JsonValue, StageSummary } from "@agentswf/contract/workflow";
14
9
  import { ago, describeAccounting, describeAttempts } from "./accounting/format";
15
10
  import { sumAttempts } from "./accounting/summary";
16
11
  import { type AttemptEnd, OUTCOMES } from "./attempt-ending";
@@ -22,6 +17,7 @@ import type { RunCommand } from "./run-command";
22
17
  import type { AttemptRefusal } from "./runs";
23
18
  import { schemaLines } from "./stage-schema";
24
19
  import { FromStageUnreached, type WorkflowStopped } from "./stopped";
20
+ import type { AnyWorkflow } from "./workflow-loader";
25
21
 
26
22
  /**
27
23
  * Prints how the attempt ended, and returns its exit code. A completed one's result goes to stdout;
@@ -32,7 +28,7 @@ export function printEnding(
32
28
  end: AttemptEnd,
33
29
  kept: Kept,
34
30
  context: {
35
- executable: ExecutableWorkflow<JsonValue, JsonValue>;
31
+ workflow: AnyWorkflow;
36
32
  /** The run's id and folder. */
37
33
  run: { id: string; dir: string };
38
34
  n: number;
@@ -56,7 +52,7 @@ export function printEnding(
56
52
  const { ending, settled } = end;
57
53
  const presented =
58
54
  ending.kind === "completed" && !context.json
59
- ? present(context.executable, ending, stderr)
55
+ ? present(context.workflow, ending, stderr)
60
56
  : undefined;
61
57
  if (end.alsoFailed) stderr(`awf: also failed: ${end.alsoFailed}`);
62
58
  const again =
@@ -67,7 +63,7 @@ export function printEnding(
67
63
  if (context.cleanupAlso) stderr(`awf: runtime cleanup also failed: ${context.cleanupAlso}`);
68
64
  // Beside the result rather than in it: stdout stays the workflow's report or the JSON.
69
65
  const lines = describeEnding(ending, {
70
- named: `${context.executable.definition.meta.name} ${context.run.id}`,
66
+ named: `${context.workflow.meta.name} ${context.run.id}`,
71
67
  n: context.n,
72
68
  ...(settled ? { accounting: settled.accounting } : {}),
73
69
  earlier: context.earlier,
@@ -78,7 +74,7 @@ export function printEnding(
78
74
  ...(end.needs ? { needs: end.needs } : {}),
79
75
  ...(settled?.panes ? { panes: settled.panes } : {}),
80
76
  // As the run's Herdr workspace is labelled.
81
- runWorkspace: `awf ${context.executable.definition.meta.name} ${context.run.id} #${context.n}`,
77
+ runWorkspace: `awf ${context.workflow.meta.name} ${context.run.id} #${context.n}`,
82
78
  paint: context.terminal?.color ? ANSI : PLAIN,
83
79
  });
84
80
  for (const line of ["", ...lines]) stderr(line);
@@ -91,7 +87,9 @@ export function printEnding(
91
87
  export function toldOf(end: AttemptEnd, kept: Kept): string {
92
88
  const { ending } = end;
93
89
  const told = `${OUTCOMES[ending.kind].told}${kept.output ? `; its record is ${kept.output}` : ""}`;
94
- return ending.kind === "completed" ? told : `${told}: ${ending.reason}`;
90
+ if (ending.kind === "completed") return told;
91
+ if (ending.kind === "parked") return ending.label ? `${told}: ${ending.label}` : told;
92
+ return `${told}: ${ending.reason}`;
95
93
  }
96
94
 
97
95
  /**
@@ -135,6 +133,17 @@ function describeEnding(
135
133
  if (ending.kind === "completed") {
136
134
  return [`${mark} ${word} · ${run}`, ...figures, ...rows([], context)];
137
135
  }
136
+ if (ending.kind === "parked") {
137
+ const waits = [ending.label, ending.until && `until ${ending.until}`]
138
+ .filter(Boolean)
139
+ .join(" · ");
140
+ return [
141
+ `${mark} ${word}${waits ? `: ${waits}` : ""}`,
142
+ ` ${run}`,
143
+ ...figures,
144
+ ...rows([], context),
145
+ ];
146
+ }
138
147
  // A reason's later lines, such as a schema's problems, sit under it, apart from the rows.
139
148
  const [first, ...more] = ending.reason.split("\n");
140
149
  const goOn = context.choose
@@ -233,13 +242,13 @@ function runTotal(n: number, current: RunAccounting, earlier: readonly AttemptRe
233
242
 
234
243
  /** A completed attempt's value as the workflow presents it; undefined to print the JSON. */
235
244
  function present(
236
- executable: ExecutableWorkflow<JsonValue, JsonValue>,
245
+ workflow: AnyWorkflow,
237
246
  ending: Extract<Ending<JsonValue>, { kind: "completed" }>,
238
247
  stderr: (text: string) => void,
239
248
  ): string | undefined {
240
- if (!executable.present) return undefined;
249
+ if (!workflow.present) return undefined;
241
250
  try {
242
- return executable.present(ending.value, ending)?.trimEnd();
251
+ return workflow.present(ending.value, ending)?.trimEnd();
243
252
  } catch (error) {
244
253
  stderr(`awf: present: ${messageOf(error)}; printing the full result instead`);
245
254
  return undefined;
@@ -343,8 +352,9 @@ export function continueCommand(
343
352
  ...(command.runRootGiven ? ["--run-root", command.runRoot] : []),
344
353
  ...(command.cwdGiven ? ["--cwd", command.cwd] : []),
345
354
  pathFrom(command.shellCwd, command.workflowFile),
346
- "--continue",
355
+ "--id",
347
356
  id,
357
+ "--continue",
348
358
  ...(redo === undefined ? [] : ["--from-stage", redo]),
349
359
  ...(values === undefined ? [] : ["--values", values]),
350
360
  ]
@@ -0,0 +1,176 @@
1
+ import { allowanceUsage } from "./allowance-command";
2
+ import { emitUsage } from "./emit-command";
3
+ import { replyUsage } from "./holder-command";
4
+ import { inboxUsage } from "./inbox-command";
5
+ import { initUsage } from "./init-command";
6
+ import { logsUsage } from "./logs-command";
7
+ import { usage as runUsage } from "./run-command";
8
+ import { shellLine } from "./run-commands";
9
+ import { runsUsage } from "./runs-command";
10
+ import { testUsage } from "./test-command";
11
+ import { tickUsage } from "./tick-command";
12
+
13
+ /**
14
+ * Every `awf` command: what the index, `awf help` and the suggestion read, and what the dispatch is
15
+ * keyed by. An entry's commands share its help; each has its own line in the index.
16
+ */
17
+ export const COMMANDS = [
18
+ {
19
+ group: "Run a workflow",
20
+ commands: { run: "Start a run of a workflow file, or continue one" },
21
+ help: runUsage,
22
+ },
23
+ { group: "Run a workflow", commands: { test: "Run a workflow's tests" }, help: testUsage },
24
+ {
25
+ group: "Run a workflow",
26
+ commands: { init: "Set up a directory for writing workflows" },
27
+ help: initUsage,
28
+ },
29
+ {
30
+ group: "Run a workflow",
31
+ commands: { emit: "Send an event to a workflow with events, starting or waking its run" },
32
+ help: emitUsage,
33
+ },
34
+ {
35
+ group: "Run a workflow",
36
+ commands: { tick: "Wake the runs of workflows with events that are due" },
37
+ help: tickUsage,
38
+ },
39
+ {
40
+ group: "Look at runs",
41
+ commands: { runs: "The runs here, or one run in depth" },
42
+ help: runsUsage,
43
+ },
44
+ {
45
+ group: "Look at runs",
46
+ commands: { logs: "What a run did and is doing, merged by time" },
47
+ help: logsUsage,
48
+ },
49
+ {
50
+ group: "Coordinate a run from outside",
51
+ commands: { inbox: "What agents said to a participant outside the run, and what it said back" },
52
+ help: inboxUsage,
53
+ },
54
+ {
55
+ group: "Coordinate a run from outside",
56
+ commands: { reply: "Answer a question an agent asked", send: "Message an agent first" },
57
+ help: replyUsage,
58
+ },
59
+ {
60
+ group: "This machine",
61
+ commands: { allowance: "What is left of each harness's plan" },
62
+ help: allowanceUsage,
63
+ },
64
+ ] as const;
65
+
66
+ type Entry = (typeof COMMANDS)[number];
67
+ type NamesOf<Each> = Each extends { commands: infer Commands } ? keyof Commands & string : never;
68
+ export type CommandName = NamesOf<Entry>;
69
+
70
+ const NAMES = COMMANDS.flatMap((entry) => Object.keys(entry.commands)) as CommandName[];
71
+
72
+ export function isCommand(word: string): word is CommandName {
73
+ return (NAMES as string[]).includes(word);
74
+ }
75
+
76
+ /** The help `awf help {name}` and `awf {name} -h` print. */
77
+ export function helpOf(name: CommandName): string {
78
+ return COMMANDS.find((entry) => name in entry.commands)!.help;
79
+ }
80
+
81
+ const WIDTH = Math.max(...NAMES.map((name) => name.length)) + 2;
82
+
83
+ function grouped(): [group: string, entries: Entry[]][] {
84
+ const groups = new Map<string, Entry[]>();
85
+ for (const entry of COMMANDS)
86
+ groups.set(entry.group, [...(groups.get(entry.group) ?? []), entry]);
87
+ return [...groups];
88
+ }
89
+
90
+ const lines = (entry: Entry) =>
91
+ Object.entries(entry.commands).map(([name, summary]) => `${name.padEnd(WIDTH)}${summary}`);
92
+
93
+ /** What `awf`, `awf -h` and `awf help` print. */
94
+ export function index(): string {
95
+ return [
96
+ "awf runs workflows of coding agents.",
97
+ "",
98
+ "usage: awf <command> [arguments]",
99
+ "",
100
+ ...grouped().flatMap(([group, entries]) => [
101
+ group,
102
+ ...entries.flatMap(lines).map((line) => ` ${line}`),
103
+ "",
104
+ ]),
105
+ "awf <command> -h a command's usage, options and exit codes",
106
+ "awf help reference every command's usage, on one page",
107
+ "awf --version",
108
+ ].join("\n");
109
+ }
110
+
111
+ /** A command's usage lines: its help up to the first blank line. */
112
+ export function usageLines(help: string): string[] {
113
+ const end = help.indexOf("\n\n");
114
+ return (end < 0 ? help : help.slice(0, end)).split("\n");
115
+ }
116
+
117
+ /** What `awf help reference` prints: each command's summary and usage lines, grouped as the index. */
118
+ export function reference(): string {
119
+ return [
120
+ ...grouped().flatMap(([group, entries]) => [
121
+ group,
122
+ ...entries.flatMap((entry) => [
123
+ ...lines(entry).map((line) => ` ${line}`),
124
+ ...usageLines(entry.help).map((line) => ` ${line}`),
125
+ "",
126
+ ]),
127
+ ]),
128
+ "awf <command> -h for what each does, and its exit codes",
129
+ ].join("\n");
130
+ }
131
+
132
+ const WORKFLOW_FILE = /\.(ts|mts|js|mjs)$/;
133
+
134
+ /** The one line `awf {word}` gets when no command is named `word`. */
135
+ export function unknownCommand(word: string): string {
136
+ const said = `awf: no command ${JSON.stringify(word)}`;
137
+ if (WORKFLOW_FILE.test(word)) return `${said}; did you mean: ${shellLine(["awf", "run", word])}`;
138
+ const meant = suggest(word);
139
+ const named =
140
+ meant.length === 0
141
+ ? ""
142
+ : ` did you mean ${meant.length === 1 ? meant[0] : `${meant.slice(0, -1).join(", ")} or ${meant.at(-1)}`}?`;
143
+ return `${said};${named} awf -h lists the commands`;
144
+ }
145
+
146
+ /**
147
+ * The commands `word` is the start of, else the closest within two edits; every one tied for
148
+ * closest.
149
+ */
150
+ export function suggest(word: string): CommandName[] {
151
+ if (word === "") return [];
152
+ const started = NAMES.filter((name) => name.startsWith(word));
153
+ if (started.length > 0) return started;
154
+ const near = NAMES.map((name) => ({ name, distance: distance(word, name) })).filter(
155
+ (each) => each.distance <= 2,
156
+ );
157
+ const closest = Math.min(...near.map((each) => each.distance));
158
+ return near.filter((each) => each.distance === closest).map((each) => each.name);
159
+ }
160
+
161
+ /** Levenshtein distance. */
162
+ function distance(a: string, b: string): number {
163
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
164
+ for (let i = 1; i <= a.length; i += 1) {
165
+ const current = [i];
166
+ for (let j = 1; j <= b.length; j += 1) {
167
+ current[j] = Math.min(
168
+ previous[j]! + 1,
169
+ current[j - 1]! + 1,
170
+ previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
171
+ );
172
+ }
173
+ previous = current;
174
+ }
175
+ return previous[b.length]!;
176
+ }