@agentswf/engine 0.1.0-beta.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +35 -22
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +5 -7
  11. package/src/attempt-output.ts +15 -32
  12. package/src/attempt-view.ts +34 -33
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +41 -11
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +41 -33
  25. package/src/deadlines.ts +40 -7
  26. package/src/decisions/directory.ts +51 -40
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +43 -27
  32. package/src/emit-command.ts +88 -58
  33. package/src/emit.ts +254 -56
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +18 -6
  39. package/src/help/inbox.txt +6 -4
  40. package/src/help/logs.txt +9 -6
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +30 -11
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +7 -15
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +56 -133
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +40 -57
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.test.ts.template +3 -3
  55. package/src/init-template/hello/workflow.ts.template +5 -4
  56. package/src/jsonl.ts +10 -6
  57. package/src/keep-alive.ts +15 -12
  58. package/src/logs-command.ts +128 -47
  59. package/src/message-operation.ts +6 -3
  60. package/src/messaging/limits.ts +4 -4
  61. package/src/messaging/messaging.ts +31 -28
  62. package/src/messaging/routes.ts +1 -1
  63. package/src/messaging/text.ts +9 -7
  64. package/src/open-command.ts +770 -0
  65. package/src/operation-events.ts +4 -6
  66. package/src/operation-liveness.ts +19 -4
  67. package/src/operator-cli.ts +224 -94
  68. package/src/operator-runtime.ts +29 -7
  69. package/src/ops.ts +270 -0
  70. package/src/pane-layout.ts +105 -59
  71. package/src/progress-view.ts +10 -10
  72. package/src/project-engine.ts +79 -0
  73. package/src/projects.ts +13 -2
  74. package/src/refusals.ts +119 -0
  75. package/src/result-slots.ts +11 -31
  76. package/src/result-validation.ts +2 -2
  77. package/src/run-command.ts +45 -28
  78. package/src/run-commands.ts +43 -115
  79. package/src/run-event-log.ts +1 -1
  80. package/src/run-lines.ts +159 -0
  81. package/src/run-lock.ts +57 -1
  82. package/src/{stage-plan.ts → run-policy/stage-plan.ts} +46 -42
  83. package/src/run-policy/stages.ts +113 -0
  84. package/src/run-policy/waits.ts +452 -0
  85. package/src/run-prepare.ts +205 -43
  86. package/src/run-read.ts +109 -41
  87. package/src/run-state.ts +170 -50
  88. package/src/run-text.ts +54 -0
  89. package/src/run-unreachable.ts +18 -0
  90. package/src/run-usage.ts +18 -12
  91. package/src/run-view.ts +81 -17
  92. package/src/runs-command.ts +83 -63
  93. package/src/runs.ts +310 -212
  94. package/src/sandbox-homes.ts +65 -13
  95. package/src/sandboxes.ts +182 -28
  96. package/src/session-lines.ts +93 -0
  97. package/src/skills/run-skills.ts +8 -1
  98. package/src/sources.ts +28 -29
  99. package/src/spend.ts +333 -0
  100. package/src/stage-ledger.ts +119 -131
  101. package/src/stop-crashed.ts +6 -5
  102. package/src/stopped.ts +4 -4
  103. package/src/test-command.ts +36 -14
  104. package/src/testing/operation-events.ts +3 -23
  105. package/src/testing.ts +73 -26
  106. package/src/tick-command.ts +145 -15
  107. package/src/tools.ts +25 -16
  108. package/src/turn-ends.ts +37 -0
  109. package/src/waits.ts +651 -0
  110. package/src/wake.ts +84 -54
  111. package/src/workflow-loader.ts +54 -4
  112. package/src/workflow-runner.ts +805 -580
  113. package/src/workflow-test-preload.ts +4 -1
  114. package/src/workflow-testing/coordinators.ts +3 -3
  115. package/src/workflow-testing/fake-client.ts +387 -0
  116. package/src/workflow-testing/host.ts +121 -53
  117. package/src/workflow-testing/index.ts +33 -842
  118. package/src/workflow-testing/run-test.ts +1128 -0
  119. package/src/workflow-testing/script.ts +74 -45
  120. package/src/hold-run-lock.fixture.ts +0 -8
  121. package/src/mailbox.ts +0 -990
  122. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  123. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -64
@@ -1,10 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
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";
3
+ import { basename, dirname, isAbsolute, join } from "node:path";
4
+
5
+ import {
6
+ decodeHolderRequest,
7
+ type HolderWireAnswer,
8
+ type NudgeWireResult,
9
+ WIRE_VERSION,
10
+ } from "@agentswf/contract/wire";
6
11
  import type { CoordinatorHolder, CoordinatorPort } from "./coordinator-port";
7
- import { realPath } from "./files";
12
+ import { messageOf } from "./errors";
13
+ import { isInside, realPath } from "./files";
8
14
  import type { MessagingEventBody } from "./messaging/messaging";
9
15
  import type { RunStatus } from "./run-state";
10
16
 
@@ -51,9 +57,8 @@ export async function prepareCoordinatorDir(
51
57
  return parent === path ? path : join(await real(parent), basename(path));
52
58
  }
53
59
  };
54
- const inside = relative(await real(workflowDir), await real(dir));
55
- const within =
56
- inside === "" || !(inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside));
60
+ const [root, realDir] = [await real(workflowDir), await real(dir)];
61
+ const within = realDir === root || isInside(root, realDir);
57
62
  if (moved && within)
58
63
  throw new Error(
59
64
  `AWF_COORDINATOR_DIR ${dir} is inside the workflow's directory, where its agents work; move it out`,
@@ -65,7 +70,7 @@ export async function prepareCoordinatorDir(
65
70
  /**
66
71
  * The socket `awf reply` and `awf send` reach a live run through. It opens once the workflow
67
72
  * connects an outside participant and goes when the run ends; each connection is one request
68
- * line and one `ParticipantResultJson` line back.
73
+ * line and one `HolderWireAnswer` line back.
69
74
  */
70
75
  export function coordinatorSocket(options: {
71
76
  dir: string;
@@ -73,7 +78,7 @@ export function coordinatorSocket(options: {
73
78
  attempt: number;
74
79
  /** Why the socket did not open: the run goes on, and no holder's command reaches it. */
75
80
  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. */
81
+ /** A nudge: the run has a new event. Given, the socket opens at once, for awf's nudge. */
77
82
  nudged?(): void;
78
83
  }): CoordinatorPort & { close(): Promise<void>; readonly path: string } {
79
84
  const path = coordinatorSocketPath(options.dir, options.runDir);
@@ -83,10 +88,11 @@ export function coordinatorSocket(options: {
83
88
  let closed = false;
84
89
  const id = (n: number) => `${options.attempt}.${n}`;
85
90
 
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> => {
91
+ /**
92
+ * Undefined for a request no `awf` command makes: the connection closes unanswered. One of
93
+ * another wire version is refused naming both, which an older `awf send` or `awf reply` prints.
94
+ */
95
+ const answer = async (frame: string): Promise<HolderWireAnswer | NudgeWireResult | undefined> => {
90
96
  let parsed: unknown;
91
97
  try {
92
98
  parsed = JSON.parse(frame);
@@ -94,7 +100,13 @@ export function coordinatorSocket(options: {
94
100
  return undefined;
95
101
  }
96
102
  const decoded = decodeHolderRequest(parsed);
97
- if (!decoded.ok) return undefined;
103
+ if (!decoded.ok)
104
+ return decoded.code === "unsupported-version"
105
+ ? refused(
106
+ "unsupported-version",
107
+ `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.`,
108
+ )
109
+ : undefined;
98
110
  const request = decoded.value;
99
111
  if (request.command === "nudge") {
100
112
  if (!options.nudged) return undefined;
@@ -102,13 +114,14 @@ export function coordinatorSocket(options: {
102
114
  return { v: 1, code: "nudged" };
103
115
  }
104
116
  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;
117
+ const [attempt, n] = request.to?.split(".") ?? [];
118
+ // Another attempt's question ended with it.
119
+ if (request.to !== undefined && attempt !== String(options.attempt))
120
+ return refused(
121
+ "expired",
122
+ `${request.to} is a question of attempt ${attempt}, which has ended; the run is on attempt ${options.attempt}.`,
123
+ );
124
+ const to = n === undefined ? undefined : Number(n);
112
125
  const asked = {
113
126
  ...(request.participant === undefined ? {} : { coordinator: request.participant }),
114
127
  agent: request.agent,
@@ -119,9 +132,9 @@ export function coordinatorSocket(options: {
119
132
  const result =
120
133
  request.command === "reply" ? await holder.reply(asked) : await holder.send(asked);
121
134
  return result.code === "delivered"
122
- ? { v: 1, code: "delivered", id: id(result.message) }
135
+ ? { v: 2, code: "delivered", id: id(result.message) }
123
136
  : {
124
- v: 1,
137
+ v: 2,
125
138
  code: result.code,
126
139
  error: result.error,
127
140
  ...(result.choices ? { choices: result.choices } : {}),
@@ -185,10 +198,7 @@ export function coordinatorSocket(options: {
185
198
  .then((result) => socket.end(result ? `${JSON.stringify(result)}\n` : undefined));
186
199
  };
187
200
 
188
- if (options.nudged)
189
- opening = open().catch((error: unknown) =>
190
- options.failed(error instanceof Error ? error.message : String(error)),
191
- );
201
+ if (options.nudged) opening = open().catch((error: unknown) => options.failed(messageOf(error)));
192
202
  return {
193
203
  path,
194
204
  attach(given) {
@@ -196,9 +206,7 @@ export function coordinatorSocket(options: {
196
206
  },
197
207
  recorded(event: MessagingEventBody) {
198
208
  if (event.kind === "participant-connected" && !opening) {
199
- opening = open().catch((error: unknown) =>
200
- options.failed(error instanceof Error ? error.message : String(error)),
201
- );
209
+ opening = open().catch((error: unknown) => options.failed(messageOf(error)));
202
210
  }
203
211
  },
204
212
  async close() {
@@ -211,10 +219,10 @@ export function coordinatorSocket(options: {
211
219
  }
212
220
 
213
221
  function refused(
214
- code: Exclude<ParticipantResultJson["code"], "delivered">,
222
+ code: Exclude<HolderWireAnswer["code"], "delivered">,
215
223
  error: string,
216
- ): ParticipantResultJson {
217
- return { v: 1, code, error };
224
+ ): HolderWireAnswer {
225
+ return { v: 2, code, error };
218
226
  }
219
227
 
220
228
  /**
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) {
@@ -87,20 +92,48 @@ export function earlierDeadline(
87
92
  return left.unixMilliseconds <= right.unixMilliseconds ? left : right;
88
93
  }
89
94
 
90
- export function laterDeadline(left: AbsoluteDeadline, right: AbsoluteDeadline): AbsoluteDeadline {
91
- return left.unixMilliseconds >= right.unixMilliseconds ? left : right;
92
- }
93
-
94
95
  export function deadlineWithin(
95
96
  milliseconds: number,
96
97
  ceiling: AbsoluteDeadline | undefined,
97
98
  ): AbsoluteDeadline {
98
99
  if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
99
- throw new Error("timeoutMs must be a positive safe integer");
100
+ throw new Error(`timeoutMs is a positive duration, in whole milliseconds: not ${milliseconds}`);
100
101
  }
101
102
  return earlierDeadline({ unixMilliseconds: Date.now() + milliseconds }, ceiling);
102
103
  }
103
104
 
105
+ /**
106
+ * The deadline a call's `timeout` sets, never past `ceiling`, the deadlines around it; `ceiling`
107
+ * when it sets none of its own. `null` from an untyped caller is no bound.
108
+ */
109
+ export function boundedDeadline(
110
+ timeout: Timeout | null | undefined,
111
+ ceiling: AbsoluteDeadline | undefined,
112
+ ): AbsoluteDeadline | undefined {
113
+ return timeout == null ? ceiling : timeoutDeadline(timeout, ceiling);
114
+ }
115
+
116
+ /** The deadline `timeout` sets from now, never past `ceiling`; `ceiling` for `false`. */
117
+ function timeoutDeadline(
118
+ timeout: Timeout,
119
+ ceiling: AbsoluteDeadline | undefined,
120
+ ): AbsoluteDeadline | undefined {
121
+ if (timeout === false) return ceiling;
122
+ if (timeout instanceof Date) {
123
+ const at = timeout.getTime();
124
+ if (!Number.isSafeInteger(at) || at < 0) throw new Error("a timeout's Date is a valid time");
125
+ return earlierDeadline({ unixMilliseconds: at }, ceiling);
126
+ }
127
+ if (typeof timeout !== "string")
128
+ throw new Error(
129
+ `a timeout is a Duration such as "10m", a Date, or false; not ${JSON.stringify(timeout)}`,
130
+ );
131
+ return earlierDeadline(
132
+ { unixMilliseconds: Date.now() + positiveDurationMilliseconds(timeout, "a timeout") },
133
+ ceiling,
134
+ );
135
+ }
136
+
104
137
  export function assertDeadline(deadline: AbsoluteDeadline | undefined): void {
105
138
  if (!deadline) return;
106
139
  assertDeadlineValue(deadline);
@@ -109,7 +142,7 @@ export function assertDeadline(deadline: AbsoluteDeadline | undefined): void {
109
142
  }
110
143
  }
111
144
 
112
- export function assertDeadlineValue(deadline: AbsoluteDeadline): void {
145
+ function assertDeadlineValue(deadline: AbsoluteDeadline): void {
113
146
  if (!Number.isSafeInteger(deadline.unixMilliseconds) || deadline.unixMilliseconds < 0) {
114
147
  throw new Error("deadline.unixMilliseconds must be a non-negative safe integer");
115
148
  }
@@ -1,7 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
- import type { DecisionArtifact, Money, SettledDecision } from "@agentswf/contract/records";
3
+ import {
4
+ type DecisionArtifact,
5
+ type Money,
6
+ OP_FILE_VERSIONS,
7
+ type SettledDecision,
8
+ } from "@agentswf/contract/records";
5
9
  import {
6
10
  type AbsoluteDeadline,
7
11
  type Answers,
@@ -14,14 +18,10 @@ import {
14
18
  type Question,
15
19
  } from "@agentswf/contract/workflow";
16
20
  import { canonical } from "../canonical-json";
17
- import {
18
- abortableSleep,
19
- assertDeadline,
20
- assertDeadlineValue,
21
- earlierDeadline,
22
- scheduleAt,
23
- } from "../deadlines";
21
+ import { abortableSleep, assertDeadline, boundedDeadline, scheduleAt } from "../deadlines";
24
22
  import { messageOf } from "../errors";
23
+ import { writeJson } from "../files";
24
+ import { decisionArtifact, type OpIds } from "../ops";
25
25
  import type { RunEventBody } from "../run-event-log";
26
26
  import {
27
27
  type DecisionInstallation,
@@ -70,6 +70,8 @@ export class RunDecisions {
70
70
  private readonly options: {
71
71
  installation?: DecisionInstallation;
72
72
  runDir: string;
73
+ /** Numbers each asking, `{key}.{n}`, and keeps the keys apart from agents'. */
74
+ ops: OpIds;
73
75
  sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
74
76
  /** Writes the run's event log. */
75
77
  event?(body: RunEventBody): void;
@@ -133,22 +135,12 @@ export class RunDecisions {
133
135
  ? installed.providers[resolved.provider]
134
136
  : undefined;
135
137
  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;
138
+ const deadline = boundedDeadline(spec.timeout, scope.deadline);
140
139
  assertDeadline(deadline);
140
+ // Last: a decision refused for anything else holds no key.
141
+ this.options.ops.claim(spec.key, "decision");
141
142
  const slot = this.#slots.length;
142
143
  this.#slots.push(undefined);
143
- this.options.event?.({
144
- kind: "decision-asked",
145
- decision: slot + 1,
146
- ...(scope.stage === undefined ? {} : { stage: scope.stage }),
147
- key: spec.key,
148
- alias: spec.model,
149
- provider: resolved.provider,
150
- model: resolved.model,
151
- });
152
144
  return { spec, ids, resolved, provider, deadline, slot };
153
145
  }
154
146
 
@@ -157,6 +149,12 @@ export class RunDecisions {
157
149
  scope: DecisionScope,
158
150
  ): Promise<{ answers: Record<string, unknown>; record: DecisionRecord }> {
159
151
  const { spec, ids, resolved, provider, deadline, slot } = call;
152
+ const { runDir, ops } = this.options;
153
+ // Its folder and op.json are written as its id is drawn, before any line names it.
154
+ const { id: operation, lost: opLost } = ops.draw(spec.key, {
155
+ kind: "decision",
156
+ ...(scope.stage === undefined ? {} : { stage: scope.stage }),
157
+ });
160
158
  const startedAt = new Date().toISOString();
161
159
  const controller = new AbortController();
162
160
  let ending: Ending | undefined;
@@ -170,17 +168,27 @@ export class RunDecisions {
170
168
  : () => undefined;
171
169
  this.#cancellers.add(cancel);
172
170
  const removeFromScope = scope.add?.(async (reason) => cancel(reason));
171
+ this.options.event?.({
172
+ kind: "decision-asked",
173
+ decision: slot + 1,
174
+ operation,
175
+ ...(scope.stage === undefined ? {} : { stage: scope.stage }),
176
+ key: spec.key,
177
+ alias: spec.model,
178
+ provider: resolved.provider,
179
+ model: resolved.model,
180
+ });
173
181
 
174
182
  let attempts = 0;
175
183
  let tokens: { input: number; output: number } | undefined;
176
- let charged: Money[] = [];
184
+ let reportedCosts: Money[] = [];
177
185
  let requestId: string | undefined;
178
186
  let response: ProviderResponse | undefined;
179
187
  let answers: Record<string, unknown> | undefined;
180
188
  let error: string | undefined;
181
189
  const count = (spent: {
182
190
  tokens?: ProviderResponse["tokens"];
183
- charged?: Money;
191
+ reportedCost?: Money;
184
192
  requestId?: string;
185
193
  }) => {
186
194
  if (spent.tokens) {
@@ -189,7 +197,7 @@ export class RunDecisions {
189
197
  output: (tokens?.output ?? 0) + spent.tokens.output,
190
198
  };
191
199
  }
192
- if (spent.charged) charged = [...charged, spent.charged];
200
+ if (spent.reportedCost) reportedCosts = [...reportedCosts, spent.reportedCost];
193
201
  if (spent.requestId) requestId = spent.requestId;
194
202
  };
195
203
  const request = {
@@ -232,7 +240,7 @@ export class RunDecisions {
232
240
  this.#cancellers.delete(cancel);
233
241
  }
234
242
 
235
- const artifact = join("decisions", `${slot + 1}.json`);
243
+ const artifact = decisionArtifact(operation);
236
244
  const outcome: DecisionRecord["outcome"] = ending
237
245
  ? ending.outcome
238
246
  : answers
@@ -263,19 +271,21 @@ export class RunDecisions {
263
271
  ...(requestId ? { requestId } : {}),
264
272
  artifact,
265
273
  };
266
- const total = sumMoney(charged);
267
- let settled: SettledDecision = { ...record, ...(total ? { charged: total } : {}) };
274
+ const total = sumMoney(reportedCosts);
275
+ let settled: SettledDecision = { ...record, ...(total ? { reportedCost: total } : {}) };
268
276
  const written: DecisionArtifact = {
277
+ version: OP_FILE_VERSIONS.decision,
269
278
  record: settled,
270
279
  request: { state: spec.state, questions: spec.questions },
271
280
  ...(answers ? { answers: answers as JsonObject } : {}),
272
281
  };
273
- try {
274
- await mkdir(join(this.options.runDir, "decisions"), { recursive: true });
275
- await writeFile(join(this.options.runDir, artifact), `${JSON.stringify(written, null, 2)}\n`);
276
- } catch (thrown) {
277
- const lost = `artifact not written: ${messageOf(thrown)}`;
278
- // Without its artifact an answer cannot be fitted again, so it does not count as one; any
282
+ const artifactLost = await writeJson(join(runDir, artifact), written).then(
283
+ () => undefined,
284
+ (thrown: unknown) => `artifact not written: ${messageOf(thrown)}`,
285
+ );
286
+ if (opLost !== undefined || artifactLost !== undefined) {
287
+ const lost = [opLost, artifactLost].filter((each) => each !== undefined).join("; ");
288
+ // Without its records an answer cannot be fitted again, so it does not count as one; any
279
289
  // other outcome stands.
280
290
  settled =
281
291
  settled.outcome === "answered"
@@ -286,11 +296,12 @@ export class RunDecisions {
286
296
  this.options.event?.({
287
297
  kind: "decision-ended",
288
298
  decision: slot + 1,
299
+ operation,
289
300
  ...(settled.stage === undefined ? {} : { stage: settled.stage }),
290
301
  outcome: settled.outcome,
291
302
  ...(settled.error === undefined ? {} : { error: settled.error }),
292
303
  });
293
- const { charged: _charged, ...returned } = settled;
304
+ const { reportedCost: _reportedCost, ...returned } = settled;
294
305
 
295
306
  if (returned.outcome === "answered") return { answers: answers!, record: returned };
296
307
  if (returned.outcome === "timed-out") throw new DeadlineExceededError(deadline!);
@@ -408,11 +419,11 @@ function isProbability(value: unknown): value is number {
408
419
  return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
409
420
  }
410
421
 
411
- /** Charges in more than one currency cannot be added without converting, so their sum is unknown. */
412
- function sumMoney(charges: readonly Money[]): Money | undefined {
413
- const currency = charges[0]?.currency;
414
- if (!currency || charges.some((charge) => charge.currency !== currency)) return undefined;
415
- return { amount: charges.reduce((sum, charge) => sum + charge.amount, 0), currency };
422
+ /** Costs in more than one currency cannot be added without converting, so their sum is unknown. */
423
+ function sumMoney(costs: readonly Money[]): Money | undefined {
424
+ const currency = costs[0]?.currency;
425
+ if (!currency || costs.some((cost) => cost.currency !== currency)) return undefined;
426
+ return { amount: costs.reduce((sum, cost) => sum + cost.amount, 0), currency };
416
427
  }
417
428
 
418
429
  function untilAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
@@ -36,7 +36,7 @@ export function confidentResponse(request: ProviderRequest): ProviderResponse {
36
36
  snapshot: `${request.model}-20260926`,
37
37
  answers,
38
38
  tokens: { input: 1_000, output: 10 },
39
- charged: { amount: 0.000042, currency: "USD" },
39
+ reportedCost: { amount: 0.000042, currency: "USD" },
40
40
  requestId: `request-${Object.keys(request.questions).join("-")}`,
41
41
  };
42
42
  }
@@ -130,7 +130,7 @@ function levelsOf(question: Question, probabilities: Record<string, unknown>): n
130
130
  }
131
131
 
132
132
  /** What a body says was spent, as far as it says: tokens only when both counts are reported. */
133
- function spendOf(body: unknown): Pick<ProviderResponse, "tokens" | "charged" | "requestId"> {
133
+ function spendOf(body: unknown): Pick<ProviderResponse, "tokens" | "reportedCost" | "requestId"> {
134
134
  if (!isRecord(body)) return {};
135
135
  const usage = isRecord(body.usage) ? body.usage : {};
136
136
  const counted = typeof usage.input_tokens === "number" && typeof usage.output_tokens === "number";
@@ -138,7 +138,9 @@ function spendOf(body: unknown): Pick<ProviderResponse, "tokens" | "charged" | "
138
138
  ...(counted
139
139
  ? { tokens: { input: usage.input_tokens as number, output: usage.output_tokens as number } }
140
140
  : {}),
141
- ...(typeof usage.cost === "number" ? { charged: { amount: usage.cost, currency: "USD" } } : {}),
141
+ ...(typeof usage.cost === "number"
142
+ ? { reportedCost: { amount: usage.cost, currency: "USD" } }
143
+ : {}),
142
144
  ...(typeof body.id === "string" ? { requestId: body.id } : {}),
143
145
  };
144
146
  }
@@ -24,7 +24,7 @@ export type ProviderResponse = {
24
24
  answers: Record<string, ProviderAnswer>;
25
25
  /** Absent when the provider did not report them: unknown, not zero. */
26
26
  tokens?: { input: number; output: number };
27
- charged?: Money;
27
+ reportedCost?: Money;
28
28
  requestId?: string;
29
29
  };
30
30
 
@@ -40,7 +40,7 @@ export interface DecisionProvider {
40
40
  export class DecisionProviderError extends Error {
41
41
  readonly retryable: boolean;
42
42
  readonly tokens?: { input: number; output: number };
43
- readonly charged?: Money;
43
+ readonly reportedCost?: Money;
44
44
  readonly requestId?: string;
45
45
 
46
46
  constructor(
@@ -48,7 +48,7 @@ export class DecisionProviderError extends Error {
48
48
  options: {
49
49
  retryable: boolean;
50
50
  tokens?: { input: number; output: number };
51
- charged?: Money;
51
+ reportedCost?: Money;
52
52
  requestId?: string;
53
53
  },
54
54
  ) {
@@ -56,7 +56,7 @@ export class DecisionProviderError extends Error {
56
56
  this.name = "DecisionProviderError";
57
57
  this.retryable = options.retryable;
58
58
  if (options.tokens) this.tokens = options.tokens;
59
- if (options.charged) this.charged = options.charged;
59
+ if (options.reportedCost) this.reportedCost = options.reportedCost;
60
60
  if (options.requestId) this.requestId = options.requestId;
61
61
  }
62
62
  }
@@ -1,4 +1,5 @@
1
- import { isAbsolute, relative, resolve } from "node:path";
1
+ import { relative, resolve } from "node:path";
2
+ import { isInside } from "./files";
2
3
 
3
4
  /**
4
5
  * `path` as typed from `cwd`: relative under it, absolute elsewhere, so it works from anywhere; with
@@ -7,8 +8,7 @@ import { isAbsolute, relative, resolve } from "node:path";
7
8
  */
8
9
  export function pathFrom(cwd: string, path: string, options: { home?: string } = {}): string {
9
10
  const absolute = resolve(cwd, path);
10
- const inside = relative(cwd, absolute);
11
- if (inside && !inside.startsWith("..") && !isAbsolute(inside)) return inside;
11
+ if (isInside(cwd, absolute)) return relative(cwd, absolute);
12
12
  const { home } = options;
13
13
  return home !== undefined && absolute.startsWith(`${home}/`)
14
14
  ? `~${absolute.slice(home.length)}`
package/src/duration.ts CHANGED
@@ -1,29 +1,45 @@
1
- /** A duration such as `500ms`, `30s`, `20m` or `2h`, in milliseconds. */
2
- export function parseDuration(value: string): number {
3
- const matched = /^(\d+)(ms|s|m|h)$/.exec(value);
4
- if (!matched) throw new Error(`invalid duration: ${value}`);
5
- const amount = Number(matched[1]);
6
- if (!Number.isSafeInteger(amount) || amount <= 0) throw new Error(`invalid duration: ${value}`);
7
- const unit = matched[2];
8
- const multiplier = unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000;
9
- const milliseconds = amount * multiplier;
10
- if (!Number.isSafeInteger(milliseconds)) throw new Error(`duration is too large: ${value}`);
11
- return milliseconds;
1
+ import {
2
+ durationMilliseconds,
3
+ positiveDurationMilliseconds,
4
+ secondsForMilliseconds,
5
+ } from "@agentswf/contract/workflow";
6
+ import { messageOf } from "./errors";
7
+
8
+ /**
9
+ * A flag's duration in milliseconds, longer than none: the one grammar, `"90s"`, `"1.5h"`, `"1d"`.
10
+ * A number of `ms`, which the CLI took before 0.1.0-beta.6, is refused with its replacement.
11
+ */
12
+ export function parseDuration(value: string, flag: string): number {
13
+ const seconds = secondsForMilliseconds(value);
14
+ if (seconds === undefined) return positiveDurationMilliseconds(value, flag);
15
+ throw new Error(
16
+ `${flag} is a duration, a decimal and s, m, h or d: give ${seconds}, not ${value}`,
17
+ );
12
18
  }
13
19
 
14
- /** `45s`, `6m40s`, `1h02m`. */
15
- export function duration(milliseconds: number): string {
16
- const seconds = Math.max(0, Math.round(milliseconds / 1000));
17
- if (seconds < 60) return `${seconds}s`;
18
- const minutes = Math.floor(seconds / 60);
19
- if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
20
- return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
20
+ /**
21
+ * `--wait`, in seconds from none to `maxSeconds`: a duration, `"90s"`. A bare number of seconds,
22
+ * which it took before 0.1.0-beta.6, is refused with its replacement.
23
+ */
24
+ export function parseWait(value: string, maxSeconds: number): number {
25
+ if (/^\d+(\.\d+)?$/.test(value))
26
+ throw new Error(
27
+ `--wait takes a duration up to ${maxSeconds}s: give --wait ${Math.min(Number(value), maxSeconds)}s`,
28
+ );
29
+ let seconds: number;
30
+ try {
31
+ seconds = durationMilliseconds(value) / 1000;
32
+ } catch (error) {
33
+ throw new Error(`--wait takes a duration up to ${maxSeconds}s: ${messageOf(error)}`);
34
+ }
35
+ if (seconds > maxSeconds) throw new Error(`--wait takes a duration up to ${maxSeconds}s`);
36
+ return seconds;
21
37
  }
22
38
 
23
39
  /**
24
40
  * When a timer given `when` is due, epoch ms, from `now`, and `when` as its record keeps it: a
25
- * duration of days, hours or minutes as given, or a `Date` as an ISO time. A workflow's grammar,
26
- * apart from the CLI's `parseDuration`: a wait is days long, and `1.5h` reads as written.
41
+ * `Duration` as given, or a `Date` as an ISO time. A workflow's grammar, apart from the CLI's
42
+ * `parseDuration`: a wait is days long, and `1.5h` reads as written.
27
43
  */
28
44
  export function parseWhen(when: unknown, now: number): { due: number; when: string } {
29
45
  if (when instanceof Date) {
@@ -31,11 +47,11 @@ export function parseWhen(when: unknown, now: number): { due: number; when: stri
31
47
  if (!Number.isFinite(due)) throw new Error("a timer's time is a valid Date");
32
48
  return { due, when: when.toISOString() };
33
49
  }
34
- const matched = typeof when === "string" ? /^(\d+(?:\.\d+)?)(d|h|m)$/.exec(when) : null;
35
- if (!matched)
36
- throw new Error(
37
- `a timer is due in days, hours or minutes, "14d", "4h", "1.5h", or at a Date; not ${String(when)}`,
38
- );
39
- const unit = matched[2] === "d" ? 86_400_000 : matched[2] === "h" ? 3_600_000 : 60_000;
40
- return { due: now + Math.round(Number(matched[1]) * unit), when: when as string };
50
+ let milliseconds: number;
51
+ try {
52
+ milliseconds = durationMilliseconds(when);
53
+ } catch (error) {
54
+ throw new Error(`a timer is due in a duration or at a Date: ${messageOf(error)}`);
55
+ }
56
+ return { due: now + milliseconds, when: when as string };
41
57
  }