@agentswf/engine 0.1.0-beta.5 → 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 (122) 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 +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  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 +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  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 +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  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 +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  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 +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
@@ -1,56 +1,50 @@
1
- import { homedir } from "node:os";
2
- import type { ParticipantResultJson } from "@agentswf/contract/records";
3
- import { type ParticipantCode, WIRE_VERSION } from "@agentswf/contract/wire";
1
+ import type { ParticipantResultJson, RefusalCode } from "@agentswf/contract/records";
2
+ import { type HolderWireAnswer, type ParticipantCode, WIRE_VERSION } from "@agentswf/contract/wire";
3
+ import { type Out, parsePlace, type ReadPlace } from "./command-line";
4
4
  import { liveCoordinatorSocket } from "./coordinator-socket";
5
- import { deprecationLine } from "./deprecations";
6
- import { deliverReply, emitToRun, eventIdProblem } from "./emit";
7
- import { printEmit } from "./emit-command";
8
5
  import { messageOf } from "./errors";
9
6
  import { isCode } from "./files";
10
7
  import help from "./help/reply.txt" with { type: "text" };
11
8
  import { ItemFold } from "./inbox-command";
12
9
  import { coordinatorDir } from "./machine";
13
- import { failed, type Out, parsePlace, type ReadPlace } from "./run-commands";
10
+ import { asksJson, printRefusal, refusalLine } from "./refusals";
11
+ import { failed } from "./run-commands";
14
12
  import { findRunByRef, RunNotFound, type RunStatus } from "./run-state";
15
- import { readAttempts, readEvents, readNumberedEvents } from "./runs";
13
+ import { RunUnreachable } from "./run-unreachable";
14
+ import { readAttempts, readNumberedEvents } from "./runs";
16
15
  import { exchange } from "./socket-exchange";
17
16
 
17
+ /** `awf reply --json`'s and `awf send --json`'s `"v"`, which their refusals carry too. */
18
+ export const HOLDER_V = 3 satisfies ParticipantResultJson["v"];
19
+
18
20
  export const replyUsage = help.trimEnd();
19
21
 
20
22
  type Options = {
21
23
  command: "reply" | "send";
22
24
  ref: string;
23
- author?: string;
24
25
  to?: string;
25
26
  text?: string;
26
- } & (
27
- | { agent: string; as?: string; id?: undefined }
28
- /**
29
- * `--event-id`: the message's own id, which a participant routed to the code takes as its event's. It
30
- * goes to no agent.
31
- */
32
- | { id: string; as: string; agent?: undefined }
33
- );
27
+ agent: string;
28
+ as?: string;
29
+ };
34
30
 
35
- export type HolderIo = Out & {
31
+ type HolderIo = Out & {
36
32
  /** Stdin, when no text is given as an argument. */
37
33
  stdin(): Promise<string>;
38
34
  /** Then there is nothing piped in to read: refused rather than waited on. */
39
35
  stdinIsTerminal?: boolean;
40
- environment?: Readonly<Record<string, string | undefined>>;
41
- home?: string;
36
+ environment: Readonly<Record<string, string | undefined>>;
37
+ home: string;
42
38
  };
43
39
 
40
+ /** A refusal of `reply` or `send`: its document with `--json`, its sentence on stderr, exit 1. */
41
+ type Refuse = (code: ParticipantCode | RefusalCode, error: string, choices?: string[]) => number;
42
+
44
43
  export async function holderCommand(
45
44
  argv: readonly string[],
46
45
  shellCwd: string,
47
46
  io: HolderIo,
48
47
  ): Promise<number> {
49
- // Help only as the first word after the command: a message's text may say anything.
50
- if (argv[1] === "-h" || argv[1] === "--help") {
51
- io.stdout(replyUsage);
52
- return 0;
53
- }
54
48
  let place: ReadPlace;
55
49
  let json: boolean;
56
50
  let options: Options;
@@ -60,34 +54,34 @@ export async function holderCommand(
60
54
  json = parsed.json;
61
55
  options = parseHolder(argv[0] as "reply" | "send", parsed.rest);
62
56
  } catch (error) {
63
- io.stderr(`awf: ${messageOf(error)}\n\n${replyUsage}`);
57
+ printRefusal(io, { json: asksJson(argv), v: HOLDER_V }, error, "usage", replyUsage);
64
58
  return 2;
65
59
  }
66
- const say = (result: ParticipantResultJson, line: string): number => {
67
- if (json) io.stdout(JSON.stringify(result));
68
- else if (result.code === "delivered") io.stdout(line);
69
- else
70
- io.stderr(
71
- `awf: ${options.command} refused (${result.code}): ${result.error}${result.choices ? ` Choose: ${result.choices.join(", ")}` : ""}`,
72
- );
73
- return result.code === "delivered" ? 0 : 1;
60
+ const delivered = (id: string, line: string): number => {
61
+ io.stdout(json ? JSON.stringify({ v: HOLDER_V, id } satisfies ParticipantResultJson) : line);
62
+ return 0;
63
+ };
64
+ const refused: Refuse = (code, error, choices) => {
65
+ if (json) io.stdout(refusalLine(HOLDER_V, code, error, choices));
66
+ io.stderr(
67
+ `awf: ${options.command} refused (${code}): ${error}${choices ? ` Choose: ${choices.join(", ")}` : ""}`,
68
+ );
69
+ return 1;
74
70
  };
75
- const refused = (code: ParticipantCode, error: string) => say({ v: 2, code, error }, "");
76
71
  const usage = (problem: string) => {
77
- io.stderr(`awf: ${problem}\n\n${replyUsage}`);
72
+ printRefusal(io, { json, v: HOLDER_V }, problem, "usage", replyUsage);
78
73
  return 2;
79
74
  };
80
75
  if (options.text !== undefined && options.text.trim() === "")
81
76
  return usage("nothing to say: the text is blank");
82
77
  if (options.text === undefined && io.stdinIsTerminal)
83
78
  return usage("nothing to say: give the text as an argument, or pipe it in");
84
- if (options.id !== undefined) return toTheCode(options, place, json, io, { refused, usage });
85
79
  try {
86
80
  let status: RunStatus;
87
81
  try {
88
- status = await findRunByRef(options.ref, place, io.home ?? homedir());
82
+ status = await findRunByRef(options.ref, place, io.home);
89
83
  } catch (error) {
90
- if (error instanceof RunNotFound) return refused("not-found", error.message);
84
+ if (error instanceof RunNotFound) return refused(error.code, error.message);
91
85
  throw error;
92
86
  }
93
87
  if (status.state !== "running")
@@ -100,7 +94,7 @@ export async function holderCommand(
100
94
  "no-participant",
101
95
  `${status.ref} has no participant${options.as ? ` ${options.as}` : ""} to reach yet; try again once it connects one`,
102
96
  );
103
- const dir = coordinatorDir(io.home ?? homedir(), io.environment ?? process.env);
97
+ const dir = coordinatorDir(io.home, io.environment);
104
98
  // Live with no socket: its attempt has not connected one yet, or is opening it.
105
99
  const socket = await liveCoordinatorSocket(dir, status);
106
100
  if (!socket) return unreachable();
@@ -119,23 +113,25 @@ export async function holderCommand(
119
113
  // A socket an attempt left when it was killed: this attempt has not opened its own yet.
120
114
  if (["ECONNREFUSED", "ENOENT", "ENOTSOCK"].some((code) => isCode(error, code)))
121
115
  return unreachable();
122
- throw error;
116
+ // Open and silent, or gone mid-answer: the run doesn't answer.
117
+ throw new RunUnreachable(`${status.ref} did not answer: ${messageOf(error)}`);
123
118
  }
124
119
  if (!answer)
125
- throw new Error(
120
+ throw new RunUnreachable(
126
121
  `${status.ref} did not answer, most likely as it runs an older awf than this one, which speaks wire version ${WIRE_VERSION}: reach it with the awf it started with, or let it end and continue it under this one.`,
127
122
  );
128
- const result = JSON.parse(answer) as ParticipantResultJson;
129
- if (result.code !== "delivered") return say(result, "");
130
- if (options.command === "reply") return say(result, `answered ${result.id}`);
123
+ const result = JSON.parse(answer) as HolderWireAnswer;
124
+ if (result.code !== "delivered") return refused(result.code, result.error, result.choices);
125
+ if (options.command === "reply") return delivered(result.id, `answered ${result.id}`);
131
126
  // Held for an agent awaiting this participant's answer: it comes with that answer.
132
- const awaited = await awaitedBy(status, options.agent, options.as);
133
- return say(
134
- result,
127
+ // Only the line's wording: the message is delivered, whatever reading this finds.
128
+ const awaited = await awaitedBy(status, options.agent, options.as).catch(() => undefined);
129
+ return delivered(
130
+ result.id,
135
131
  `delivered as ${result.id}${awaited ? `; ${options.agent} still awaits the reply to ${awaited}` : ""}`,
136
132
  );
137
133
  } catch (error) {
138
- return failed(error, place, io, messageOf(error));
134
+ return failed(error, place, io, messageOf(error), { json, v: HOLDER_V });
139
135
  }
140
136
  }
141
137
 
@@ -143,8 +139,6 @@ function parseHolder(command: "reply" | "send", rest: readonly string[]): Option
143
139
  const words: string[] = [];
144
140
  let to: string | undefined;
145
141
  let as: string | undefined;
146
- let id: string | undefined;
147
- let author: string | undefined;
148
142
  for (let index = 0; index < rest.length; index += 1) {
149
143
  const arg = rest[index]!;
150
144
  const value = () => {
@@ -155,34 +149,19 @@ function parseHolder(command: "reply" | "send", rest: readonly string[]): Option
155
149
  };
156
150
  if (arg === "--to" && command === "reply") to = value();
157
151
  else if (arg === "--as") as = value();
158
- else if (arg === "--event-id" && command === "send") id = value();
159
- else if (arg === "--author" && command === "send") author = value();
152
+ else if ((arg === "--event-id" || arg === "--author") && command === "send")
153
+ throw new Error(
154
+ `send ${arg} was removed: a message on a participant to the run's code is awf emit {run} {participant}:reply '{"text":…,"author":…}' --event-id {id}, with {participant}:reply declared in the workflow's events`,
155
+ );
160
156
  else if (arg === "--") {
161
157
  words.push(...rest.slice(index + 1));
162
158
  break;
163
159
  } else if (arg.startsWith("--")) throw new Error(`unknown option: ${arg}`);
164
160
  else words.push(arg);
165
161
  }
166
- // With --event-id, the participant's reply goes to the run's code: there is no agent to name.
167
- let target: { agent: string; as?: string } | { id: string; as: string };
168
- let ref: string | undefined;
169
- let text: string | undefined;
170
- let extra: string[];
171
- if (id !== undefined) {
172
- [ref, text, ...extra] = words;
173
- if (!ref) throw new Error("send needs a run, <workflow>/<id> or <id>");
174
- if (!as) throw new Error("--event-id sends as a participant routed to the code: --as names it");
175
- const problem = eventIdProblem(id);
176
- if (problem) throw new Error(`--event-id ${id}: ${problem}`);
177
- target = { id, as };
178
- } else {
179
- let agent: string | undefined;
180
- [ref, agent, text, ...extra] = words;
181
- if (author !== undefined) throw new Error("--author goes with --event-id");
182
- if (!ref || !agent?.trim())
183
- throw new Error(`${command} needs a run, <workflow>/<id> or <id>, and an agent`);
184
- target = { agent, ...(as === undefined ? {} : { as }) };
185
- }
162
+ const [ref, agent, text, ...extra] = words;
163
+ if (!ref || !agent?.trim())
164
+ throw new Error(`${command} needs a run, <workflow>/<id> or <id>, and an agent`);
186
165
  if (extra.length > 0) throw new Error("the text is one argument: quote it, or pipe it in");
187
166
  if (as !== undefined && as.trim() === "") throw new Error("--as needs a participant's key");
188
167
  if (to !== undefined && !/^\d+\.\d+$/.test(to))
@@ -190,10 +169,10 @@ function parseHolder(command: "reply" | "send", rest: readonly string[]): Option
190
169
  return {
191
170
  command,
192
171
  ref,
193
- ...target,
172
+ agent,
173
+ ...(as === undefined ? {} : { as }),
194
174
  ...(to === undefined ? {} : { to }),
195
175
  ...(text === undefined ? {} : { text }),
196
- ...(author === undefined ? {} : { author }),
197
176
  };
198
177
  }
199
178
 
@@ -221,64 +200,3 @@ async function awaitedBy(
221
200
  );
222
201
  return open.at(-1);
223
202
  }
224
-
225
- /**
226
- * `awf send {ref} --as {participant} --event-id {message id} {text}`, deprecated: `awf emit {ref}
227
- * {participant}:reply`. It keeps its own refusals and exit codes for the beta: a participant the
228
- * run never connected is refused; one the run recorded as routed to the code, declared or by the
229
- * deprecated `to: "code"`, gets the reply as before, the workflow's file not loaded; any other is
230
- * `awf emit`'s to check against the definition.
231
- */
232
- async function toTheCode(
233
- options: Extract<Options, { id: string }>,
234
- place: ReadPlace,
235
- json: boolean,
236
- io: HolderIo,
237
- say: { refused(code: ParticipantCode, error: string): number; usage(problem: string): number },
238
- ): Promise<number> {
239
- const home = io.home ?? homedir();
240
- const environment = io.environment ?? process.env;
241
- const { id, as } = options;
242
- const { refused } = say;
243
- io.stderr(deprecationLine("send-event-id"));
244
- let found: RunStatus;
245
- try {
246
- found = await findRunByRef(options.ref, place, home);
247
- } catch (error) {
248
- if (error instanceof RunNotFound) return refused("not-found", error.message);
249
- return failed(error, place, io, messageOf(error));
250
- }
251
- const connected = (await readEvents(found.run.dir)).filter(
252
- (line) => line.kind === "participant-connected" && line.participant === as,
253
- );
254
- // As it always did: only a participant the run has connected; `awf emit` doesn't ask.
255
- if (!found.run.record.events || connected.length === 0)
256
- return refused(
257
- "no-participant",
258
- `${found.ref} has no participant ${as} connected to its code; --event-id is for one`,
259
- );
260
- const text = options.text ?? (await io.stdin());
261
- if (text.trim() === "") return say.usage("nothing to say: stdin was empty");
262
- const data = { text, ...(options.author === undefined ? {} : { author: options.author }) };
263
- const context = { home, environment };
264
- // Recorded as routed to the code, by either form: delivered as before, the file not loaded.
265
- if (connected.some((line) => line.kind === "participant-connected" && line.to)) {
266
- try {
267
- return printEmit(await deliverReply(found, as, { data, id }, context), json, io);
268
- } catch (error) {
269
- return failed(error, place, io, messageOf(error));
270
- }
271
- }
272
- // Connected to agents only, which send has always refused: whatever stops `awf emit`, a file
273
- // that won't load included, is that refusal, with its reason.
274
- let result: Awaited<ReturnType<typeof emitToRun>>;
275
- try {
276
- result = await emitToRun(found, { name: `${as}:reply`, data, id }, context);
277
- } catch (error) {
278
- return refused(
279
- "no-participant",
280
- `${found.ref} has no participant ${as} whose messages go to its code: ${messageOf(error)}`,
281
- );
282
- }
283
- return printEmit(result, json, io);
284
- }
@@ -0,0 +1,28 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { EventLine } from "@agentswf/contract/records";
4
+ import { appendLine, endTornLine, readLines } from "./jsonl";
5
+
6
+ /** `in.jsonl`: the events senders gave the run, appended under the run's lock. */
7
+ export function inFile(runDir: string): string {
8
+ return join(runDir, "in.jsonl");
9
+ }
10
+
11
+ /** The run's events in the order written, less a line a crash tore. */
12
+ export async function readIn(runDir: string): Promise<EventLine[]> {
13
+ return readLines<EventLine>(inFile(runDir));
14
+ }
15
+
16
+ /** Appends an event; the caller holds the run's lock. */
17
+ export async function appendIn(runDir: string, line: EventLine): Promise<void> {
18
+ await endTornLine(inFile(runDir));
19
+ await appendLine(inFile(runDir), JSON.stringify(line));
20
+ }
21
+
22
+ /** `in.jsonl`'s size, which a waiting run polls: a sender's append changes it. */
23
+ export async function inSize(runDir: string): Promise<number> {
24
+ return stat(inFile(runDir)).then(
25
+ (stats) => stats.size,
26
+ () => 0,
27
+ );
28
+ }
@@ -2,30 +2,27 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import type {
4
4
  AttemptRecord,
5
- InboxErrorJson,
6
5
  InboxExpiry,
7
6
  InboxItemJson,
7
+ RefusalCode,
8
8
  RequestEnd,
9
9
  RunLogLine,
10
10
  } from "@agentswf/contract/records";
11
11
  import type { InboxCode } from "@agentswf/contract/wire";
12
12
  import type { JsonObject } from "@agentswf/contract/workflow";
13
13
  import { maskCredentials } from "@agentswf/harness";
14
- import { formatDuration, parseWait } from "./duration";
14
+ import { duration } from "./accounting/format";
15
+ import { command, type Out, parsePlace, type ReadPlace, shellLine } from "./command-line";
16
+ import { parseWait } from "./duration";
15
17
  import { messageOf } from "./errors";
16
18
  import { exists } from "./files";
17
19
  import help from "./help/inbox.txt" with { type: "text" };
18
- import { readMailbox } from "./mailbox";
19
- import {
20
- clockTime,
21
- command,
22
- failed,
23
- type Out,
24
- parsePlace,
25
- type ReadPlace,
26
- shellLine,
27
- } from "./run-commands";
20
+ import { asksJson, printRefusal } from "./refusals";
21
+ import { failed, MAX_WAIT_SECONDS, POLL_MS } from "./run-commands";
22
+ import { readRunLines } from "./run-lines";
23
+ import { waitLabels as labelsOf } from "./run-policy/waits";
28
24
  import { findRunByRef, listRuns, RunNotFound, statusOf } from "./run-state";
25
+ import { clockTime } from "./run-text";
29
26
  import {
30
27
  attemptsToken,
31
28
  type LogMark,
@@ -35,10 +32,10 @@ import {
35
32
  readEventsFrom,
36
33
  } from "./runs";
37
34
 
38
- export const inboxUsage = help.trimEnd();
35
+ /** `awf inbox --json`'s `"v"`, which its refusals carry too. */
36
+ export const INBOX_V = 2 satisfies InboxItemJson["v"];
39
37
 
40
- const MAX_WAIT_SECONDS = 300;
41
- const POLL_MS = 1_000;
38
+ export const inboxUsage = help.trimEnd();
42
39
 
43
40
  /** The clock and the pause between polls, which tests drive. */
44
41
  export type InboxClock = { now(): number; sleep(ms: number): Promise<void> };
@@ -66,7 +63,7 @@ type CursorState = { f: string; r: Record<string, Position> };
66
63
 
67
64
  class Refused extends Error {
68
65
  constructor(
69
- readonly code: InboxCode,
66
+ readonly code: InboxCode | RefusalCode,
70
67
  message: string,
71
68
  ) {
72
69
  super(message);
@@ -89,10 +86,6 @@ export async function inboxCommand(
89
86
  signal?: AbortSignal;
90
87
  } = {},
91
88
  ): Promise<number> {
92
- if (argv.includes("-h") || argv.includes("--help")) {
93
- out.stdout(inboxUsage);
94
- return 0;
95
- }
96
89
  let place: ReadPlace;
97
90
  let json: boolean;
98
91
  let options: Options;
@@ -100,14 +93,13 @@ export async function inboxCommand(
100
93
  const parsed = parsePlace(argv, shellCwd);
101
94
  place = parsed.place;
102
95
  json = parsed.json;
103
- options = parseInbox(parsed.rest, (line) => out.stderr(line));
96
+ options = parseInbox(parsed.rest);
104
97
  } catch (error) {
105
- out.stderr(`awf: ${messageOf(error)}\n\n${inboxUsage}`);
98
+ printRefusal(out, { json: asksJson(argv), v: INBOX_V }, error, "usage", inboxUsage);
106
99
  return 2;
107
100
  }
108
- const refuse = (code: InboxCode, message: string) => {
109
- if (json) out.stdout(JSON.stringify({ v: 1, code, error: message } satisfies InboxErrorJson));
110
- else out.stderr(`awf: ${message}`);
101
+ const refuse = (code: InboxCode | RefusalCode, message: string) => {
102
+ printRefusal(out, { json, v: INBOX_V }, message, code);
111
103
  return 1;
112
104
  };
113
105
  try {
@@ -115,18 +107,19 @@ export async function inboxCommand(
115
107
  ? `all|${options.workflow ?? ""}|${options.as ?? ""}`
116
108
  : `run|${options.ref}|${options.as ?? ""}`;
117
109
  let state: CursorState = { f: filter, r: {} };
118
- // Reading every run here, none is missing or unconnected, so a cursor is checked first, sparing a read.
119
110
  let checked = options.after === undefined;
120
- if (!checked && options.all) {
111
+ const takeCursor = () => {
121
112
  const given = decodeCursor(options.after!);
122
113
  if (!given || given.f !== filter)
123
114
  throw new Refused(
124
- "bad-cursor",
115
+ "cursor",
125
116
  `cursor ${options.after} was made with another run or filter; read again without --after`,
126
117
  );
127
118
  state = given;
128
119
  checked = true;
129
- }
120
+ };
121
+ // Reading every run here, none is missing or unconnected, so a cursor is checked first, sparing a read.
122
+ if (!checked && options.all) takeCursor();
130
123
  const deadline = clock.now() + options.waitSeconds * 1000;
131
124
  // An event run lives in its project, found through ~/.awf/projects from anywhere (Q15).
132
125
  const found =
@@ -137,14 +130,7 @@ export async function inboxCommand(
137
130
  const runs = await readInbox(options, state, watch, one);
138
131
  // After a run that is not there, or never connected one: those come first.
139
132
  if (!checked) {
140
- const given = decodeCursor(options.after!);
141
- if (!given || given.f !== filter)
142
- throw new Refused(
143
- "bad-cursor",
144
- `cursor ${options.after} was made with another run or filter; read again without --after`,
145
- );
146
- state = given;
147
- checked = true;
133
+ takeCursor();
148
134
  continue;
149
135
  }
150
136
  if (options.open) {
@@ -165,7 +151,7 @@ export async function inboxCommand(
165
151
  state = { f: filter, r: { ...state.r, [ref]: item.position } };
166
152
  const { position: _position, ...shown } = item;
167
153
  const published = {
168
- v: 1,
154
+ v: 2,
169
155
  ref,
170
156
  ...shown,
171
157
  cursor: encodeCursor(state),
@@ -178,13 +164,12 @@ export async function inboxCommand(
178
164
  const words = options.all
179
165
  ? ["awf", "inbox", ...(options.workflow ? ["--workflow", options.workflow] : [])]
180
166
  : ["awf", "inbox", options.ref!];
181
- const again = command(
182
- place,
183
- ...words,
167
+ const again = [
168
+ ...command(place, ...words),
184
169
  ...(options.as ? ["--as", options.as] : []),
185
170
  "--after",
186
171
  encodeCursor(state),
187
- );
172
+ ];
188
173
  out.stdout([...lines, `next: ${shellLine(again)}`].join("\n"));
189
174
  }
190
175
  return 0;
@@ -193,12 +178,12 @@ export async function inboxCommand(
193
178
  }
194
179
  } catch (error) {
195
180
  if (error instanceof Refused) return refuse(error.code, error.message);
196
- if (error instanceof RunNotFound) return refuse("not-found", error.message);
197
- return failed(error, place, out, messageOf(error));
181
+ if (error instanceof RunNotFound) return refuse(error.code, error.message);
182
+ return failed(error, place, out, messageOf(error), { json, v: INBOX_V });
198
183
  }
199
184
  }
200
185
 
201
- function parseInbox(rest: readonly string[], deprecated: (line: string) => void): Options {
186
+ function parseInbox(rest: readonly string[]): Options {
202
187
  const options: Options = { all: false, open: false, waitSeconds: 0 };
203
188
  for (let index = 0; index < rest.length; index += 1) {
204
189
  const arg = rest[index]!;
@@ -212,8 +197,7 @@ function parseInbox(rest: readonly string[], deprecated: (line: string) => void)
212
197
  else if (arg === "--workflow") options.workflow = value();
213
198
  else if (arg === "--as") options.as = value();
214
199
  else if (arg === "--after") options.after = value();
215
- else if (arg === "--wait")
216
- options.waitSeconds = parseWait(value(), MAX_WAIT_SECONDS, deprecated);
200
+ else if (arg === "--wait") options.waitSeconds = parseWait(value(), MAX_WAIT_SECONDS);
217
201
  else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
218
202
  else if (options.ref === undefined) options.ref = arg;
219
203
  else throw new Error(`one run at a time: ${options.ref} and ${arg}`);
@@ -268,7 +252,7 @@ type Watched = {
268
252
  /** The attempt files when `attempts` was read; undefined before the first read. */
269
253
  token?: string;
270
254
  attempts: AttemptRecord[];
271
- /** Each attempt's last wait label, from the mailbox, read with `attempts`. */
255
+ /** Each attempt's last wait label, from `run.jsonl`, read with `attempts`. */
272
256
  labels: ReadonlyMap<number, string>;
273
257
  fold: ItemFold;
274
258
  mark: LogMark;
@@ -628,7 +612,7 @@ function openQuestions(runs: readonly RunItems[]): InboxItemJson[] {
628
612
  });
629
613
  return merged(open).map(({ ref, item }) => {
630
614
  const { position: _position, ...shown } = item;
631
- return { v: 1, ref, ...shown } as InboxItemJson;
615
+ return { v: INBOX_V, ref, ...shown } as InboxItemJson;
632
616
  });
633
617
  }
634
618
 
@@ -655,7 +639,7 @@ function textOf(item: InboxItemJson, all: boolean): string {
655
639
  ? `${item.agent} → ${item.participant}`
656
640
  : `${item.participant} → ${item.agent}`;
657
641
  return item.state === "opened"
658
- ? `${head}route ${arrow} opened “${item.purpose}”${item.timeoutMs === undefined ? "" : ` ${formatDuration(item.timeoutMs)}`}`
642
+ ? `${head}route ${arrow} opened “${item.purpose}”${item.timeoutMs === undefined ? "" : ` ${duration(item.timeoutMs)}`}`
659
643
  : `${head}route ${arrow} closed ${item.reason}`;
660
644
  }
661
645
  case "message": {
@@ -702,11 +686,13 @@ function decodeCursor(text: string): CursorState | undefined {
702
686
  }
703
687
  }
704
688
 
705
- /** Each attempt's last wait label: what a parked attempt is waiting for. */
689
+ /**
690
+ * Each attempt's last wait label: what a parked attempt is waiting for. A `run.jsonl` that can't be
691
+ * read gives none: the conversation is the log's.
692
+ */
706
693
  async function waitLabels(runDir: string): Promise<Map<number, string>> {
707
- const labels = new Map<number, string>();
708
- for (const record of await readMailbox(runDir))
709
- if (record.kind === "wait" && record.label !== undefined)
710
- labels.set(record.attempt, record.label);
711
- return labels;
694
+ return readRunLines(runDir).then(
695
+ ({ lines }) => labelsOf(lines),
696
+ () => new Map(),
697
+ );
712
698
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // A finished run is re-priced from its `output.json` with another table.
1
+ // A finished run is priced again from its operations' `spend.json`, or `out.json`'s `usage`, with another table.
2
2
  export { describeAccounting } from "./accounting/format";
3
3
  export { type ModelRate, type PriceTable, PUBLISHED_PRICES } from "./accounting/prices";
4
4
  export { summarizeRun } from "./accounting/summary";
@@ -1,13 +1,14 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import manifest from "../package.json" with { type: "json" };
4
+ import { type Out, shellWord } from "./command-line";
4
5
  import { messageOf } from "./errors";
5
6
  import help from "./help/init.txt" with { type: "text" };
6
- import type { Out } from "./run-commands";
7
+ import { NO_JSON, printRefusal } from "./refusals";
7
8
 
8
9
  export const initUsage = help.trimEnd();
9
10
 
10
- export type InitCommand = { dir: string; install: boolean };
11
+ type InitCommand = { dir: string; install: boolean };
11
12
 
12
13
  /** `awf init`: a folder set up for writing workflows. */
13
14
  export async function initCommand(
@@ -15,17 +16,13 @@ export async function initCommand(
15
16
  cwd: string,
16
17
  io: Out & { install?: (dir: string) => Promise<number> },
17
18
  ): Promise<number> {
18
- let command: InitCommand | "help";
19
+ let command: InitCommand;
19
20
  try {
20
21
  command = parseInitCommand(argv, cwd);
21
22
  } catch (error) {
22
- io.stderr(`awf: ${messageOf(error)}\n\n${initUsage}`);
23
+ printRefusal(io, NO_JSON, error, "usage", initUsage);
23
24
  return 2;
24
25
  }
25
- if (command === "help") {
26
- io.stdout(initUsage);
27
- return 0;
28
- }
29
26
  try {
30
27
  return await runInit(command, cwd, io, io.install);
31
28
  } catch (error) {
@@ -34,11 +31,10 @@ export async function initCommand(
34
31
  }
35
32
  }
36
33
 
37
- export function parseInitCommand(argv: readonly string[], cwd: string): InitCommand | "help" {
34
+ export function parseInitCommand(argv: readonly string[], cwd: string): InitCommand {
38
35
  let dir: string | undefined;
39
36
  let install = true;
40
37
  for (const option of argv) {
41
- if (option === "-h" || option === "--help") return "help";
42
38
  if (option === "--no-install") install = false;
43
39
  else if (option.startsWith("-")) throw new Error(`unknown option: ${option}`);
44
40
  else if (dir !== undefined) throw new Error(`one directory only, not ${dir} and ${option}`);
@@ -86,7 +82,7 @@ function targetOf(dir: string, path: string): Target {
86
82
  * Writes what `awf init` writes into `dir`, and says what it did to each file. Everything that
87
83
  * would stop it, such as a package.json that isn't a JSON object, is found before it writes.
88
84
  */
89
- export function initFolder(dir: string): Change[] {
85
+ function initFolder(dir: string): Change[] {
90
86
  for (const parent of new Set(COPIED.map(dirname))) {
91
87
  const stat = lstatSync(join(dir, parent), { throwIfNoEntry: false });
92
88
  if (stat !== undefined && !stat.isDirectory()) {
@@ -213,13 +209,8 @@ function indentOf(text: string): string | number {
213
209
  return /^\{\s*\r?\n([ \t]+)"/.exec(text)?.[1] ?? 2;
214
210
  }
215
211
 
216
- /** As a shell reads it: bare when that's safe, otherwise single-quoted. */
217
- function shellWord(word: string): string {
218
- return /^[\w./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
219
- }
220
-
221
212
  /** `awf init`: writes the folder, installs unless told not to, and says what to run next. */
222
- export async function runInit(
213
+ async function runInit(
223
214
  command: InitCommand,
224
215
  cwd: string,
225
216
  output: { stdout(text: string): void; stderr(text: string): void },
@@ -1,7 +1,8 @@
1
+ import { typed } from "agentswf/typebox";
1
2
  import { defineWorkflow } from "agentswf/workflow";
2
3
  import Type from "typebox";
3
4
 
4
- export const ANSWER = Type.Object({ answer: Type.Integer() }, { additionalProperties: false });
5
+ export const ANSWER = typed(Type.Object({ answer: Type.Integer() }, { additionalProperties: false }));
5
6
 
6
7
  export default defineWorkflow({
7
8
  meta: { name: "hello", description: "Ask one agent a question with a known answer." },
@@ -12,7 +13,7 @@ export default defineWorkflow({
12
13
  key: "hello",
13
14
  runtime: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
14
15
  });
15
- const { outcome } = await agent.run<Type.Static<typeof ANSWER>>({
16
+ const { outcome } = await agent.run({
16
17
  prompt: "What is 17 multiplied by 23? Work it out yourself; do not run any code.",
17
18
  schema: ANSWER,
18
19
  timeout: "3m",