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

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 +5 -6
  2. package/src/agent-cli/cli.ts +571 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +31 -21
  6. package/src/attempt-ending.ts +23 -5
  7. package/src/attempt-output.ts +48 -18
  8. package/src/attempt-view.ts +25 -15
  9. package/src/commands.ts +176 -0
  10. package/src/control-plane.ts +73 -22
  11. package/src/coordinator-port.ts +29 -0
  12. package/src/coordinator-prompt.ts +237 -0
  13. package/src/coordinator-socket.ts +236 -0
  14. package/src/deadlines.ts +47 -10
  15. package/src/decisions/directory.ts +8 -6
  16. package/src/duration.ts +29 -0
  17. package/src/emit-command.ts +119 -0
  18. package/src/emit.ts +246 -0
  19. package/src/files.ts +11 -1
  20. package/src/help/allowance.txt +13 -0
  21. package/src/help/emit.txt +13 -0
  22. package/src/help/inbox.txt +12 -0
  23. package/src/help/init.txt +12 -0
  24. package/src/help/logs.txt +13 -0
  25. package/src/help/reply.txt +18 -0
  26. package/src/help/run.txt +44 -0
  27. package/src/help/runs.txt +11 -0
  28. package/src/help/test.txt +9 -0
  29. package/src/help/tick.txt +12 -0
  30. package/src/here.ts +9 -3
  31. package/src/hold-run-lock.fixture.ts +8 -0
  32. package/src/holder-command.ts +279 -0
  33. package/src/inbox-command.ts +715 -0
  34. package/src/init-command.ts +29 -11
  35. package/src/init-template/AGENTS.md.template +4 -4
  36. package/src/init-template/hello/workflow.test.ts.template +2 -2
  37. package/src/init-template/hello/workflow.ts.template +16 -20
  38. package/src/keep-alive.ts +40 -0
  39. package/src/logs-command.ts +106 -35
  40. package/src/machine.ts +12 -1
  41. package/src/mailbox.ts +990 -0
  42. package/src/message-operation.ts +26 -3
  43. package/src/messaging/limits.ts +1 -1
  44. package/src/messaging/messaging.ts +478 -85
  45. package/src/messaging/routes.ts +11 -4
  46. package/src/messaging/text.ts +25 -22
  47. package/src/operation-liveness.ts +55 -9
  48. package/src/operator-cli.ts +290 -119
  49. package/src/operator-runtime.ts +8 -8
  50. package/src/progress-view.ts +49 -7
  51. package/src/projects.ts +57 -0
  52. package/src/result-slots.ts +43 -19
  53. package/src/result-validation.ts +18 -27
  54. package/src/run-command.ts +29 -66
  55. package/src/run-commands.ts +13 -2
  56. package/src/run-lock.ts +123 -0
  57. package/src/run-prepare.ts +94 -22
  58. package/src/run-state.ts +125 -6
  59. package/src/run-usage.ts +2 -2
  60. package/src/run-view.ts +0 -9
  61. package/src/runs-command.ts +43 -25
  62. package/src/runs.ts +142 -23
  63. package/src/sandboxes.ts +11 -4
  64. package/src/simple-input.ts +268 -0
  65. package/src/socket-exchange.ts +26 -0
  66. package/src/sources.ts +70 -0
  67. package/src/stage-ledger.ts +89 -14
  68. package/src/stage-plan.ts +23 -0
  69. package/src/stop-crashed.ts +39 -0
  70. package/src/test-command.ts +34 -11
  71. package/src/testing.ts +72 -4
  72. package/src/tick-command.ts +76 -0
  73. package/src/tools.ts +645 -0
  74. package/src/wake.ts +264 -0
  75. package/src/workflow-loader.ts +32 -22
  76. package/src/workflow-runner.ts +1277 -97
  77. package/src/workflow-testing/coordinator.typecheck.ts +54 -0
  78. package/src/workflow-testing/coordinators.ts +137 -0
  79. package/src/workflow-testing/host.ts +67 -7
  80. package/src/workflow-testing/index.ts +488 -34
  81. package/src/workflow-testing/script.ts +74 -4
  82. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -62,7 +62,7 @@ export function renderProgress(
62
62
  const shown =
63
63
  agents.length <= MAX_LISTED
64
64
  ? agents
65
- : agents.filter((a) => isRunning(a) || isFailed(a) || a.stopped);
65
+ : agents.filter((a) => isRunning(a) || isFailed(a) || a.stopped || a.liveUntil);
66
66
  lines.push(...shown.slice(0, MAX_LISTED).map(agentLine));
67
67
  const hidden = agents.length - Math.min(shown.length, MAX_LISTED);
68
68
  if (hidden > 0) lines.push(paint.dim(` … ${hidden} more`));
@@ -96,7 +96,9 @@ export function renderProgress(
96
96
  });
97
97
  // Agents opened outside any labelled parallel.
98
98
  const loose = snapshot.agents.filter((agent) => agent.group === undefined && !inStage(agent));
99
- if (loose.some((agent) => isRunning(agent) || isFailed(agent) || agent.stopped))
99
+ if (
100
+ loose.some((agent) => isRunning(agent) || isFailed(agent) || agent.stopped || agent.liveUntil)
101
+ )
100
102
  listAgents(loose);
101
103
  return lines;
102
104
  }
@@ -105,7 +107,13 @@ export function renderProgress(
105
107
  export function progressEvents(
106
108
  before: WorkflowRunSnapshot | undefined,
107
109
  after: WorkflowRunSnapshot,
108
- view: { startedAt: number; now: number; herdrSession?: string },
110
+ view: {
111
+ startedAt: number;
112
+ now: number;
113
+ herdrSession?: string;
114
+ /** Messages another view shows already, which get no line here. */
115
+ shown?: (message: WorkflowRunSnapshot["messages"][number]) => boolean;
116
+ },
109
117
  ): string[] {
110
118
  // Each stamped and ordered by when it happened; at the same instant, what ends goes before what
111
119
  // starts, and the inner before the outer as each ends, the outer before the inner as each starts.
@@ -141,6 +149,8 @@ export function progressEvents(
141
149
  `■ ${agent.key} stopped: ${oneLine(agent.stopped.reason)}`,
142
150
  );
143
151
  }
152
+ if (agent.liveUntil !== undefined && agent.liveUntil !== earlier?.liveUntil)
153
+ add(view.now, "agent phase", `… ${agent.key} · ${liveNote(agent.liveUntil)}`);
144
154
  if (!turn) continue;
145
155
  const fresh = earlier?.turns !== agent.turns;
146
156
  if (fresh && (turn.outcome === undefined || agent.stopped))
@@ -175,6 +185,7 @@ export function progressEvents(
175
185
  }
176
186
  }
177
187
  for (const message of after.messages.slice(before?.messages.length ?? 0)) {
188
+ if (view.shown?.(message)) continue;
178
189
  add(message.at, "message", messageLine(message));
179
190
  }
180
191
  after.stages.forEach((stage, index) => {
@@ -340,7 +351,7 @@ function stageAgentLine(
340
351
  const turn = agent.turn!;
341
352
  const [mark, note] =
342
353
  turn.outcome === "answered" && !agent.stopped
343
- ? [paint.dim("·"), paint.dim("waiting")]
354
+ ? [paint.dim("·"), paint.dim(agent.liveUntil ? liveNote(agent.liveUntil) : "waiting")]
344
355
  : agentState(agent, now, paint, herdrSession);
345
356
  const took = duration((turn.settledAt ?? now) - turn.startedAt);
346
357
  const label = turn.label ?? (turn.kind === "turn" ? undefined : turn.kind);
@@ -375,7 +386,8 @@ function agentState(
375
386
  const queued = agent.queued ? `${agent.queued} queued` : "";
376
387
  return [paint.busy(spin(now)), [note, queued].filter(Boolean).join(" · ")];
377
388
  }
378
- if (turn.outcome === "answered") return [paint.ok("✓"), ""];
389
+ if (turn.outcome === "answered")
390
+ return [paint.ok("✓"), agent.liveUntil ? paint.dim(liveNote(agent.liveUntil)) : ""];
379
391
  return [
380
392
  paint.bad("✗"),
381
393
  paint.bad(
@@ -465,6 +477,13 @@ function isFailed(agent: Agent): boolean {
465
477
  return agent.turn.outcome !== undefined && agent.turn.outcome !== "answered";
466
478
  }
467
479
 
480
+ /** Kept alive: the run stays live, rather than parked, to this agent's window's end. */
481
+ function liveNote(until: number): string {
482
+ const date = new Date(until);
483
+ const two = (n: number) => String(n).padStart(2, "0");
484
+ return `live until ${two(date.getHours())}:${two(date.getMinutes())}`;
485
+ }
486
+
468
487
  function spin(now: number): string {
469
488
  return SPINNER[Math.floor(now / 100) % SPINNER.length]!;
470
489
  }
@@ -504,6 +523,10 @@ export function watchProgress(
504
523
  // nothing is said under the closing block.
505
524
  let stopped = false;
506
525
  let drawn = 0;
526
+ /** While a prompt waits for a line: the block is not drawn, and lines go above the prompt. */
527
+ let above: ((line: string) => void) | undefined;
528
+ /** Messages another view shows already, which get no line here. */
529
+ let shown: (message: WorkflowRunSnapshot["messages"][number]) => boolean = () => false;
507
530
  const clear = () => {
508
531
  if (terminal && drawn > 0) terminal.write(`\x1b[${drawn}F\x1b[0J`);
509
532
  drawn = 0;
@@ -513,7 +536,14 @@ export function watchProgress(
513
536
  const snapshot = handle.inspect();
514
537
  if (terminal) {
515
538
  // A message is an event: a line above the block, as a log line is.
516
- const sent = snapshot.messages.slice(last?.messages.length ?? 0);
539
+ const sent = snapshot.messages
540
+ .slice(last?.messages.length ?? 0)
541
+ .filter((message) => !shown(message));
542
+ if (above && !final) {
543
+ for (const message of sent) above(messageLine(message));
544
+ last = snapshot;
545
+ return;
546
+ }
517
547
  if (sent.length > 0) {
518
548
  clear();
519
549
  for (const message of sent) stderr(messageLine(message));
@@ -533,7 +563,7 @@ export function watchProgress(
533
563
  if (lines.length > 0) terminal.write(`${lines.join("\n")}\n`);
534
564
  drawn = lines.length;
535
565
  } else {
536
- const view = { startedAt, now: now(), ...(herdrSession ? { herdrSession } : {}) };
566
+ const view = { startedAt, now: now(), shown, ...(herdrSession ? { herdrSession } : {}) };
537
567
  for (const line of progressEvents(last, snapshot, view)) stderr(line);
538
568
  }
539
569
  last = snapshot;
@@ -548,10 +578,22 @@ export function watchProgress(
548
578
  },
549
579
  log(text: string) {
550
580
  if (stopped) return;
581
+ if (above) return above(text);
551
582
  clear();
552
583
  stderr(text);
553
584
  if (terminal) tick();
554
585
  },
586
+ /** Messages the prompt shows itself, with their text: no size line for them here. */
587
+ showsMessages(by: (message: WorkflowRunSnapshot["messages"][number]) => boolean) {
588
+ shown = by;
589
+ },
590
+ /** A prompt opened, writing lines through `under`, or closed with none. */
591
+ hold(under: ((line: string) => void) | undefined) {
592
+ if (stopped || !terminal) return;
593
+ above = under;
594
+ if (under) clear();
595
+ else tick();
596
+ },
555
597
  /** The Herdr session pane agents opened in, which a blocked one's line names. */
556
598
  placedIn(session: string) {
557
599
  if (!stopped) herdrSession = session;
@@ -0,0 +1,57 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { realPath } from "./files";
4
+ import { appendLine } from "./jsonl";
5
+ import { withRunLock } from "./run-lock";
6
+
7
+ /**
8
+ * The project a workflow with `events` runs in: the nearest directory at or above its file that
9
+ * holds `.awf/`, or else the file's own directory. Every sender names the file, so every sender,
10
+ * wherever it runs, finds the same runs (Q15). `{home}/.awf` is awf's own, not a project's.
11
+ */
12
+ export async function projectOf(file: string, home: string): Promise<string> {
13
+ // The real path, so a sender naming the file through a link finds the same runs.
14
+ const start = realPath(dirname(resolve(file)));
15
+ const ownHome = realPath(home);
16
+ if (start === ownHome)
17
+ throw new Error(
18
+ `${file} is directly in home, where awf keeps its own .awf: move it into a directory`,
19
+ );
20
+ for (let dir = start; ; dir = dirname(dir)) {
21
+ if (dir === ownHome) return start;
22
+ const found = await stat(join(dir, ".awf")).then(
23
+ (info) => info.isDirectory(),
24
+ () => false,
25
+ );
26
+ if (found) return dir;
27
+ if (dirname(dir) === dir) return start;
28
+ }
29
+ }
30
+
31
+ /** Where a project's runs are kept. */
32
+ export function projectRunRoot(project: string): string {
33
+ return join(project, ".awf", "runs");
34
+ }
35
+
36
+ /** `~/.awf/projects`: each project awf has started an event run in, one absolute path a line. */
37
+ export function projectsFile(home: string): string {
38
+ return join(home, ".awf", "projects");
39
+ }
40
+
41
+ /** The projects awf has started an event run in, each once, in the order first started. */
42
+ export async function listProjects(home: string): Promise<string[]> {
43
+ const text = await Bun.file(projectsFile(home))
44
+ .text()
45
+ .catch(() => "");
46
+ return [...new Set(text.split("\n").filter((line) => line.startsWith("/")))];
47
+ }
48
+
49
+ /** Adds `project` to `~/.awf/projects`, once. */
50
+ export async function recordProject(home: string, project: string): Promise<void> {
51
+ const file = projectsFile(home);
52
+ if ((await listProjects(home)).includes(project)) return;
53
+ await withRunLock(`${file}.lock`, async () => {
54
+ if ((await listProjects(home)).includes(project)) return;
55
+ await appendLine(file, project);
56
+ });
57
+ }
@@ -1,10 +1,10 @@
1
1
  import { acceptAny, type SemanticCheck } from "@agentswf/contract";
2
2
  import type { CandidateSource } from "@agentswf/contract/records";
3
3
  import type { JsonSchema } from "@agentswf/contract/schema";
4
- import type { ResultSubmitCode } from "@agentswf/contract/wire";
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 { evaluateResult } from "./result-validation";
7
+ import { evaluateGiven } from "./result-validation";
8
8
  import { recordCandidate, writeAcceptedExclusive, writeCall } from "./runs";
9
9
 
10
10
  export type ResultRejectionCode = ResultSubmitCode;
@@ -19,7 +19,7 @@ export type ResultSlotAccepted = {
19
19
  };
20
20
  type Rejected = { kind: "rejected"; code: ResultRejectionCode; error: string };
21
21
  export type ResultSubmission = ResultSlotAccepted | Rejected;
22
- export type WaitingGrant = { kind: "waiting"; waitUntil: number; deadline: number };
22
+ export type WaitingGrant = { kind: "waiting"; waitUntil: number; deadline?: number };
23
23
  export type ResultSlotSettlement =
24
24
  | ResultSlotAccepted
25
25
  | { kind: "closed" }
@@ -36,7 +36,8 @@ export type ResultSlotSpec = {
36
36
  question: string;
37
37
  schema?: JsonSchema;
38
38
  semantic?: SemanticCheck;
39
- deadline: AbsoluteDeadline;
39
+ /** Absent when nothing bounds the operation. */
40
+ deadline?: AbsoluteDeadline;
40
41
  allowWaiting?: boolean;
41
42
  onEvent?(event: ResultSlotEvent): void;
42
43
  };
@@ -58,9 +59,8 @@ interface ResultSlotPersistence {
58
59
  type SubmissionInput = {
59
60
  operationId: string;
60
61
  agentId: string;
61
- raw: string;
62
62
  source: CandidateSource;
63
- };
63
+ } & GivenInput;
64
64
  export interface ResultSlotRegistry {
65
65
  open(spec: ResultSlotSpec): Promise<ResultSlotBinding>;
66
66
  submit(input: SubmissionInput): Promise<ResultSubmission>;
@@ -71,6 +71,8 @@ export interface ResultSlotRegistry {
71
71
  timeoutMs?: number;
72
72
  }): Promise<WaitingGrant | Rejected>;
73
73
  close(operationId: string): Promise<boolean>;
74
+ /** Its operation's deadline moved, out by a wait that did not count, or to none. */
75
+ extend(operationId: string, deadline: AbsoluteDeadline | undefined): void;
74
76
  }
75
77
  type Slot = ResultSlotSpec & {
76
78
  state: "open" | "committing" | "accepted" | "closed" | "expired" | "failed";
@@ -107,7 +109,8 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
107
109
  persistence.recordCandidate(options.runDir, slot.operationId, {
108
110
  at: new Date(now()).toISOString(),
109
111
  source: input.source,
110
- raw: input.raw,
112
+ // Flags as given, whether or not they convert, so the record shows what the agent typed.
113
+ raw: input.raw ?? JSON.stringify(input.args ?? []),
111
114
  accepted,
112
115
  ...(error === undefined ? {} : { error }),
113
116
  }),
@@ -160,13 +163,7 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
160
163
  } else slots.delete(spec.operationId);
161
164
  throw error;
162
165
  }
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
- }
166
+ if (slot.state === "open") expireAt(slot);
170
167
  return { operationId: spec.operationId, settled };
171
168
  },
172
169
 
@@ -190,7 +187,7 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
190
187
  )
191
188
  return rejected("invalid-request", "waiting timeout must be a positive safe integer");
192
189
  const at = now();
193
- const remaining = slot.deadline.unixMilliseconds - at;
190
+ const remaining = (slot.deadline?.unixMilliseconds ?? Number.POSITIVE_INFINITY) - at;
194
191
  if (remaining <= 0) {
195
192
  closeSlot(slot, "expired");
196
193
  return rejected("expired-operation");
@@ -204,7 +201,11 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
204
201
  at,
205
202
  revision: ++slot.revision,
206
203
  });
207
- return { kind: "waiting", waitUntil, deadline: slot.deadline.unixMilliseconds };
204
+ return {
205
+ kind: "waiting",
206
+ waitUntil,
207
+ ...(slot.deadline ? { deadline: slot.deadline.unixMilliseconds } : {}),
208
+ };
208
209
  },
209
210
 
210
211
  async submit(input) {
@@ -213,7 +214,7 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
213
214
  if (input.agentId !== slot.agentId) return rejected("wrong-agent");
214
215
  const unavailable = unavailableCode(slot, now());
215
216
  if (unavailable) return rejectKnown(slot, input, unavailable);
216
- const evaluated = await evaluateResult(slot, input.raw, slot.semantic ?? acceptAny);
217
+ const evaluated = await evaluateGiven(slot, input, slot.semantic ?? acceptAny);
217
218
  const admittedAt = now();
218
219
  const late = unavailableCode(slot, admittedAt);
219
220
  if (late) return rejectKnown(slot, input, late);
@@ -281,16 +282,39 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
281
282
  async close(operationId) {
282
283
  const slot = slots.get(operationId);
283
284
  if (!slot || (slot.state !== "open" && slot.state !== "committing")) return false;
284
- closeSlot(slot, now() >= slot.deadline.unixMilliseconds ? "expired" : "closed");
285
+ closeSlot(
286
+ slot,
287
+ slot.deadline && now() >= slot.deadline.unixMilliseconds ? "expired" : "closed",
288
+ );
285
289
  return true;
286
290
  },
291
+
292
+ extend(operationId, deadline) {
293
+ const slot = slots.get(operationId);
294
+ if (slot?.state !== "open") return;
295
+ slot.cancelExpiry();
296
+ slot.cancelExpiry = () => {};
297
+ if (deadline) slot.deadline = deadline;
298
+ else delete slot.deadline;
299
+ expireAt(slot);
300
+ },
287
301
  };
302
+
303
+ function expireAt(slot: Slot): void {
304
+ const { deadline } = slot;
305
+ if (!deadline) return;
306
+ if (now() >= deadline.unixMilliseconds) closeSlot(slot, "expired");
307
+ else
308
+ slot.cancelExpiry = schedule(deadline.unixMilliseconds - now(), () => {
309
+ if (slot.state === "open") closeSlot(slot, "expired");
310
+ });
311
+ }
288
312
  }
289
313
 
290
314
  function unavailableCode(slot: Slot, now: number): ResultRejectionCode | null {
291
315
  if (slot.state === "expired") return "expired-operation";
292
316
  if (slot.state !== "open") return "closed-operation";
293
- if (now >= slot.deadline.unixMilliseconds) {
317
+ if (slot.deadline && now >= slot.deadline.unixMilliseconds) {
294
318
  closeSlot(slot, "expired");
295
319
  return "expired-operation";
296
320
  }
@@ -1,37 +1,32 @@
1
1
  import { acceptAny, type SemanticCheck } from "@agentswf/contract";
2
2
  import type { CallSpec } from "@agentswf/contract/records";
3
- import { formatErrors, validate } from "@agentswf/contract/schema";
4
- import { messageOf } from "./errors";
3
+ import type { GivenInput } from "@agentswf/contract/wire";
4
+ import { checkGiven, readGiven } from "./simple-input";
5
5
 
6
6
  export type CandidateEvaluation =
7
7
  | { kind: "accepted"; value: unknown }
8
8
  | { kind: "rejected"; error: string };
9
9
 
10
- /** Validates without repairing or persisting; the result-slot transition owns acceptance. */
11
- export async function evaluateResult(
10
+ /**
11
+ * A result as `wfa` sent it, validated without repairing or persisting; the result-slot transition
12
+ * owns acceptance.
13
+ */
14
+ export async function evaluateGiven(
12
15
  call: Pick<CallSpec, "question" | "schema">,
13
- raw: string,
16
+ given: GivenInput,
14
17
  semantic: SemanticCheck = acceptAny,
15
18
  ): 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) };
19
+ const read = readGiven(call.schema, given);
20
+ if (!read.ok) {
21
+ const advice =
22
+ given.raw === undefined
23
+ ? ""
24
+ : "\nsend one JSON value in a quoted heredoc, as the request shows.";
25
+ return { kind: "rejected", error: `${read.error}${advice}` };
34
26
  }
27
+ const value = read.value;
28
+ const invalid = call.schema ? checkGiven(call.schema, given, value) : undefined;
29
+ if (invalid) return { kind: "rejected", error: invalid };
35
30
 
36
31
  const verdict = await semantic({ question: call.question, value: structuredClone(value) });
37
32
  return verdict.kind === "accepted"
@@ -41,7 +36,3 @@ export async function evaluateResult(
41
36
  error: `the value does not answer what was asked: ${verdict.reason}`,
42
37
  };
43
38
  }
44
-
45
- function expectedShape(call: Pick<CallSpec, "schema">): string {
46
- return call.schema ? JSON.stringify(call.schema) : "any JSON value";
47
- }
@@ -3,56 +3,11 @@ import { resolve } from "node:path";
3
3
  import type { JsonValue } from "@agentswf/contract/workflow";
4
4
  import { parseDuration } from "./duration";
5
5
  import { messageOf } from "./errors";
6
+ import help from "./help/run.txt" with { type: "text" };
6
7
  import { SESSION_CODE } from "./here";
7
8
  import { idProblem, runRootOf, stageNameProblem } from "./runs";
8
9
 
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");
10
+ export const usage = help.trimEnd();
56
11
 
57
12
  export type RunCommand = {
58
13
  /** Where paths typed on the command line resolve. */
@@ -65,13 +20,13 @@ export type RunCommand = {
65
20
  runRootGiven: boolean;
66
21
  workflowFile: string;
67
22
  workflowArgs: string[];
68
- /** As typed, for the attempt's record. */
69
- timeout: string;
70
- timeoutMilliseconds: number;
23
+ /** As typed, for the attempt's record; absent, the run has no deadline. */
24
+ timeout?: string;
25
+ timeoutMilliseconds?: number;
71
26
  runRoot: string;
72
27
  /** A new run's id, as `--id` gave it. */
73
28
  id?: string;
74
- /** The run `--continue` adds an attempt to. */
29
+ /** The run `--id` names, which `--continue` adds an attempt to. */
75
30
  continueId?: string;
76
31
  /** The stage the attempt starts at, reusing those before it. */
77
32
  fromStage?: string;
@@ -88,13 +43,17 @@ export type RunCommand = {
88
43
  session?: string;
89
44
  };
90
45
 
91
- export function parseCommand(argv: readonly string[], cwd: string): RunCommand {
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" {
92
48
  if (argv[0] !== "run") throw new Error("expected the run command");
93
- let timeout = DEFAULT_TIMEOUT;
94
- let timeoutMilliseconds = parseDuration(DEFAULT_TIMEOUT);
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";
52
+ let timeout: string | undefined;
53
+ let timeoutMilliseconds: number | undefined;
95
54
  let runRoot: string | undefined;
96
- let id: string | undefined;
97
- let continueId: string | undefined;
55
+ let named: string | undefined;
56
+ let continuing = false;
98
57
  let fromStage: string | undefined;
99
58
  let values: RunCommand["values"];
100
59
  let cwdGiven = false;
@@ -126,6 +85,10 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand {
126
85
  here = true;
127
86
  continue;
128
87
  }
88
+ if (option === "--continue") {
89
+ continuing = true;
90
+ continue;
91
+ }
129
92
  const value = argv[index + 1];
130
93
  if (option === "--timeout") {
131
94
  if (!value) throw new Error("--timeout needs a duration such as 30m");
@@ -141,12 +104,11 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand {
141
104
  if (!statSync(workCwd, { throwIfNoEntry: false })?.isDirectory()) {
142
105
  throw new Error(`--cwd: not a directory: ${workCwd}`);
143
106
  }
144
- } else if (option === "--id" || option === "--continue") {
145
- if (!value) throw new Error(`${option} needs a run's id`);
107
+ } else if (option === "--id") {
108
+ if (!value) throw new Error("--id needs a run's id");
146
109
  const problem = idProblem(value);
147
- if (problem) throw new Error(`${option}: ${problem}`);
148
- if (option === "--id") id = value;
149
- else continueId = value;
110
+ if (problem) throw new Error(`--id: ${problem}`);
111
+ named = value;
150
112
  } else if (option === "--from-stage") {
151
113
  if (!value) throw new Error("--from-stage needs a stage's name");
152
114
  const problem = stageNameProblem(value);
@@ -171,9 +133,10 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand {
171
133
  }
172
134
  if (!workflowFile) throw new Error("run needs one workflow file");
173
135
  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
- }
136
+ if (continuing && named === undefined)
137
+ throw new Error("--continue goes on with the run --id names");
138
+ const id = continuing ? undefined : named;
139
+ const continueId = continuing ? named : undefined;
177
140
  if (values !== undefined && fromStage === undefined) {
178
141
  throw new Error("--values goes with --from-stage: it gives the stages before it");
179
142
  }
@@ -185,8 +148,8 @@ export function parseCommand(argv: readonly string[], cwd: string): RunCommand {
185
148
  runRootGiven: runRoot !== undefined,
186
149
  workflowFile,
187
150
  workflowArgs,
188
- timeout,
189
- timeoutMilliseconds,
151
+ ...(timeout === undefined ? {} : { timeout }),
152
+ ...(timeoutMilliseconds === undefined ? {} : { timeoutMilliseconds }),
190
153
  runRoot: runRoot ?? runRootOf(workCwd),
191
154
  ...(id === undefined ? {} : { id }),
192
155
  ...(continueId === undefined ? {} : { continueId }),
@@ -26,6 +26,11 @@ export function parsePlace(
26
26
  const rest: string[] = [];
27
27
  for (let index = 0; index < argv.length; index += 1) {
28
28
  const arg = argv[index]!;
29
+ // What follows is the command's own, a message's text included.
30
+ if (arg === "--") {
31
+ rest.push(...argv.slice(index));
32
+ break;
33
+ }
29
34
  if (arg === "--json") json = true;
30
35
  else if (arg === "--cwd" || arg === "--run-root") {
31
36
  const value = argv[index + 1];
@@ -106,11 +111,17 @@ export function startedLine(
106
111
  run: Pick<RunCommand, "cwd" | "cwdGiven" | "runRoot" | "runRootGiven">,
107
112
  ): string {
108
113
  const ref = `${workflow}/${id}`;
109
- const repeat = [
114
+ return `awf: ${ref} started · watch: ${shellLine(["awf", "logs", ref, ...placeFlags(run)])}`;
115
+ }
116
+
117
+ /** The flags that find this run again from another command: the folder or run root given. */
118
+ export function placeFlags(
119
+ run: Pick<RunCommand, "cwd" | "cwdGiven" | "runRoot" | "runRootGiven">,
120
+ ): string[] {
121
+ return [
110
122
  ...(run.cwdGiven ? ["--cwd", run.cwd] : []),
111
123
  ...(run.runRootGiven ? ["--run-root", run.runRoot] : []),
112
124
  ];
113
- return `awf: ${ref} started · watch: ${shellLine(["awf", "logs", ref, ...repeat])}`;
114
125
  }
115
126
 
116
127
  /** `awf logs` from `cursor`: with `--wait` unless the page stopped short of the newest. */