@agentswf/engine 0.1.0-beta.4 → 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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/agent-cli/cli.ts +57 -17
  3. package/src/attempt-ending.ts +2 -4
  4. package/src/attempt-output.ts +2 -1
  5. package/src/attempt-view.ts +1 -1
  6. package/src/control-plane.ts +37 -5
  7. package/src/coordinator-socket.ts +15 -6
  8. package/src/deadlines.ts +69 -2
  9. package/src/decisions/directory.ts +9 -11
  10. package/src/deprecations.ts +177 -0
  11. package/src/duration.ts +50 -20
  12. package/src/emit-command.ts +55 -41
  13. package/src/emit.ts +124 -15
  14. package/src/help/emit.txt +12 -4
  15. package/src/help/inbox.txt +2 -2
  16. package/src/help/logs.txt +5 -4
  17. package/src/help/reply.txt +5 -5
  18. package/src/help/run.txt +21 -7
  19. package/src/herdr-run-session.ts +2 -2
  20. package/src/holder-command.ts +36 -31
  21. package/src/inbox-command.ts +7 -10
  22. package/src/init-template/hello/workflow.test.ts.template +3 -3
  23. package/src/init-template/hello/workflow.ts.template +2 -2
  24. package/src/keep-alive.ts +28 -13
  25. package/src/logs-command.ts +12 -12
  26. package/src/mailbox.ts +2 -20
  27. package/src/messaging/limits.ts +4 -4
  28. package/src/messaging/messaging.ts +26 -21
  29. package/src/messaging/routes.ts +1 -1
  30. package/src/messaging/text.ts +7 -5
  31. package/src/operator-cli.ts +23 -9
  32. package/src/operator-runtime.ts +5 -5
  33. package/src/pane-layout.ts +125 -32
  34. package/src/progress-view.ts +8 -8
  35. package/src/run-command.ts +25 -4
  36. package/src/run-commands.ts +1 -1
  37. package/src/run-prepare.ts +46 -8
  38. package/src/run-usage.ts +1 -1
  39. package/src/run-view.ts +9 -4
  40. package/src/runs-command.ts +12 -10
  41. package/src/runs.ts +44 -8
  42. package/src/sources.ts +42 -6
  43. package/src/stage-ledger.ts +42 -12
  44. package/src/stage-plan.ts +37 -1
  45. package/src/test-command.ts +7 -3
  46. package/src/testing.ts +7 -8
  47. package/src/tools.ts +46 -18
  48. package/src/workflow-loader.ts +5 -2
  49. package/src/workflow-runner.ts +553 -235
  50. package/src/workflow-testing/coordinator.typecheck.ts +7 -7
  51. package/src/workflow-testing/coordinators.ts +2 -2
  52. package/src/workflow-testing/host.ts +107 -38
  53. package/src/workflow-testing/index.ts +45 -836
  54. package/src/workflow-testing/run-test.ts +1213 -0
  55. package/src/workflow-testing/script.ts +102 -41
  56. package/src/workflow-testing/workflow-testing.typecheck.ts +11 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentswf/engine",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.5",
4
4
  "description": "agents.wf: the engine that runs workflows made of coding agents",
5
5
  "license": "MIT",
6
6
  "homepage": "https://agents.wf",
@@ -29,9 +29,9 @@
29
29
  "!src/**/fixtures"
30
30
  ],
31
31
  "dependencies": {
32
- "@agentswf/contract": "0.1.0-beta.4",
33
- "@agentswf/harness": "0.1.0-beta.4",
34
- "@agentswf/sandbox": "0.1.0-beta.4",
32
+ "@agentswf/contract": "0.1.0-beta.5",
33
+ "@agentswf/harness": "0.1.0-beta.5",
34
+ "@agentswf/sandbox": "0.1.0-beta.5",
35
35
  "typebox": "^1.3.34"
36
36
  },
37
37
  "publishConfig": {
@@ -5,6 +5,7 @@ import {
5
5
  validWaitingReason,
6
6
  WIRE_VERSION,
7
7
  } from "@agentswf/contract/wire";
8
+ import { durationMilliseconds } from "@agentswf/contract/workflow";
8
9
  import { NotSentError, submitControl } from "./client";
9
10
 
10
11
  /** `wfa` is its launcher's full path, as an agent must type it; `wfa` alone when it is not known. */
@@ -73,21 +74,36 @@ export async function runCli(
73
74
  }
74
75
  switch (command) {
75
76
  case "waiting":
76
- return runWaiting(endpoint, session, args, submit);
77
+ case "send":
78
+ case "reply": {
79
+ const said: Said = {};
80
+ const outcome =
81
+ command === "waiting"
82
+ ? await runWaiting(endpoint, session, args, submit, said)
83
+ : await runMessage(endpoint, session, command, args, stdin, wfa, submit, said);
84
+ return noticed(outcome, said.notice);
85
+ }
77
86
  case "result":
78
87
  return runResult(endpoint, session, args, stdin, submit);
79
88
  case "tool":
80
89
  return runTool(endpoint, session, args, stdin, submit);
81
90
  case "peers":
82
91
  return runPeers(endpoint, session, args, submit);
83
- case "send":
84
- case "reply":
85
- return runMessage(endpoint, session, command, args, stdin, wfa, submit);
86
92
  default:
87
93
  return usageError(usage(wfa));
88
94
  }
89
95
  }
90
96
 
97
+ /** What a command accepted in a deprecated form, said beside its outcome. */
98
+ type Said = { notice?: string };
99
+
100
+ /** Never beside a usage error: the form said was not taken then. */
101
+ function noticed(outcome: CliOutcome, notice: string | undefined): CliOutcome {
102
+ return notice && outcome.exitCode !== 2
103
+ ? { ...outcome, stderr: outcome.stderr ? `${notice}\n${outcome.stderr}` : notice }
104
+ : outcome;
105
+ }
106
+
91
107
  async function runResult(
92
108
  endpoint: string,
93
109
  session: string | undefined,
@@ -208,6 +224,7 @@ async function runWaiting(
208
224
  session: string | undefined,
209
225
  args: readonly string[],
210
226
  submit: Submit,
227
+ said: Said,
211
228
  ): Promise<CliOutcome> {
212
229
  const [operationId, ...flags] = args;
213
230
  if (!operationId?.trim() || operationId.startsWith("--"))
@@ -226,9 +243,10 @@ async function runWaiting(
226
243
  const duration = values.get("--timeout");
227
244
  let timeoutMs: number | undefined;
228
245
  if (duration !== undefined) {
229
- timeoutMs = durationOf(duration);
230
- if (timeoutMs === undefined)
231
- return usageError("wfa waiting --timeout must be a positive integer with ms, s, m or h");
246
+ const read = durationOf(duration);
247
+ if (!read) return usageError(`wfa waiting --timeout ${DURATION_GRAMMAR}`);
248
+ timeoutMs = read.milliseconds;
249
+ said.notice = read.notice;
232
250
  }
233
251
  let response: ControlResponse;
234
252
  try {
@@ -313,12 +331,14 @@ async function runMessage(
313
331
  stdin: string | null,
314
332
  wfa: string,
315
333
  submit: Submit,
334
+ said: Said,
316
335
  ): Promise<CliOutcome> {
317
336
  const parsed = messageArgs(args);
318
337
  const wrong = (problem: string) =>
319
338
  usageError(`wfa: ${command}: ${problem}\n\n${messageUsage(wfa).join("\n")}`);
320
339
  if ("error" in parsed) return wrong(parsed.error);
321
340
  const { peer, message, awaitReply, timeoutMs } = parsed;
341
+ said.notice = parsed.notice;
322
342
  // A heredoc ends with a newline the agent did not mean as part of the message.
323
343
  const text = message ?? stdin?.replace(/\r?\n$/, "") ?? null;
324
344
  if (text === null || text.trim() === "") return wrong("it needs a message");
@@ -364,9 +384,12 @@ async function runMessage(
364
384
  */
365
385
  function messageArgs(
366
386
  args: readonly string[],
367
- ): { peer: string; message?: string; awaitReply: boolean; timeoutMs?: number } | { error: string } {
387
+ ):
388
+ | { peer: string; message?: string; awaitReply: boolean; timeoutMs?: number; notice?: string }
389
+ | { error: string } {
368
390
  let awaitReply = false;
369
391
  let timeoutMs: number | undefined;
392
+ let notice: string | undefined;
370
393
  const positional: string[] = [];
371
394
  for (let i = 0; i < args.length; i += 1) {
372
395
  const arg = args[i]!;
@@ -377,9 +400,9 @@ function messageArgs(
377
400
  if (positional.length > 1) return { error: "flags go before the message" };
378
401
  if (arg === "--await-reply" && !awaitReply) awaitReply = true;
379
402
  else if (arg === "--timeout" && timeoutMs === undefined) {
380
- timeoutMs = durationOf(args[i + 1] ?? "");
381
- if (timeoutMs === undefined)
382
- return { error: "--timeout takes a positive integer with ms, s, m or h, such as 5m" };
403
+ const read = durationOf(args[i + 1] ?? "");
404
+ if (!read) return { error: `--timeout ${DURATION_GRAMMAR}` };
405
+ ({ milliseconds: timeoutMs, notice } = read);
383
406
  i += 1;
384
407
  } else if (positional.length === 1 && arg !== "--await-reply" && arg !== "--timeout") {
385
408
  return { error: `${arg} is not a flag; a message that starts with -- goes in a heredoc` };
@@ -395,15 +418,32 @@ function messageArgs(
395
418
  ...(rest[0] === undefined ? {} : { message: rest[0] }),
396
419
  awaitReply,
397
420
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
421
+ ...(notice === undefined ? {} : { notice }),
398
422
  };
399
423
  }
400
424
 
401
- function durationOf(text: string): number | undefined {
402
- const match = /^([0-9]+)(ms|s|m|h)$/.exec(text);
403
- if (!match) return undefined;
404
- const factor = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[match[2]!]!;
405
- const value = Number(match[1]) * factor;
406
- return Number.isSafeInteger(value) && value > 0 ? value : undefined;
425
+ const DURATION_GRAMMAR = 'takes a duration, a decimal and s, m, h or d, such as "5m"';
426
+ const LEGACY_MS = /^([0-9]+)ms$/;
427
+
428
+ /**
429
+ * The one grammar's, longer than none. A whole number of `ms` is read until 0.1.0, with the line
430
+ * `wfa` says beside its outcome.
431
+ */
432
+ function durationOf(text: string): { milliseconds: number; notice?: string } | undefined {
433
+ const legacy = LEGACY_MS.exec(text);
434
+ let milliseconds: number;
435
+ try {
436
+ milliseconds = legacy ? Number(legacy[1]) : durationMilliseconds(text);
437
+ } catch {
438
+ return undefined;
439
+ }
440
+ if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) return undefined;
441
+ return legacy
442
+ ? {
443
+ milliseconds,
444
+ notice: `wfa: --timeout ${text} is deprecated; give ${milliseconds / 1000}s: a duration is a decimal and s, m, h or d`,
445
+ }
446
+ : { milliseconds };
407
447
  }
408
448
 
409
449
  /** What is owed leads, so it comes back after a harness compacts; then the acknowledgement. */
@@ -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 },
@@ -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}`,
@@ -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);
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Every name deprecated before 0.1.0, with what replaces it: each is logged once per attempt it is
3
+ * used in, so a continued or woken run logs it again, and removed one beta after the beta that
4
+ * deprecated it (story 039).
5
+ */
6
+ export const DEPRECATIONS = {
7
+ labels: {
8
+ old: "an agent's labels option",
9
+ instead: "drop them: nothing reads an agent's labels",
10
+ },
11
+ whenToUse: { old: "meta.whenToUse", instead: "drop it: nothing reads it" },
12
+ "stage-receive": {
13
+ old: "a stage around wf.receive()",
14
+ instead: "wait with wf.race(name, …), which is recorded under its name, outside a stage",
15
+ },
16
+ isAnswered: { old: "isAnswered(outcome)", instead: "check outcome.answered" },
17
+ defineExecutableWorkflow: {
18
+ old: "defineExecutableWorkflow",
19
+ instead: "use defineWorkflow, with meta and run beside prepare",
20
+ },
21
+ "call-id": {
22
+ old: "the id of one run, start, compact or set",
23
+ instead:
24
+ "drop it: a later attempt doesn't know it, and a stage keeps a call from running twice",
25
+ },
26
+ "turn-source": {
27
+ old: "a started turn as a wait's branch",
28
+ instead: "name the agent, { done: agent }, which has at most one started turn",
29
+ },
30
+ "turn-is": { old: "turn.is(e)", instead: "use agent.is(e)" },
31
+ "test-reply": {
32
+ old: "reply.… in a test's script",
33
+ instead: "use noAnswer.…, the same calls: a reply answers, these end a turn without an answer",
34
+ },
35
+ "message-silent": {
36
+ old: "message.silent() in a test's message script",
37
+ instead: "return nothing, which ends the turn without a reply",
38
+ },
39
+ "message-fail": {
40
+ old: "message.fail() in a test's message script",
41
+ instead: "return noAnswer.failed(reason), which fails the turn as a failed harness does",
42
+ },
43
+ "test-event-id": {
44
+ old: "an event's id in a test",
45
+ instead: "give it as eventId, as awf emit --event-id does",
46
+ },
47
+ "run-stopped": { old: "run.stopped", instead: 'read run.ending, whose kind is "stopped"' },
48
+ "run-parked": { old: "run.parked", instead: 'read run.ending, whose kind is "parked"' },
49
+ "colon-key": {
50
+ old: "a key with a colon",
51
+ instead: (key: string) =>
52
+ `${key === "" ? "use - in its place" : `name it ${key.replaceAll(":", "-")}`}: a colon is kept for awf's own event names`,
53
+ },
54
+ "agents-stop": {
55
+ old: "wf.agents.stop(key)",
56
+ instead: "call close(reason?) on the agent's ref",
57
+ },
58
+ "fork-runtime": {
59
+ old: "a fork's own placement, metered or effort",
60
+ instead:
61
+ "give them in its runtime, as open does: fork({ key, runtime: { placement, metered, effort } })",
62
+ },
63
+ "timeout-ms": {
64
+ old: "timeoutMs",
65
+ instead: 'give timeout, a Duration such as "90s" or "10m"',
66
+ },
67
+ "deadline-option": {
68
+ old: "a deadline: { unixMilliseconds } option",
69
+ instead: 'give timeout: a Date, or a Duration such as "20m"',
70
+ },
71
+ "nudge-deadline": {
72
+ old: "nudge.deadline",
73
+ instead: "drop it: the turn's timeout bounds its check-ins",
74
+ },
75
+ "keep-alive-timeout-ms": {
76
+ old: "keepAlive.timeoutMs",
77
+ instead: 'give keepAlive.window, a Duration such as "90s"',
78
+ },
79
+ "run-deadline-ms": {
80
+ old: "wf.deadline.unixMilliseconds",
81
+ instead: "wf.deadline is a Date: read wf.deadline.getTime()",
82
+ },
83
+ "stall-ms": { old: "a test's stallMs", instead: 'give stall, a Duration such as "2s"' },
84
+ "test-args": {
85
+ old: "testWorkflow(workflow, args, options)",
86
+ instead: "give args among the options: testWorkflow(workflow, { args, … })",
87
+ },
88
+ "test-recorded": { old: "a test's recorded", instead: "give it as earlier: { stages }" },
89
+ "test-mailbox": { old: "a test's mailbox", instead: "give it as earlier: { mailbox }" },
90
+ "to-code": {
91
+ old: 'to: "code"',
92
+ instead: (key: string) =>
93
+ `declare "${key}:reply": ParticipantReply in events and connect it without to: the code takes its messages as ${key}:reply`,
94
+ },
95
+ "undeclared-ended": {
96
+ old: "start() for an agent whose end isn't declared",
97
+ instead: (key: string) => {
98
+ const named = key.replaceAll(":", "-");
99
+ const declare = `declare "${named}:ended": AgentEnded in events: its end then arrives as ${named}:ended, not agent:ended`;
100
+ return key.includes(":")
101
+ ? `rename the key ${named}, which can name its end, and ${declare}`
102
+ : declare;
103
+ },
104
+ },
105
+ "participant-is": {
106
+ old: "is(e) on a participant",
107
+ instead: 'read e.name === "{key}:reply", declared in events',
108
+ },
109
+ "test-participant-reply": {
110
+ old: 'a scripted { name: "participant:reply", from } in a test',
111
+ instead: 'name the declared event, { name: "{key}:reply", data }',
112
+ },
113
+ "send-event-id": {
114
+ old: "awf send --event-id",
115
+ instead:
116
+ 'use awf emit {run} {key}:reply \'{"text":…,"author":…}\' --event-id {id}, with {key}:reply declared in the workflow\'s events',
117
+ },
118
+ "pane-layout": {
119
+ old: "an agent's layout",
120
+ instead:
121
+ "give pane: { tab, front } or pane: { beside, side, front }; the workspace is the operator's, awf run --workspace, and the session AWF_HERDR_SESSION",
122
+ },
123
+ "keep-pane": {
124
+ old: "an agent's keepPane",
125
+ instead: "drop it: keeping panes is the operator's, awf run --keep-panes",
126
+ },
127
+ "duration-ms": {
128
+ old: "a duration in ms",
129
+ instead: (value: string) =>
130
+ `give ${Number.parseInt(value, 10) / 1000}s: a duration is a decimal and s, m, h or d`,
131
+ },
132
+ "wait-seconds": {
133
+ old: "a bare number for --wait",
134
+ instead: (value: string) => `give a duration, --wait ${value}s`,
135
+ },
136
+ } as const satisfies Record<
137
+ string,
138
+ { old: string; instead: string | ((subject: string) => string) }
139
+ >;
140
+
141
+ export type Deprecated = keyof typeof DEPRECATIONS;
142
+
143
+ /** One attempt's deprecated uses: each name said once, as a warning in the run's log. */
144
+ export class RunDeprecations {
145
+ /** Each name said, with its subject where it has one: each key with a colon is named. */
146
+ readonly #said = new Set<string>();
147
+ /** The attempt has ended: an outcome kept past it reports to no log. */
148
+ #settled = false;
149
+
150
+ constructor(private readonly warn: (text: string) => void) {}
151
+
152
+ /** `subject`, where given, is what was used, and each subject is said once. */
153
+ used(name: Deprecated, subject?: string): void {
154
+ const said = subject === undefined ? name : `${name}\0${subject}`;
155
+ if (this.#said.has(said)) return;
156
+ this.#said.add(said);
157
+ this.warn(deprecationLine(name, subject));
158
+ }
159
+
160
+ settle(): void {
161
+ this.#settled = true;
162
+ }
163
+
164
+ /** As `used`, for a name the contract reports by its own spelling; one not listed is ignored. */
165
+ reported = (name: unknown): void => {
166
+ if (this.#settled) return;
167
+ if (typeof name === "string" && Object.hasOwn(DEPRECATIONS, name))
168
+ this.used(name as Deprecated);
169
+ };
170
+ }
171
+
172
+ /** The line a deprecated use logs. */
173
+ export function deprecationLine(name: Deprecated, subject = ""): string {
174
+ const { old, instead } = DEPRECATIONS[name];
175
+ const named = subject === "" ? "" : `, ${subject},`;
176
+ return `awf: ${old}${named} is deprecated; ${typeof instead === "function" ? instead(subject) : instead}`;
177
+ }