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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +5 -5
  2. package/src/allowance-command.ts +29 -7
  3. package/src/attempt-ending.ts +23 -5
  4. package/src/attempt-output.ts +48 -18
  5. package/src/attempt-view.ts +25 -15
  6. package/src/commands.ts +176 -0
  7. package/src/control-plane.ts +72 -21
  8. package/src/coordinator-port.ts +29 -0
  9. package/src/coordinator-prompt.ts +237 -0
  10. package/src/coordinator-socket.ts +236 -0
  11. package/src/deadlines.ts +47 -10
  12. package/src/decisions/directory.ts +8 -6
  13. package/src/duration.ts +29 -0
  14. package/src/emit-command.ts +130 -0
  15. package/src/emit.ts +246 -0
  16. package/src/files.ts +11 -1
  17. package/src/here.ts +8 -2
  18. package/src/hold-run-lock.fixture.ts +8 -0
  19. package/src/holder-command.ts +297 -0
  20. package/src/inbox-command.ts +727 -0
  21. package/src/init-command.ts +27 -0
  22. package/src/init-template/AGENTS.md.template +1 -1
  23. package/src/init-template/hello/workflow.test.ts.template +2 -2
  24. package/src/init-template/hello/workflow.ts.template +16 -20
  25. package/src/keep-alive.ts +40 -0
  26. package/src/logs-command.ts +104 -20
  27. package/src/machine.ts +12 -1
  28. package/src/mailbox.ts +977 -0
  29. package/src/message-operation.ts +26 -3
  30. package/src/messaging/messaging.ts +458 -65
  31. package/src/messaging/routes.ts +10 -3
  32. package/src/messaging/text.ts +8 -5
  33. package/src/operation-liveness.ts +54 -8
  34. package/src/operator-cli.ts +290 -119
  35. package/src/operator-runtime.ts +5 -2
  36. package/src/progress-view.ts +49 -7
  37. package/src/projects.ts +57 -0
  38. package/src/result-slots.ts +43 -19
  39. package/src/result-validation.ts +18 -27
  40. package/src/run-command.ts +36 -32
  41. package/src/run-commands.ts +13 -2
  42. package/src/run-lock.ts +123 -0
  43. package/src/run-prepare.ts +94 -22
  44. package/src/run-state.ts +125 -6
  45. package/src/run-usage.ts +1 -1
  46. package/src/run-view.ts +0 -9
  47. package/src/runs-command.ts +46 -17
  48. package/src/runs.ts +142 -23
  49. package/src/sandboxes.ts +8 -2
  50. package/src/simple-input.ts +258 -0
  51. package/src/socket-exchange.ts +26 -0
  52. package/src/sources.ts +70 -0
  53. package/src/stage-ledger.ts +88 -13
  54. package/src/stage-plan.ts +23 -0
  55. package/src/stop-crashed.ts +39 -0
  56. package/src/test-command.ts +32 -0
  57. package/src/testing.ts +71 -3
  58. package/src/tick-command.ts +87 -0
  59. package/src/tools.ts +636 -0
  60. package/src/wake.ts +264 -0
  61. package/src/workflow-loader.ts +32 -22
  62. package/src/workflow-runner.ts +1210 -77
  63. package/src/workflow-testing/coordinator.typecheck.ts +55 -0
  64. package/src/workflow-testing/coordinators.ts +137 -0
  65. package/src/workflow-testing/host.ts +61 -1
  66. package/src/workflow-testing/index.ts +488 -33
  67. package/src/workflow-testing/script.ts +72 -2
  68. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -1,10 +1,19 @@
1
1
  import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { type ControlResponse, decodeControlRequest, WIRE_VERSION } from "@agentswf/contract/wire";
3
+ import {
4
+ type ControlResponse,
5
+ decodeControlRequest,
6
+ type GivenInput,
7
+ WIRE_VERSION,
8
+ } from "@agentswf/contract/wire";
4
9
  import { isCode } from "./files";
5
10
  import type { Messaging } from "./messaging/messaging";
6
11
  import { UNRECORDED } from "./messaging/text";
7
12
  import type { ResultSlotRegistry } from "./result-slots";
13
+ import type { ToolCalls } from "./tools";
14
+
15
+ /** What the control plane asks of the run's tools, by the agent whose socket asked. */
16
+ export type ToolHandler = Pick<ToolCalls<unknown>, "call" | "running" | "accepted">;
8
17
 
9
18
  /** What the control plane asks of the run's messaging, by the agent whose socket asked. */
10
19
  export type MessagingHandler = Pick<
@@ -16,6 +25,7 @@ export type MessagingHandler = Pick<
16
25
  const MAX_RESULT_REQUEST_BYTES = 2 * 1024 * 1024 + 4 * 1024;
17
26
  const MAX_CONTROL_CONNECTIONS = 64;
18
27
  const CONNECTION_TIMEOUT_SECONDS = 30;
28
+ const MAX_TIMER_MS = 2_147_483_647;
19
29
 
20
30
  export type ResultChannel = {
21
31
  /** The socket this agent's launcher connects to. */
@@ -36,6 +46,8 @@ export type ResultControlPlane = {
36
46
  openChannel(agentId: string, onSession?: (id: string) => void): Promise<ResultChannel>;
37
47
  /** The run's messaging, once it exists; until then `peers`, `send` and `reply` are refused. */
38
48
  attach(messaging: MessagingHandler): void;
49
+ /** The run's tools, once it has them; until then `tool` is refused. */
50
+ attachTools(tools: ToolHandler): void;
39
51
  close(): Promise<void>;
40
52
  };
41
53
 
@@ -91,6 +103,7 @@ export async function startResultControlPlane(options: {
91
103
  await chmod(directory, 0o300);
92
104
 
93
105
  let messaging: MessagingHandler | undefined;
106
+ let tools: ToolHandler | undefined;
94
107
  // Shared across channels: the budget protects the engine, not any one agent.
95
108
  let activeConnections = 0;
96
109
  let accepting = true;
@@ -175,13 +188,22 @@ export async function startResultControlPlane(options: {
175
188
  const { promise: written, resolve } = Promise.withResolvers<void>();
176
189
  socket.data.responded = resolve;
177
190
  const handling = Promise.race([
178
- handleFrame(
179
- Buffer.concat(socket.data.chunks).toString("utf8"),
180
- options.slots,
191
+ handleFrame(Buffer.concat(socket.data.chunks).toString("utf8"), {
192
+ slots: options.slots,
181
193
  messaging,
194
+ tools,
182
195
  agentId,
183
196
  onSession,
184
- ),
197
+ // A tool call lasts as long as its budget, not the connection's 30 s.
198
+ lifetime: (milliseconds) => {
199
+ if (socket.data.lifetime) clearTimeout(socket.data.lifetime);
200
+ // One past a timer's range is as good as none: the call's own end closes it.
201
+ socket.data.lifetime =
202
+ milliseconds === undefined || milliseconds > MAX_TIMER_MS
203
+ ? undefined
204
+ : setTimeout(() => socket.terminate(), milliseconds);
205
+ },
206
+ }),
185
207
  written.then(() => undefined),
186
208
  ])
187
209
  .then((response) => {
@@ -249,6 +271,9 @@ export async function startResultControlPlane(options: {
249
271
  attach(handler) {
250
272
  messaging = handler;
251
273
  },
274
+ attachTools(handler) {
275
+ tools = handler;
276
+ },
252
277
  close() {
253
278
  closing ??= (async () => {
254
279
  accepting = false;
@@ -287,14 +312,18 @@ function emptyConnection(): ConnectionState {
287
312
  };
288
313
  }
289
314
 
290
- async function handleFrame(
291
- frame: string,
292
- slots: ResultSlotRegistry,
293
- messaging: MessagingHandler | undefined,
294
- agentId: string,
295
- onSession: ((id: string) => void) | undefined,
296
- ): Promise<ControlResponse> {
297
- const response = await answerFrame(frame, slots, messaging, agentId, onSession);
315
+ type Handlers = {
316
+ slots: ResultSlotRegistry;
317
+ messaging: MessagingHandler | undefined;
318
+ tools: ToolHandler | undefined;
319
+ agentId: string;
320
+ onSession: ((id: string) => void) | undefined;
321
+ lifetime: (milliseconds: number | undefined) => void;
322
+ };
323
+
324
+ async function handleFrame(frame: string, handlers: Handlers): Promise<ControlResponse> {
325
+ const { messaging, agentId } = handlers;
326
+ const response = await answerFrame(frame, handlers);
298
327
  // Leads every response, so what is owed comes back after a compaction; not one that already
299
328
  // says it, about the wait it began or the answer it is refused for.
300
329
  const repeats =
@@ -308,10 +337,7 @@ async function handleFrame(
308
337
 
309
338
  async function answerFrame(
310
339
  frame: string,
311
- slots: ResultSlotRegistry,
312
- messaging: MessagingHandler | undefined,
313
- agentId: string,
314
- onSession: ((id: string) => void) | undefined,
340
+ { slots, messaging, tools, agentId, onSession, lifetime }: Handlers,
315
341
  ): Promise<ControlResponse> {
316
342
  if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
317
343
  return rejected("invalid-request", "expected exactly one newline-delimited JSON request");
@@ -329,6 +355,17 @@ async function answerFrame(
329
355
  if (decoded.value.session) onSession?.(decoded.value.session);
330
356
 
331
357
  const request = decoded.value;
358
+ if (request.command === "tool") {
359
+ if (!tools) return rejected("unsupported-command", "tools are not available in this run");
360
+ let answer: Awaited<ReturnType<ToolHandler["call"]>>;
361
+ try {
362
+ answer = await tools.call(agentId, request, lifetime);
363
+ } catch {
364
+ return rejected("internal-error", "the tool call failed internally; it may have run");
365
+ }
366
+ if (answer.kind === "refused") return rejected(answer.code, answer.error);
367
+ return { version: WIRE_VERSION, ...answer };
368
+ }
332
369
  if (request.command !== "result" && request.command !== "waiting") {
333
370
  if (!messaging)
334
371
  return rejected("unsupported-command", "messaging is not available in this run");
@@ -358,6 +395,15 @@ async function answerFrame(
358
395
  }
359
396
  // Before the slot is asked: a message operation has none, and an answer owed comes first.
360
397
  const gate = messaging?.checkFinish(agentId, request.operationId, request.command);
398
+ const running = tools?.running(agentId);
399
+ if (running && gate?.kind !== "refused") {
400
+ const by =
401
+ running.settledBy === undefined ? "" : ` by ${new Date(running.settledBy).toISOString()}`;
402
+ return rejected(
403
+ "tool-running",
404
+ `your call of ${running.tool} is still running; it will have answered${by || " when it ends"}. Run this again then.`,
405
+ );
406
+ }
361
407
  if (gate?.kind === "refused") return rejected(gate.code, gate.error);
362
408
  if (gate?.kind === "awaiting" && request.command === "waiting") {
363
409
  const { peer, until, notice } = gate;
@@ -378,17 +424,22 @@ async function answerFrame(
378
424
  const result = await slots.submit({
379
425
  operationId: request.operationId,
380
426
  agentId,
381
- raw: request.raw,
427
+ ...givenOf(request),
382
428
  source: "control-plane",
383
429
  });
384
- return result.kind === "accepted"
385
- ? { version: WIRE_VERSION, kind: "accepted" }
386
- : rejected(result.code, result.error);
430
+ if (result.kind !== "accepted") return rejected(result.code, result.error);
431
+ tools?.accepted(request.operationId);
432
+ return { version: WIRE_VERSION, kind: "accepted" };
387
433
  } catch {
388
434
  return rejected("internal-error", "control request failed internally");
389
435
  }
390
436
  }
391
437
 
438
+ /** The input as the wire decoded it, without the request's other fields. */
439
+ function givenOf({ raw, args }: GivenInput): GivenInput {
440
+ return raw !== undefined ? { raw } : args !== undefined ? { args } : {};
441
+ }
442
+
392
443
  function rejected(
393
444
  code: Extract<ControlResponse, { kind: "rejected" }>["code"],
394
445
  error: string,
@@ -0,0 +1,29 @@
1
+ import type {
2
+ HolderRequest,
3
+ HolderResult,
4
+ MessagingEventBody,
5
+ OpenQuestion,
6
+ } from "./messaging/messaging";
7
+
8
+ /**
9
+ * Answers for a run's outside participants, as a holder does with `awf reply` and `awf send`. Its
10
+ * message numbers are the attempt's; `awf inbox` names them `{attempt}.{n}`.
11
+ */
12
+ export type CoordinatorHolder = {
13
+ reply(request: HolderRequest): Promise<HolderResult>;
14
+ send(request: HolderRequest): Promise<HolderResult>;
15
+ /** Questions open to outside participants now, oldest first. */
16
+ open(): OpenQuestion[];
17
+ };
18
+
19
+ /**
20
+ * What answers for a run's outside participants plugs in here: `awf run`'s prompt, the socket, or a
21
+ * test's scripts. Told each messaging event once the run's log has it, and handed the holder before
22
+ * the workflow runs. Reachable through `RunWorkflowOptions`, as `awaitReply` is, but not a
23
+ * published surface: holders use `awf inbox`, `awf reply` and `awf send`.
24
+ * @internal
25
+ */
26
+ export type CoordinatorPort = {
27
+ attach(holder: CoordinatorHolder): void;
28
+ recorded(event: MessagingEventBody): void;
29
+ };
@@ -0,0 +1,237 @@
1
+ import { createInterface, type Interface } from "node:readline/promises";
2
+ import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
3
+ import type { MessagingEventBody } from "./messaging/messaging";
4
+ import { shellLine } from "./run-commands";
5
+
6
+ /** Where the prompt reads a line: `awf run`'s terminal, or a test's script. */
7
+ export type LineReader = {
8
+ /** The line typed after `prompt`; undefined when `signal` aborts first, or input ended. */
9
+ read(prompt: string, signal: AbortSignal): Promise<string | undefined>;
10
+ /** Writes a line above the prompt open, if any, and draws it again under it. */
11
+ above(line: string): void;
12
+ close(): void;
13
+ };
14
+
15
+ /** What the prompt shows beside the progress view, which holds its block while a prompt is open. */
16
+ export type PromptOutput = {
17
+ /** A line in the run's log, above the progress block. */
18
+ log(line: string): void;
19
+ /** The progress block stops redrawing while a prompt waits for a line, and lines go above it. */
20
+ hold(above: ((line: string) => void) | undefined): void;
21
+ };
22
+
23
+ type Question = {
24
+ coordinator: string;
25
+ message: number;
26
+ asker: string;
27
+ text: string;
28
+ /** Asked after others, its text shown when it came in may have scrolled away: shown again. */
29
+ again: boolean;
30
+ };
31
+
32
+ /** What an agent wrote, safe on a terminal: no escape sequences or other control characters. */
33
+ function shown(text: string): string[] {
34
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it strips.
35
+ return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "").split("\n");
36
+ }
37
+
38
+ const indented = (text: string) => shown(text).map((line) => ` ${line}`);
39
+
40
+ /**
41
+ * `awf run`'s interactive mode: each message to a coordinator is a line in the run's log, and each
42
+ * question is asked at the terminal, oldest first, for every coordinator. An empty line leaves it
43
+ * open for another holder; one another holder answers, or that closes, is passed over.
44
+ */
45
+ export function promptCoordinator(
46
+ reader: LineReader,
47
+ output: PromptOutput,
48
+ ): CoordinatorPort & { close(): void; shows(message: { from: string; to: string }): boolean } {
49
+ let holder: CoordinatorHolder | undefined;
50
+ const connected = new Set<string>();
51
+ const queue: Question[] = [];
52
+ let asking: { question: Question; stop: AbortController } | undefined;
53
+ /** Questions this prompt answered: their answers are what was typed, shown already. */
54
+ const typed = new Set<number>();
55
+ /** Questions still open, by number: an end names its asker. */
56
+ const open = new Map<number, Question>();
57
+ let closed = false;
58
+
59
+ const ask = async (question: Question): Promise<void> => {
60
+ if (question.again)
61
+ output.log([`● ${question.asker} asked:`, ...indented(question.text)].join("\n"));
62
+ const stop = new AbortController();
63
+ asking = { question, stop };
64
+ output.hold((line) => reader.above(line));
65
+ let line: string | undefined;
66
+ try {
67
+ const as = connected.size > 1 ? ` as ${question.coordinator}` : "";
68
+ line = (await reader.read(`reply to ${question.asker}${as} › `, stop.signal))?.trim();
69
+ } finally {
70
+ // Released before the reply: what it records comes as lines, not under a prompt answered.
71
+ asking = undefined;
72
+ output.hold(undefined);
73
+ }
74
+ if (!line || !holder) return;
75
+ // Before the reply: its record arrives while it is being made.
76
+ typed.add(question.message);
77
+ const result = await holder.reply({
78
+ coordinator: question.coordinator,
79
+ agent: question.asker,
80
+ to: question.message,
81
+ text: line,
82
+ via: "terminal",
83
+ });
84
+ if (result.code !== "delivered") {
85
+ typed.delete(question.message);
86
+ output.log(`awf: not delivered: ${result.error}`);
87
+ }
88
+ };
89
+
90
+ const next = async (): Promise<void> => {
91
+ while (!asking && !closed && holder) {
92
+ const question = queue.shift();
93
+ if (!question) return;
94
+ // One that fails is said and passed over; the others are still asked.
95
+ await ask(question).catch((error: unknown) =>
96
+ output.log(`awf: the prompt failed: ${error instanceof Error ? error.message : error}`),
97
+ );
98
+ }
99
+ };
100
+
101
+ const ended = (message: number) => {
102
+ const at = queue.findIndex((each) => each.message === message);
103
+ if (at >= 0) queue.splice(at, 1);
104
+ if (asking?.question.message === message) asking.stop.abort();
105
+ };
106
+
107
+ return {
108
+ attach(given) {
109
+ holder = given;
110
+ },
111
+ recorded(event: MessagingEventBody) {
112
+ if (event.kind === "participant-connected") connected.add(event.participant);
113
+ if (event.kind === "request-ended" && connected.has(event.owedBy)) {
114
+ ended(event.message);
115
+ const question = open.get(event.message);
116
+ open.delete(event.message);
117
+ if (question && event.end !== "replied")
118
+ output.log(`● ${question.asker}'s question closed unanswered: ${event.end}`);
119
+ return;
120
+ }
121
+ if (event.kind !== "message-sent") return;
122
+ // The coordinator's answers and sends that came another way: a holder's, or a script's.
123
+ if (connected.has(event.from)) {
124
+ if (event.replyTo !== undefined && typed.has(event.replyTo)) return;
125
+ const how = "via" in event && event.via ? ` (${event.via})` : "";
126
+ output.log([`● ${event.from} → ${event.to}${how}:`, ...indented(event.text)].join("\n"));
127
+ return;
128
+ }
129
+ if (!connected.has(event.to)) return;
130
+ const asks = event.awaitsReply !== undefined;
131
+ output.log(
132
+ [`● ${event.from} ${asks ? "asks" : `→ ${event.to}`}:`, ...indented(event.text)].join("\n"),
133
+ );
134
+ if (!asks) return;
135
+ const question = {
136
+ coordinator: event.to,
137
+ message: event.message,
138
+ asker: event.from,
139
+ text: event.text,
140
+ again: asking !== undefined || queue.length > 0,
141
+ };
142
+ open.set(event.message, question);
143
+ queue.push(question);
144
+ void next();
145
+ },
146
+ shows: ({ from, to }) => connected.has(from) || connected.has(to),
147
+ close() {
148
+ closed = true;
149
+ queue.length = 0;
150
+ asking?.stop.abort();
151
+ reader.close();
152
+ },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Reads lines from this process's terminal, writing on stderr beside the progress view. It holds
158
+ * stdin only while a question is asked, so typing at any other time reaches nothing, and a run
159
+ * that asks none leaves the terminal as it found it.
160
+ */
161
+ export function terminalReader(): LineReader {
162
+ let lines: Interface | undefined;
163
+ let closed = false;
164
+ const clearLine = () => process.stderr.write("\r\x1b[2K");
165
+ return {
166
+ async read(prompt, signal) {
167
+ if (closed) return undefined;
168
+ const current = createInterface({
169
+ input: process.stdin,
170
+ output: process.stderr,
171
+ terminal: true,
172
+ });
173
+ lines = current;
174
+ // Ctrl-C at the prompt asks the run to stop, as it does anywhere else.
175
+ current.on("SIGINT", () => process.kill(process.pid, "SIGINT"));
176
+ // Ctrl-D ends the input: the question is left open, as an empty line leaves it.
177
+ const ended = new Promise<undefined>((resolve) =>
178
+ current.once("close", () => resolve(undefined)),
179
+ );
180
+ // The progress view hides the cursor and clips long lines; typing needs both back.
181
+ process.stderr.write("\x1b[?7h\x1b[?25h");
182
+ try {
183
+ return await Promise.race([current.question(prompt, { signal }), ended]);
184
+ } catch (error) {
185
+ if (signal.aborted) return undefined;
186
+ throw error;
187
+ } finally {
188
+ if (signal.aborted || closed) clearLine();
189
+ lines = undefined;
190
+ current.close();
191
+ process.stderr.write("\x1b[?25l\x1b[?7l");
192
+ }
193
+ },
194
+ above(line) {
195
+ clearLine();
196
+ process.stderr.write(`${line}\n`);
197
+ lines?.prompt(true);
198
+ },
199
+ close() {
200
+ closed = true;
201
+ if (lines) clearLine();
202
+ lines?.close();
203
+ },
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Without a prompt (`--json`, or no terminal): each question to a coordinator is a line with its
209
+ * text and the command that answers it, so a run awaiting one does not look stuck.
210
+ */
211
+ export function replyHints(
212
+ run: string,
213
+ /** What finds the run from another command, such as `--run-root`. */
214
+ place: readonly string[],
215
+ attempt: number,
216
+ log: (line: string) => void,
217
+ ): CoordinatorPort & { shows(message: { to: string; asks?: boolean }): boolean } {
218
+ const connected = new Set<string>();
219
+ return {
220
+ attach() {},
221
+ recorded(event: MessagingEventBody) {
222
+ if (event.kind === "participant-connected") connected.add(event.participant);
223
+ if (event.kind !== "message-sent" || !connected.has(event.to) || !event.awaitsReply) return;
224
+ const id = `${attempt}.${event.message}`;
225
+ const as = connected.size > 1 ? ["--as", event.to] : [];
226
+ const command = ["awf", "reply", run, event.from, "--to", id, ...as, ...place];
227
+ log(
228
+ [
229
+ `● ${event.from} asks ${event.to} (${id}):`,
230
+ ...indented(event.text),
231
+ ` answer: ${shellLine(command)} '…'`,
232
+ ].join("\n"),
233
+ );
234
+ },
235
+ shows: ({ to, asks }) => asks === true && connected.has(to),
236
+ };
237
+ }
@@ -0,0 +1,236 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdir, realpath, rm, stat } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
4
+ import type { ParticipantResultJson } from "@agentswf/contract/records";
5
+ import { decodeHolderRequest, type NudgeWireResult } from "@agentswf/contract/wire";
6
+ import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
7
+ import { realPath } from "./files";
8
+ import type { MessagingEventBody } from "./messaging/messaging";
9
+ import type { RunStatus } from "./run-state";
10
+
11
+ /** A request is a line of text and its JSON; well past a message's own 16 KB limit. */
12
+ const MAX_REQUEST_BYTES = 256 * 1024;
13
+ const CONNECTION_LIFETIME_MS = 30_000;
14
+
15
+ type Connection = {
16
+ chunks: Buffer[];
17
+ bytes: number;
18
+ /** Its request was taken, answered or refused: nothing more is read. */
19
+ taken: boolean;
20
+ timer?: ReturnType<typeof setTimeout>;
21
+ };
22
+
23
+ /**
24
+ * A run's coordinator socket: named by a hash of its run directory, so any depth of directory fits
25
+ * `sun_path`'s 104 bytes, in a folder only the operator can open.
26
+ */
27
+ export function coordinatorSocketPath(dir: string, runDir: string): string {
28
+ // Its real path: a holder reaching it through a symlink, or `/tmp` for `/private/tmp`, names it alike.
29
+ const name = createHash("sha256").update(realPath(runDir)).digest("hex").slice(0, 32);
30
+ return join(dir, `${name}.sock`);
31
+ }
32
+
33
+ /**
34
+ * Refuses a coordinator folder inside the workflow's directory, where its agents work, through
35
+ * symlinks too, and one given as a relative path, which each command would read from where it ran.
36
+ * Then makes it, the operator's alone, before any sandbox is resolved against it.
37
+ */
38
+ export async function prepareCoordinatorDir(
39
+ dir: string,
40
+ workflowDir: string,
41
+ /** It was moved with `AWF_COORDINATOR_DIR`; `~/.awf`'s own is kept from sandboxes as `~/.awf` is. */
42
+ moved = true,
43
+ ): Promise<void> {
44
+ if (!isAbsolute(dir)) throw new Error(`AWF_COORDINATOR_DIR ${dir} is not an absolute path`);
45
+ const real = async (path: string): Promise<string> => {
46
+ try {
47
+ return await realpath(path);
48
+ } catch {
49
+ // Not there yet: its parent's real path, and its own name.
50
+ const parent = dirname(path);
51
+ return parent === path ? path : join(await real(parent), basename(path));
52
+ }
53
+ };
54
+ const inside = relative(await real(workflowDir), await real(dir));
55
+ const within =
56
+ inside === "" || !(inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside));
57
+ if (moved && within)
58
+ throw new Error(
59
+ `AWF_COORDINATOR_DIR ${dir} is inside the workflow's directory, where its agents work; move it out`,
60
+ );
61
+ await mkdir(dir, { recursive: true, mode: 0o700 });
62
+ await chmod(dir, 0o700);
63
+ }
64
+
65
+ /**
66
+ * The socket `awf reply` and `awf send` reach a live run through. It opens once the workflow
67
+ * connects an outside participant and goes when the run ends; each connection is one request
68
+ * line and one `ParticipantResultJson` line back.
69
+ */
70
+ export function coordinatorSocket(options: {
71
+ dir: string;
72
+ runDir: string;
73
+ attempt: number;
74
+ /** Why the socket did not open: the run goes on, and no holder's command reaches it. */
75
+ failed(reason: string): void;
76
+ /** A nudge: the run's mailbox has a new event. Given, the socket opens at once, for awf's nudge. */
77
+ nudged?(): void;
78
+ }): CoordinatorPort & { close(): Promise<void>; readonly path: string } {
79
+ const path = coordinatorSocketPath(options.dir, options.runDir);
80
+ let holder: CoordinatorHolder | undefined;
81
+ let listener: Bun.UnixSocketListener<{ chunks: Buffer[]; bytes: number }> | undefined;
82
+ let opening: Promise<void> | undefined;
83
+ let closed = false;
84
+ const id = (n: number) => `${options.attempt}.${n}`;
85
+
86
+ /** Undefined for a request no `awf` command makes: the connection closes unanswered. */
87
+ const answer = async (
88
+ frame: string,
89
+ ): Promise<ParticipantResultJson | NudgeWireResult | undefined> => {
90
+ let parsed: unknown;
91
+ try {
92
+ parsed = JSON.parse(frame);
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ const decoded = decodeHolderRequest(parsed);
97
+ if (!decoded.ok) return undefined;
98
+ const request = decoded.value;
99
+ if (request.command === "nudge") {
100
+ if (!options.nudged) return undefined;
101
+ options.nudged();
102
+ return { v: 1, code: "nudged" };
103
+ }
104
+ if (!holder) return refused("no-participant", "the run has no participant connected");
105
+ // Another attempt's question is never open in this one: a number no message has.
106
+ const to =
107
+ request.to === undefined
108
+ ? undefined
109
+ : request.to.split(".")[0] === String(options.attempt)
110
+ ? Number(request.to.split(".")[1])
111
+ : 0;
112
+ const asked = {
113
+ ...(request.participant === undefined ? {} : { coordinator: request.participant }),
114
+ agent: request.agent,
115
+ ...(to === undefined ? {} : { to }),
116
+ text: request.text,
117
+ via: "command" as const,
118
+ };
119
+ const result =
120
+ request.command === "reply" ? await holder.reply(asked) : await holder.send(asked);
121
+ return result.code === "delivered"
122
+ ? { v: 1, code: "delivered", id: id(result.message) }
123
+ : {
124
+ v: 1,
125
+ code: result.code,
126
+ error: result.error,
127
+ ...(result.choices ? { choices: result.choices } : {}),
128
+ };
129
+ };
130
+
131
+ const open = async () => {
132
+ // A socket a killed run left here is no one's: this run's directory names it.
133
+ await rm(path, { force: true });
134
+ if (closed) return;
135
+ listener = Bun.listen<Connection>({
136
+ unix: path,
137
+ allowHalfOpen: true,
138
+ data: { chunks: [], bytes: 0, taken: false },
139
+ socket: {
140
+ open(socket) {
141
+ socket.data = {
142
+ chunks: [],
143
+ bytes: 0,
144
+ taken: false,
145
+ // Only while the request is read: an answer being made is never cut off.
146
+ timer: setTimeout(() => socket.terminate(), CONNECTION_LIFETIME_MS),
147
+ };
148
+ },
149
+ data(socket, data) {
150
+ if (socket.data.taken) return;
151
+ socket.data.bytes += data.byteLength;
152
+ if (socket.data.bytes > MAX_REQUEST_BYTES) {
153
+ take(socket);
154
+ socket.end(`${JSON.stringify(refused("too-large", "the request is too large"))}\n`);
155
+ return;
156
+ }
157
+ socket.data.chunks.push(Buffer.from(data));
158
+ // A request is one line: it is answered at its end, whether or not the client half-closes.
159
+ const text = Buffer.concat(socket.data.chunks).toString("utf8");
160
+ const newline = text.indexOf("\n");
161
+ if (newline >= 0) respond(socket, text.slice(0, newline));
162
+ },
163
+ end(socket) {
164
+ if (!socket.data.taken)
165
+ respond(socket, Buffer.concat(socket.data.chunks).toString("utf8"));
166
+ },
167
+ close(socket) {
168
+ clearTimeout(socket.data.timer);
169
+ },
170
+ error(socket) {
171
+ socket.terminate();
172
+ },
173
+ },
174
+ });
175
+ await chmod(path, 0o600);
176
+ };
177
+ const take = (socket: Bun.Socket<Connection>) => {
178
+ socket.data.taken = true;
179
+ clearTimeout(socket.data.timer);
180
+ };
181
+ const respond = (socket: Bun.Socket<Connection>, frame: string) => {
182
+ take(socket);
183
+ void answer(frame)
184
+ .catch(() => undefined)
185
+ .then((result) => socket.end(result ? `${JSON.stringify(result)}\n` : undefined));
186
+ };
187
+
188
+ if (options.nudged)
189
+ opening = open().catch((error: unknown) =>
190
+ options.failed(error instanceof Error ? error.message : String(error)),
191
+ );
192
+ return {
193
+ path,
194
+ attach(given) {
195
+ holder = given;
196
+ },
197
+ recorded(event: MessagingEventBody) {
198
+ if (event.kind === "participant-connected" && !opening) {
199
+ opening = open().catch((error: unknown) =>
200
+ options.failed(error instanceof Error ? error.message : String(error)),
201
+ );
202
+ }
203
+ },
204
+ async close() {
205
+ closed = true;
206
+ await opening;
207
+ listener?.stop(true);
208
+ if (listener) await rm(path, { force: true });
209
+ },
210
+ };
211
+ }
212
+
213
+ function refused(
214
+ code: Exclude<ParticipantResultJson["code"], "delivered">,
215
+ error: string,
216
+ ): ParticipantResultJson {
217
+ return { v: 1, code, error };
218
+ }
219
+
220
+ /**
221
+ * The socket of a run still live, or undefined: `run-state` decides, so a socket a killed run left
222
+ * behind is not trusted.
223
+ */
224
+ export async function liveCoordinatorSocket(
225
+ dir: string,
226
+ status: Pick<RunStatus, "state" | "run">,
227
+ ): Promise<string | undefined> {
228
+ if (status.state !== "running") return undefined;
229
+ const path = coordinatorSocketPath(dir, status.run.dir);
230
+ return (await stat(path).then(
231
+ () => true,
232
+ () => false,
233
+ ))
234
+ ? path
235
+ : undefined;
236
+ }