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

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 (72) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +611 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +3 -15
  6. package/src/attempt-ending.ts +2 -4
  7. package/src/attempt-output.ts +3 -2
  8. package/src/attempt-view.ts +1 -1
  9. package/src/control-plane.ts +38 -6
  10. package/src/coordinator-socket.ts +15 -6
  11. package/src/deadlines.ts +69 -2
  12. package/src/decisions/directory.ts +9 -11
  13. package/src/deprecations.ts +177 -0
  14. package/src/duration.ts +50 -20
  15. package/src/emit-command.ts +57 -54
  16. package/src/emit.ts +124 -15
  17. package/src/help/allowance.txt +13 -0
  18. package/src/help/emit.txt +21 -0
  19. package/src/help/inbox.txt +12 -0
  20. package/src/help/init.txt +12 -0
  21. package/src/help/logs.txt +14 -0
  22. package/src/help/reply.txt +18 -0
  23. package/src/help/run.txt +58 -0
  24. package/src/help/runs.txt +11 -0
  25. package/src/help/test.txt +9 -0
  26. package/src/help/tick.txt +12 -0
  27. package/src/herdr-run-session.ts +2 -2
  28. package/src/here.ts +1 -1
  29. package/src/holder-command.ts +38 -51
  30. package/src/inbox-command.ts +9 -24
  31. package/src/init-command.ts +2 -11
  32. package/src/init-template/AGENTS.md.template +3 -3
  33. package/src/init-template/hello/workflow.test.ts.template +3 -3
  34. package/src/init-template/hello/workflow.ts.template +6 -6
  35. package/src/keep-alive.ts +28 -13
  36. package/src/logs-command.ts +14 -27
  37. package/src/mailbox.ts +16 -21
  38. package/src/messaging/limits.ts +5 -5
  39. package/src/messaging/messaging.ts +48 -43
  40. package/src/messaging/routes.ts +2 -2
  41. package/src/messaging/text.ts +24 -22
  42. package/src/operation-liveness.ts +1 -1
  43. package/src/operator-cli.ts +23 -9
  44. package/src/operator-runtime.ts +9 -12
  45. package/src/pane-layout.ts +125 -32
  46. package/src/progress-view.ts +8 -8
  47. package/src/result-validation.ts +1 -1
  48. package/src/run-command.ts +27 -47
  49. package/src/run-commands.ts +1 -1
  50. package/src/run-prepare.ts +46 -8
  51. package/src/run-usage.ts +2 -2
  52. package/src/run-view.ts +9 -4
  53. package/src/runs-command.ts +14 -23
  54. package/src/runs.ts +44 -8
  55. package/src/sandboxes.ts +3 -2
  56. package/src/simple-input.ts +13 -3
  57. package/src/sources.ts +42 -6
  58. package/src/stage-ledger.ts +43 -13
  59. package/src/stage-plan.ts +37 -1
  60. package/src/test-command.ts +9 -14
  61. package/src/testing.ts +8 -9
  62. package/src/tick-command.ts +2 -13
  63. package/src/tools.ts +62 -25
  64. package/src/workflow-loader.ts +5 -2
  65. package/src/workflow-runner.ts +636 -271
  66. package/src/workflow-testing/coordinator.typecheck.ts +12 -13
  67. package/src/workflow-testing/coordinators.ts +2 -2
  68. package/src/workflow-testing/host.ts +115 -46
  69. package/src/workflow-testing/index.ts +45 -837
  70. package/src/workflow-testing/run-test.ts +1213 -0
  71. package/src/workflow-testing/script.ts +106 -45
  72. package/src/workflow-testing/workflow-testing.typecheck.ts +13 -10
@@ -0,0 +1,115 @@
1
+ import { createConnection, type Socket } from "node:net";
2
+ import {
3
+ type ControlRequest,
4
+ decodeResponseFor,
5
+ type ResponseFor,
6
+ type ResultSubmitRequest,
7
+ type ResultSubmitResponse,
8
+ type WaitingRequest,
9
+ type WaitingResponse,
10
+ } from "@agentswf/contract/wire";
11
+
12
+ const MAX_RESPONSE_BYTES = 256 * 1024;
13
+
14
+ /** The connection never opened: nothing was written, so nothing reached the engine. */
15
+ export class NotSentError extends Error {}
16
+
17
+ export function submitResult(
18
+ endpoint: string,
19
+ request: ResultSubmitRequest,
20
+ timeoutSeconds = 30,
21
+ connect: (endpoint: string) => Socket = createConnection,
22
+ ): Promise<ResultSubmitResponse> {
23
+ return submitControl(endpoint, request, timeoutSeconds, connect);
24
+ }
25
+
26
+ export function submitWaiting(
27
+ endpoint: string,
28
+ request: WaitingRequest,
29
+ timeoutSeconds = 30,
30
+ connect: (endpoint: string) => Socket = createConnection,
31
+ ): Promise<WaitingResponse> {
32
+ return submitControl(endpoint, request, timeoutSeconds, connect);
33
+ }
34
+
35
+ /** Sends one request and resolves with a response that answers its command. */
36
+ export function submitControl<R extends ControlRequest>(
37
+ endpoint: string,
38
+ request: R,
39
+ /** Null waits as long as the engine takes, as a tool call does: it answers once the call settles. */
40
+ timeoutSeconds: number | null = 30,
41
+ connect: (endpoint: string) => Socket = createConnection,
42
+ ): Promise<ResponseFor[R["command"]]> {
43
+ type Response = ResponseFor[R["command"]];
44
+ if (timeoutSeconds !== null && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) {
45
+ return Promise.reject(new Error("control-plane timeout must be positive"));
46
+ }
47
+ const outgoing = Buffer.from(`${JSON.stringify(request)}\n`);
48
+
49
+ return new Promise<Response>((resolve, reject) => {
50
+ const chunks: Buffer[] = [];
51
+ let bytes = 0;
52
+ let settled = false;
53
+ let connected = false;
54
+ const socket = connect(endpoint);
55
+ const lifetime =
56
+ timeoutSeconds === null
57
+ ? undefined
58
+ : setTimeout(
59
+ () => fail(new Error("control plane response timed out")),
60
+ timeoutSeconds * 1000,
61
+ );
62
+
63
+ const fail = (error: Error): void => {
64
+ if (settled) return;
65
+ settled = true;
66
+ clearTimeout(lifetime);
67
+ socket.destroy();
68
+ reject(error);
69
+ };
70
+
71
+ socket.once("connect", () => {
72
+ connected = true;
73
+ socket.end(outgoing);
74
+ });
75
+ socket.on("data", (data) => {
76
+ if (settled) return;
77
+ const chunk = typeof data === "string" ? Buffer.from(data) : data;
78
+ bytes += chunk.byteLength;
79
+ if (bytes > MAX_RESPONSE_BYTES) {
80
+ fail(new Error("control-plane response exceeds the size limit"));
81
+ return;
82
+ }
83
+ chunks.push(chunk);
84
+ });
85
+ socket.once("end", () => {
86
+ if (settled) return;
87
+ const frame = Buffer.concat(chunks).toString("utf8");
88
+ if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
89
+ fail(new Error("control plane returned an invalid response frame"));
90
+ return;
91
+ }
92
+ let parsed: unknown;
93
+ try {
94
+ parsed = JSON.parse(frame.slice(0, -1));
95
+ } catch {
96
+ fail(new Error("control plane returned invalid JSON"));
97
+ return;
98
+ }
99
+ const decoded = decodeResponseFor<R["command"]>(request.command, parsed);
100
+ if (!decoded.ok) {
101
+ fail(new Error(decoded.error));
102
+ return;
103
+ }
104
+ settled = true;
105
+ clearTimeout(lifetime);
106
+ resolve(decoded.value);
107
+ });
108
+ socket.once("error", (error) =>
109
+ fail(connected ? error : new NotSentError(error.message, { cause: error })),
110
+ );
111
+ socket.once("close", () => {
112
+ if (!settled) fail(new Error("control plane closed without a response"));
113
+ });
114
+ });
115
+ }
@@ -1,9 +1,11 @@
1
- import { chmod, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
2
- import { createRequire } from "node:module";
3
- import { basename, dirname, join, resolve } from "node:path";
1
+ import { chmod, mkdir, realpath, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
4
  import { type AgentDoor, shellQuote } from "@agentswf/sandbox";
5
5
 
6
- /** bun loads the `.env` of the directory it starts in: an agent's, which is none of `wf`'s. */
6
+ const AGENT_CLI = fileURLToPath(new URL("./agent-cli/cli.ts", import.meta.url));
7
+
8
+ /** bun loads the `.env` of the directory it starts in: an agent's, which is none of `wfa`'s. */
7
9
  const NO_ENV = "--no-env-file";
8
10
 
9
11
  /**
@@ -27,19 +29,18 @@ export async function installAgentLauncher(
27
29
  */
28
30
  sessionEnv?: string,
29
31
  ): Promise<string> {
30
- const command = await resolveAgentCommand();
31
- return writeLauncher(directory, [process.execPath, NO_ENV, command], endpoint, sessionEnv);
32
+ return writeLauncher(directory, [process.execPath, NO_ENV, AGENT_CLI], endpoint, sessionEnv);
32
33
  }
33
34
 
34
35
  /**
35
- * A sandboxed agent's door: the same launcher, running the bundled `wf` with the host's bun by
36
+ * A sandboxed agent's door: the same launcher, running the bundled `wfa` with the host's bun by
36
37
  * its real path, since a sandbox reads neither the workspace's sources nor bun's links under `~`.
37
38
  * A box copies `boxScript` to the launcher's path, which runs the bundle with the box's own bun.
38
39
  */
39
40
  export async function installSandboxedDoor(
40
41
  directory: string,
41
42
  endpoint: string,
42
- /** The bundled `wf`'s source, written beside the launcher. */
43
+ /** The bundled `wfa`'s source, written beside the launcher. */
43
44
  source: string,
44
45
  sessionEnv?: string,
45
46
  ): Promise<AgentDoor> {
@@ -50,7 +51,7 @@ export async function installSandboxedDoor(
50
51
  const socket = join(real, basename(endpoint));
51
52
  // In the agent's own directory: bun will not load a module from the control plane's, which is
52
53
  // not listable so that no agent can find another's socket.
53
- const bundle = join(real, "wf.js");
54
+ const bundle = join(real, "wfa.js");
54
55
  await writeFile(bundle, source, { mode: 0o500 });
55
56
  const launcher = await writeLauncher(real, [bun, NO_ENV, bundle], socket, sessionEnv);
56
57
  return {
@@ -64,12 +65,12 @@ export async function installSandboxedDoor(
64
65
  }
65
66
 
66
67
  /**
67
- * `wf` as one file's source, which a sandbox can read where it cannot read this workspace. Built by
68
+ * `wfa` as one file's source, which a sandbox can read where it cannot read this workspace. Built by
68
69
  * a `bun build` of its own: in process, the bundler resolved packages against the test runner's
69
70
  * state and missed the workspace's links.
70
71
  */
71
72
  export async function buildAgentBundle(): Promise<string> {
72
- const entry = await resolveAgentCommand();
73
+ const entry = AGENT_CLI;
73
74
  const built = Bun.spawn({
74
75
  cmd: [process.execPath, "build", entry, "--target=bun"],
75
76
  cwd: dirname(entry),
@@ -81,7 +82,7 @@ export async function buildAgentBundle(): Promise<string> {
81
82
  new Response(built.stdout).text(),
82
83
  new Response(built.stderr).text(),
83
84
  ]);
84
- if (code !== 0) throw new Error(`could not bundle wf: ${stderr.trim().slice(0, 400)}`);
85
+ if (code !== 0) throw new Error(`could not bundle wfa: ${stderr.trim().slice(0, 400)}`);
85
86
  return bundle;
86
87
  }
87
88
 
@@ -93,7 +94,7 @@ async function writeLauncher(
93
94
  ): Promise<string> {
94
95
  await mkdir(directory, { recursive: true });
95
96
  await chmod(directory, 0o700);
96
- const path = join(directory, "wf");
97
+ const path = join(directory, "wfa");
97
98
  // Not writable, so an agent that redirects into its own launcher fails rather than replacing its
98
99
  // only way to answer. A `chmod` still gets past it; a sandbox denies the write outright.
99
100
  await writeFile(path, launcherScript(command, endpoint, sessionEnv, path), { mode: 0o500 });
@@ -105,7 +106,7 @@ function launcherScript(
105
106
  command: readonly string[],
106
107
  endpoint: string,
107
108
  sessionEnv: string | undefined,
108
- /** Its own path, which `wf` names in what it prints, as an agent must type it. */
109
+ /** Its own path, which `wfa` names in what it prints, as an agent must type it. */
109
110
  path: string,
110
111
  ): string {
111
112
  if (sessionEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sessionEnv)) {
@@ -114,14 +115,3 @@ function launcherScript(
114
115
  const session = sessionEnv ? ` --session "\${${sessionEnv}:-}"` : "";
115
116
  return `#!/bin/sh\nexec ${command.map(shellQuote).join(" ")} --at ${shellQuote(endpoint)} --as ${shellQuote(path)}${session} "$@"\n`;
116
117
  }
117
-
118
- async function resolveAgentCommand(): Promise<string> {
119
- const require = createRequire(import.meta.url);
120
- const manifestPath = require.resolve("@agentswf/wf/package.json");
121
- const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
122
- bin?: string | Record<string, string>;
123
- };
124
- const relative = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.wf;
125
- if (!relative) throw new Error("@agentswf/wf does not publish the wf command");
126
- return resolve(dirname(manifestPath), relative);
127
- }
@@ -4,24 +4,12 @@ import type {
4
4
  HarnessAllowance,
5
5
  } from "@agentswf/contract/records";
6
6
  import { HARNESS_NAMES, type Harness } from "@agentswf/harness";
7
- import { PLAN_BASIS, planPrice } from "./accounting/plans";
7
+ import { planPrice } from "./accounting/plans";
8
8
  import { messageOf } from "./errors";
9
+ import help from "./help/allowance.txt" with { type: "text" };
9
10
  import type { Out } from "./run-commands";
10
11
 
11
- export const allowanceUsage = [
12
- "usage: awf allowance [harness...] [--json]",
13
- "",
14
- "What is left of each harness's plan, as the harness itself shows it: claude's /usage, codex's",
15
- "/status, and cursor's /usage, read in a Herdr pane. Nothing asks a model. Every harness when",
16
- `none is named: ${HARNESS_NAMES.join(", ")}.`,
17
- "",
18
- "A plan's monthly price is the vendor's list price as of the table's date, not what was",
19
- `charged: ${PLAN_BASIS}.`,
20
- "",
21
- `--json prints one document, the interface for scripts, with the plan as the`,
22
- "harness names it and no price; what it prints otherwise is for reading. A harness that could",
23
- "not be read says why. Exits 0 once every harness was asked, 2 on a usage error.",
24
- ].join("\n");
12
+ export const allowanceUsage = help.trimEnd();
25
13
 
26
14
  export type AllowanceCommand = { harnesses: Harness[]; json: boolean } | "help";
27
15
 
@@ -1,5 +1,5 @@
1
1
  import { constants } from "node:os";
2
- import type { StageNeed, StageRecord } from "@agentswf/contract/records";
2
+ import type { StageRecord } from "@agentswf/contract/records";
3
3
  import {
4
4
  type AbsoluteDeadline,
5
5
  type AttemptOutcome,
@@ -37,8 +37,6 @@ export type AttemptEnd = {
37
37
  * the run's stages to choose from.
38
38
  */
39
39
  choose?: readonly StageRecord[];
40
- /** The stages whose values `--values` gives, to start at a later one. */
41
- needs?: readonly StageNeed[];
42
40
  };
43
41
 
44
42
  /** What a started run came to: the result it returned, or what it threw. */
@@ -107,12 +105,12 @@ export function decideEnding(
107
105
  reason: stop?.reason ?? errorDetail(error),
108
106
  stages,
109
107
  continue: goOn(stop, stages),
108
+ ...(stop?.needs ? { needs: [...stop.needs] } : {}),
110
109
  },
111
110
  ...(settled ? { settled } : {}),
112
111
  exitCode: cancellation ? signalExitCode(cancellation.reason) : OUTCOMES[kind].exitCode!,
113
112
  ...(beside ? { alsoFailed: beside } : {}),
114
113
  ...(stop instanceof FromStageUnreached ? { choose: stop.recorded } : {}),
115
- ...(stop?.needs ? { needs: stop.needs } : {}),
116
114
  };
117
115
  }
118
116
 
@@ -58,7 +58,7 @@ export async function keepRecords(
58
58
  outcome: ending.kind,
59
59
  reason: ending.reason,
60
60
  ...at,
61
- ...(end.needs ? { needs: [...end.needs] } : {}),
61
+ ...(ending.needs ? { needs: ending.needs } : {}),
62
62
  };
63
63
  const kept: Kept = report ? { report } : {};
64
64
  const output = outputFile(dir);
@@ -70,6 +70,7 @@ export async function keepRecords(
70
70
  } else {
71
71
  const record: OutputRecord = {
72
72
  version: OUTPUT_RECORD_VERSION,
73
+ ref: `${context.loaded.workflow.meta.name}/${settled.runId}`,
73
74
  runId: settled.runId,
74
75
  attempt: attempt.record.attempt,
75
76
  workflow: { name: context.loaded.workflow.meta.name, file: context.loaded.file },
@@ -107,7 +108,7 @@ export async function keepRecords(
107
108
  const unacknowledged =
108
109
  ending.kind === "completed" && attempt.run.record.events
109
110
  ? await readMailbox(dir).then(
110
- // The event that started the run is `workflow.started`'s, taken or not.
111
+ // The event that started the run is `wf.started`'s, taken or not.
111
112
  (records) => {
112
113
  const state = foldMailbox(records);
113
114
  return state.unacknowledged.filter((id) => id !== state.started?.id).length;
@@ -71,7 +71,7 @@ export function printEnding(
71
71
  records: pathFrom(shellCwd, context.run.dir, { home: context.home }),
72
72
  ...(kept.report ? { report: pathFrom(shellCwd, kept.report, { home: context.home }) } : {}),
73
73
  ...(end.choose?.length ? { choose: listRecorded(end.choose, context.now) } : {}),
74
- ...(end.needs ? { needs: end.needs } : {}),
74
+ ...("needs" in end.ending && end.ending.needs ? { needs: end.ending.needs } : {}),
75
75
  ...(settled?.panes ? { panes: settled.panes } : {}),
76
76
  // As the run's Herdr workspace is labelled.
77
77
  runWorkspace: `awf ${context.workflow.meta.name} ${context.run.id} #${context.n}`,
@@ -21,7 +21,7 @@ export type MessagingHandler = Pick<
21
21
  "checkFinish" | "peers" | "send" | "reply" | "owing"
22
22
  >;
23
23
 
24
- /** `wf` accepts a 1 MiB value; escaped into the request's JSON it can double, plus the envelope. */
24
+ /** `wfa` accepts a 1 MiB value; escaped into the request's JSON it can double, plus the envelope. */
25
25
  const MAX_RESULT_REQUEST_BYTES = 2 * 1024 * 1024 + 4 * 1024;
26
26
  const MAX_CONTROL_CONNECTIONS = 64;
27
27
  const CONNECTION_TIMEOUT_SECONDS = 30;
@@ -321,16 +321,17 @@ type Handlers = {
321
321
  lifetime: (milliseconds: number | undefined) => void;
322
322
  };
323
323
 
324
- async function handleFrame(frame: string, handlers: Handlers): Promise<ControlResponse> {
324
+ async function handleFrame(frame: string, handlers: Handlers): Promise<Answer> {
325
325
  const { messaging, agentId } = handlers;
326
326
  const response = await answerFrame(frame, handlers);
327
+ if (inOtherVersion(response)) return response;
327
328
  // Leads every response, so what is owed comes back after a compaction; not one that already
328
329
  // says it, about the wait it began or the answer it is refused for.
329
330
  const repeats =
330
331
  response.kind === "awaiting-reply" ||
331
332
  (response.kind === "sent" && response.until !== undefined) ||
332
333
  (response.kind === "rejected" &&
333
- (response.code === "owes-reply" || response.code === "awaiting"));
334
+ (response.code === "owes-reply" || response.code === "already-awaiting"));
334
335
  const owing = repeats ? undefined : messaging?.owing(agentId);
335
336
  return owing ? { ...response, owing } : response;
336
337
  }
@@ -338,7 +339,7 @@ async function handleFrame(frame: string, handlers: Handlers): Promise<ControlRe
338
339
  async function answerFrame(
339
340
  frame: string,
340
341
  { slots, messaging, tools, agentId, onSession, lifetime }: Handlers,
341
- ): Promise<ControlResponse> {
342
+ ): Promise<Answer> {
342
343
  if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
343
344
  return rejected("invalid-request", "expected exactly one newline-delimited JSON request");
344
345
  }
@@ -350,7 +351,10 @@ async function answerFrame(
350
351
  return rejected("invalid-request", "request is not valid JSON");
351
352
  }
352
353
  const decoded = decodeControlRequest(parsed);
353
- if (!decoded.ok) return rejected(decoded.code, decoded.error);
354
+ if (!decoded.ok)
355
+ return decoded.code === "unsupported-version"
356
+ ? otherVersion((parsed as { version: number }).version)
357
+ : rejected(decoded.code, decoded.error);
354
358
  // Before the result is judged: a rejected submission still proves which session sent it.
355
359
  if (decoded.value.session) onSession?.(decoded.value.session);
356
360
 
@@ -447,7 +451,35 @@ function rejected(
447
451
  return { version: WIRE_VERSION, kind: "rejected", code, error };
448
452
  }
449
453
 
450
- function queueResponse(socket: Bun.Socket<ConnectionState>, response: ControlResponse): void {
454
+ /** A refusal in another wire version than this engine's: the one answer not a `ControlResponse`. */
455
+ type OtherVersion = {
456
+ version: number;
457
+ kind: "rejected";
458
+ code: "unsupported-version";
459
+ error: string;
460
+ };
461
+ type Answer = ControlResponse | OtherVersion;
462
+
463
+ function inOtherVersion(answer: Answer): answer is OtherVersion {
464
+ return answer.version !== WIRE_VERSION;
465
+ }
466
+
467
+ /**
468
+ * Refused in the version the request gave: a `wfa` of version 4 or 5 decodes a rejection of that
469
+ * shape and prints its error, where it would refuse this engine's version unread. They meet when
470
+ * awf is installed anew in place while a run is live: a launcher runs the `wfa` on disk, which is
471
+ * no longer the one the engine in memory shipped with.
472
+ */
473
+ function otherVersion(version: number): OtherVersion {
474
+ return {
475
+ version,
476
+ kind: "rejected",
477
+ code: "unsupported-version",
478
+ error: `this wfa speaks wire version ${version} and the workflow engine ${WIRE_VERSION}: awf was installed anew while the run was live. Nothing was done; the run must go on under one version of awf.`,
479
+ };
480
+ }
481
+
482
+ function queueResponse(socket: Bun.Socket<ConnectionState>, response: Answer): void {
451
483
  socket.data.outgoing = Buffer.from(`${JSON.stringify(response)}\n`);
452
484
  socket.data.written = 0;
453
485
  flushResponse(socket);
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { chmod, mkdir, realpath, rm, stat } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
4
4
  import type { ParticipantResultJson } from "@agentswf/contract/records";
5
- import { decodeHolderRequest, type NudgeWireResult } from "@agentswf/contract/wire";
5
+ import { decodeHolderRequest, type NudgeWireResult, WIRE_VERSION } from "@agentswf/contract/wire";
6
6
  import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
7
7
  import { realPath } from "./files";
8
8
  import type { MessagingEventBody } from "./messaging/messaging";
@@ -83,7 +83,10 @@ export function coordinatorSocket(options: {
83
83
  let closed = false;
84
84
  const id = (n: number) => `${options.attempt}.${n}`;
85
85
 
86
- /** Undefined for a request no `awf` command makes: the connection closes unanswered. */
86
+ /**
87
+ * Undefined for a request no `awf` command makes: the connection closes unanswered. One of
88
+ * another wire version is refused naming both, which an older `awf send` or `awf reply` prints.
89
+ */
87
90
  const answer = async (
88
91
  frame: string,
89
92
  ): Promise<ParticipantResultJson | NudgeWireResult | undefined> => {
@@ -94,7 +97,13 @@ export function coordinatorSocket(options: {
94
97
  return undefined;
95
98
  }
96
99
  const decoded = decodeHolderRequest(parsed);
97
- if (!decoded.ok) return undefined;
100
+ if (!decoded.ok)
101
+ return decoded.code === "unsupported-version"
102
+ ? refused(
103
+ "unsupported-version",
104
+ `this awf speaks wire version ${(parsed as { version: number }).version} and the run's engine ${WIRE_VERSION}: awf was installed anew while the run was live. Reach it with the awf it started with, or let it end and continue it under this one.`,
105
+ )
106
+ : undefined;
98
107
  const request = decoded.value;
99
108
  if (request.command === "nudge") {
100
109
  if (!options.nudged) return undefined;
@@ -119,9 +128,9 @@ export function coordinatorSocket(options: {
119
128
  const result =
120
129
  request.command === "reply" ? await holder.reply(asked) : await holder.send(asked);
121
130
  return result.code === "delivered"
122
- ? { v: 1, code: "delivered", id: id(result.message) }
131
+ ? { v: 2, code: "delivered", id: id(result.message) }
123
132
  : {
124
- v: 1,
133
+ v: 2,
125
134
  code: result.code,
126
135
  error: result.error,
127
136
  ...(result.choices ? { choices: result.choices } : {}),
@@ -214,7 +223,7 @@ function refused(
214
223
  code: Exclude<ParticipantResultJson["code"], "delivered">,
215
224
  error: string,
216
225
  ): ParticipantResultJson {
217
- return { v: 1, code, error };
226
+ return { v: 2, code, error };
218
227
  }
219
228
 
220
229
  /**
package/src/deadlines.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { type AbsoluteDeadline, DeadlineExceededError } from "@agentswf/contract/workflow";
1
+ import {
2
+ type AbsoluteDeadline,
3
+ DeadlineExceededError,
4
+ positiveDurationMilliseconds,
5
+ type Timeout,
6
+ } from "@agentswf/contract/workflow";
2
7
 
3
8
  export class WorkflowCancelledError extends Error {
4
9
  constructor(readonly reason: unknown) {
@@ -96,11 +101,73 @@ export function deadlineWithin(
96
101
  ceiling: AbsoluteDeadline | undefined,
97
102
  ): AbsoluteDeadline {
98
103
  if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
99
- throw new Error("timeoutMs must be a positive safe integer");
104
+ throw new Error(`timeoutMs is a positive duration, in whole milliseconds: not ${milliseconds}`);
100
105
  }
101
106
  return earlierDeadline({ unixMilliseconds: Date.now() + milliseconds }, ceiling);
102
107
  }
103
108
 
109
+ /** A call's bound as a workflow gives it: `timeout`, or the deprecated `deadline` or `timeoutMs`. */
110
+ export type GivenBound = {
111
+ timeout?: Timeout;
112
+ deadline?: AbsoluteDeadline;
113
+ timeoutMs?: number;
114
+ };
115
+
116
+ /**
117
+ * The deadline a call's bound sets, never past `ceiling`, the deadlines around it; `ceiling` when it
118
+ * sets none of its own. A deprecated form is checked, then reported under `call`, the call it was
119
+ * given to, and refused beside `timeout`. `null` from an untyped caller is no bound, as it was.
120
+ */
121
+ export function boundedDeadline(
122
+ given: GivenBound,
123
+ ceiling: AbsoluteDeadline | undefined,
124
+ deprecated: (name: "timeout-ms" | "deadline-option", call: string) => void,
125
+ call: string,
126
+ ): AbsoluteDeadline | undefined {
127
+ const { timeout, deadline, timeoutMs } = given;
128
+ if (timeout != null) {
129
+ if (deadline != null || timeoutMs != null)
130
+ throw new Error(
131
+ "give timeout alone: deadline and timeoutMs are its deprecated forms, not limits beside it",
132
+ );
133
+ return timeoutDeadline(timeout, ceiling);
134
+ }
135
+ if (deadline != null && timeoutMs != null)
136
+ throw new Error("an operation cannot specify both deadline and timeoutMs");
137
+ if (timeoutMs != null) {
138
+ const bounded = deadlineWithin(timeoutMs, ceiling);
139
+ deprecated("timeout-ms", call);
140
+ return bounded;
141
+ }
142
+ if (deadline == null) return ceiling;
143
+ // A copy: the object given may be `wf.deadline`, whose `unixMilliseconds` reports each read.
144
+ const own = { unixMilliseconds: deadline.unixMilliseconds };
145
+ assertDeadlineValue(own);
146
+ deprecated("deadline-option", call);
147
+ return earlierDeadline(own, ceiling);
148
+ }
149
+
150
+ /** The deadline `timeout` sets from now, never past `ceiling`; `ceiling` for `false`. */
151
+ export function timeoutDeadline(
152
+ timeout: Timeout,
153
+ ceiling: AbsoluteDeadline | undefined,
154
+ ): AbsoluteDeadline | undefined {
155
+ if (timeout === false) return ceiling;
156
+ if (timeout instanceof Date) {
157
+ const at = timeout.getTime();
158
+ if (!Number.isSafeInteger(at) || at < 0) throw new Error("a timeout's Date is a valid time");
159
+ return earlierDeadline({ unixMilliseconds: at }, ceiling);
160
+ }
161
+ if (typeof timeout !== "string")
162
+ throw new Error(
163
+ `a timeout is a Duration such as "10m", a Date, or false; not ${JSON.stringify(timeout)}`,
164
+ );
165
+ return earlierDeadline(
166
+ { unixMilliseconds: Date.now() + positiveDurationMilliseconds(timeout, "a timeout") },
167
+ ceiling,
168
+ );
169
+ }
170
+
104
171
  export function assertDeadline(deadline: AbsoluteDeadline | undefined): void {
105
172
  if (!deadline) return;
106
173
  assertDeadlineValue(deadline);
@@ -14,13 +14,7 @@ import {
14
14
  type Question,
15
15
  } from "@agentswf/contract/workflow";
16
16
  import { canonical } from "../canonical-json";
17
- import {
18
- abortableSleep,
19
- assertDeadline,
20
- assertDeadlineValue,
21
- earlierDeadline,
22
- scheduleAt,
23
- } from "../deadlines";
17
+ import { abortableSleep, assertDeadline, boundedDeadline, scheduleAt } from "../deadlines";
24
18
  import { messageOf } from "../errors";
25
19
  import type { RunEventBody } from "../run-event-log";
26
20
  import {
@@ -41,6 +35,8 @@ export type DecisionScope = {
41
35
  add?(cancel: (reason: string) => Promise<unknown>): () => void;
42
36
  /** The workflow stage it is asked in. */
43
37
  stage?: string;
38
+ /** The spec gave a deprecated form of `timeout`. */
39
+ deprecated?(name: "timeout-ms" | "deadline-option", call: string): void;
44
40
  };
45
41
 
46
42
  type PreparedCall = {
@@ -133,10 +129,12 @@ export class RunDecisions {
133
129
  ? installed.providers[resolved.provider]
134
130
  : undefined;
135
131
  if (!provider) throw new Error(`decision provider "${resolved.provider}" is not installed`);
136
- if (spec.deadline) assertDeadlineValue(spec.deadline);
137
- const deadline = spec.deadline
138
- ? earlierDeadline(spec.deadline, scope.deadline)
139
- : scope.deadline;
132
+ const deadline = boundedDeadline(
133
+ { timeout: spec.timeout, deadline: spec.deadline },
134
+ scope.deadline,
135
+ (name, call) => scope.deprecated?.(name, call),
136
+ "on decide",
137
+ );
140
138
  assertDeadline(deadline);
141
139
  const slot = this.#slots.length;
142
140
  this.#slots.push(undefined);