@agentswf/engine 0.1.0-beta.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
@@ -1,10 +1,11 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
+ import { isCode, writeJson } from "./files";
4
5
  import { machinePaths } from "./machine";
5
6
  import { type ProcessProbe, processStart, sameProcess } from "./runs";
6
7
 
7
- export const WORKSPACE_MARK_VERSION = 2;
8
+ const WORKSPACE_MARK_VERSION = 2;
8
9
 
9
10
  /**
10
11
  * A run's claim on its workspace in a shared Herdr session: the run's process, and when that
@@ -73,7 +74,7 @@ export async function markWorkspace(
73
74
  };
74
75
  try {
75
76
  await mkdir(dir, { recursive: true });
76
- await writeWhole(file, mark);
77
+ await writeJson(file, mark);
77
78
  } catch {
78
79
  return unmarked;
79
80
  }
@@ -85,7 +86,7 @@ export async function markWorkspace(
85
86
  const write = (next: WorkspaceMark) => {
86
87
  if (released) return writing;
87
88
  current = next;
88
- writing = writing.then(() => writeWhole(file, current).catch(() => undefined));
89
+ writing = writing.then(() => writeJson(file, current).catch(() => undefined));
89
90
  return writing;
90
91
  };
91
92
  return {
@@ -110,17 +111,6 @@ export async function markWorkspace(
110
111
  };
111
112
  }
112
113
 
113
- /** Renamed into place, so a reader never sees half a mark and skips a live run. */
114
- async function writeWhole(file: string, mark: WorkspaceMark): Promise<void> {
115
- const temporary = `${file}.${randomBytes(4).toString("hex")}.tmp`;
116
- try {
117
- await writeFile(temporary, JSON.stringify(mark));
118
- await rename(temporary, file);
119
- } finally {
120
- await rm(temporary, { force: true }).catch(() => undefined);
121
- }
122
- }
123
-
124
114
  export async function readMarks(
125
115
  home: string,
126
116
  session: string,
@@ -163,7 +153,7 @@ export async function readAllMarks(
163
153
 
164
154
  /** Rewrites a mark read from `file`, as one sweep leaves it. */
165
155
  export function rewriteMark(file: string, mark: WorkspaceMark): Promise<void> {
166
- return writeWhole(file, mark).catch(() => undefined);
156
+ return writeJson(file, mark).catch(() => undefined);
167
157
  }
168
158
 
169
159
  export function removeMark(file: string): Promise<void> {
@@ -189,6 +179,6 @@ function processExists(pid: number): boolean {
189
179
  process.kill(pid, 0);
190
180
  return true;
191
181
  } catch (error) {
192
- return (error as NodeJS.ErrnoException).code !== "ESRCH";
182
+ return !isCode(error, "ESRCH");
193
183
  }
194
184
  }
@@ -1,55 +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 { deliver, eventIdProblem } from "./emit";
6
- import { printEmit } from "./emit-command";
7
5
  import { messageOf } from "./errors";
8
6
  import { isCode } from "./files";
9
7
  import help from "./help/reply.txt" with { type: "text" };
10
8
  import { ItemFold } from "./inbox-command";
11
9
  import { coordinatorDir } from "./machine";
12
- import { failed, type Out, parsePlace, type ReadPlace } from "./run-commands";
10
+ import { asksJson, printRefusal, refusalLine } from "./refusals";
11
+ import { failed } from "./run-commands";
13
12
  import { findRunByRef, RunNotFound, type RunStatus } from "./run-state";
14
- import { readAttempts, readEvents, readNumberedEvents } from "./runs";
13
+ import { RunUnreachable } from "./run-unreachable";
14
+ import { readAttempts, readNumberedEvents } from "./runs";
15
15
  import { exchange } from "./socket-exchange";
16
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
+
17
20
  export const replyUsage = help.trimEnd();
18
21
 
19
22
  type Options = {
20
23
  command: "reply" | "send";
21
24
  ref: string;
22
- author?: string;
23
25
  to?: string;
24
26
  text?: string;
25
- } & (
26
- | { agent: string; as?: string; id?: undefined }
27
- /**
28
- * `--event-id`: the message's own id, which a participant routed to the code takes as its event's. It
29
- * goes to no agent.
30
- */
31
- | { id: string; as: string; agent?: undefined }
32
- );
27
+ agent: string;
28
+ as?: string;
29
+ };
33
30
 
34
- export type HolderIo = Out & {
31
+ type HolderIo = Out & {
35
32
  /** Stdin, when no text is given as an argument. */
36
33
  stdin(): Promise<string>;
37
34
  /** Then there is nothing piped in to read: refused rather than waited on. */
38
35
  stdinIsTerminal?: boolean;
39
- environment?: Readonly<Record<string, string | undefined>>;
40
- home?: string;
36
+ environment: Readonly<Record<string, string | undefined>>;
37
+ home: string;
41
38
  };
42
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
+
43
43
  export async function holderCommand(
44
44
  argv: readonly string[],
45
45
  shellCwd: string,
46
46
  io: HolderIo,
47
47
  ): Promise<number> {
48
- // Help only as the first word after the command: a message's text may say anything.
49
- if (argv[1] === "-h" || argv[1] === "--help") {
50
- io.stdout(replyUsage);
51
- return 0;
52
- }
53
48
  let place: ReadPlace;
54
49
  let json: boolean;
55
50
  let options: Options;
@@ -59,34 +54,34 @@ export async function holderCommand(
59
54
  json = parsed.json;
60
55
  options = parseHolder(argv[0] as "reply" | "send", parsed.rest);
61
56
  } catch (error) {
62
- io.stderr(`awf: ${messageOf(error)}\n\n${replyUsage}`);
57
+ printRefusal(io, { json: asksJson(argv), v: HOLDER_V }, error, "usage", replyUsage);
63
58
  return 2;
64
59
  }
65
- const say = (result: ParticipantResultJson, line: string): number => {
66
- if (json) io.stdout(JSON.stringify(result));
67
- else if (result.code === "delivered") io.stdout(line);
68
- else
69
- io.stderr(
70
- `awf: ${options.command} refused (${result.code}): ${result.error}${result.choices ? ` Choose: ${result.choices.join(", ")}` : ""}`,
71
- );
72
- 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;
73
70
  };
74
- const refused = (code: ParticipantCode, error: string) => say({ v: 1, code, error }, "");
75
71
  const usage = (problem: string) => {
76
- io.stderr(`awf: ${problem}\n\n${replyUsage}`);
72
+ printRefusal(io, { json, v: HOLDER_V }, problem, "usage", replyUsage);
77
73
  return 2;
78
74
  };
79
75
  if (options.text !== undefined && options.text.trim() === "")
80
76
  return usage("nothing to say: the text is blank");
81
77
  if (options.text === undefined && io.stdinIsTerminal)
82
78
  return usage("nothing to say: give the text as an argument, or pipe it in");
83
- if (options.id !== undefined) return toTheCode(options, place, json, io, { refused, usage });
84
79
  try {
85
80
  let status: RunStatus;
86
81
  try {
87
- status = await findRunByRef(options.ref, place, io.home ?? homedir());
82
+ status = await findRunByRef(options.ref, place, io.home);
88
83
  } catch (error) {
89
- if (error instanceof RunNotFound) return refused("not-found", error.message);
84
+ if (error instanceof RunNotFound) return refused(error.code, error.message);
90
85
  throw error;
91
86
  }
92
87
  if (status.state !== "running")
@@ -99,7 +94,7 @@ export async function holderCommand(
99
94
  "no-participant",
100
95
  `${status.ref} has no participant${options.as ? ` ${options.as}` : ""} to reach yet; try again once it connects one`,
101
96
  );
102
- const dir = coordinatorDir(io.home ?? homedir(), io.environment ?? process.env);
97
+ const dir = coordinatorDir(io.home, io.environment);
103
98
  // Live with no socket: its attempt has not connected one yet, or is opening it.
104
99
  const socket = await liveCoordinatorSocket(dir, status);
105
100
  if (!socket) return unreachable();
@@ -118,23 +113,25 @@ export async function holderCommand(
118
113
  // A socket an attempt left when it was killed: this attempt has not opened its own yet.
119
114
  if (["ECONNREFUSED", "ENOENT", "ENOTSOCK"].some((code) => isCode(error, code)))
120
115
  return unreachable();
121
- 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)}`);
122
118
  }
123
119
  if (!answer)
124
- throw new Error(
125
- `${status.ref} did not answer the request; it may run another version of awf`,
120
+ throw new RunUnreachable(
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.`,
126
122
  );
127
- const result = JSON.parse(answer) as ParticipantResultJson;
128
- if (result.code !== "delivered") return say(result, "");
129
- 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}`);
130
126
  // Held for an agent awaiting this participant's answer: it comes with that answer.
131
- const awaited = await awaitedBy(status, options.agent, options.as);
132
- return say(
133
- 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,
134
131
  `delivered as ${result.id}${awaited ? `; ${options.agent} still awaits the reply to ${awaited}` : ""}`,
135
132
  );
136
133
  } catch (error) {
137
- return failed(error, place, io, messageOf(error));
134
+ return failed(error, place, io, messageOf(error), { json, v: HOLDER_V });
138
135
  }
139
136
  }
140
137
 
@@ -142,8 +139,6 @@ function parseHolder(command: "reply" | "send", rest: readonly string[]): Option
142
139
  const words: string[] = [];
143
140
  let to: string | undefined;
144
141
  let as: string | undefined;
145
- let id: string | undefined;
146
- let author: string | undefined;
147
142
  for (let index = 0; index < rest.length; index += 1) {
148
143
  const arg = rest[index]!;
149
144
  const value = () => {
@@ -154,44 +149,30 @@ function parseHolder(command: "reply" | "send", rest: readonly string[]): Option
154
149
  };
155
150
  if (arg === "--to" && command === "reply") to = value();
156
151
  else if (arg === "--as") as = value();
157
- else if (arg === "--event-id" && command === "send") id = value();
158
- 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
+ );
159
156
  else if (arg === "--") {
160
157
  words.push(...rest.slice(index + 1));
161
158
  break;
162
159
  } else if (arg.startsWith("--")) throw new Error(`unknown option: ${arg}`);
163
160
  else words.push(arg);
164
161
  }
165
- // With --event-id, the participant's reply goes to the run's code: there is no agent to name.
166
- let target: { agent: string; as?: string } | { id: string; as: string };
167
- let ref: string | undefined;
168
- let text: string | undefined;
169
- let extra: string[];
170
- if (id !== undefined) {
171
- [ref, text, ...extra] = words;
172
- if (!ref) throw new Error("send needs a run, <workflow>/<id> or <id>");
173
- if (!as) throw new Error("--event-id sends as a participant routed to the code: --as names it");
174
- const problem = eventIdProblem(id);
175
- if (problem) throw new Error(`--event-id ${id}: ${problem}`);
176
- target = { id, as };
177
- } else {
178
- let agent: string | undefined;
179
- [ref, agent, text, ...extra] = words;
180
- if (author !== undefined) throw new Error("--author goes with --event-id");
181
- if (!ref || !agent)
182
- throw new Error(`${command} needs a run, <workflow>/<id> or <id>, and an agent`);
183
- target = { agent, ...(as === undefined ? {} : { as }) };
184
- }
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`);
185
165
  if (extra.length > 0) throw new Error("the text is one argument: quote it, or pipe it in");
166
+ if (as !== undefined && as.trim() === "") throw new Error("--as needs a participant's key");
186
167
  if (to !== undefined && !/^\d+\.\d+$/.test(to))
187
168
  throw new Error(`--to takes a question's id as awf inbox prints it, such as 1.5, not ${to}`);
188
169
  return {
189
170
  command,
190
171
  ref,
191
- ...target,
172
+ agent,
173
+ ...(as === undefined ? {} : { as }),
192
174
  ...(to === undefined ? {} : { to }),
193
175
  ...(text === undefined ? {} : { text }),
194
- ...(author === undefined ? {} : { author }),
195
176
  };
196
177
  }
197
178
 
@@ -219,61 +200,3 @@ async function awaitedBy(
219
200
  );
220
201
  return open.at(-1);
221
202
  }
222
-
223
- /**
224
- * `awf send {ref} --as {participant} --event-id {message id} {text}`: a reply on a participant the run
225
- * routed to the code, delivered through the one way in as `participant:reply`, so it continues a
226
- * parked run. The run is found here, or in a project awf started an event run in (Q15).
227
- */
228
- async function toTheCode(
229
- options: Extract<Options, { id: string }>,
230
- place: ReadPlace,
231
- json: boolean,
232
- io: HolderIo,
233
- say: { refused(code: ParticipantCode, error: string): number; usage(problem: string): number },
234
- ): Promise<number> {
235
- const home = io.home ?? homedir();
236
- const environment = io.environment ?? process.env;
237
- const { id, as } = options;
238
- const { refused } = say;
239
- let found: RunStatus;
240
- try {
241
- found = await findRunByRef(options.ref, place, home);
242
- } catch (error) {
243
- if (error instanceof RunNotFound) return refused("not-found", error.message);
244
- return failed(error, place, io, messageOf(error));
245
- }
246
- const { run } = found;
247
- const routed = (await readEvents(run.dir)).some(
248
- (line) =>
249
- line.kind === "participant-connected" && line.participant === as && line.to === "code",
250
- );
251
- if (!run.record.events || !routed)
252
- return refused(
253
- "no-participant",
254
- `${found.ref} has no participant ${as} routed to its code; --event-id is for one`,
255
- );
256
- const text = options.text ?? (await io.stdin());
257
- if (text.trim() === "") return say.usage("nothing to say: stdin was empty");
258
- try {
259
- const result = await deliver(
260
- {
261
- runRoot: run.root,
262
- workflow: run.record.workflow,
263
- runId: run.record.id,
264
- event: {
265
- id,
266
- name: "participant:reply",
267
- data: { text, ...(options.author === undefined ? {} : { author: options.author }) },
268
- from: as,
269
- at: new Date().toISOString(),
270
- },
271
- deduplicate: true,
272
- },
273
- { home, environment },
274
- );
275
- return printEmit(result, json, io);
276
- } catch (error) {
277
- return failed(error, place, io, messageOf(error));
278
- }
279
- }
@@ -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 { duration } 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;
@@ -102,12 +95,11 @@ export async function inboxCommand(
102
95
  json = parsed.json;
103
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,8 +178,8 @@ 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
 
@@ -212,12 +197,8 @@ function parseInbox(rest: readonly string[]): Options {
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
- const seconds = Number(value());
217
- if (!Number.isFinite(seconds) || seconds < 0 || seconds > MAX_WAIT_SECONDS)
218
- throw new Error(`--wait needs seconds from 0 to ${MAX_WAIT_SECONDS}`);
219
- options.waitSeconds = seconds;
220
- } else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
200
+ else if (arg === "--wait") options.waitSeconds = parseWait(value(), MAX_WAIT_SECONDS);
201
+ else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
221
202
  else if (options.ref === undefined) options.ref = arg;
222
203
  else throw new Error(`one run at a time: ${options.ref} and ${arg}`);
223
204
  }
@@ -271,7 +252,7 @@ type Watched = {
271
252
  /** The attempt files when `attempts` was read; undefined before the first read. */
272
253
  token?: string;
273
254
  attempts: AttemptRecord[];
274
- /** 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`. */
275
256
  labels: ReadonlyMap<number, string>;
276
257
  fold: ItemFold;
277
258
  mark: LogMark;
@@ -631,7 +612,7 @@ function openQuestions(runs: readonly RunItems[]): InboxItemJson[] {
631
612
  });
632
613
  return merged(open).map(({ ref, item }) => {
633
614
  const { position: _position, ...shown } = item;
634
- return { v: 1, ref, ...shown } as InboxItemJson;
615
+ return { v: INBOX_V, ref, ...shown } as InboxItemJson;
635
616
  });
636
617
  }
637
618
 
@@ -705,11 +686,13 @@ function decodeCursor(text: string): CursorState | undefined {
705
686
  }
706
687
  }
707
688
 
708
- /** 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
+ */
709
693
  async function waitLabels(runDir: string): Promise<Map<number, string>> {
710
- const labels = new Map<number, string>();
711
- for (const record of await readMailbox(runDir))
712
- if (record.kind === "wait" && record.label !== undefined)
713
- labels.set(record.attempt, record.label);
714
- return labels;
694
+ return readRunLines(runDir).then(
695
+ ({ lines }) => labelsOf(lines),
696
+ () => new Map(),
697
+ );
715
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";