@venturekit-pro/ai 0.0.48 → 0.0.50

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 (147) hide show
  1. package/README.md +192 -0
  2. package/dist/agent-loop/index.d.ts +9 -0
  3. package/dist/agent-loop/index.d.ts.map +1 -0
  4. package/dist/agent-loop/index.js +7 -0
  5. package/dist/agent-loop/index.js.map +1 -0
  6. package/dist/agent-loop/registry.d.ts +85 -0
  7. package/dist/agent-loop/registry.d.ts.map +1 -0
  8. package/dist/agent-loop/registry.js +100 -0
  9. package/dist/agent-loop/registry.js.map +1 -0
  10. package/dist/agent-loop/run-agent.d.ts +37 -0
  11. package/dist/agent-loop/run-agent.d.ts.map +1 -0
  12. package/dist/agent-loop/run-agent.js +405 -0
  13. package/dist/agent-loop/run-agent.js.map +1 -0
  14. package/dist/agent-loop/tools.d.ts +10 -0
  15. package/dist/agent-loop/tools.d.ts.map +1 -0
  16. package/dist/agent-loop/tools.js +28 -0
  17. package/dist/agent-loop/tools.js.map +1 -0
  18. package/dist/agent-loop/types.d.ts +175 -0
  19. package/dist/agent-loop/types.d.ts.map +1 -0
  20. package/dist/agent-loop/types.js +8 -0
  21. package/dist/agent-loop/types.js.map +1 -0
  22. package/dist/chat/types.d.ts +4 -2
  23. package/dist/chat/types.d.ts.map +1 -1
  24. package/dist/cost/types.d.ts +15 -0
  25. package/dist/cost/types.d.ts.map +1 -1
  26. package/dist/cost-ledger/migrations/vk_ai_cost_0004_llm_gateway.sql +69 -0
  27. package/dist/cost-ledger/migrations/vk_ai_cost_0005_harness.sql +99 -0
  28. package/dist/cost-ledger/migrations/vk_ai_cost_0006_cache_write_prices.sql +17 -0
  29. package/dist/cost-ledger/migrations/vk_ai_cost_0007_harness_fan_out.sql +47 -0
  30. package/dist/gateway/errors.d.ts +24 -0
  31. package/dist/gateway/errors.d.ts.map +1 -0
  32. package/dist/gateway/errors.js +29 -0
  33. package/dist/gateway/errors.js.map +1 -0
  34. package/dist/gateway/gateway.d.ts +27 -0
  35. package/dist/gateway/gateway.d.ts.map +1 -0
  36. package/dist/gateway/gateway.js +503 -0
  37. package/dist/gateway/gateway.js.map +1 -0
  38. package/dist/gateway/index.d.ts +13 -0
  39. package/dist/gateway/index.d.ts.map +1 -0
  40. package/dist/gateway/index.js +10 -0
  41. package/dist/gateway/index.js.map +1 -0
  42. package/dist/gateway/policies.d.ts +32 -0
  43. package/dist/gateway/policies.d.ts.map +1 -0
  44. package/dist/gateway/policies.js +45 -0
  45. package/dist/gateway/policies.js.map +1 -0
  46. package/dist/gateway/postgres.d.ts +55 -0
  47. package/dist/gateway/postgres.d.ts.map +1 -0
  48. package/dist/gateway/postgres.js +127 -0
  49. package/dist/gateway/postgres.js.map +1 -0
  50. package/dist/gateway/pricing.d.ts +11 -0
  51. package/dist/gateway/pricing.d.ts.map +1 -0
  52. package/dist/gateway/pricing.js +47 -0
  53. package/dist/gateway/pricing.js.map +1 -0
  54. package/dist/gateway/routing.d.ts +25 -0
  55. package/dist/gateway/routing.d.ts.map +1 -0
  56. package/dist/gateway/routing.js +23 -0
  57. package/dist/gateway/routing.js.map +1 -0
  58. package/dist/gateway/types.d.ts +225 -0
  59. package/dist/gateway/types.d.ts.map +1 -0
  60. package/dist/gateway/types.js +7 -0
  61. package/dist/gateway/types.js.map +1 -0
  62. package/dist/generate/anthropic.d.ts +38 -0
  63. package/dist/generate/anthropic.d.ts.map +1 -0
  64. package/dist/generate/anthropic.js +446 -0
  65. package/dist/generate/anthropic.js.map +1 -0
  66. package/dist/generate/client.d.ts +9 -0
  67. package/dist/generate/client.d.ts.map +1 -0
  68. package/dist/generate/client.js +44 -0
  69. package/dist/generate/client.js.map +1 -0
  70. package/dist/generate/google.d.ts +39 -0
  71. package/dist/generate/google.d.ts.map +1 -0
  72. package/dist/generate/google.js +372 -0
  73. package/dist/generate/google.js.map +1 -0
  74. package/dist/generate/http.d.ts +48 -0
  75. package/dist/generate/http.d.ts.map +1 -0
  76. package/dist/generate/http.js +180 -0
  77. package/dist/generate/http.js.map +1 -0
  78. package/dist/generate/index.d.ts +13 -0
  79. package/dist/generate/index.d.ts.map +1 -0
  80. package/dist/generate/index.js +12 -0
  81. package/dist/generate/index.js.map +1 -0
  82. package/dist/generate/openai.d.ts +29 -0
  83. package/dist/generate/openai.d.ts.map +1 -0
  84. package/dist/generate/openai.js +314 -0
  85. package/dist/generate/openai.js.map +1 -0
  86. package/dist/generate/perplexity.d.ts +27 -0
  87. package/dist/generate/perplexity.d.ts.map +1 -0
  88. package/dist/generate/perplexity.js +208 -0
  89. package/dist/generate/perplexity.js.map +1 -0
  90. package/dist/generate/types.d.ts +253 -0
  91. package/dist/generate/types.d.ts.map +1 -0
  92. package/dist/generate/types.js +19 -0
  93. package/dist/generate/types.js.map +1 -0
  94. package/dist/harness/define.d.ts +15 -0
  95. package/dist/harness/define.d.ts.map +1 -0
  96. package/dist/harness/define.js +38 -0
  97. package/dist/harness/define.js.map +1 -0
  98. package/dist/harness/harness.d.ts +29 -0
  99. package/dist/harness/harness.d.ts.map +1 -0
  100. package/dist/harness/harness.js +1287 -0
  101. package/dist/harness/harness.js.map +1 -0
  102. package/dist/harness/index.d.ts +14 -0
  103. package/dist/harness/index.d.ts.map +1 -0
  104. package/dist/harness/index.js +10 -0
  105. package/dist/harness/index.js.map +1 -0
  106. package/dist/harness/memory-store.d.ts +8 -0
  107. package/dist/harness/memory-store.d.ts.map +1 -0
  108. package/dist/harness/memory-store.js +270 -0
  109. package/dist/harness/memory-store.js.map +1 -0
  110. package/dist/harness/postgres-store.d.ts +12 -0
  111. package/dist/harness/postgres-store.d.ts.map +1 -0
  112. package/dist/harness/postgres-store.js +368 -0
  113. package/dist/harness/postgres-store.js.map +1 -0
  114. package/dist/harness/scheduler.d.ts +12 -0
  115. package/dist/harness/scheduler.d.ts.map +1 -0
  116. package/dist/harness/scheduler.js +23 -0
  117. package/dist/harness/scheduler.js.map +1 -0
  118. package/dist/harness/store.d.ts +172 -0
  119. package/dist/harness/store.d.ts.map +1 -0
  120. package/dist/harness/store.js +21 -0
  121. package/dist/harness/store.js.map +1 -0
  122. package/dist/harness/types.d.ts +312 -0
  123. package/dist/harness/types.d.ts.map +1 -0
  124. package/dist/harness/types.js +11 -0
  125. package/dist/harness/types.js.map +1 -0
  126. package/dist/index.d.ts +12 -0
  127. package/dist/index.d.ts.map +1 -1
  128. package/dist/index.js +11 -0
  129. package/dist/index.js.map +1 -1
  130. package/dist/model-catalog/crud.d.ts.map +1 -1
  131. package/dist/model-catalog/crud.js +33 -2
  132. package/dist/model-catalog/crud.js.map +1 -1
  133. package/dist/model-catalog/types.d.ts +20 -0
  134. package/dist/model-catalog/types.d.ts.map +1 -1
  135. package/dist/tracing/index.d.ts +68 -0
  136. package/dist/tracing/index.d.ts.map +1 -0
  137. package/dist/tracing/index.js +72 -0
  138. package/dist/tracing/index.js.map +1 -0
  139. package/dist/tracing/recording.d.ts +23 -0
  140. package/dist/tracing/recording.d.ts.map +1 -0
  141. package/dist/tracing/recording.js +49 -0
  142. package/dist/tracing/recording.js.map +1 -0
  143. package/package.json +7 -4
  144. package/src/cost-ledger/migrations/vk_ai_cost_0004_llm_gateway.sql +69 -0
  145. package/src/cost-ledger/migrations/vk_ai_cost_0005_harness.sql +99 -0
  146. package/src/cost-ledger/migrations/vk_ai_cost_0006_cache_write_prices.sql +17 -0
  147. package/src/cost-ledger/migrations/vk_ai_cost_0007_harness_fan_out.sql +47 -0
@@ -0,0 +1,1287 @@
1
+ /**
2
+ * `createHarness` — the durable runtime for graphs.
3
+ *
4
+ * One task per node attempt (`{ runId, step, attempt }`):
5
+ *
6
+ * lease (CAS queued → running, `node.started`)
7
+ * → run the node (heartbeat renews the lease; a cancel poll and the
8
+ * node timeout abort `ctx.signal`)
9
+ * → ONE compare-and-set commit: output, next node, events, node-run row
10
+ * → schedule the next task
11
+ *
12
+ * A gate is reached in the same commit (status `waiting`, nothing in
13
+ * flight) and left by `resume()`. A failed attempt is retried with
14
+ * backoff unless it is fatal (`NodeFatalError`, a gateway policy denial,
15
+ * a 4xx caller error) or the node's attempts are used up. Duplicate or
16
+ * late deliveries are no-ops (`stale` / `busy`). `sweep()` re-sends tasks
17
+ * that were lost or held back, and fails the attempt of a worker whose
18
+ * lease expired.
19
+ */
20
+ import { randomUUID } from 'node:crypto';
21
+ import { AgentRunError, runAgent } from '../agent-loop/run-agent.js';
22
+ import { round8 } from '../chat/cost-util.js';
23
+ import { GatewayPolicyError } from '../gateway/errors.js';
24
+ import { attributes, inSpan, SPAN_KIND, SPAN_STATUS } from '../tracing/index.js';
25
+ import { NodeFatalError } from './define.js';
26
+ import { toRunRecord, } from './store.js';
27
+ const DEFAULT_NODE_TIMEOUT_MS = 10 * 60_000;
28
+ const DEFAULT_MAX_STEPS = 100;
29
+ const DEFAULT_MAX_ATTEMPTS = 3;
30
+ const DEFAULT_MAP_CONCURRENCY = 4;
31
+ /** A due queued task is re-sent once it has been quiet this long (or `staleAfterMs`, if shorter). */
32
+ const SWEEP_QUIET_MS = 15_000;
33
+ const SWEEP_BATCH = 200;
34
+ /** Misuse of the harness API (unknown run, wrong status, bad decision, lost race). */
35
+ export class HarnessError extends Error {
36
+ code;
37
+ constructor(code, message) {
38
+ super(message);
39
+ this.name = 'HarnessError';
40
+ this.code = code;
41
+ }
42
+ }
43
+ class RunCancelledError extends Error {
44
+ constructor() {
45
+ super('Cancelled');
46
+ this.name = 'AbortError';
47
+ }
48
+ }
49
+ const iso = (ms) => new Date(ms).toISOString();
50
+ const nowIso = () => new Date().toISOString();
51
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
52
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
53
+ function isFatal(err) {
54
+ if (err instanceof NodeFatalError || err instanceof GatewayPolicyError)
55
+ return true;
56
+ if (err instanceof AgentRunError && err.cause !== undefined && err.cause !== err)
57
+ return isFatal(err.cause);
58
+ const status = err?.statusCode;
59
+ return (typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429);
60
+ }
61
+ const timeoutError = (node, timeoutMs) => Object.assign(new Error(`'${node.label}' timed out after ${Math.round(timeoutMs / 1000)} s`), {
62
+ category: 'timeout',
63
+ });
64
+ const RETRY_CATEGORIES = [
65
+ 'rate_limit',
66
+ 'server',
67
+ 'timeout',
68
+ 'network',
69
+ 'schema',
70
+ 'other',
71
+ ];
72
+ /** What kind of failure `err` is, for `retry.on` (an `AgentRunError` is judged by its cause). */
73
+ export function retryCategory(err) {
74
+ const e = err instanceof AgentRunError && err.cause !== undefined ? err.cause : err;
75
+ const declared = e?.category;
76
+ if (typeof declared === 'string' && RETRY_CATEGORIES.includes(declared))
77
+ return declared;
78
+ const message = messageOf(e);
79
+ const statusCode = e?.statusCode ??
80
+ e?.status;
81
+ const status = typeof statusCode === 'number'
82
+ ? statusCode
83
+ : Number(/\]\s+(\d{3})\b/.exec(message)?.[1] ?? NaN);
84
+ if (status === 429)
85
+ return 'rate_limit';
86
+ if (status === 408 || status === 504)
87
+ return 'timeout';
88
+ if (status >= 500 && status < 600)
89
+ return 'server';
90
+ const name = e?.name;
91
+ if (name === 'AbortError' || name === 'TimeoutError' || /timed out|timeout/i.test(message))
92
+ return 'timeout';
93
+ if (/ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EPIPE|socket hang up|fetch failed|network/i.test(message))
94
+ return 'network';
95
+ return 'other';
96
+ }
97
+ /** Whether the node's `retry.on` lets this (non-fatal) failure retry. */
98
+ function retryAllowed(node, err) {
99
+ const on = node.retry?.on;
100
+ if (!on)
101
+ return true;
102
+ const category = retryCategory(err);
103
+ return typeof on === 'function' ? on(err, category) : on.includes(category);
104
+ }
105
+ function errorCode(err, fatal) {
106
+ const code = err?.code;
107
+ if (typeof code === 'string' && code)
108
+ return code;
109
+ if (err instanceof AgentRunError && err.cause !== undefined)
110
+ return errorCode(err.cause, fatal);
111
+ return fatal ? 'node_fatal' : 'node_failed';
112
+ }
113
+ export function createHarness(options) {
114
+ const { store, scheduler } = options;
115
+ const generateId = options.generateId ?? randomUUID;
116
+ const progressIntervalMs = options.progressIntervalMs ?? 1000;
117
+ const cancelPollMs = Math.max(1, options.cancelPollMs ?? 2000);
118
+ const leaseMs = options.leaseMs ?? 60_000;
119
+ const backoffBase = options.retryBackoffMs ?? 5000;
120
+ const backoffMax = options.maxRetryBackoffMs ?? 5 * 60_000;
121
+ const correlationId = options.correlationId ?? ((run) => run.id);
122
+ const registry = new Map();
123
+ for (const def of options.graphs) {
124
+ const versions = registry.get(def.key) ?? new Map();
125
+ if (versions.has(def.version))
126
+ throw new Error(`[ai/harness] graph ${def.key}@${def.version} is registered twice`);
127
+ versions.set(def.version, { def, nodes: new Map(def.nodes.map((n) => [n.id, n])) });
128
+ registry.set(def.key, versions);
129
+ }
130
+ const graphOf = (run) => registry.get(run.graphKey)?.get(run.graphVersion);
131
+ const event = (run, type, label, extra = {}) => ({
132
+ type,
133
+ nodeId: run.currentNode,
134
+ step: run.step,
135
+ attempt: run.attempt,
136
+ label,
137
+ detail: null,
138
+ data: null,
139
+ ...extra,
140
+ });
141
+ // ─── Transitions ─────────────────────────────────────────────────
142
+ /** Leave `node` with `output` towards `next`: the patch and events of the commit. */
143
+ function transition(run, graph, node, output, next, addedCost) {
144
+ const outputs = { ...run.outputs, [node.id]: output };
145
+ const costUsd = round8(run.costUsd + addedCost);
146
+ if (next === null) {
147
+ const patch = {
148
+ status: 'completed',
149
+ outputs,
150
+ currentNode: null,
151
+ leaseUntil: null,
152
+ notBefore: null,
153
+ error: null,
154
+ costUsd,
155
+ completedAt: nowIso(),
156
+ };
157
+ return {
158
+ patch,
159
+ events: [event(run, 'run.completed', 'Done', { nodeId: null })],
160
+ outcome: 'completed',
161
+ };
162
+ }
163
+ const nextNode = graph.nodes.get(next);
164
+ if (!nextNode)
165
+ throw new NodeFatalError(`Node '${node.id}' goes to unknown node '${next}'`, 'unknown_node');
166
+ const maxSteps = graph.def.maxSteps ?? DEFAULT_MAX_STEPS;
167
+ if (run.step + 1 > maxSteps)
168
+ throw new NodeFatalError(`The run passed its limit of ${maxSteps} steps`, 'max_steps');
169
+ const step = run.step + 1;
170
+ const base = {
171
+ outputs,
172
+ currentNode: next,
173
+ step,
174
+ attempt: 1,
175
+ attemptBase: 0,
176
+ notBefore: null,
177
+ leaseUntil: null,
178
+ error: null,
179
+ costUsd,
180
+ };
181
+ if (nextNode.kind === 'gate') {
182
+ return {
183
+ patch: { ...base, status: 'waiting' },
184
+ events: [
185
+ {
186
+ type: 'gate.waiting',
187
+ nodeId: next,
188
+ step,
189
+ attempt: 1,
190
+ label: nextNode.label,
191
+ detail: null,
192
+ data: { decisions: nextNode.decisions },
193
+ },
194
+ ],
195
+ outcome: 'waiting',
196
+ };
197
+ }
198
+ return {
199
+ patch: { ...base, status: 'queued' },
200
+ events: [],
201
+ outcome: 'advanced',
202
+ };
203
+ }
204
+ const resolveNext = (node, output, ctx) => typeof node.next === 'function' ? node.next({ output, ctx }) : (node.next ?? null);
205
+ async function finishCancelled(run, attempt, detail = null) {
206
+ const nodeRun = attempt
207
+ ? {
208
+ step: run.step,
209
+ nodeId: attempt.node.id,
210
+ attempt: run.attempt,
211
+ status: 'cancelled',
212
+ startedAt: attempt.startedAt,
213
+ finishedAt: nowIso(),
214
+ output: null,
215
+ error: null,
216
+ costUsd: attempt.nodeCost,
217
+ }
218
+ : undefined;
219
+ const committed = await store.updateRun(run.id, run.version, {
220
+ status: 'cancelled',
221
+ cancelRequested: true,
222
+ leaseUntil: null,
223
+ notBefore: null,
224
+ completedAt: nowIso(),
225
+ costUsd: round8(run.costUsd + (attempt?.nodeCost ?? 0)),
226
+ }, {
227
+ events: [event(run, 'run.cancelled', 'Cancelled', { detail })],
228
+ ...(nodeRun ? { nodeRun } : {}),
229
+ });
230
+ return committed ? 'cancelled' : 'stale';
231
+ }
232
+ async function handleFailure(run, node, err, startedAt, nodeCost) {
233
+ const message = messageOf(err);
234
+ const fatal = isFatal(err);
235
+ const used = run.attempt - run.attemptBase;
236
+ const maxAttempts = node.retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
237
+ const error = {
238
+ message,
239
+ code: errorCode(err, fatal),
240
+ nodeId: node.id,
241
+ attempt: run.attempt,
242
+ };
243
+ const nodeRun = {
244
+ step: run.step,
245
+ nodeId: node.id,
246
+ attempt: run.attempt,
247
+ status: 'failed',
248
+ startedAt,
249
+ finishedAt: nowIso(),
250
+ output: null,
251
+ error,
252
+ costUsd: nodeCost,
253
+ };
254
+ if (!fatal && retryAllowed(node, err) && used < maxAttempts) {
255
+ const delayMs = Math.min(backoffMax, (node.retry?.backoffMs ?? backoffBase) * 2 ** Math.max(0, used - 1));
256
+ const committed = await store.updateRun(run.id, run.version, {
257
+ status: 'queued',
258
+ attempt: run.attempt + 1,
259
+ notBefore: iso(Date.now() + delayMs),
260
+ leaseUntil: null,
261
+ costUsd: round8(run.costUsd + nodeCost),
262
+ }, {
263
+ events: [
264
+ event(run, 'node.retrying', node.label, {
265
+ detail: message,
266
+ data: { nextAttempt: run.attempt + 1, delayMs, code: error.code },
267
+ }),
268
+ ],
269
+ nodeRun,
270
+ });
271
+ if (!committed)
272
+ return 'stale';
273
+ await scheduler.enqueue({ runId: run.id, step: run.step, attempt: run.attempt + 1 }, { delayMs });
274
+ return 'retrying';
275
+ }
276
+ const committed = await store.updateRun(run.id, run.version, { status: 'failed', leaseUntil: null, error, costUsd: round8(run.costUsd + nodeCost) }, {
277
+ events: [
278
+ event(run, 'node.failed', node.label, { detail: message, data: { code: error.code } }),
279
+ event(run, 'run.failed', 'Failed', { detail: message, data: { code: error.code } }),
280
+ ],
281
+ nodeRun,
282
+ });
283
+ return committed ? 'failed' : 'stale';
284
+ }
285
+ // ─── Node context ────────────────────────────────────────────────
286
+ function makeContext(run, graph, node, signal, cost, item) {
287
+ let lastProgress = 0;
288
+ let calls = 0;
289
+ const gatewayContext = (opts) => ({
290
+ tenantId: run.context.tenantId ?? null,
291
+ feature: opts?.feature ?? `${run.graphKey}/${node.id}`,
292
+ ...(run.context.actor ? { actor: run.context.actor } : {}),
293
+ ...(run.context.target ? { target: run.context.target } : {}),
294
+ correlationId: correlationId(run),
295
+ labels: {
296
+ ...run.context.labels,
297
+ ...opts?.labels,
298
+ graph: `${run.graphKey}@${run.graphVersion}`,
299
+ node: node.id,
300
+ step: run.step,
301
+ attempt: run.attempt,
302
+ ...(item ? { item: item.index } : {}),
303
+ },
304
+ });
305
+ /** Idempotency key base: one per node attempt, or per item attempt of a fanned-out map. */
306
+ const keyBase = `${run.id}:${run.step}:${run.attempt}${item ? `:i${item.index}.${item.attempt}` : ''}`;
307
+ const remainingBudget = () => {
308
+ const limits = [
309
+ node.maxCostUsd !== undefined ? node.maxCostUsd - cost.value : undefined,
310
+ graph.def.maxCostUsd !== undefined
311
+ ? graph.def.maxCostUsd - run.costUsd - cost.value
312
+ : undefined,
313
+ ].filter((v) => v !== undefined);
314
+ return limits.length ? Math.min(...limits) : undefined;
315
+ };
316
+ const beforeCall = () => {
317
+ if (signal.aborted)
318
+ throw new RunCancelledError();
319
+ const left = remainingBudget();
320
+ if (left !== undefined && left <= 0)
321
+ throw new NodeFatalError(`The spending limit for '${node.id}' is used up`, 'budget_exhausted');
322
+ if (!options.gateway)
323
+ throw new NodeFatalError('This harness has no LLM gateway', 'no_gateway');
324
+ calls += 1;
325
+ return { gateway: options.gateway, key: `${keyBase}:${calls}` };
326
+ };
327
+ const append = (events) => store.appendEvents(run.id, events);
328
+ const progress = async (detail, data, opts) => {
329
+ const now = Date.now();
330
+ if (!opts?.force && now - lastProgress < progressIntervalMs)
331
+ return;
332
+ lastProgress = now;
333
+ await append([
334
+ {
335
+ type: 'node.progress',
336
+ nodeId: node.id,
337
+ step: run.step,
338
+ attempt: run.attempt,
339
+ label: node.label,
340
+ detail,
341
+ data: data ?? null,
342
+ },
343
+ ]);
344
+ };
345
+ /** Spend in USD, kept to the ledger's 8 decimals. */
346
+ const spend = (usd) => {
347
+ cost.value = round8(cost.value + usd);
348
+ };
349
+ /** The node's tier on requests that name none. */
350
+ const withTier = (request) => node.tier && request.tier === undefined ? { ...request, tier: node.tier } : request;
351
+ return {
352
+ run: {
353
+ id: run.id,
354
+ graphKey: run.graphKey,
355
+ graphVersion: run.graphVersion,
356
+ context: run.context,
357
+ createdAt: run.createdAt,
358
+ },
359
+ nodeId: node.id,
360
+ step: run.step,
361
+ attempt: run.attempt,
362
+ signal,
363
+ get costUsd() {
364
+ return cost.value;
365
+ },
366
+ input: () => run.input,
367
+ output: (id) => run.outputs[id],
368
+ require(id) {
369
+ if (!(id in run.outputs))
370
+ throw new NodeFatalError(`Node '${node.id}' needs the output of '${id}', which has not run`, 'missing_output');
371
+ return run.outputs[id];
372
+ },
373
+ progress,
374
+ state: (state, opts) => progress(node.detail?.(state) ?? '', state, opts),
375
+ async event(type, label, data, detail) {
376
+ await append([
377
+ {
378
+ type,
379
+ nodeId: node.id,
380
+ step: run.step,
381
+ attempt: run.attempt,
382
+ label,
383
+ detail: detail ?? null,
384
+ data: data ?? null,
385
+ },
386
+ ]);
387
+ },
388
+ async generate(request, opts) {
389
+ const { gateway, key } = beforeCall();
390
+ const result = await gateway.generate(gatewayContext(opts), {
391
+ ...withTier(request),
392
+ signal,
393
+ idempotencyKey: request.idempotencyKey ?? key,
394
+ });
395
+ spend(result.cost?.costUsd ?? 0);
396
+ return result;
397
+ },
398
+ async *stream(request, opts) {
399
+ const { gateway, key } = beforeCall();
400
+ const call = {
401
+ ...withTier(request),
402
+ signal,
403
+ idempotencyKey: request.idempotencyKey ?? key,
404
+ };
405
+ if (!gateway.stream) {
406
+ // A gateway that can't stream: the whole answer as one chunk.
407
+ const result = await gateway.generate(gatewayContext(opts), call);
408
+ spend(result.cost?.costUsd ?? 0);
409
+ if (result.text)
410
+ yield { type: 'text', delta: result.text };
411
+ yield { type: 'done', result };
412
+ return;
413
+ }
414
+ for await (const e of gateway.stream(gatewayContext(opts), call)) {
415
+ if (signal.aborted)
416
+ throw new RunCancelledError();
417
+ if (e.type === 'done')
418
+ spend(e.result.cost?.costUsd ?? 0);
419
+ yield e;
420
+ }
421
+ },
422
+ async runAgent(agentOptions, opts) {
423
+ const { gateway, key } = beforeCall();
424
+ if (!gateway.client)
425
+ throw new NodeFatalError('The LLM gateway has no client() for agents', 'no_gateway');
426
+ const left = remainingBudget();
427
+ const maxCostUsd = [agentOptions.limits?.maxCostUsd, left].filter((v) => v !== undefined);
428
+ const onEvent = async (e) => {
429
+ if (e.type === 'tool.completed') {
430
+ const r = e.result;
431
+ await append([
432
+ {
433
+ type: 'tool.called',
434
+ nodeId: node.id,
435
+ step: run.step,
436
+ attempt: run.attempt,
437
+ label: r.name,
438
+ detail: r.isError ? r.content.slice(0, 300) : null,
439
+ data: {
440
+ callId: r.callId,
441
+ isError: r.isError,
442
+ durationMs: r.durationMs,
443
+ ...(r.data !== undefined ? { data: r.data } : {}),
444
+ },
445
+ },
446
+ ]).catch(() => undefined);
447
+ }
448
+ await agentOptions.onEvent?.(e);
449
+ };
450
+ try {
451
+ const tracer = agentOptions.tracer ?? options.tracer;
452
+ const result = await runAgent({
453
+ ...agentOptions,
454
+ request: withTier(agentOptions.request),
455
+ name: agentOptions.name ?? `${run.graphKey}/${node.id}`,
456
+ ...(tracer ? { tracer } : {}),
457
+ ...(maxCostUsd.length
458
+ ? { limits: { ...agentOptions.limits, maxCostUsd: Math.min(...maxCostUsd) } }
459
+ : {}),
460
+ client: gateway.client(gatewayContext(opts)),
461
+ signal,
462
+ idempotencyKey: key,
463
+ onEvent,
464
+ });
465
+ // Tools in a node spend through ctx.generate / ctx.runAgent, which
466
+ // count already: add only the agent's own model turns.
467
+ spend((result.cost?.costUsd ?? 0) - (result.toolCost?.costUsd ?? 0));
468
+ return result;
469
+ }
470
+ catch (err) {
471
+ if (err instanceof AgentRunError)
472
+ spend((err.snapshot.cost?.costUsd ?? 0) - (err.snapshot.toolCost?.costUsd ?? 0));
473
+ throw err;
474
+ }
475
+ },
476
+ };
477
+ }
478
+ async function runNode(node, ctx, run) {
479
+ if (node.kind === 'gate')
480
+ throw new NodeFatalError(`Gate '${node.id}' cannot run`, 'gate');
481
+ if (node.kind !== 'map')
482
+ return node.run(ctx);
483
+ const items = await node.items(ctx);
484
+ const done = await store.getMapItems(run.id, run.step);
485
+ const results = new Array(items.length);
486
+ for (const [i, out] of done)
487
+ if (i < items.length)
488
+ results[i] = out;
489
+ const todo = items.map((_, i) => i).filter((i) => !done.has(i));
490
+ let cursor = 0;
491
+ let failure = null;
492
+ const worker = async () => {
493
+ while (cursor < todo.length && !failure && !ctx.signal.aborted) {
494
+ const index = todo[cursor++];
495
+ try {
496
+ const out = await node.runItem(items[index], index, ctx);
497
+ await store.putMapItem(run.id, run.step, index, out ?? null);
498
+ results[index] = out;
499
+ }
500
+ catch (error) {
501
+ failure ??= { error };
502
+ }
503
+ }
504
+ };
505
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, node.concurrency ?? DEFAULT_MAP_CONCURRENCY), todo.length) }, worker));
506
+ if (failure)
507
+ throw failure.error;
508
+ if (ctx.signal.aborted)
509
+ throw new RunCancelledError();
510
+ return results;
511
+ }
512
+ /** One node attempt, in a `harness.node` span when a tracer is set. */
513
+ function runClaimed(run, graph, node, startedAt) {
514
+ const cost = { value: 0 };
515
+ return inSpan(options.tracer, `harness.node ${run.graphKey}/${node.id}`, {
516
+ kind: SPAN_KIND.INTERNAL,
517
+ attributes: attributes({
518
+ 'vk.harness.run_id': run.id,
519
+ 'vk.harness.graph': `${run.graphKey}@${run.graphVersion}`,
520
+ 'vk.harness.node': node.id,
521
+ 'vk.harness.node_kind': node.kind,
522
+ 'vk.harness.step': run.step,
523
+ 'vk.harness.attempt': run.attempt,
524
+ 'vk.ai.tenant_id': run.context.tenantId,
525
+ 'vk.ai.tier': node.tier,
526
+ }),
527
+ }, async (span) => {
528
+ const outcome = isFanOut(node)
529
+ ? await fanOut(run, graph, node, startedAt)
530
+ : await attemptNode(run, graph, node, startedAt, cost);
531
+ span?.setAttributes({ 'vk.harness.outcome': outcome, 'vk.ai.cost_usd': cost.value });
532
+ if (outcome === 'failed' || outcome === 'retrying') {
533
+ span?.setStatus({ code: SPAN_STATUS.ERROR, message: `'${node.label}' ${outcome}` });
534
+ }
535
+ return outcome;
536
+ });
537
+ }
538
+ async function attemptNode(run, graph, node, startedAt, cost) {
539
+ const controller = new AbortController();
540
+ let abortReason = null;
541
+ const abort = (reason) => {
542
+ if (abortReason)
543
+ return;
544
+ abortReason = reason;
545
+ controller.abort();
546
+ };
547
+ const timeoutMs = node.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
548
+ const heartbeat = setInterval(() => {
549
+ void store
550
+ .extendLease(run.id, run.version, iso(Date.now() + leaseMs))
551
+ .catch(() => undefined);
552
+ }, Math.max(1000, Math.floor(leaseMs / 3)));
553
+ const poll = setInterval(() => {
554
+ void store
555
+ .isCancelRequested(run.id)
556
+ .then((requested) => {
557
+ if (requested)
558
+ abort('cancel');
559
+ })
560
+ .catch(() => undefined);
561
+ }, cancelPollMs);
562
+ const timer = setTimeout(() => abort('timeout'), timeoutMs);
563
+ const aborted = new Promise((_, reject) => {
564
+ controller.signal.addEventListener('abort', () => reject(abortReason === 'timeout' ? timeoutError(node, timeoutMs) : new RunCancelledError()), { once: true });
565
+ });
566
+ aborted.catch(() => undefined);
567
+ const ctx = makeContext(run, graph, node, controller.signal, cost);
568
+ try {
569
+ const work = Promise.resolve().then(() => runNode(node, ctx, run));
570
+ work.catch(() => undefined);
571
+ const output = await Promise.race([work, aborted]);
572
+ if (abortReason === 'cancel')
573
+ throw new RunCancelledError();
574
+ const t = transition(run, graph, node, output, resolveNext(node, output, ctx), cost.value);
575
+ const committed = await store.updateRun(run.id, run.version, t.patch, {
576
+ events: [
577
+ event(run, 'node.completed', node.label, {
578
+ nodeId: node.id,
579
+ data: { costUsd: cost.value },
580
+ }),
581
+ ...t.events,
582
+ ],
583
+ nodeRun: {
584
+ step: run.step,
585
+ nodeId: node.id,
586
+ attempt: run.attempt,
587
+ status: 'succeeded',
588
+ startedAt,
589
+ finishedAt: nowIso(),
590
+ output: output ?? null,
591
+ error: null,
592
+ costUsd: cost.value,
593
+ },
594
+ });
595
+ if (!committed)
596
+ return 'stale';
597
+ if (t.outcome === 'advanced')
598
+ await scheduler.enqueue({ runId: run.id, step: committed.step, attempt: 1 });
599
+ return t.outcome;
600
+ }
601
+ catch (err) {
602
+ if (abortReason === 'cancel' || err instanceof RunCancelledError)
603
+ return finishCancelled(run, { node, startedAt, nodeCost: cost.value });
604
+ return handleFailure(run, node, err, startedAt, cost.value);
605
+ }
606
+ finally {
607
+ clearInterval(heartbeat);
608
+ clearInterval(poll);
609
+ clearTimeout(timer);
610
+ }
611
+ }
612
+ // ─── Fan-out (map nodes with `dispatch: 'tasks'`) ─────────────────
613
+ const isFanOut = (node) => node.kind === 'map' && node.dispatch === 'tasks';
614
+ function mapStore() {
615
+ const s = store;
616
+ const methods = [
617
+ 'listMapItems',
618
+ 'beginMap',
619
+ 'claimMapItem',
620
+ 'extendMapItemLease',
621
+ 'completeMapItem',
622
+ 'releaseMapItem',
623
+ 'claimNextMapItem',
624
+ 'findMapItemsToSweep',
625
+ 'touchMapItem',
626
+ ];
627
+ if (methods.some((m) => typeof s[m] !== 'function'))
628
+ throw new NodeFatalError("This harness store can't fan map items out (dispatch: 'tasks')", 'no_fan_out');
629
+ return s;
630
+ }
631
+ const itemTask = (run, index) => ({
632
+ runId: run.id,
633
+ step: run.step,
634
+ attempt: run.attempt,
635
+ item: index,
636
+ });
637
+ /**
638
+ * The node attempt of a fanned-out map: list the items, write them with
639
+ * the join counter, release the run's lease and send the first
640
+ * `concurrency` item tasks. The run stays `running` (lease null) until
641
+ * the last item joins.
642
+ */
643
+ async function fanOut(run, graph, node, startedAt) {
644
+ const cost = { value: 0 };
645
+ try {
646
+ const maps = mapStore();
647
+ const ctx = makeContext(run, graph, node, new AbortController().signal, cost);
648
+ const items = await node.items(ctx);
649
+ const existing = new Map((await maps.listMapItems(run.id, run.step)).map((r) => [r.index, r]));
650
+ let slots = Math.max(1, node.concurrency ?? DEFAULT_MAP_CONCURRENCY);
651
+ const plan = items.map((input, index) => {
652
+ const done = existing.get(index)?.status === 'done';
653
+ const status = !done && slots > 0 ? (slots--, 'queued') : 'pending';
654
+ return { index, input: input ?? null, status, done };
655
+ });
656
+ const begun = await maps.beginMap(run.id, run.version, {
657
+ step: run.step,
658
+ items: plan.map(({ done: _d, ...it }) => (void _d, it)),
659
+ addCostUsd: cost.value,
660
+ events: [
661
+ event(run, 'map.started', node.label, {
662
+ nodeId: node.id,
663
+ data: {
664
+ total: items.length,
665
+ done: plan.filter((p) => p.done).length,
666
+ concurrency: node.concurrency ?? DEFAULT_MAP_CONCURRENCY,
667
+ },
668
+ }),
669
+ ],
670
+ });
671
+ if (!begun)
672
+ return 'stale';
673
+ if (begun.done >= begun.total)
674
+ return await joinMap(run.id, graph, node, begun.total, startedAt);
675
+ for (const p of plan)
676
+ if (!p.done && p.status === 'queued')
677
+ await scheduler.enqueue(itemTask(run, p.index));
678
+ return 'fanned_out';
679
+ }
680
+ catch (err) {
681
+ return handleFailure(run, node, err, startedAt, cost.value);
682
+ }
683
+ }
684
+ /** One item task: claim the item, run it under its own lease, timeout and cancel poll. */
685
+ async function executeMapItem(task, executeOptions) {
686
+ const index = task.item;
687
+ const maps = mapStore();
688
+ const load = async () => {
689
+ const run = await store.getRun(task.runId);
690
+ if (!run ||
691
+ run.step !== task.step ||
692
+ run.attempt !== task.attempt ||
693
+ run.status !== 'running' ||
694
+ run.leaseUntil !== null)
695
+ return null;
696
+ return run;
697
+ };
698
+ let run = await load();
699
+ if (!run)
700
+ return 'stale';
701
+ const graph = graphOf(run);
702
+ const node = run.currentNode ? graph?.nodes.get(run.currentNode) : undefined;
703
+ if (!graph || !node || !isFanOut(node))
704
+ return 'stale';
705
+ if (run.cancelRequested)
706
+ return finishCancelled(run, null);
707
+ const row = (await maps.listMapItems(run.id, run.step)).find((r) => r.index === index);
708
+ if (!row)
709
+ return 'stale';
710
+ if (row.status === 'running')
711
+ return 'busy';
712
+ if (row.status !== 'queued')
713
+ return 'stale';
714
+ if (!executeOptions.ignoreDelay && row.notBefore) {
715
+ const wait = Date.parse(row.notBefore) - Date.now();
716
+ if (wait > 0) {
717
+ if (wait > (executeOptions.maxWaitMs ?? 0))
718
+ return 'deferred';
719
+ const cancelled = await waitOut(run.id, wait);
720
+ run = await load();
721
+ if (!run)
722
+ return 'stale';
723
+ if (cancelled || run.cancelRequested)
724
+ return finishCancelled(run, null);
725
+ }
726
+ }
727
+ const claimed = await maps.claimMapItem(run.id, run.step, index, iso(Date.now() + leaseMs));
728
+ if (!claimed)
729
+ return 'busy';
730
+ return runMapItem(run, graph, node, claimed);
731
+ }
732
+ async function runMapItem(run, graph, node, item) {
733
+ const maps = mapStore();
734
+ const controller = new AbortController();
735
+ let abortReason = null;
736
+ const abort = (reason) => {
737
+ if (abortReason)
738
+ return;
739
+ abortReason = reason;
740
+ controller.abort();
741
+ };
742
+ const cost = { value: 0 };
743
+ const timeoutMs = node.timeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
744
+ const heartbeat = setInterval(() => {
745
+ void maps
746
+ .extendMapItemLease(run.id, run.step, item.index, item.attempt, iso(Date.now() + leaseMs))
747
+ .catch(() => undefined);
748
+ }, Math.max(1000, Math.floor(leaseMs / 3)));
749
+ const poll = setInterval(() => {
750
+ void store
751
+ .isCancelRequested(run.id)
752
+ .then((requested) => {
753
+ if (requested)
754
+ abort('cancel');
755
+ })
756
+ .catch(() => undefined);
757
+ }, cancelPollMs);
758
+ const timer = setTimeout(() => abort('timeout'), timeoutMs);
759
+ const aborted = new Promise((_, reject) => {
760
+ controller.signal.addEventListener('abort', () => reject(abortReason === 'timeout' ? timeoutError(node, timeoutMs) : new RunCancelledError()), { once: true });
761
+ });
762
+ aborted.catch(() => undefined);
763
+ const ctx = makeContext(run, graph, node, controller.signal, cost, {
764
+ index: item.index,
765
+ attempt: item.attempt,
766
+ });
767
+ try {
768
+ const work = Promise.resolve().then(() => node.runItem(item.input, item.index, ctx));
769
+ work.catch(() => undefined);
770
+ const output = await Promise.race([work, aborted]);
771
+ if (abortReason === 'cancel')
772
+ throw new RunCancelledError();
773
+ const counter = await maps.completeMapItem(run.id, run.step, item.index, item.attempt, {
774
+ output: output ?? null,
775
+ costUsd: cost.value,
776
+ });
777
+ if (!counter)
778
+ return 'stale';
779
+ await store
780
+ .appendEvents(run.id, [
781
+ {
782
+ type: 'node.progress',
783
+ nodeId: node.id,
784
+ step: run.step,
785
+ attempt: run.attempt,
786
+ label: node.label,
787
+ detail: node.detail?.({ done: counter.done, total: counter.total, item: item.index }) ??
788
+ `${counter.done} of ${counter.total}`,
789
+ data: { done: counter.done, total: counter.total, item: item.index },
790
+ },
791
+ ])
792
+ .catch(() => undefined);
793
+ if (counter.done >= counter.total)
794
+ return await joinMap(run.id, graph, node, counter.total, run.updatedAt);
795
+ const next = await maps.claimNextMapItem(run.id, run.step);
796
+ if (next !== null)
797
+ await scheduler.enqueue(itemTask(run, next));
798
+ return 'item_done';
799
+ }
800
+ catch (err) {
801
+ if (abortReason === 'cancel' || err instanceof RunCancelledError) {
802
+ await maps
803
+ .releaseMapItem(run.id, run.step, item.index, item.attempt, {
804
+ status: 'queued',
805
+ notBefore: null,
806
+ error: {
807
+ message: 'Cancelled',
808
+ code: 'cancelled',
809
+ nodeId: node.id,
810
+ attempt: run.attempt,
811
+ },
812
+ costUsd: cost.value,
813
+ })
814
+ .catch(() => undefined);
815
+ const fresh = await store.getRun(run.id);
816
+ return fresh && fresh.status === 'running' ? finishCancelled(fresh, null) : 'cancelled';
817
+ }
818
+ return failMapItem(run, node, item, err, cost.value);
819
+ }
820
+ finally {
821
+ clearInterval(heartbeat);
822
+ clearInterval(poll);
823
+ clearTimeout(timer);
824
+ }
825
+ }
826
+ /** A failed item: retry it with backoff, or fail the run once its attempts are used up. */
827
+ async function failMapItem(run, node, item, err, itemCost) {
828
+ const maps = mapStore();
829
+ const message = messageOf(err);
830
+ const fatal = isFatal(err);
831
+ const used = item.attempt - item.attemptBase;
832
+ const maxAttempts = node.retry?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
833
+ const error = {
834
+ message,
835
+ code: errorCode(err, fatal),
836
+ nodeId: node.id,
837
+ attempt: run.attempt,
838
+ };
839
+ if (!fatal && retryAllowed(node, err) && used < maxAttempts) {
840
+ const delayMs = Math.min(backoffMax, (node.retry?.backoffMs ?? backoffBase) * 2 ** Math.max(0, used - 1));
841
+ const released = await maps.releaseMapItem(run.id, run.step, item.index, item.attempt, {
842
+ status: 'queued',
843
+ notBefore: iso(Date.now() + delayMs),
844
+ error,
845
+ costUsd: itemCost,
846
+ });
847
+ if (!released)
848
+ return 'stale';
849
+ await store
850
+ .appendEvents(run.id, [
851
+ event(run, 'node.retrying', node.label, {
852
+ nodeId: node.id,
853
+ detail: message,
854
+ data: { item: item.index, nextAttempt: used + 1, delayMs, code: error.code },
855
+ }),
856
+ ])
857
+ .catch(() => undefined);
858
+ await scheduler.enqueue(itemTask(run, item.index), { delayMs });
859
+ return 'retrying';
860
+ }
861
+ const released = await maps.releaseMapItem(run.id, run.step, item.index, item.attempt, {
862
+ status: 'failed',
863
+ notBefore: null,
864
+ error,
865
+ costUsd: itemCost,
866
+ });
867
+ if (!released)
868
+ return 'stale';
869
+ const fresh = await store.getRun(run.id);
870
+ if (!fresh ||
871
+ fresh.status !== 'running' ||
872
+ fresh.step !== run.step ||
873
+ fresh.attempt !== run.attempt)
874
+ return 'stale';
875
+ const spent = round8((await maps.listMapItems(run.id, run.step)).reduce((sum, r) => sum + r.costUsd, 0));
876
+ const runError = { ...error, message: `Item ${item.index + 1}: ${message}` };
877
+ const committed = await store.updateRun(fresh.id, fresh.version, {
878
+ status: 'failed',
879
+ leaseUntil: null,
880
+ error: runError,
881
+ costUsd: round8(fresh.costUsd + spent),
882
+ }, {
883
+ events: [
884
+ event(fresh, 'node.failed', node.label, {
885
+ nodeId: node.id,
886
+ detail: runError.message,
887
+ data: { code: error.code, item: item.index },
888
+ }),
889
+ event(fresh, 'run.failed', 'Failed', {
890
+ nodeId: node.id,
891
+ detail: runError.message,
892
+ data: { code: error.code },
893
+ }),
894
+ ],
895
+ nodeRun: {
896
+ step: fresh.step,
897
+ nodeId: node.id,
898
+ attempt: fresh.attempt,
899
+ status: 'failed',
900
+ startedAt: fresh.updatedAt,
901
+ finishedAt: nowIso(),
902
+ output: null,
903
+ error: runError,
904
+ costUsd: spent,
905
+ },
906
+ });
907
+ return committed ? 'failed' : 'stale';
908
+ }
909
+ /** The join: every item is done — commit the node with the outputs in item order and the items' spend. */
910
+ async function joinMap(runId, graph, node, total, startedAt) {
911
+ const maps = mapStore();
912
+ const run = await store.getRun(runId);
913
+ if (!run || run.status !== 'running' || run.leaseUntil !== null)
914
+ return 'stale';
915
+ if (run.cancelRequested)
916
+ return finishCancelled(run, null);
917
+ const rows = await maps.listMapItems(run.id, run.step);
918
+ const byIndex = new Map(rows.map((r) => [r.index, r]));
919
+ const outputs = Array.from({ length: total }, (_, i) => byIndex.get(i)?.output ?? null);
920
+ const spent = round8(rows.filter((r) => r.index < total).reduce((sum, r) => sum + r.costUsd, 0));
921
+ try {
922
+ const ctx = makeContext(run, graph, node, new AbortController().signal, { value: 0 });
923
+ const t = transition(run, graph, node, outputs, resolveNext(node, outputs, ctx), spent);
924
+ const committed = await store.updateRun(run.id, run.version, t.patch, {
925
+ events: [
926
+ event(run, 'node.completed', node.label, {
927
+ nodeId: node.id,
928
+ data: { costUsd: spent, items: total },
929
+ }),
930
+ ...t.events,
931
+ ],
932
+ nodeRun: {
933
+ step: run.step,
934
+ nodeId: node.id,
935
+ attempt: run.attempt,
936
+ status: 'succeeded',
937
+ startedAt,
938
+ finishedAt: nowIso(),
939
+ output: outputs,
940
+ error: null,
941
+ costUsd: spent,
942
+ },
943
+ });
944
+ if (!committed)
945
+ return 'stale';
946
+ if (t.outcome === 'advanced')
947
+ await scheduler.enqueue({ runId: run.id, step: committed.step, attempt: 1 });
948
+ return t.outcome;
949
+ }
950
+ catch (err) {
951
+ return handleFailure(run, node, err, startedAt, spent);
952
+ }
953
+ }
954
+ /** Wait in place for a delayed task; true when the run was cancelled meanwhile. */
955
+ async function waitOut(runId, ms) {
956
+ const until = Date.now() + ms;
957
+ while (Date.now() < until) {
958
+ await sleep(Math.min(cancelPollMs, until - Date.now()));
959
+ if (await store.isCancelRequested(runId))
960
+ return true;
961
+ }
962
+ return false;
963
+ }
964
+ const requireRun = async (runId) => {
965
+ const run = await store.getRun(runId);
966
+ if (!run)
967
+ throw new HarnessError('not_found', `Run '${runId}' not found`);
968
+ return run;
969
+ };
970
+ // ─── Public API ──────────────────────────────────────────────────
971
+ return {
972
+ graphs: () => [...registry.values()].flatMap((versions) => [...versions.values()].map((g) => g.def)),
973
+ async start(startOptions) {
974
+ const versions = registry.get(startOptions.graph);
975
+ if (!versions)
976
+ throw new HarnessError('unknown_graph', `Graph '${startOptions.graph}' is not registered`);
977
+ const version = startOptions.version ?? Math.max(...versions.keys());
978
+ const graph = versions.get(version);
979
+ if (!graph)
980
+ throw new HarnessError('unknown_graph', `Graph '${startOptions.graph}@${version}' is not registered`);
981
+ const startNode = graph.nodes.get(graph.def.start);
982
+ const at = nowIso();
983
+ const delayMs = Math.max(0, startOptions.delayMs ?? 0);
984
+ const waiting = startNode.kind === 'gate';
985
+ const run = {
986
+ id: generateId(),
987
+ graphKey: graph.def.key,
988
+ graphVersion: version,
989
+ status: waiting ? 'waiting' : 'queued',
990
+ context: startOptions.context ?? {},
991
+ input: startOptions.input ?? null,
992
+ outputs: {},
993
+ currentNode: startNode.id,
994
+ step: 1,
995
+ attempt: 1,
996
+ attemptBase: 0,
997
+ notBefore: !waiting && delayMs > 0 ? iso(Date.now() + delayMs) : null,
998
+ error: null,
999
+ cancelRequested: false,
1000
+ costUsd: 0,
1001
+ createdAt: at,
1002
+ updatedAt: at,
1003
+ completedAt: null,
1004
+ version: 0,
1005
+ leaseUntil: null,
1006
+ };
1007
+ const events = [
1008
+ {
1009
+ type: 'run.started',
1010
+ nodeId: null,
1011
+ step: null,
1012
+ attempt: null,
1013
+ label: `${graph.def.key}@${version}`,
1014
+ detail: null,
1015
+ data: delayMs ? { delayMs } : null,
1016
+ },
1017
+ ];
1018
+ if (waiting)
1019
+ events.push({
1020
+ type: 'gate.waiting',
1021
+ nodeId: startNode.id,
1022
+ step: 1,
1023
+ attempt: 1,
1024
+ label: startNode.label,
1025
+ detail: null,
1026
+ data: { decisions: startNode.kind === 'gate' ? startNode.decisions : [] },
1027
+ });
1028
+ await store.insertRun(run, events);
1029
+ if (!waiting)
1030
+ await scheduler.enqueue({ runId: run.id, step: 1, attempt: 1 }, delayMs > 0 ? { delayMs } : undefined);
1031
+ return toRunRecord(run);
1032
+ },
1033
+ async execute(task, executeOptions = {}) {
1034
+ const result = (outcome) => ({ outcome, runId: task.runId });
1035
+ if (task.item !== undefined)
1036
+ return result(await executeMapItem(task, executeOptions));
1037
+ const load = async () => {
1038
+ const run = await store.getRun(task.runId);
1039
+ if (!run || run.step !== task.step || run.attempt !== task.attempt)
1040
+ return { run: null, outcome: 'stale' };
1041
+ if (run.status === 'running')
1042
+ return { run: null, outcome: 'busy' };
1043
+ if (run.status !== 'queued')
1044
+ return { run: null, outcome: 'stale' };
1045
+ return { run, outcome: null };
1046
+ };
1047
+ let loaded = await load();
1048
+ if (!loaded.run)
1049
+ return result(loaded.outcome);
1050
+ let run = loaded.run;
1051
+ if (run.cancelRequested)
1052
+ return result(await finishCancelled(run, null));
1053
+ if (!executeOptions.ignoreDelay && run.notBefore) {
1054
+ const wait = Date.parse(run.notBefore) - Date.now();
1055
+ if (wait > 0) {
1056
+ if (wait > (executeOptions.maxWaitMs ?? 0))
1057
+ return result('deferred');
1058
+ const cancelled = await waitOut(run.id, wait);
1059
+ loaded = await load();
1060
+ if (!loaded.run)
1061
+ return result(loaded.outcome);
1062
+ run = loaded.run;
1063
+ if (cancelled || run.cancelRequested)
1064
+ return result(await finishCancelled(run, null));
1065
+ }
1066
+ }
1067
+ const graph = graphOf(run);
1068
+ const node = run.currentNode ? graph?.nodes.get(run.currentNode) : undefined;
1069
+ if (!graph || !node) {
1070
+ const message = `Graph ${run.graphKey}@${run.graphVersion} or its node '${run.currentNode}' is not registered`;
1071
+ const failed = await store.updateRun(run.id, run.version, { status: 'failed', error: { message, code: 'unknown_graph' } }, { events: [event(run, 'run.failed', 'Failed', { detail: message })] });
1072
+ return result(failed ? 'failed' : 'stale');
1073
+ }
1074
+ if (node.kind === 'gate') {
1075
+ const waiting = await store.updateRun(run.id, run.version, { status: 'waiting', notBefore: null }, {
1076
+ events: [
1077
+ event(run, 'gate.waiting', node.label, { data: { decisions: node.decisions } }),
1078
+ ],
1079
+ });
1080
+ return result(waiting ? 'waiting' : 'busy');
1081
+ }
1082
+ const startedAt = nowIso();
1083
+ const claimed = await store.updateRun(run.id, run.version, { status: 'running', leaseUntil: iso(Date.now() + leaseMs), notBefore: null }, {
1084
+ events: [event(run, 'node.started', node.label, { data: { kind: node.kind } })],
1085
+ });
1086
+ if (!claimed)
1087
+ return result('busy');
1088
+ return result(await runClaimed(claimed, graph, node, startedAt));
1089
+ },
1090
+ async resume(runId, decision) {
1091
+ const run = await requireRun(runId);
1092
+ if (run.status !== 'waiting')
1093
+ throw new HarnessError('not_waiting', `Run '${runId}' is not waiting for a decision (status: ${run.status})`);
1094
+ const graph = graphOf(run);
1095
+ const node = run.currentNode ? graph?.nodes.get(run.currentNode) : undefined;
1096
+ if (!graph || !node || node.kind !== 'gate')
1097
+ throw new HarnessError('not_waiting', `Run '${runId}' is not at a gate`);
1098
+ if (!node.decisions.includes(decision.decision)) {
1099
+ throw new HarnessError('invalid_decision', `'${decision.decision}' is not a decision of '${node.id}' (${node.decisions.join(', ')})`);
1100
+ }
1101
+ const output = {
1102
+ decision: decision.decision,
1103
+ ...(decision.data !== undefined ? { data: decision.data } : {}),
1104
+ ...(decision.actor ? { actor: decision.actor } : {}),
1105
+ at: nowIso(),
1106
+ };
1107
+ const ctx = makeContext(run, graph, node, new AbortController().signal, { value: 0 });
1108
+ const t = transition(run, graph, node, output, resolveNext(node, output, ctx), 0);
1109
+ const committed = await store.updateRun(run.id, run.version, t.patch, {
1110
+ events: [
1111
+ event(run, 'gate.decided', decision.decision, {
1112
+ data: { actor: decision.actor ?? null },
1113
+ }),
1114
+ ...t.events,
1115
+ ],
1116
+ nodeRun: {
1117
+ step: run.step,
1118
+ nodeId: node.id,
1119
+ attempt: run.attempt,
1120
+ status: 'succeeded',
1121
+ startedAt: run.updatedAt,
1122
+ finishedAt: nowIso(),
1123
+ output,
1124
+ error: null,
1125
+ costUsd: 0,
1126
+ },
1127
+ });
1128
+ if (!committed)
1129
+ throw new HarnessError('conflict', `Run '${runId}' changed meanwhile; try again`);
1130
+ if (t.outcome === 'advanced')
1131
+ await scheduler.enqueue({ runId: run.id, step: committed.step, attempt: 1 });
1132
+ return toRunRecord(committed);
1133
+ },
1134
+ async cancel(runId, cancelOptions = {}) {
1135
+ const detail = cancelOptions.reason ?? null;
1136
+ const actor = cancelOptions.actor ?? null;
1137
+ for (let tries = 0; tries < 3; tries++) {
1138
+ const run = await requireRun(runId);
1139
+ if (run.status === 'completed' || run.status === 'cancelled')
1140
+ return toRunRecord(run);
1141
+ if (run.status === 'running') {
1142
+ await store.requestCancel(run.id);
1143
+ await store.appendEvents(run.id, [
1144
+ event(run, 'run.cancel_requested', 'Stopping', { detail, data: { actor } }),
1145
+ ]);
1146
+ // The worker is gone (lease expired), or a fanned-out map holds no
1147
+ // lease (its items stop at their next cancel poll): finish it now.
1148
+ if (run.leaseUntil === null || Date.parse(run.leaseUntil) < Date.now()) {
1149
+ const fresh = await requireRun(runId);
1150
+ if (fresh.status === 'running')
1151
+ await finishCancelled(fresh, null, detail);
1152
+ }
1153
+ return toRunRecord(await requireRun(runId));
1154
+ }
1155
+ const committed = await store.updateRun(run.id, run.version, {
1156
+ status: 'cancelled',
1157
+ cancelRequested: true,
1158
+ leaseUntil: null,
1159
+ notBefore: null,
1160
+ completedAt: nowIso(),
1161
+ }, { events: [event(run, 'run.cancelled', 'Cancelled', { detail, data: { actor } })] });
1162
+ if (committed)
1163
+ return toRunRecord(committed);
1164
+ }
1165
+ throw new HarnessError('conflict', `Run '${runId}' kept changing; try again`);
1166
+ },
1167
+ async retry(runId, retryOptions = {}) {
1168
+ const run = await requireRun(runId);
1169
+ if (run.status !== 'failed')
1170
+ throw new HarnessError('not_failed', `Run '${runId}' is not failed (status: ${run.status})`);
1171
+ const attempt = run.attempt + 1;
1172
+ const committed = await store.updateRun(run.id, run.version, {
1173
+ status: 'queued',
1174
+ attempt,
1175
+ attemptBase: run.attempt,
1176
+ error: null,
1177
+ notBefore: null,
1178
+ leaseUntil: null,
1179
+ cancelRequested: false,
1180
+ completedAt: null,
1181
+ }, {
1182
+ events: [
1183
+ {
1184
+ ...event(run, 'run.retried', 'Retrying', {
1185
+ data: { actor: retryOptions.actor ?? null },
1186
+ }),
1187
+ attempt,
1188
+ },
1189
+ ],
1190
+ });
1191
+ if (!committed)
1192
+ throw new HarnessError('conflict', `Run '${runId}' changed meanwhile; try again`);
1193
+ await scheduler.enqueue({ runId: run.id, step: committed.step, attempt });
1194
+ return toRunRecord(committed);
1195
+ },
1196
+ async sweep(sweepOptions = {}) {
1197
+ const staleAfterMs = sweepOptions.staleAfterMs ?? 2 * 60_000;
1198
+ const now = Date.now();
1199
+ const runs = await store.findRunsToSweep({
1200
+ now: iso(now),
1201
+ staleBefore: iso(now - staleAfterMs),
1202
+ quietBefore: iso(now - Math.min(staleAfterMs, SWEEP_QUIET_MS)),
1203
+ limit: SWEEP_BATCH,
1204
+ });
1205
+ let touched = 0;
1206
+ for (const run of runs) {
1207
+ try {
1208
+ if (run.cancelRequested) {
1209
+ if ((await finishCancelled(run, null)) === 'cancelled')
1210
+ touched += 1;
1211
+ continue;
1212
+ }
1213
+ if (run.status === 'queued') {
1214
+ // Refresh `updated_at` so the next sweep waits again, then re-send.
1215
+ const bumped = await store.updateRun(run.id, run.version, {});
1216
+ if (!bumped)
1217
+ continue;
1218
+ await scheduler.enqueue({ runId: run.id, step: run.step, attempt: run.attempt });
1219
+ touched += 1;
1220
+ }
1221
+ else if (run.status === 'running') {
1222
+ const node = run.currentNode ? graphOf(run)?.nodes.get(run.currentNode) : undefined;
1223
+ if (!node)
1224
+ continue;
1225
+ const outcome = await handleFailure(run, node, new Error('The worker stopped before this step finished'), run.updatedAt, 0);
1226
+ if (outcome !== 'stale')
1227
+ touched += 1;
1228
+ }
1229
+ }
1230
+ catch {
1231
+ // One bad run must not stop the sweep.
1232
+ }
1233
+ }
1234
+ // Fanned-out map items: re-send lost item tasks, fail the attempt of a dead item worker.
1235
+ if (typeof store.findMapItemsToSweep === 'function') {
1236
+ const maps = mapStore();
1237
+ const items = await maps.findMapItemsToSweep({
1238
+ now: iso(now),
1239
+ quietBefore: iso(now - Math.min(staleAfterMs, SWEEP_QUIET_MS)),
1240
+ limit: SWEEP_BATCH,
1241
+ });
1242
+ for (const it of items) {
1243
+ try {
1244
+ const run = await store.getRun(it.runId);
1245
+ if (!run || run.status !== 'running')
1246
+ continue;
1247
+ if (run.cancelRequested) {
1248
+ if ((await finishCancelled(run, null)) === 'cancelled')
1249
+ touched += 1;
1250
+ continue;
1251
+ }
1252
+ if (it.status === 'queued') {
1253
+ if (!(await maps.touchMapItem(run.id, it.step, it.index)))
1254
+ continue;
1255
+ await scheduler.enqueue(itemTask(run, it.index));
1256
+ touched += 1;
1257
+ }
1258
+ else {
1259
+ const node = run.currentNode ? graphOf(run)?.nodes.get(run.currentNode) : undefined;
1260
+ const row = (await maps.listMapItems(run.id, it.step)).find((r) => r.index === it.index);
1261
+ if (!node || !isFanOut(node) || !row)
1262
+ continue;
1263
+ const outcome = await failMapItem(run, node, row, new Error('The worker stopped before this item finished'), 0);
1264
+ if (outcome !== 'stale')
1265
+ touched += 1;
1266
+ }
1267
+ }
1268
+ catch {
1269
+ // One bad item must not stop the sweep.
1270
+ }
1271
+ }
1272
+ }
1273
+ return touched;
1274
+ },
1275
+ async getRun(runId) {
1276
+ const run = await store.getRun(runId);
1277
+ return run ? toRunRecord(run) : null;
1278
+ },
1279
+ async events(runId, eventOptions = {}) {
1280
+ return store.listEvents(runId, {
1281
+ ...(eventOptions.afterSeq !== undefined ? { afterSeq: eventOptions.afterSeq } : {}),
1282
+ limit: Math.min(Math.max(eventOptions.limit ?? 500, 1), 5000),
1283
+ });
1284
+ },
1285
+ };
1286
+ }
1287
+ //# sourceMappingURL=harness.js.map