@atom-workflow-agent/workflow-engine 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 (92) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +36 -0
  3. package/README.zh-CN.md +36 -0
  4. package/dist/cache/cache.d.ts +18 -0
  5. package/dist/cache/cache.d.ts.map +1 -0
  6. package/dist/cache/cache.js +44 -0
  7. package/dist/cache/cache.js.map +1 -0
  8. package/dist/diagnostics/public-value.d.ts +5 -0
  9. package/dist/diagnostics/public-value.d.ts.map +1 -0
  10. package/dist/diagnostics/public-value.js +23 -0
  11. package/dist/diagnostics/public-value.js.map +1 -0
  12. package/dist/diagnostics/run-diagnostics.d.ts +12 -0
  13. package/dist/diagnostics/run-diagnostics.d.ts.map +1 -0
  14. package/dist/diagnostics/run-diagnostics.js +90 -0
  15. package/dist/diagnostics/run-diagnostics.js.map +1 -0
  16. package/dist/diagnostics/run-graph-overlay.d.ts +5 -0
  17. package/dist/diagnostics/run-graph-overlay.d.ts.map +1 -0
  18. package/dist/diagnostics/run-graph-overlay.js +125 -0
  19. package/dist/diagnostics/run-graph-overlay.js.map +1 -0
  20. package/dist/diagnostics/run-root-cause.d.ts +4 -0
  21. package/dist/diagnostics/run-root-cause.d.ts.map +1 -0
  22. package/dist/diagnostics/run-root-cause.js +111 -0
  23. package/dist/diagnostics/run-root-cause.js.map +1 -0
  24. package/dist/handoff/host-handoff.d.ts +59 -0
  25. package/dist/handoff/host-handoff.d.ts.map +1 -0
  26. package/dist/handoff/host-handoff.js +235 -0
  27. package/dist/handoff/host-handoff.js.map +1 -0
  28. package/dist/index.d.ts +21 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +21 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/machine/runtime-machine.d.ts +151 -0
  33. package/dist/machine/runtime-machine.d.ts.map +1 -0
  34. package/dist/machine/runtime-machine.js +141 -0
  35. package/dist/machine/runtime-machine.js.map +1 -0
  36. package/dist/policy/retry.d.ts +10 -0
  37. package/dist/policy/retry.d.ts.map +1 -0
  38. package/dist/policy/retry.js +40 -0
  39. package/dist/policy/retry.js.map +1 -0
  40. package/dist/ports/runtime-ports.d.ts +25 -0
  41. package/dist/ports/runtime-ports.d.ts.map +1 -0
  42. package/dist/ports/runtime-ports.js +2 -0
  43. package/dist/ports/runtime-ports.js.map +1 -0
  44. package/dist/revision/workflow-revision.d.ts +18 -0
  45. package/dist/revision/workflow-revision.d.ts.map +1 -0
  46. package/dist/revision/workflow-revision.js +115 -0
  47. package/dist/revision/workflow-revision.js.map +1 -0
  48. package/dist/runtime/durable-workflow-engine.d.ts +78 -0
  49. package/dist/runtime/durable-workflow-engine.d.ts.map +1 -0
  50. package/dist/runtime/durable-workflow-engine.js +1593 -0
  51. package/dist/runtime/durable-workflow-engine.js.map +1 -0
  52. package/dist/runtime/resume-delivery.d.ts +19 -0
  53. package/dist/runtime/resume-delivery.d.ts.map +1 -0
  54. package/dist/runtime/resume-delivery.js +53 -0
  55. package/dist/runtime/resume-delivery.js.map +1 -0
  56. package/dist/runtime/resume-directive.d.ts +6 -0
  57. package/dist/runtime/resume-directive.d.ts.map +1 -0
  58. package/dist/runtime/resume-directive.js +22 -0
  59. package/dist/runtime/resume-directive.js.map +1 -0
  60. package/dist/runtime/state-machines.d.ts +5 -0
  61. package/dist/runtime/state-machines.d.ts.map +1 -0
  62. package/dist/runtime/state-machines.js +53 -0
  63. package/dist/runtime/state-machines.js.map +1 -0
  64. package/dist/store/execution-run-storage-layout.d.ts +15 -0
  65. package/dist/store/execution-run-storage-layout.d.ts.map +1 -0
  66. package/dist/store/execution-run-storage-layout.js +26 -0
  67. package/dist/store/execution-run-storage-layout.js.map +1 -0
  68. package/dist/store/local-artifact-store.d.ts +18 -0
  69. package/dist/store/local-artifact-store.d.ts.map +1 -0
  70. package/dist/store/local-artifact-store.js +79 -0
  71. package/dist/store/local-artifact-store.js.map +1 -0
  72. package/dist/store/local-execution-run-store.d.ts +52 -0
  73. package/dist/store/local-execution-run-store.d.ts.map +1 -0
  74. package/dist/store/local-execution-run-store.js +143 -0
  75. package/dist/store/local-execution-run-store.js.map +1 -0
  76. package/dist/store/local-workflow-store.d.ts +22 -0
  77. package/dist/store/local-workflow-store.d.ts.map +1 -0
  78. package/dist/store/local-workflow-store.js +145 -0
  79. package/dist/store/local-workflow-store.js.map +1 -0
  80. package/dist/store/runtime-snapshot.d.ts +56 -0
  81. package/dist/store/runtime-snapshot.d.ts.map +1 -0
  82. package/dist/store/runtime-snapshot.js +4 -0
  83. package/dist/store/runtime-snapshot.js.map +1 -0
  84. package/dist/store/workflow-storage-layout.d.ts +7 -0
  85. package/dist/store/workflow-storage-layout.d.ts.map +1 -0
  86. package/dist/store/workflow-storage-layout.js +18 -0
  87. package/dist/store/workflow-storage-layout.js.map +1 -0
  88. package/dist/topology/workflow-topology.d.ts +4 -0
  89. package/dist/topology/workflow-topology.d.ts.map +1 -0
  90. package/dist/topology/workflow-topology.js +76 -0
  91. package/dist/topology/workflow-topology.js.map +1 -0
  92. package/package.json +54 -0
@@ -0,0 +1,1593 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { AtomWorkflowError, toAtomWorkflowError, validateJsonSchemaInstance, } from "@atom-workflow-agent/contracts";
3
+ import { InProcessAtomAttemptRunner, withTimeout, ManagedAttemptResources, } from "@atom-workflow-agent/atom-executor";
4
+ import { createAtomLifecycleHelpers, } from "@atom-workflow-agent/atom-sdk";
5
+ import { FileCache, stableCacheKey } from "../cache/cache.js";
6
+ import { HostHandoffManager } from "../handoff/host-handoff.js";
7
+ import { createSignalResumeDirective } from "./resume-directive.js";
8
+ import { assertSameResumeSubmission, createResumeDelivery, markResumeDeliveryFailed, markResumeDeliveryUnknownOutcome, resumeDeliveryId, } from "./resume-delivery.js";
9
+ import { assertNodeRunTransition, assertRunTransition } from "./state-machines.js";
10
+ import { cloneSnapshot } from "../store/runtime-snapshot.js";
11
+ const TERMINAL = new Set(["succeeded", "failed", "cancelled", "unknown_outcome"]);
12
+ function nowIso(now) { return now().toISOString(); }
13
+ function asJson(value) {
14
+ try {
15
+ return JSON.parse(JSON.stringify(value));
16
+ }
17
+ catch {
18
+ return String(value);
19
+ }
20
+ }
21
+ function errorRecord(error) {
22
+ const normalized = toAtomWorkflowError(error);
23
+ return {
24
+ code: normalized.code,
25
+ message: normalized.message,
26
+ retryable: normalized.retryable,
27
+ ...(normalized.cause !== undefined ? { details: asJson(normalized.cause) } : {}),
28
+ };
29
+ }
30
+ function valueAtPath(root, path) {
31
+ let current = root;
32
+ for (const segment of path) {
33
+ if (Array.isArray(current) && /^\d+$/.test(segment))
34
+ current = current[Number(segment)];
35
+ else if (current !== null && typeof current === "object" && !Array.isArray(current) && segment in current)
36
+ current = current[segment];
37
+ else
38
+ return undefined;
39
+ }
40
+ return current;
41
+ }
42
+ function sameLineage(left, right) {
43
+ return left.length === right.length && left.every((value, index) => value === right[index]);
44
+ }
45
+ /**
46
+ * A successful activation is visible to a consumer when both activations are
47
+ * on the same comparable control path. A source may be above the consumer
48
+ * (pre-fork data) or one joined fork level below it (branch data after fan-in).
49
+ *
50
+ * Frozen IR dependency validation remains the authority that proves the source
51
+ * executes on every path to the consumer. Runtime lineage only correlates the
52
+ * right fork/revisit instance; it must not contradict that static guarantee.
53
+ */
54
+ function lineageVisibleFrom(source, consumer) {
55
+ const prefix = (shorter, longer) => shorter.length <= longer.length
56
+ && shorter.every((value, index) => value === longer[index]);
57
+ return prefix(source, consumer) || prefix(consumer, source);
58
+ }
59
+ function latestSuccess(snapshot, nodeId, nodeRun) {
60
+ return [...snapshot.nodeRuns]
61
+ .filter((candidate) => candidate.nodeId === nodeId && candidate.status === "succeeded"
62
+ && (!nodeRun || lineageVisibleFrom(candidate.controlLineage, nodeRun.controlLineage)))
63
+ .sort((a, b) => b.activationIndex - a.activationIndex)[0];
64
+ }
65
+ function resolvedNodeOutput(snapshot, nodeId, nodeRun) {
66
+ const node = snapshot.ir.nodes.find((candidate) => candidate.id === nodeId);
67
+ const mapScope = node?.mapScope;
68
+ if (mapScope && nodeRun?.mapRunId && nodeRun.mapIndex !== undefined) {
69
+ return [...snapshot.nodeRuns]
70
+ .filter((candidate) => candidate.nodeId === nodeId && candidate.status === "succeeded" && candidate.mapRunId === nodeRun.mapRunId && candidate.mapIndex === nodeRun.mapIndex)
71
+ .sort((left, right) => right.activationIndex - left.activationIndex)[0]?.output;
72
+ }
73
+ if (mapScope) {
74
+ const mapped = snapshot.nodeRuns.filter((candidate) => candidate.nodeId === nodeId && candidate.status === "succeeded" && candidate.mapRunId);
75
+ const latestRunId = [...mapped].sort((left, right) => right.activationIndex - left.activationIndex)[0]?.mapRunId;
76
+ if (latestRunId)
77
+ return mapped.filter((candidate) => candidate.mapRunId === latestRunId)
78
+ .sort((left, right) => (left.mapIndex ?? 0) - (right.mapIndex ?? 0)).map((candidate) => candidate.output ?? null);
79
+ if (snapshot.mapRuns.some((run) => run.mapNodeId === mapScope && run.status === "succeeded" && run.items.length === 0))
80
+ return [];
81
+ }
82
+ return latestSuccess(snapshot, nodeId, nodeRun)?.output;
83
+ }
84
+ function resolveReference(reference, snapshot, nodeRun) {
85
+ if (reference === "workflow.inputs" || reference.startsWith("workflow.inputs.")) {
86
+ const inputs = { ...snapshot.ir.inputs, ...snapshot.run.inputs };
87
+ const resolved = valueAtPath(inputs, reference === "workflow.inputs" ? [] : reference.split(".").slice(2));
88
+ if (resolved === undefined)
89
+ throw new AtomWorkflowError("REFERENCE_RESOLUTION_FAILED", `Workflow input is missing: ${reference}`);
90
+ return resolved;
91
+ }
92
+ if (reference === "map.item" || reference.startsWith("map.item.")) {
93
+ const item = nodeRun?.mapItem;
94
+ if (item === undefined)
95
+ throw new AtomWorkflowError("REFERENCE_RESOLUTION_FAILED", `${reference} is outside MapNode`);
96
+ const resolved = valueAtPath(item, reference.endsWith(".item") ? [] : reference.split(".").slice(2));
97
+ if (resolved === undefined)
98
+ throw new AtomWorkflowError("REFERENCE_RESOLUTION_FAILED", `Map item is missing: ${reference}`);
99
+ return resolved;
100
+ }
101
+ if (reference === "map.index") {
102
+ const index = nodeRun?.mapIndex;
103
+ if (index === undefined)
104
+ throw new AtomWorkflowError("REFERENCE_RESOLUTION_FAILED", `${reference} is outside MapNode`);
105
+ return index;
106
+ }
107
+ const dependency = /^dependencies\.([a-z][a-z0-9-]*)\.output(?:\.(.*))?$/.exec(reference);
108
+ const activation = /^nodes\.([a-z][a-z0-9-]*)\.activations\.lastSuccessful\.output(?:\.(.*))?$/.exec(reference);
109
+ const match = dependency ?? activation;
110
+ if (match) {
111
+ const output = resolvedNodeOutput(snapshot, match[1], nodeRun);
112
+ if (output === undefined)
113
+ throw new AtomWorkflowError("REFERENCE_RESOLUTION_FAILED", `Node output is unavailable: ${reference}`);
114
+ const path = match[2] ? match[2].split(".") : [];
115
+ const sourceNode = snapshot.ir.nodes.find((candidate) => candidate.id === match[1]);
116
+ const sourceGroup = sourceNode?.mapScope;
117
+ const sameGroup = sourceGroup !== undefined && nodeRun?.mapRunId !== undefined
118
+ && snapshot.mapRuns.some((run) => run.mapRunId === nodeRun.mapRunId && run.mapNodeId === sourceGroup);
119
+ const resolved = sourceGroup && !sameGroup && Array.isArray(output)
120
+ ? output.map((item) => valueAtPath(item, path)).filter((item) => item !== undefined)
121
+ : valueAtPath(output, path);
122
+ if (resolved === undefined)
123
+ throw new AtomWorkflowError("REFERENCE_RESOLUTION_FAILED", `Node output path is missing: ${reference}`);
124
+ return resolved;
125
+ }
126
+ throw new AtomWorkflowError("REFERENCE_RESOLUTION_FAILED", `Unsupported runtime reference: ${reference}`);
127
+ }
128
+ function resolveValue(value, snapshot, nodeRun) {
129
+ if (Array.isArray(value))
130
+ return value.map((item) => resolveValue(item, snapshot, nodeRun));
131
+ if (value !== null && typeof value === "object") {
132
+ if (Object.keys(value).length === 1 && typeof value.$ref === "string")
133
+ return resolveReference(value.$ref, snapshot, nodeRun);
134
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, resolveValue(nested, snapshot, nodeRun)]));
135
+ }
136
+ return value;
137
+ }
138
+ function nodeInput(snapshot, node, nodeRun) {
139
+ const base = resolveValue(node.input, snapshot, nodeRun);
140
+ if (Object.keys(nodeRun.inputOverlay).length === 0)
141
+ return base;
142
+ if (base === null || typeof base !== "object" || Array.isArray(base)) {
143
+ throw new AtomWorkflowError("EDGE_INPUT_INVALID", `Edge overlay requires object input: ${node.id}`);
144
+ }
145
+ return { ...base, ...nodeRun.inputOverlay };
146
+ }
147
+ function runtimeLogger(append) {
148
+ return {
149
+ debug: (message, fields = {}) => append("debug", message, fields),
150
+ info: (message, fields = {}) => append("info", message, fields),
151
+ warn: (message, fields = {}) => append("warn", message, fields),
152
+ error: (message, fields = {}) => append("error", message, fields),
153
+ };
154
+ }
155
+ function snapshotAsJson(snapshot) {
156
+ return JSON.parse(JSON.stringify(snapshot));
157
+ }
158
+ function sleepMs(policy, attemptIndex) {
159
+ const raw = policy.strategy === "exponential"
160
+ ? policy.baseDelayMs * 2 ** Math.max(0, attemptIndex - 1)
161
+ : policy.baseDelayMs;
162
+ const limited = Math.min(policy.maxDelayMs, raw);
163
+ return policy.jitter ? Math.round(limited * (0.8 + Math.random() * 0.4)) : limited;
164
+ }
165
+ /** Durable scheduler and executor for one frozen WorkflowIR revision. */
166
+ export class DurableWorkflowEngine {
167
+ options;
168
+ cache;
169
+ controllers = new Map();
170
+ resumeProcessing = new Map();
171
+ now;
172
+ atomAttemptRunner;
173
+ constructor(options) {
174
+ this.options = options;
175
+ this.cache = new FileCache(options.cacheDirectory);
176
+ this.now = options.now ?? (() => new Date());
177
+ this.atomAttemptRunner = options.atomAttemptRunner ?? new InProcessAtomAttemptRunner();
178
+ }
179
+ async createRun(input) {
180
+ if (!Object.isFrozen(input.ir))
181
+ throw new AtomWorkflowError("WORKFLOW_IR_NOT_FROZEN", "Runtime only accepts an immutable WorkflowIR");
182
+ if (input.revision.workflowIrHash !== input.ir.hash)
183
+ throw new AtomWorkflowError("WORKFLOW_REVISION_IR_HASH_MISMATCH", "WorkflowRevision does not match the supplied immutable IR");
184
+ const inputIssues = validateJsonSchemaInstance(input.revision.inputSchema, input.inputs, "$.inputs");
185
+ if (inputIssues.length > 0)
186
+ throw new AtomWorkflowError("WORKFLOW_INPUT_INVALID", "Workflow inputs do not match the frozen input contract", false, { cause: inputIssues });
187
+ const createdAt = nowIso(this.now);
188
+ let snapshot = {
189
+ schemaVersion: "1.0",
190
+ run: {
191
+ schemaVersion: "1.0",
192
+ runId: input.runId,
193
+ goal: input.goal,
194
+ cwd: input.cwd,
195
+ projectScope: input.projectScope ?? input.cwd,
196
+ hostScope: input.hostScope ?? "local-host",
197
+ status: "created",
198
+ ...(input.planId ? { planId: input.planId } : {}),
199
+ workflowRevisionId: input.revision.workflowRevisionId,
200
+ inputs: { ...input.inputs },
201
+ createdAt,
202
+ updatedAt: createdAt,
203
+ },
204
+ revision: input.revision,
205
+ ir: input.ir,
206
+ nodeRuns: [],
207
+ attempts: [],
208
+ artifacts: [],
209
+ signals: [],
210
+ hostTasks: [],
211
+ resumeDeliveries: [],
212
+ resourceLeases: [],
213
+ events: [],
214
+ eventSequence: 0,
215
+ transitionCount: 0,
216
+ edgeTraversals: {},
217
+ activationCounts: {},
218
+ pendingEdges: [],
219
+ decisions: [],
220
+ mapRuns: [],
221
+ };
222
+ const entryLineage = input.ir.control.entry.length > 1 ? [`${snapshot.revision.workflowRevisionId}:entry`] : [];
223
+ for (const nodeId of input.ir.control.entry)
224
+ snapshot = this.activate(snapshot, nodeId, {}, undefined, undefined, entryLineage);
225
+ snapshot = this.event(snapshot, "run.created", { workflowIrHash: input.ir.hash });
226
+ await this.options.store.create(snapshot);
227
+ return snapshot;
228
+ }
229
+ async execute(runId) {
230
+ const existing = await this.options.store.load(runId);
231
+ if (TERMINAL.has(existing.run.status))
232
+ return existing;
233
+ assertRunTransition(existing.run.status, "running");
234
+ const controller = this.controllers.get(runId) ?? new AbortController();
235
+ this.controllers.set(runId, controller);
236
+ const started = Date.now();
237
+ await this.options.store.update(runId, (snapshot) => ({
238
+ ...snapshot,
239
+ run: {
240
+ ...snapshot.run,
241
+ status: "running",
242
+ startedAt: snapshot.run.startedAt ?? nowIso(this.now),
243
+ updatedAt: nowIso(this.now),
244
+ error: undefined,
245
+ },
246
+ }));
247
+ try {
248
+ while (true) {
249
+ if (controller.signal.aborted)
250
+ return this.cancel(runId);
251
+ const runTimeoutMs = this.options.runTimeoutMs ?? null;
252
+ if (runTimeoutMs !== null && runTimeoutMs <= Date.now() - started) {
253
+ throw new AtomWorkflowError("RUN_TIMEOUT", "Workflow active execution timeout exceeded", true);
254
+ }
255
+ let snapshot = await this.options.store.load(runId);
256
+ if (TERMINAL.has(snapshot.run.status))
257
+ return snapshot;
258
+ snapshot = await this.flushReadyJoins(snapshot);
259
+ const ready = snapshot.nodeRuns.filter((nodeRun) => nodeRun.status === "ready");
260
+ if (ready.length === 0) {
261
+ const waitingSignal = snapshot.nodeRuns.some((nodeRun) => nodeRun.status === "awaiting_signal");
262
+ const waitingHost = snapshot.nodeRuns.some((nodeRun) => nodeRun.status === "awaiting_host");
263
+ if (waitingSignal || waitingHost) {
264
+ return this.options.store.update(runId, (current) => ({
265
+ ...current,
266
+ run: { ...current.run, status: waitingSignal ? "awaiting_signal" : "awaiting_host", updatedAt: nowIso(this.now) },
267
+ }));
268
+ }
269
+ throw new AtomWorkflowError("WORKFLOW_DEADLOCK", "Workflow has no runnable or waiting node");
270
+ }
271
+ const settled = await Promise.allSettled(ready.map((nodeRun) => this.executeNode(runId, nodeRun.activationId, controller.signal)));
272
+ const failed = settled.find((result) => result.status === "rejected");
273
+ if (failed)
274
+ throw failed.reason;
275
+ }
276
+ }
277
+ catch (error) {
278
+ const normalized = toAtomWorkflowError(error);
279
+ if (normalized.code === "CANCELLED")
280
+ return this.cancel(runId);
281
+ return this.options.store.update(runId, (snapshot) => this.event({
282
+ ...snapshot,
283
+ mapRuns: snapshot.mapRuns.map((mapRun) => mapRun.status === "running" ? {
284
+ ...mapRun,
285
+ status: "failed",
286
+ updatedAt: nowIso(this.now),
287
+ endedAt: nowIso(this.now),
288
+ } : mapRun),
289
+ run: {
290
+ ...snapshot.run,
291
+ status: "failed",
292
+ updatedAt: nowIso(this.now),
293
+ endedAt: nowIso(this.now),
294
+ error: errorRecord(normalized),
295
+ },
296
+ }, "run.failed", { code: normalized.code, message: normalized.message }));
297
+ }
298
+ finally {
299
+ this.controllers.delete(runId);
300
+ }
301
+ }
302
+ async signal(runId, signalId, response) {
303
+ const snapshot = await this.options.store.load(runId);
304
+ const signal = snapshot.signals.find((candidate) => candidate.signalId === signalId);
305
+ if (!signal)
306
+ throw new AtomWorkflowError("SIGNAL_NOT_FOUND", `Signal not found: ${signalId}`);
307
+ if (signal.expiresAt && this.now().getTime() >= Date.parse(signal.expiresAt)) {
308
+ await this.options.store.update(runId, (current) => ({
309
+ ...current,
310
+ signals: current.signals.map((item) => item.signalId === signalId ? { ...item, status: "expired" } : item),
311
+ }));
312
+ throw new AtomWorkflowError("SIGNAL_EXPIRED", `Signal expired: ${signalId}`);
313
+ }
314
+ const deliveryId = resumeDeliveryId("signal", signalId);
315
+ await this.options.store.update(runId, (current) => {
316
+ const existing = current.resumeDeliveries.find((delivery) => delivery.resumeDeliveryId === deliveryId);
317
+ if (existing) {
318
+ assertSameResumeSubmission(existing, response);
319
+ return current;
320
+ }
321
+ const waiting = current.signals.find((candidate) => candidate.signalId === signalId);
322
+ if (!waiting)
323
+ throw new AtomWorkflowError("SIGNAL_NOT_FOUND", `Signal not found: ${signalId}`);
324
+ if (waiting.status !== "waiting")
325
+ throw new AtomWorkflowError("SIGNAL_ALREADY_CONSUMED", `Signal is ${waiting.status}`);
326
+ if (waiting.expiresAt && this.now().getTime() >= Date.parse(waiting.expiresAt)) {
327
+ throw new AtomWorkflowError("SIGNAL_EXPIRED", `Signal expired: ${signalId}`);
328
+ }
329
+ const issues = validateJsonSchemaInstance(waiting.responseSchema, response, "$.response");
330
+ if (issues.length > 0) {
331
+ throw new AtomWorkflowError("SIGNAL_RESPONSE_INVALID", "Signal response failed Schema validation", false, { cause: issues });
332
+ }
333
+ const attempt = current.attempts.find((candidate) => candidate.attemptId === waiting.attemptId);
334
+ if (!attempt)
335
+ throw new AtomWorkflowError("ATTEMPT_NOT_RESUMABLE", `Attempt is not resumable: ${waiting.attemptId}`);
336
+ const resume = {
337
+ schemaVersion: "1.0",
338
+ kind: "signal",
339
+ signalId,
340
+ key: waiting.key,
341
+ response,
342
+ };
343
+ const delivery = createResumeDelivery({
344
+ runId,
345
+ workflowRevisionId: current.revision.workflowRevisionId,
346
+ nodeId: attempt.nodeId,
347
+ activationId: attempt.activationId,
348
+ attemptId: attempt.attemptId,
349
+ kind: "signal",
350
+ sourceId: signalId,
351
+ submission: response,
352
+ payload: resume,
353
+ now: this.now(),
354
+ });
355
+ return this.event({
356
+ ...current,
357
+ signals: current.signals.map((item) => item.signalId === signalId ? {
358
+ ...item,
359
+ status: "consumed",
360
+ response,
361
+ consumedAt: nowIso(this.now),
362
+ } : item),
363
+ resumeDeliveries: [...current.resumeDeliveries, delivery],
364
+ run: { ...current.run, status: "running", updatedAt: nowIso(this.now) },
365
+ }, "resume.accepted", { kind: "signal", sourceId: signalId, resumeDeliveryId: deliveryId }, attempt.nodeId, attempt.activationId, attempt.attemptId);
366
+ });
367
+ return this.processResumeDelivery(runId, deliveryId);
368
+ }
369
+ async resumeHost(runId, raw) {
370
+ const deliveryId = resumeDeliveryId("host", raw.hostTaskId);
371
+ await this.options.store.update(runId, (current) => {
372
+ const existing = current.resumeDeliveries.find((delivery) => delivery.resumeDeliveryId === deliveryId);
373
+ if (existing) {
374
+ assertSameResumeSubmission(existing, raw);
375
+ return current;
376
+ }
377
+ const record = current.hostTasks.find((item) => item.request.hostTaskId === raw.hostTaskId);
378
+ if (!record)
379
+ throw new AtomWorkflowError("HOST_TASK_NOT_FOUND", `HostTask not found: ${raw.hostTaskId}`);
380
+ const manager = this.hostManager(current);
381
+ const request = record.request;
382
+ const resume = manager.resume(raw, {
383
+ runId,
384
+ workflowRevisionId: request.workflowRevisionId,
385
+ nodeId: request.nodeId,
386
+ activationId: request.activationId,
387
+ attemptId: request.attemptId,
388
+ workflowIrHash: request.continuation.workflowIrHash,
389
+ projectScope: request.projectScope,
390
+ hostScope: request.hostScope,
391
+ });
392
+ const delivery = createResumeDelivery({
393
+ runId,
394
+ workflowRevisionId: request.workflowRevisionId,
395
+ nodeId: request.nodeId,
396
+ activationId: request.activationId,
397
+ attemptId: request.attemptId,
398
+ kind: "host",
399
+ sourceId: request.hostTaskId,
400
+ submission: raw,
401
+ payload: resume,
402
+ now: this.now(),
403
+ });
404
+ return this.event({
405
+ ...current,
406
+ hostTasks: manager.exportRecords(),
407
+ resumeDeliveries: [...current.resumeDeliveries, delivery],
408
+ run: { ...current.run, status: "running", updatedAt: nowIso(this.now) },
409
+ }, "resume.accepted", { kind: "host", sourceId: request.hostTaskId, resumeDeliveryId: deliveryId }, request.nodeId, request.activationId, request.attemptId);
410
+ });
411
+ return this.processResumeDelivery(runId, deliveryId);
412
+ }
413
+ async cancel(runId) {
414
+ this.controllers.get(runId)?.abort(new AtomWorkflowError("CANCELLED", "Workflow cancelled"));
415
+ return this.options.store.update(runId, (snapshot) => {
416
+ if (snapshot.run.status === "succeeded" || snapshot.run.status === "cancelled")
417
+ return snapshot;
418
+ assertRunTransition(snapshot.run.status, "cancelled");
419
+ const manager = this.hostManager(snapshot);
420
+ for (const task of manager.listResumable())
421
+ manager.cancel(task.hostTaskId);
422
+ return this.event({
423
+ ...snapshot,
424
+ hostTasks: manager.exportRecords(),
425
+ signals: snapshot.signals.map((signal) => signal.status === "waiting" ? { ...signal, status: "cancelled" } : signal),
426
+ nodeRuns: snapshot.nodeRuns.map((nodeRun) => TERMINAL.has(nodeRun.status) ? nodeRun : {
427
+ ...nodeRun,
428
+ status: "cancelled",
429
+ updatedAt: nowIso(this.now),
430
+ endedAt: nowIso(this.now),
431
+ }),
432
+ run: { ...snapshot.run, status: "cancelled", updatedAt: nowIso(this.now), endedAt: nowIso(this.now) },
433
+ }, "run.cancelled", {});
434
+ });
435
+ }
436
+ async retry(runId, activationId) {
437
+ await this.options.store.update(runId, (snapshot) => ({
438
+ ...snapshot,
439
+ nodeRuns: snapshot.nodeRuns.map((nodeRun) => {
440
+ if (nodeRun.activationId !== activationId)
441
+ return nodeRun;
442
+ assertNodeRunTransition(nodeRun.status, "ready");
443
+ const { error: _error, endedAt: _endedAt, ...rest } = nodeRun;
444
+ return { ...rest, status: "ready", updatedAt: nowIso(this.now) };
445
+ }),
446
+ run: { ...snapshot.run, status: "running", error: undefined, endedAt: undefined, updatedAt: nowIso(this.now) },
447
+ }));
448
+ return this.execute(runId);
449
+ }
450
+ async resume(runId) {
451
+ const snapshot = await this.options.store.load(runId);
452
+ const pendingDelivery = snapshot.resumeDeliveries.find((delivery) => delivery.status === "accepted" || delivery.status === "processing" || delivery.status === "retry_wait");
453
+ if (pendingDelivery)
454
+ return this.processResumeDelivery(runId, pendingDelivery.resumeDeliveryId);
455
+ if (snapshot.run.status === "awaiting_host" || snapshot.run.status === "awaiting_signal")
456
+ return snapshot;
457
+ assertRunTransition(snapshot.run.status, "running");
458
+ await this.options.store.update(runId, (current) => ({
459
+ ...current,
460
+ run: { ...current.run, status: "running", error: undefined, endedAt: undefined, updatedAt: nowIso(this.now) },
461
+ nodeRuns: current.nodeRuns.map((nodeRun) => nodeRun.status === "interrupted"
462
+ ? { ...nodeRun, status: "ready", updatedAt: nowIso(this.now) }
463
+ : nodeRun),
464
+ }));
465
+ return this.execute(runId);
466
+ }
467
+ async recover(autoResume = true) {
468
+ const recovered = [];
469
+ for (const stale of await this.options.store.listNonTerminal()) {
470
+ const next = await this.options.store.update(stale.run.runId, (snapshot) => {
471
+ let unknown = false;
472
+ const activeDeliveries = snapshot.resumeDeliveries.filter((delivery) => delivery.status === "accepted" || delivery.status === "processing" || delivery.status === "retry_wait");
473
+ const unknownResumeAttemptIds = new Set(activeDeliveries
474
+ .filter((delivery) => {
475
+ if (delivery.status !== "processing")
476
+ return false;
477
+ const node = snapshot.ir.nodes.find((candidate) => candidate.id === delivery.nodeId);
478
+ return node?.kind === "atom" && !node.atom.manifest.idempotent;
479
+ })
480
+ .map((delivery) => delivery.attemptId));
481
+ const pendingDeliveries = activeDeliveries.filter((delivery) => !unknownResumeAttemptIds.has(delivery.attemptId));
482
+ const pendingByAttempt = new Map(pendingDeliveries.map((delivery) => [delivery.attemptId, delivery]));
483
+ const attempts = snapshot.attempts.map((attempt) => {
484
+ if (unknownResumeAttemptIds.has(attempt.attemptId)) {
485
+ unknown = true;
486
+ return {
487
+ ...attempt,
488
+ state: "unknown_outcome",
489
+ phase: "complete",
490
+ endedAt: nowIso(this.now),
491
+ updatedAt: nowIso(this.now),
492
+ error: { code: "RESUME_UNKNOWN_OUTCOME", message: "Runtime stopped during a non-idempotent resume invocation", retryable: false },
493
+ };
494
+ }
495
+ const pending = pendingByAttempt.get(attempt.attemptId);
496
+ if (pending) {
497
+ return {
498
+ ...attempt,
499
+ state: pending.kind === "signal" ? "awaiting_signal" : "awaiting_host",
500
+ phase: "resume",
501
+ updatedAt: nowIso(this.now),
502
+ };
503
+ }
504
+ if (attempt.state !== "running")
505
+ return attempt;
506
+ const node = snapshot.ir.nodes.find((candidate) => candidate.id === attempt.nodeId);
507
+ if (node.kind !== "atom")
508
+ throw new AtomWorkflowError("CONTROL_NODE_ATTEMPT_INVALID", `Control node cannot own an Atom Attempt: ${node.id}`);
509
+ if (!node.atom.manifest.idempotent)
510
+ unknown = true;
511
+ return {
512
+ ...attempt,
513
+ state: node.atom.manifest.idempotent ? "interrupted" : "unknown_outcome",
514
+ phase: "complete",
515
+ endedAt: nowIso(this.now),
516
+ updatedAt: nowIso(this.now),
517
+ error: { code: "PROCESS_INTERRUPTED", message: "Runtime stopped before Attempt completion was committed", retryable: node.atom.manifest.idempotent },
518
+ };
519
+ });
520
+ const nodeRuns = snapshot.nodeRuns.map((nodeRun) => {
521
+ if (activeDeliveries.some((delivery) => delivery.activationId === nodeRun.activationId && unknownResumeAttemptIds.has(delivery.attemptId))) {
522
+ return {
523
+ ...nodeRun,
524
+ status: "unknown_outcome",
525
+ error: { code: "RESUME_UNKNOWN_OUTCOME", message: "Runtime stopped during a non-idempotent resume invocation", retryable: false },
526
+ updatedAt: nowIso(this.now),
527
+ };
528
+ }
529
+ const pending = pendingDeliveries.find((delivery) => delivery.activationId === nodeRun.activationId);
530
+ if (pending) {
531
+ return {
532
+ ...nodeRun,
533
+ status: pending.status === "retry_wait"
534
+ ? "retry_wait"
535
+ : pending.kind === "signal" ? "awaiting_signal" : "awaiting_host",
536
+ updatedAt: nowIso(this.now),
537
+ };
538
+ }
539
+ if (nodeRun.status !== "running" && nodeRun.status !== "retry_wait")
540
+ return nodeRun;
541
+ const node = snapshot.ir.nodes.find((candidate) => candidate.id === nodeRun.nodeId);
542
+ if (node.kind !== "atom")
543
+ return { ...nodeRun, status: "ready", updatedAt: nowIso(this.now) };
544
+ return {
545
+ ...nodeRun,
546
+ status: node.atom.manifest.idempotent ? "interrupted" : "unknown_outcome",
547
+ updatedAt: nowIso(this.now),
548
+ };
549
+ });
550
+ return this.event({
551
+ ...snapshot,
552
+ attempts,
553
+ nodeRuns,
554
+ resumeDeliveries: snapshot.resumeDeliveries.map((delivery) => unknownResumeAttemptIds.has(delivery.attemptId)
555
+ ? markResumeDeliveryUnknownOutcome(delivery, {
556
+ code: "RESUME_UNKNOWN_OUTCOME",
557
+ message: "Runtime stopped during a non-idempotent resume invocation",
558
+ retryable: false,
559
+ }, this.now())
560
+ : delivery),
561
+ resourceLeases: snapshot.resourceLeases.map((lease) => lease.status === "active" ? {
562
+ ...lease,
563
+ status: "compensated",
564
+ releasedAt: nowIso(this.now),
565
+ } : lease),
566
+ run: {
567
+ ...snapshot.run,
568
+ status: unknown ? "unknown_outcome" : pendingDeliveries.length > 0 ? "interrupted"
569
+ : snapshot.run.status === "awaiting_host" || snapshot.run.status === "awaiting_signal"
570
+ ? snapshot.run.status : "interrupted",
571
+ updatedAt: nowIso(this.now),
572
+ },
573
+ }, "run.recovered", { unknownOutcome: unknown });
574
+ });
575
+ recovered.push(next);
576
+ if (autoResume && next.run.status === "interrupted")
577
+ recovered[recovered.length - 1] = await this.resume(next.run.runId);
578
+ }
579
+ return recovered;
580
+ }
581
+ /** Applies one already accepted continuation. The persisted payload is the sole retry source. */
582
+ processResumeDelivery(runId, deliveryId) {
583
+ const key = `${runId}:${deliveryId}`;
584
+ const active = this.resumeProcessing.get(key);
585
+ if (active)
586
+ return active;
587
+ const processing = this.processResumeDeliveryExclusive(runId, deliveryId)
588
+ .finally(() => this.resumeProcessing.delete(key));
589
+ this.resumeProcessing.set(key, processing);
590
+ return processing;
591
+ }
592
+ async processResumeDeliveryExclusive(runId, deliveryId) {
593
+ while (true) {
594
+ let snapshot = await this.options.store.load(runId);
595
+ let delivery = snapshot.resumeDeliveries.find((candidate) => candidate.resumeDeliveryId === deliveryId);
596
+ if (!delivery)
597
+ throw new AtomWorkflowError("RESUME_DELIVERY_NOT_FOUND", `ResumeDelivery not found: ${deliveryId}`);
598
+ if (delivery.status === "applied" || delivery.status === "failed")
599
+ return snapshot;
600
+ const node = snapshot.ir.nodes.find((candidate) => candidate.id === delivery.nodeId);
601
+ if (!node || node.kind !== "atom") {
602
+ throw new AtomWorkflowError("RESUME_DELIVERY_NODE_INVALID", `ResumeDelivery does not target an Atom node: ${delivery.nodeId}`);
603
+ }
604
+ const attempt = snapshot.attempts.find((candidate) => candidate.attemptId === delivery.attemptId);
605
+ if (!attempt?.lifecycleSnapshot) {
606
+ throw new AtomWorkflowError("ATTEMPT_NOT_RESUMABLE", `Attempt is not resumable: ${delivery.attemptId}`);
607
+ }
608
+ if (delivery.status === "retry_wait" && delivery.nextRetryAt) {
609
+ const remainingMs = Date.parse(delivery.nextRetryAt) - this.now().getTime();
610
+ if (remainingMs > 0)
611
+ await delay(remainingMs);
612
+ }
613
+ const invocationAttempt = delivery.invocationAttempt + 1;
614
+ snapshot = await this.options.store.update(runId, (current) => this.event({
615
+ ...current,
616
+ resumeDeliveries: current.resumeDeliveries.map((candidate) => candidate.resumeDeliveryId === deliveryId ? {
617
+ ...candidate,
618
+ status: "processing",
619
+ invocationAttempt,
620
+ nextRetryAt: undefined,
621
+ error: undefined,
622
+ updatedAt: nowIso(this.now),
623
+ } : candidate),
624
+ attempts: current.attempts.map((candidate) => candidate.attemptId === delivery.attemptId ? {
625
+ ...candidate,
626
+ state: "running",
627
+ phase: "resume",
628
+ endedAt: undefined,
629
+ error: undefined,
630
+ updatedAt: nowIso(this.now),
631
+ } : candidate),
632
+ nodeRuns: current.nodeRuns.map((candidate) => candidate.activationId === delivery.activationId ? {
633
+ ...candidate,
634
+ status: "running",
635
+ endedAt: undefined,
636
+ error: undefined,
637
+ updatedAt: nowIso(this.now),
638
+ } : candidate),
639
+ run: {
640
+ ...current.run,
641
+ status: "running",
642
+ endedAt: undefined,
643
+ error: undefined,
644
+ updatedAt: nowIso(this.now),
645
+ },
646
+ }, "resume.processing", { resumeDeliveryId: deliveryId, invocationAttempt }, delivery.nodeId, delivery.activationId, delivery.attemptId));
647
+ delivery = snapshot.resumeDeliveries.find((candidate) => candidate.resumeDeliveryId === deliveryId);
648
+ try {
649
+ await this.resumeAttempt(runId, delivery.attemptId, delivery.payload);
650
+ await this.options.store.update(runId, (current) => {
651
+ const timestamp = nowIso(this.now);
652
+ return this.event({
653
+ ...current,
654
+ resumeDeliveries: current.resumeDeliveries.map((candidate) => candidate.resumeDeliveryId === deliveryId ? {
655
+ ...candidate,
656
+ status: "applied",
657
+ appliedAt: timestamp,
658
+ updatedAt: timestamp,
659
+ nextRetryAt: undefined,
660
+ error: undefined,
661
+ } : candidate),
662
+ }, "resume.applied", { resumeDeliveryId: deliveryId, invocationAttempt }, delivery.nodeId, delivery.activationId, delivery.attemptId);
663
+ });
664
+ return this.execute(runId);
665
+ }
666
+ catch (error) {
667
+ const normalized = toAtomWorkflowError(error);
668
+ const maxAttempts = node.atom.manifest.idempotent ? node.effectivePolicy.retry.maxAttempts : 1;
669
+ const canRetry = normalized.retryable && node.atom.manifest.idempotent && invocationAttempt < maxAttempts;
670
+ if (canRetry) {
671
+ const retryAfterMs = sleepMs(node.effectivePolicy.retry, invocationAttempt);
672
+ const nextRetryAt = new Date(this.now().getTime() + retryAfterMs).toISOString();
673
+ await this.options.store.update(runId, (current) => this.event({
674
+ ...current,
675
+ resumeDeliveries: current.resumeDeliveries.map((candidate) => candidate.resumeDeliveryId === deliveryId ? {
676
+ ...candidate,
677
+ status: "retry_wait",
678
+ nextRetryAt,
679
+ error: errorRecord(normalized),
680
+ updatedAt: nowIso(this.now),
681
+ } : candidate),
682
+ attempts: current.attempts.map((candidate) => candidate.attemptId === delivery.attemptId ? {
683
+ ...candidate,
684
+ state: delivery.kind === "signal" ? "awaiting_signal" : "awaiting_host",
685
+ phase: "resume",
686
+ error: errorRecord(normalized),
687
+ updatedAt: nowIso(this.now),
688
+ } : candidate),
689
+ nodeRuns: current.nodeRuns.map((candidate) => candidate.activationId === delivery.activationId ? {
690
+ ...candidate,
691
+ status: "retry_wait",
692
+ error: errorRecord(normalized),
693
+ updatedAt: nowIso(this.now),
694
+ } : candidate),
695
+ }, "resume.retry_scheduled", {
696
+ resumeDeliveryId: deliveryId,
697
+ invocationAttempt,
698
+ retryAfterMs,
699
+ code: normalized.code,
700
+ }, delivery.nodeId, delivery.activationId, delivery.attemptId));
701
+ if (retryAfterMs > 0)
702
+ await delay(retryAfterMs);
703
+ continue;
704
+ }
705
+ return this.options.store.update(runId, (current) => {
706
+ const timestamp = nowIso(this.now);
707
+ const failedDelivery = current.resumeDeliveries.find((candidate) => candidate.resumeDeliveryId === deliveryId);
708
+ return this.event({
709
+ ...current,
710
+ resumeDeliveries: current.resumeDeliveries.map((candidate) => candidate.resumeDeliveryId === deliveryId
711
+ ? markResumeDeliveryFailed(failedDelivery, errorRecord(normalized), this.now())
712
+ : candidate),
713
+ attempts: current.attempts.map((candidate) => candidate.attemptId === delivery.attemptId ? {
714
+ ...candidate,
715
+ state: "failed",
716
+ phase: "complete",
717
+ error: errorRecord(normalized),
718
+ endedAt: timestamp,
719
+ updatedAt: timestamp,
720
+ } : candidate),
721
+ nodeRuns: current.nodeRuns.map((candidate) => candidate.activationId === delivery.activationId ? {
722
+ ...candidate,
723
+ status: "failed",
724
+ error: errorRecord(normalized),
725
+ endedAt: timestamp,
726
+ updatedAt: timestamp,
727
+ } : candidate),
728
+ mapRuns: current.mapRuns.map((mapRun) => mapRun.status === "running" ? {
729
+ ...mapRun,
730
+ status: "failed",
731
+ endedAt: timestamp,
732
+ updatedAt: timestamp,
733
+ } : mapRun),
734
+ run: {
735
+ ...current.run,
736
+ status: "failed",
737
+ error: errorRecord(normalized),
738
+ endedAt: timestamp,
739
+ updatedAt: timestamp,
740
+ },
741
+ }, "resume.failed", {
742
+ resumeDeliveryId: deliveryId,
743
+ invocationAttempt,
744
+ code: normalized.code,
745
+ message: normalized.message,
746
+ }, delivery.nodeId, delivery.activationId, delivery.attemptId);
747
+ });
748
+ }
749
+ }
750
+ }
751
+ async executeNode(runId, activationId, parentSignal) {
752
+ let snapshot = await this.options.store.load(runId);
753
+ let nodeRun = snapshot.nodeRuns.find((candidate) => candidate.activationId === activationId);
754
+ const node = snapshot.ir.nodes.find((candidate) => candidate.id === nodeRun.nodeId);
755
+ if (node.kind !== "atom") {
756
+ try {
757
+ if (node.kind === "switch")
758
+ await this.executeSwitch(runId, nodeRun, node);
759
+ else if (node.kind === "map")
760
+ await this.executeMap(runId, nodeRun, node);
761
+ else if (node.kind === "merge") {
762
+ const output = { ...nodeRun.inputOverlay };
763
+ const issues = validateJsonSchemaInstance(node.outputSchema, output, "$.output");
764
+ if (issues.length > 0)
765
+ throw new AtomWorkflowError("BRANCH_MERGE_OUTPUT_INVALID", `Branch merge input failed for ${node.id}`, false, { cause: issues });
766
+ await this.commitControlCompletion(runId, activationId, output, "merge.completed");
767
+ }
768
+ else
769
+ await this.commitControlCompletion(runId, activationId, {}, "join.completed");
770
+ }
771
+ catch (error) {
772
+ const normalized = toAtomWorkflowError(error);
773
+ await this.options.store.update(runId, (current) => this.event({
774
+ ...current,
775
+ nodeRuns: current.nodeRuns.map((run) => run.activationId === activationId ? {
776
+ ...run,
777
+ status: "failed",
778
+ error: errorRecord(normalized),
779
+ updatedAt: nowIso(this.now),
780
+ endedAt: nowIso(this.now),
781
+ } : run),
782
+ mapRuns: current.mapRuns.map((mapRun) => mapRun.activationId === activationId && mapRun.status === "running" ? {
783
+ ...mapRun,
784
+ status: "failed",
785
+ updatedAt: nowIso(this.now),
786
+ endedAt: nowIso(this.now),
787
+ } : mapRun),
788
+ }, "control.failed", { code: normalized.code, message: normalized.message }, node.id, activationId));
789
+ throw error;
790
+ }
791
+ return;
792
+ }
793
+ const input = nodeInput(snapshot, node, nodeRun);
794
+ const issues = validateJsonSchemaInstance(node.atom.manifest.inputSchema, input, "$.input");
795
+ if (issues.length > 0)
796
+ throw new AtomWorkflowError("ATOM_INPUT_SCHEMA_MISMATCH", `Runtime input failed for ${node.id}`, false, { cause: issues });
797
+ const cacheKey = stableCacheKey({ runId, atom: node.atom.key, input });
798
+ if (node.effectivePolicy.cache.enabled) {
799
+ const cached = await this.cache.get(cacheKey);
800
+ if (cached) {
801
+ await this.commitCompletion(runId, activationId, cached, "node.cache_hit");
802
+ return;
803
+ }
804
+ }
805
+ const primaryAttempts = node.effectivePolicy.retry.maxAttempts;
806
+ const automaticMaxAttempts = node.atom.manifest.idempotent ? primaryAttempts : 1;
807
+ // A persisted node with every automatic attempt consumed can only become
808
+ // ready again through the explicit operator retry command. Grant exactly
809
+ // one new attempt while preserving prior Attempt records for auditability.
810
+ // Repeating the command after another failure grants one more attempt.
811
+ const maxAttempts = nodeRun.status === "ready" && nodeRun.attemptIds.length >= automaticMaxAttempts
812
+ ? nodeRun.attemptIds.length + 1
813
+ : automaticMaxAttempts;
814
+ let lastError;
815
+ for (let attemptIndex = nodeRun.attemptIds.length; attemptIndex < maxAttempts; attemptIndex += 1) {
816
+ snapshot = await this.options.store.load(runId);
817
+ nodeRun = snapshot.nodeRuns.find((candidate) => candidate.activationId === activationId);
818
+ const attemptId = `${activationId}:attempt:${attemptIndex}`;
819
+ const timestamp = nowIso(this.now);
820
+ const attempt = {
821
+ schemaVersion: "1.0",
822
+ attemptId,
823
+ runId,
824
+ workflowRevisionId: snapshot.revision.workflowRevisionId,
825
+ activationId,
826
+ nodeId: node.id,
827
+ attemptIndex,
828
+ state: "created",
829
+ phase: "created",
830
+ tickCount: 0,
831
+ activeDurationMs: 0,
832
+ startedAt: timestamp,
833
+ updatedAt: timestamp,
834
+ };
835
+ await this.options.store.update(runId, (current) => this.event({
836
+ ...current,
837
+ attempts: [...current.attempts, attempt],
838
+ nodeRuns: current.nodeRuns.map((item) => item.activationId === activationId ? {
839
+ ...item,
840
+ status: "running",
841
+ input,
842
+ attemptIds: [...item.attemptIds, attemptId],
843
+ startedAt: item.startedAt ?? timestamp,
844
+ updatedAt: timestamp,
845
+ } : item),
846
+ }, attemptIndex > 0 ? "attempt.retry" : "attempt.started", {
847
+ attemptIndex,
848
+ }, node.id, activationId, attemptId));
849
+ try {
850
+ const completion = await this.runNewAttempt(runId, activationId, attemptId, input, node, parentSignal);
851
+ if (!completion)
852
+ return;
853
+ if (node.effectivePolicy.cache.enabled) {
854
+ await this.cache.set(cacheKey, { ...completion, cachedAt: nowIso(this.now) }, node.effectivePolicy.cache.ttlMs ?? 86_400_000);
855
+ }
856
+ await this.commitCompletion(runId, activationId, completion, "node.succeeded");
857
+ return;
858
+ }
859
+ catch (error) {
860
+ lastError = error;
861
+ const normalized = toAtomWorkflowError(error);
862
+ await this.options.store.update(runId, (current) => this.event({
863
+ ...current,
864
+ attempts: current.attempts.map((item) => item.attemptId === attemptId ? {
865
+ ...item,
866
+ state: normalized.code === "CANCELLED" ? "cancelled" : "failed",
867
+ phase: "complete",
868
+ error: errorRecord(normalized),
869
+ endedAt: nowIso(this.now),
870
+ updatedAt: nowIso(this.now),
871
+ } : item),
872
+ nodeRuns: current.nodeRuns.map((item) => item.activationId === activationId ? {
873
+ ...item,
874
+ status: attemptIndex + 1 < maxAttempts && normalized.retryable ? "retry_wait" : "failed",
875
+ error: errorRecord(normalized),
876
+ updatedAt: nowIso(this.now),
877
+ ...(attemptIndex + 1 < maxAttempts && normalized.retryable ? {} : { endedAt: nowIso(this.now) }),
878
+ } : item),
879
+ }, "attempt.failed", { code: normalized.code, retryable: normalized.retryable }, node.id, activationId, attemptId));
880
+ if (!normalized.retryable || attemptIndex + 1 >= maxAttempts)
881
+ break;
882
+ const wait = sleepMs(node.effectivePolicy.retry, attemptIndex + 1);
883
+ if (wait > 0)
884
+ await delay(wait, undefined, { signal: parentSignal });
885
+ }
886
+ }
887
+ throw lastError ?? new AtomWorkflowError("ATTEMPT_FAILED", `Node failed: ${node.id}`);
888
+ }
889
+ async executeSwitch(runId, nodeRun, node) {
890
+ const snapshot = await this.options.store.load(runId);
891
+ const selected = resolveReference(node.select.$ref, snapshot, nodeRun);
892
+ if (typeof selected !== "string") {
893
+ throw new AtomWorkflowError("SWITCH_SELECTION_INVALID", `SwitchNode selection is not a string: ${node.id}`);
894
+ }
895
+ const dependencyId = node.dependsOn[0];
896
+ const payload = dependencyId ? resolvedNodeOutput(snapshot, dependencyId, nodeRun) : undefined;
897
+ if (payload === undefined)
898
+ throw new AtomWorkflowError("SWITCH_PAYLOAD_UNAVAILABLE", `SwitchNode dependency output is unavailable: ${node.id}`);
899
+ await this.commitControlCompletion(runId, nodeRun.activationId, payload, "switch.completed");
900
+ }
901
+ async executeMap(runId, nodeRun, node) {
902
+ await this.options.store.update(runId, (snapshot) => {
903
+ const existing = snapshot.mapRuns.find((run) => run.activationId === nodeRun.activationId);
904
+ if (existing) {
905
+ if (existing.status === "succeeded")
906
+ return snapshot;
907
+ if (existing.status === "failed")
908
+ throw new AtomWorkflowError("MAP_RUN_FAILED", `MapRun already failed: ${existing.mapRunId}`);
909
+ const timestamp = nowIso(this.now);
910
+ let next = {
911
+ ...snapshot,
912
+ nodeRuns: snapshot.nodeRuns.map((run) => run.activationId === nodeRun.activationId
913
+ ? { ...run, status: "running", startedAt: run.startedAt ?? timestamp, updatedAt: timestamp }
914
+ : run),
915
+ };
916
+ if (existing.completedIndices.length === existing.items.length) {
917
+ return this.finishMap(next, existing.mapRunId, node, this.mapOutput(next, existing.mapRunId, node));
918
+ }
919
+ return this.scheduleMapItems(next, existing.mapRunId, node);
920
+ }
921
+ const resolved = resolveReference(node.items.$ref, snapshot, nodeRun);
922
+ if (!Array.isArray(resolved))
923
+ throw new AtomWorkflowError("MAP_ITEMS_NOT_ARRAY", `MapNode items did not resolve to an array: ${node.id}`);
924
+ if (resolved.length > node.maxItems) {
925
+ throw new AtomWorkflowError("MAP_ITEMS_LIMIT_EXCEEDED", `MapNode received ${resolved.length} items but maxItems is ${node.maxItems}: ${node.id}`);
926
+ }
927
+ const timestamp = nowIso(this.now);
928
+ const mapRun = {
929
+ mapRunId: `${nodeRun.activationId}:map`,
930
+ mapNodeId: node.id,
931
+ activationId: nodeRun.activationId,
932
+ status: "running",
933
+ items: resolved,
934
+ nextIndex: 0,
935
+ activeIndices: [],
936
+ completedIndices: [],
937
+ createdAt: timestamp,
938
+ updatedAt: timestamp,
939
+ };
940
+ let next = {
941
+ ...snapshot,
942
+ mapRuns: [...snapshot.mapRuns, mapRun],
943
+ nodeRuns: snapshot.nodeRuns.map((run) => run.activationId === nodeRun.activationId
944
+ ? { ...run, status: "running", startedAt: run.startedAt ?? timestamp, updatedAt: timestamp }
945
+ : run),
946
+ };
947
+ next = this.event(next, "map.started", { itemCount: resolved.length, maxConcurrency: node.maxConcurrency }, node.id, nodeRun.activationId);
948
+ if (resolved.length === 0)
949
+ return this.finishMap(next, mapRun.mapRunId, node, []);
950
+ return this.scheduleMapItems(next, mapRun.mapRunId, node);
951
+ });
952
+ }
953
+ async commitControlCompletion(runId, activationId, output, eventType) {
954
+ await this.options.store.update(runId, (snapshot) => {
955
+ const timestamp = nowIso(this.now);
956
+ const nodeRun = snapshot.nodeRuns.find((candidate) => candidate.activationId === activationId);
957
+ if (!nodeRun)
958
+ throw new AtomWorkflowError("NODE_ACTIVATION_NOT_FOUND", `Control activation not found: ${activationId}`);
959
+ const next = {
960
+ ...snapshot,
961
+ nodeRuns: snapshot.nodeRuns.map((candidate) => candidate.activationId === activationId ? {
962
+ ...candidate,
963
+ status: "succeeded",
964
+ output,
965
+ startedAt: candidate.startedAt ?? timestamp,
966
+ endedAt: timestamp,
967
+ updatedAt: timestamp,
968
+ } : candidate),
969
+ };
970
+ return this.event(this.advance(next, activationId, output), eventType, {}, nodeRun.nodeId, activationId);
971
+ });
972
+ }
973
+ async runNewAttempt(runId, activationId, attemptId, input, node, parentSignal) {
974
+ const lease = this.options.atoms.acquire(node.atom.id, node.atom.version);
975
+ const resources = this.resources(runId, attemptId);
976
+ const started = Date.now();
977
+ try {
978
+ const attemptSnapshot = await this.options.store.load(runId);
979
+ const hostManager = this.hostManager(attemptSnapshot);
980
+ let lifecycle = await this.tickTimeout(async (tickSignal) => {
981
+ const context = await this.context(runId, activationId, attemptId, node, resources, tickSignal);
982
+ return this.atomAttemptRunner.start({
983
+ definition: lease.definition,
984
+ ...(lease.executionSource ? { executionSource: lease.executionSource } : {}),
985
+ input,
986
+ context,
987
+ lifecycle: this.lifecycleOptions(attemptSnapshot, hostManager, node, input, context, activationId, attemptId),
988
+ });
989
+ }, node, parentSignal, started);
990
+ while (lifecycle.state === "running") {
991
+ if (lifecycle.nextPollAfterMs)
992
+ await delay(lifecycle.nextPollAfterMs, undefined, { signal: parentSignal });
993
+ lifecycle = await this.tickTimeout(async (tickSignal) => {
994
+ const context = await this.context(runId, activationId, attemptId, node, resources, tickSignal);
995
+ return this.atomAttemptRunner.continue({
996
+ definition: lease.definition,
997
+ ...(lease.executionSource ? { executionSource: lease.executionSource } : {}),
998
+ input,
999
+ context,
1000
+ snapshot: lifecycle,
1001
+ lifecycle: this.lifecycleOptions(attemptSnapshot, hostManager, node, input, context, activationId, attemptId),
1002
+ });
1003
+ }, node, parentSignal, started);
1004
+ }
1005
+ await this.persistLifecycle(runId, activationId, attemptId, lifecycle, hostManager, Date.now() - started);
1006
+ if (lifecycle.state !== "succeeded") {
1007
+ await resources.disposeAll("interrupted");
1008
+ return undefined;
1009
+ }
1010
+ await resources.disposeAll("completed");
1011
+ return {
1012
+ output: lifecycle.completion.output,
1013
+ artifacts: lifecycle.completion.artifacts ?? [],
1014
+ };
1015
+ }
1016
+ catch (error) {
1017
+ await resources.disposeAll(toAtomWorkflowError(error).code === "CANCELLED" ? "cancelled" : toAtomWorkflowError(error).code.includes("TIMEOUT") ? "timeout" : "failed").catch(() => undefined);
1018
+ throw error;
1019
+ }
1020
+ finally {
1021
+ lease.release();
1022
+ }
1023
+ }
1024
+ async resumeAttempt(runId, attemptId, resume) {
1025
+ const snapshot = await this.options.store.load(runId);
1026
+ const attempt = snapshot.attempts.find((candidate) => candidate.attemptId === attemptId);
1027
+ if (!attempt || !attempt.lifecycleSnapshot)
1028
+ throw new AtomWorkflowError("ATTEMPT_NOT_RESUMABLE", `Attempt is not resumable: ${attemptId}`);
1029
+ const nodeRun = snapshot.nodeRuns.find((candidate) => candidate.activationId === attempt.activationId);
1030
+ const node = snapshot.ir.nodes.find((candidate) => candidate.id === attempt.nodeId);
1031
+ if (node.kind !== "atom")
1032
+ throw new AtomWorkflowError("CONTROL_NODE_ATTEMPT_INVALID", `Control node cannot resume an Atom Attempt: ${node.id}`);
1033
+ const input = nodeRun.input ?? nodeInput(snapshot, node, nodeRun);
1034
+ const lease = this.options.atoms.acquire(node.atom.id, node.atom.version);
1035
+ const resources = this.resources(runId, attemptId);
1036
+ const activeStarted = Date.now();
1037
+ const budgetStarted = activeStarted - attempt.activeDurationMs;
1038
+ try {
1039
+ const hostManager = this.hostManager(snapshot);
1040
+ let lifecycle = await this.tickTimeout(async (tickSignal) => {
1041
+ const context = await this.context(runId, attempt.activationId, attemptId, node, resources, tickSignal);
1042
+ return this.atomAttemptRunner.continue({
1043
+ definition: lease.definition,
1044
+ ...(lease.executionSource ? { executionSource: lease.executionSource } : {}),
1045
+ input,
1046
+ context,
1047
+ snapshot: attempt.lifecycleSnapshot,
1048
+ lifecycle: this.lifecycleOptions(snapshot, hostManager, node, input, context, attempt.activationId, attemptId),
1049
+ resume,
1050
+ });
1051
+ }, node, new AbortController().signal, budgetStarted);
1052
+ while (lifecycle.state === "running") {
1053
+ if (lifecycle.nextPollAfterMs)
1054
+ await delay(lifecycle.nextPollAfterMs);
1055
+ lifecycle = await this.tickTimeout(async (tickSignal) => {
1056
+ const context = await this.context(runId, attempt.activationId, attemptId, node, resources, tickSignal);
1057
+ return this.atomAttemptRunner.continue({
1058
+ definition: lease.definition,
1059
+ ...(lease.executionSource ? { executionSource: lease.executionSource } : {}),
1060
+ input,
1061
+ context,
1062
+ snapshot: lifecycle,
1063
+ lifecycle: this.lifecycleOptions(snapshot, hostManager, node, input, context, attempt.activationId, attemptId),
1064
+ });
1065
+ }, node, new AbortController().signal, budgetStarted);
1066
+ }
1067
+ await this.persistLifecycle(runId, attempt.activationId, attemptId, lifecycle, hostManager, Date.now() - activeStarted);
1068
+ if (lifecycle.state === "succeeded") {
1069
+ await resources.disposeAll("completed");
1070
+ await this.commitCompletion(runId, attempt.activationId, {
1071
+ output: lifecycle.completion.output,
1072
+ artifacts: lifecycle.completion.artifacts ?? [],
1073
+ }, "node.resumed_succeeded");
1074
+ }
1075
+ else
1076
+ await resources.disposeAll("interrupted");
1077
+ }
1078
+ catch (error) {
1079
+ const normalized = toAtomWorkflowError(error);
1080
+ await resources.disposeAll(normalized.code === "CANCELLED" ? "cancelled" : normalized.code.includes("TIMEOUT") ? "timeout" : "failed").catch(() => undefined);
1081
+ throw error;
1082
+ }
1083
+ finally {
1084
+ lease.release();
1085
+ }
1086
+ }
1087
+ async context(runId, activationId, attemptId, node, resources, signal) {
1088
+ const snapshot = await this.options.store.load(runId);
1089
+ const appendLog = (level, message, fields) => {
1090
+ void this.options.store.update(runId, (current) => this.event(current, "atom.log", { level, message, ...fields }, node.id, activationId, attemptId));
1091
+ };
1092
+ const cwd = snapshot.run.cwd;
1093
+ const base = {
1094
+ runtime: {
1095
+ runId,
1096
+ workflowRevisionId: snapshot.revision.workflowRevisionId,
1097
+ nodeId: node.id,
1098
+ attemptId,
1099
+ attempt: snapshot.attempts.find((candidate) => candidate.attemptId === attemptId)?.attemptIndex ?? 0,
1100
+ cwd,
1101
+ },
1102
+ cwd,
1103
+ signal,
1104
+ artifacts: this.options.artifacts.forAttempt(runId, attemptId, async (artifact) => {
1105
+ await this.options.store.update(runId, (current) => ({ ...current, artifacts: [...current.artifacts, artifact] }));
1106
+ }),
1107
+ resources,
1108
+ logger: runtimeLogger(appendLog),
1109
+ metadata: { projectScope: snapshot.run.projectScope, hostScope: snapshot.run.hostScope },
1110
+ ...(node.decisionBranches ? { decision: { branches: node.decisionBranches } } : {}),
1111
+ ...createAtomLifecycleHelpers(),
1112
+ };
1113
+ return base;
1114
+ }
1115
+ lifecycleOptions(snapshot, hostManager, node, input, context, activationId, attemptId) {
1116
+ return {
1117
+ maxLifecycleTicks: node.effectivePolicy.maxLifecycleTicks,
1118
+ persistTick: (tick) => this.persistTick(snapshot.run.runId, attemptId, tick),
1119
+ issueHostTask: (declaration) => hostManager.issue({
1120
+ manifest: node.atom.manifest,
1121
+ declaration,
1122
+ input,
1123
+ runId: snapshot.run.runId,
1124
+ workflowRevisionId: context.runtime.workflowRevisionId,
1125
+ nodeId: node.id,
1126
+ activationId,
1127
+ attemptId,
1128
+ workflowIrHash: snapshot.ir.hash,
1129
+ projectScope: context.metadata.projectScope,
1130
+ hostScope: context.metadata.hostScope,
1131
+ }),
1132
+ };
1133
+ }
1134
+ resources(runId, attemptId) {
1135
+ const persist = (resource, status) => {
1136
+ void this.options.store.update(runId, (snapshot) => ({
1137
+ ...snapshot,
1138
+ resourceLeases: status === "active"
1139
+ ? [...snapshot.resourceLeases, {
1140
+ schemaVersion: "1.0",
1141
+ leaseId: `${attemptId}:${resource.id}`,
1142
+ runId,
1143
+ attemptId,
1144
+ kind: resource.kind,
1145
+ ...(resource.recoveryLease !== undefined ? { recoveryLease: resource.recoveryLease } : {}),
1146
+ status,
1147
+ createdAt: nowIso(this.now),
1148
+ }]
1149
+ : snapshot.resourceLeases.map((lease) => lease.leaseId === `${attemptId}:${resource.id}` ? {
1150
+ ...lease,
1151
+ status,
1152
+ releasedAt: nowIso(this.now),
1153
+ } : lease),
1154
+ }));
1155
+ };
1156
+ return new ManagedAttemptResources((resource) => persist(resource, "active"), (resource) => persist(resource, "released"));
1157
+ }
1158
+ async tickTimeout(operation, node, signal, started) {
1159
+ const attemptTimeoutMs = node.effectivePolicy.attemptTimeoutMs;
1160
+ const remaining = attemptTimeoutMs === null ? null : attemptTimeoutMs - (Date.now() - started);
1161
+ if (remaining !== null && remaining <= 0)
1162
+ throw new AtomWorkflowError("ATTEMPT_TIMEOUT", `Attempt timeout exceeded: ${node.id}`, true);
1163
+ const limits = [remaining, this.options.nodeTimeoutMs ?? null, node.effectivePolicy.lifecycleTickTimeoutMs]
1164
+ .filter((value) => value !== null);
1165
+ const timeoutMs = limits.length > 0 ? Math.min(...limits) : null;
1166
+ return withTimeout(operation, timeoutMs, signal);
1167
+ }
1168
+ async persistTick(runId, attemptId, tick) {
1169
+ await this.options.store.update(runId, (snapshot) => {
1170
+ const attempts = snapshot.attempts.map((attempt) => attempt.attemptId === attemptId ? {
1171
+ ...attempt,
1172
+ state: "running",
1173
+ phase: tick.phase,
1174
+ tickCount: tick.tickIndex + 1,
1175
+ ...(tick.checkpoint !== undefined ? { checkpoint: tick.checkpoint } : {}),
1176
+ ...(tick.checkpointHash !== undefined ? { checkpointHash: tick.checkpointHash } : {}),
1177
+ updatedAt: nowIso(this.now),
1178
+ } : attempt);
1179
+ return this.event({ ...snapshot, attempts }, "lifecycle.tick", {
1180
+ tickIndex: tick.tickIndex,
1181
+ phase: tick.phase,
1182
+ status: tick.status,
1183
+ ...(tick.checkpointHash ? { checkpointHash: tick.checkpointHash } : {}),
1184
+ }, attempts.find((attempt) => attempt.attemptId === attemptId)?.nodeId, undefined, attemptId);
1185
+ });
1186
+ }
1187
+ async persistLifecycle(runId, activationId, attemptId, lifecycle, manager, activeDurationDeltaMs) {
1188
+ await this.options.store.update(runId, (snapshot) => {
1189
+ const timestamp = nowIso(this.now);
1190
+ const attempts = snapshot.attempts.map((attempt) => attempt.attemptId === attemptId ? {
1191
+ ...attempt,
1192
+ state: lifecycle.state,
1193
+ phase: lifecycle.state === "succeeded" ? "complete" : "resume",
1194
+ checkpoint: lifecycle.checkpoint,
1195
+ lifecycleSnapshot: snapshotAsJson(lifecycle),
1196
+ tickCount: lifecycle.ticks.length,
1197
+ activeDurationMs: attempt.activeDurationMs + Math.max(0, activeDurationDeltaMs),
1198
+ updatedAt: timestamp,
1199
+ ...(lifecycle.state === "succeeded" ? { endedAt: timestamp } : {}),
1200
+ } : attempt);
1201
+ const nodeStatus = lifecycle.state === "succeeded" ? "running" : lifecycle.state;
1202
+ let signals = snapshot.signals;
1203
+ if (lifecycle.state === "awaiting_signal") {
1204
+ const pending = lifecycle.pendingSignal;
1205
+ const existing = signals.some((signal) => signal.signalId === pending.signalId);
1206
+ if (!existing) {
1207
+ const created = this.now();
1208
+ const signal = {
1209
+ schemaVersion: "1.0",
1210
+ signalId: pending.signalId,
1211
+ runId,
1212
+ workflowRevisionId: snapshot.revision.workflowRevisionId,
1213
+ nodeId: attempts.find((attempt) => attempt.attemptId === attemptId).nodeId,
1214
+ activationId,
1215
+ attemptId,
1216
+ projectScope: snapshot.run.projectScope,
1217
+ hostScope: snapshot.run.hostScope,
1218
+ key: pending.request.key,
1219
+ prompt: pending.request.prompt,
1220
+ context: pending.request.context,
1221
+ responseSchema: pending.request.responseSchema,
1222
+ resume: createSignalResumeDirective(runId, pending.signalId),
1223
+ status: "waiting",
1224
+ createdAt: created.toISOString(),
1225
+ ...(pending.request.expiresInMs !== undefined ? { expiresAt: new Date(created.getTime() + pending.request.expiresInMs).toISOString() } : {}),
1226
+ };
1227
+ signals = [...signals, signal];
1228
+ }
1229
+ }
1230
+ return this.event({
1231
+ ...snapshot,
1232
+ attempts,
1233
+ signals,
1234
+ hostTasks: manager.exportRecords(),
1235
+ nodeRuns: snapshot.nodeRuns.map((nodeRun) => nodeRun.activationId === activationId ? {
1236
+ ...nodeRun,
1237
+ status: nodeStatus,
1238
+ updatedAt: timestamp,
1239
+ } : nodeRun),
1240
+ run: lifecycle.state === "awaiting_host" || lifecycle.state === "awaiting_signal" ? {
1241
+ ...snapshot.run,
1242
+ status: lifecycle.state,
1243
+ updatedAt: timestamp,
1244
+ } : snapshot.run,
1245
+ }, `attempt.${lifecycle.state}`, {}, attempts.find((attempt) => attempt.attemptId === attemptId)?.nodeId, activationId, attemptId);
1246
+ });
1247
+ }
1248
+ async commitCompletion(runId, activationId, completion, eventType) {
1249
+ await this.options.store.update(runId, (snapshot) => {
1250
+ const timestamp = nowIso(this.now);
1251
+ const completing = snapshot.nodeRuns.find((nodeRun) => nodeRun.activationId === activationId);
1252
+ if (completing)
1253
+ assertNodeRunTransition(completing.status, "succeeded");
1254
+ let next = {
1255
+ ...snapshot,
1256
+ nodeRuns: snapshot.nodeRuns.map((nodeRun) => nodeRun.activationId === activationId ? {
1257
+ ...nodeRun,
1258
+ status: "succeeded",
1259
+ output: completion.output,
1260
+ artifacts: [...completion.artifacts],
1261
+ updatedAt: timestamp,
1262
+ endedAt: timestamp,
1263
+ } : nodeRun),
1264
+ };
1265
+ next = this.advance(next, activationId, completion.output);
1266
+ return this.event(next, eventType, {}, completing?.nodeId, activationId);
1267
+ });
1268
+ }
1269
+ advance(snapshot, activationId, output) {
1270
+ const source = snapshot.nodeRuns.find((nodeRun) => nodeRun.activationId === activationId);
1271
+ const node = snapshot.ir.nodes.find((candidate) => candidate.id === source.nodeId);
1272
+ let selectedBranch;
1273
+ let edge = snapshot.ir.control.edges.find((candidate) => candidate.sourceNodeId === source.nodeId
1274
+ && (candidate.kind === "next" || candidate.kind === "join-next" || candidate.kind === "map-return"));
1275
+ if (node.kind === "switch") {
1276
+ const selected = resolveReference(node.select.$ref, snapshot, source);
1277
+ if (typeof selected !== "string")
1278
+ throw new AtomWorkflowError("SWITCH_SELECTION_INVALID", `SwitchNode selection is not a string: ${node.id}`);
1279
+ selectedBranch = selected;
1280
+ edge = snapshot.ir.control.edges.find((candidate) => candidate.sourceNodeId === source.nodeId && candidate.kind === "switch-case" && candidate.branchId === selected)
1281
+ ?? snapshot.ir.control.edges.find((candidate) => candidate.sourceNodeId === source.nodeId && candidate.kind === "switch-default");
1282
+ }
1283
+ if (edge?.kind === "map-return")
1284
+ return this.returnFromMap(snapshot, source, output, edge);
1285
+ if (!edge)
1286
+ throw new AtomWorkflowError("WORKFLOW_EDGE_NOT_FOUND", `No frozen control edge for ${source.nodeId}`);
1287
+ return this.routeEdge(snapshot, source, output, edge, selectedBranch);
1288
+ }
1289
+ routeEdge(snapshot, source, output, edge, selectedBranch) {
1290
+ if (snapshot.transitionCount >= snapshot.ir.effectiveRuntimePolicy.maxWorkflowTransitions) {
1291
+ throw new AtomWorkflowError("WORKFLOW_TRANSITION_BUDGET_EXHAUSTED", "Workflow transition budget exhausted");
1292
+ }
1293
+ const traversals = snapshot.edgeTraversals[edge.id] ?? 0;
1294
+ const limit = edge.maxTraversals ?? (edge.backEdge ? snapshot.ir.effectiveRuntimePolicy.maxEdgeTraversals : undefined);
1295
+ if (limit !== undefined && traversals >= limit)
1296
+ throw new AtomWorkflowError("WORKFLOW_EDGE_BUDGET_EXHAUSTED", `Edge budget exhausted: ${edge.id}`);
1297
+ let next = {
1298
+ ...snapshot,
1299
+ transitionCount: snapshot.transitionCount + 1,
1300
+ edgeTraversals: { ...snapshot.edgeTraversals, [edge.id]: traversals + 1 },
1301
+ ...(selectedBranch !== undefined ? { decisions: [...snapshot.decisions, { activationId: source.activationId, nodeId: source.nodeId, selectedBranch, matched: edge.kind === "switch-case", edgeId: edge.id, selectedAt: nowIso(this.now) }] } : {}),
1302
+ };
1303
+ if (selectedBranch !== undefined) {
1304
+ next = this.event(next, "switch.selected", { selectedBranch, matched: edge.kind === "switch-case", edgeId: edge.id }, source.nodeId, source.activationId);
1305
+ }
1306
+ if (edge.terminal) {
1307
+ const status = edge.terminal === "$end" ? "succeeded" : edge.terminal === "$cancelled" ? "cancelled" : "failed";
1308
+ const outputs = status === "succeeded" ? this.workflowOutputs(next) : undefined;
1309
+ if (outputs) {
1310
+ const issues = validateJsonSchemaInstance(snapshot.revision.outputSchema, outputs, "$.outputs");
1311
+ if (issues.length)
1312
+ throw new AtomWorkflowError("WORKFLOW_OUTPUT_INVALID", "Workflow outputs do not satisfy the frozen output contract", false, { cause: issues });
1313
+ }
1314
+ return {
1315
+ ...next,
1316
+ run: {
1317
+ ...next.run,
1318
+ status,
1319
+ ...(outputs ? { outputs } : {}),
1320
+ updatedAt: nowIso(this.now),
1321
+ endedAt: nowIso(this.now),
1322
+ },
1323
+ };
1324
+ }
1325
+ const overlay = Object.fromEntries(Object.entries(edge.with).map(([key, value]) => [key, this.resolveEdgeValue(value, output, next, source)]));
1326
+ const sourceNode = next.ir.nodes.find((candidate) => candidate.id === source.nodeId);
1327
+ const mapContext = source.mapRunId && sourceNode.mapScope ? {
1328
+ mapRunId: source.mapRunId,
1329
+ ...(source.mapItem !== undefined ? { mapItem: source.mapItem } : {}),
1330
+ ...(source.mapIndex !== undefined ? { mapIndex: source.mapIndex } : {}),
1331
+ } : undefined;
1332
+ let targetLineage = source.controlLineage;
1333
+ if (edge.kind === "join-next")
1334
+ targetLineage = targetLineage.slice(0, -1);
1335
+ else if (edge.targets.length > 1)
1336
+ targetLineage = [...targetLineage, `${source.activationId}:${edge.id}`];
1337
+ for (const target of edge.targets) {
1338
+ const targetNode = next.ir.nodes.find((node) => node.id === target);
1339
+ const targetContext = targetNode.mapScope && targetNode.mapScope === sourceNode.mapScope ? mapContext : undefined;
1340
+ if (targetNode.kind === "join") {
1341
+ if (!next.pendingEdges.some((pending) => pending.edgeId === edge.id && pending.targetNodeId === target
1342
+ && pending.mapRunId === targetContext?.mapRunId && pending.mapIndex === targetContext?.mapIndex
1343
+ && sameLineage(pending.controlLineage, targetLineage))) {
1344
+ next = { ...next, pendingEdges: [...next.pendingEdges, { edgeId: edge.id, targetNodeId: target, overlay, controlLineage: targetLineage, ...(targetContext ?? {}) }] };
1345
+ }
1346
+ }
1347
+ else
1348
+ next = this.activate(next, target, overlay, edge.id, targetContext, targetLineage);
1349
+ }
1350
+ return this.flushReadyJoinsSync(next);
1351
+ }
1352
+ returnFromMap(snapshot, source, output, edge) {
1353
+ if (!source.mapRunId || source.mapIndex === undefined) {
1354
+ throw new AtomWorkflowError("MAP_CONTEXT_MISSING", `Map processor activation has no durable MapRun context: ${source.activationId}`);
1355
+ }
1356
+ if (snapshot.transitionCount >= snapshot.ir.effectiveRuntimePolicy.maxWorkflowTransitions) {
1357
+ throw new AtomWorkflowError("WORKFLOW_TRANSITION_BUDGET_EXHAUSTED", "Workflow transition budget exhausted");
1358
+ }
1359
+ const traversals = snapshot.edgeTraversals[edge.id] ?? 0;
1360
+ const limit = edge.maxTraversals ?? (edge.backEdge ? snapshot.ir.effectiveRuntimePolicy.maxEdgeTraversals : undefined);
1361
+ if (limit !== undefined && traversals >= limit)
1362
+ throw new AtomWorkflowError("WORKFLOW_EDGE_BUDGET_EXHAUSTED", `Edge budget exhausted: ${edge.id}`);
1363
+ let next = {
1364
+ ...snapshot,
1365
+ transitionCount: snapshot.transitionCount + 1,
1366
+ edgeTraversals: { ...snapshot.edgeTraversals, [edge.id]: traversals + 1 },
1367
+ };
1368
+ const mapRun = next.mapRuns.find((run) => run.mapRunId === source.mapRunId);
1369
+ if (!mapRun)
1370
+ throw new AtomWorkflowError("MAP_RUN_NOT_FOUND", `MapRun does not exist: ${source.mapRunId}`);
1371
+ const mapNode = next.ir.nodes.find((candidate) => candidate.id === mapRun.mapNodeId && candidate.kind === "map");
1372
+ if (!mapNode)
1373
+ throw new AtomWorkflowError("MAP_NODE_NOT_FOUND", `MapNode does not exist: ${mapRun.mapNodeId}`);
1374
+ const itemComplete = mapNode.bodyExitNodeIds.every((nodeId) => next.nodeRuns.some((run) => run.nodeId === nodeId && run.mapRunId === mapRun.mapRunId && run.mapIndex === source.mapIndex && run.status === "succeeded"));
1375
+ if (!itemComplete || mapRun.completedIndices.includes(source.mapIndex))
1376
+ return next;
1377
+ const timestamp = nowIso(this.now);
1378
+ next = {
1379
+ ...next,
1380
+ mapRuns: next.mapRuns.map((run) => run.mapRunId === mapRun.mapRunId ? {
1381
+ ...run,
1382
+ activeIndices: run.activeIndices.filter((index) => index !== source.mapIndex),
1383
+ completedIndices: [...run.completedIndices, source.mapIndex].sort((left, right) => left - right),
1384
+ updatedAt: timestamp,
1385
+ } : run),
1386
+ };
1387
+ const updated = next.mapRuns.find((run) => run.mapRunId === mapRun.mapRunId);
1388
+ if (updated.completedIndices.length === updated.items.length) {
1389
+ return this.finishMap(next, mapRun.mapRunId, mapNode, this.mapOutput(next, mapRun.mapRunId, mapNode));
1390
+ }
1391
+ return this.scheduleMapItems(next, mapRun.mapRunId, mapNode);
1392
+ }
1393
+ scheduleMapItems(snapshot, mapRunId, node) {
1394
+ let next = snapshot;
1395
+ let run = next.mapRuns.find((candidate) => candidate.mapRunId === mapRunId);
1396
+ while (run.nextIndex < run.items.length && run.activeIndices.length < node.maxConcurrency) {
1397
+ const index = run.nextIndex;
1398
+ const item = run.items[index];
1399
+ const controller = next.nodeRuns.find((candidate) => candidate.activationId === run.activationId);
1400
+ if (!controller)
1401
+ throw new AtomWorkflowError("MAP_ACTIVATION_NOT_FOUND", `MapNode activation does not exist: ${run.activationId}`);
1402
+ const itemLineage = node.bodyEntries.length > 1
1403
+ ? [...controller.controlLineage, `${controller.activationId}:${node.id}:item:${index}`]
1404
+ : controller.controlLineage;
1405
+ for (const entry of node.bodyEntries) {
1406
+ next = this.activate(next, entry, {}, `${node.id}:enter`, { mapRunId, mapItem: item, mapIndex: index }, itemLineage);
1407
+ }
1408
+ const timestamp = nowIso(this.now);
1409
+ next = {
1410
+ ...next,
1411
+ mapRuns: next.mapRuns.map((candidate) => candidate.mapRunId === mapRunId ? {
1412
+ ...candidate,
1413
+ nextIndex: index + 1,
1414
+ activeIndices: [...candidate.activeIndices, index],
1415
+ updatedAt: timestamp,
1416
+ } : candidate),
1417
+ };
1418
+ run = next.mapRuns.find((candidate) => candidate.mapRunId === mapRunId);
1419
+ }
1420
+ return next;
1421
+ }
1422
+ mapOutput(snapshot, mapRunId, node) {
1423
+ const outputFor = (nodeId) => snapshot.nodeRuns
1424
+ .filter((run) => run.nodeId === nodeId && run.mapRunId === mapRunId && run.status === "succeeded")
1425
+ .sort((left, right) => (left.mapIndex ?? 0) - (right.mapIndex ?? 0))
1426
+ .map((run) => run.output ?? null);
1427
+ if (node.bodyExitNodeIds.length === 1)
1428
+ return outputFor(node.bodyExitNodeIds[0]);
1429
+ return Object.fromEntries(node.bodyExitNodeIds.map((nodeId) => [nodeId, outputFor(nodeId)]));
1430
+ }
1431
+ finishMap(snapshot, mapRunId, node, output) {
1432
+ const mapRun = snapshot.mapRuns.find((candidate) => candidate.mapRunId === mapRunId);
1433
+ const timestamp = nowIso(this.now);
1434
+ let next = {
1435
+ ...snapshot,
1436
+ mapRuns: snapshot.mapRuns.map((candidate) => candidate.mapRunId === mapRunId ? {
1437
+ ...candidate,
1438
+ status: "succeeded",
1439
+ activeIndices: [],
1440
+ updatedAt: timestamp,
1441
+ endedAt: timestamp,
1442
+ } : candidate),
1443
+ nodeRuns: snapshot.nodeRuns.map((run) => run.activationId === mapRun.activationId ? {
1444
+ ...run,
1445
+ status: "succeeded",
1446
+ output,
1447
+ updatedAt: timestamp,
1448
+ endedAt: timestamp,
1449
+ } : run),
1450
+ };
1451
+ next = this.event(next, "map.completed", { itemCount: mapRun.items.length }, node.id, mapRun.activationId);
1452
+ const completionEdges = next.ir.control.edges.filter((edge) => edge.sourceNodeId === node.id && edge.kind === "map-complete");
1453
+ if (completionEdges.length === 0)
1454
+ throw new AtomWorkflowError("MAP_COMPLETION_EDGE_NOT_FOUND", `MapNode has no completion edge: ${node.id}`);
1455
+ const source = next.nodeRuns.find((run) => run.activationId === mapRun.activationId);
1456
+ for (const edge of completionEdges) {
1457
+ next = this.routeEdge(next, source, output, edge);
1458
+ if (next.run.status !== "running")
1459
+ break;
1460
+ }
1461
+ return next;
1462
+ }
1463
+ resolveEdgeValue(value, output, snapshot, source) {
1464
+ if (Array.isArray(value))
1465
+ return value.map((item) => this.resolveEdgeValue(item, output, snapshot, source));
1466
+ if (value !== null && typeof value === "object") {
1467
+ if (Object.keys(value).length === 1 && typeof value.$ref === "string") {
1468
+ const reference = value.$ref;
1469
+ if (reference === "node.output" || reference.startsWith("node.output.")) {
1470
+ const resolved = valueAtPath(output, reference === "node.output" ? [] : reference.split(".").slice(2));
1471
+ if (resolved === undefined)
1472
+ throw new AtomWorkflowError("ROUTE_BINDING_RESOLUTION_FAILED", `Missing ${reference}`);
1473
+ return resolved;
1474
+ }
1475
+ return resolveReference(reference, snapshot, source);
1476
+ }
1477
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, this.resolveEdgeValue(nested, output, snapshot, source)]));
1478
+ }
1479
+ return value;
1480
+ }
1481
+ activate(snapshot, nodeId, overlay, edgeId, mapContext, controlLineage = []) {
1482
+ const node = snapshot.ir.nodes.find((candidate) => candidate.id === nodeId);
1483
+ if (!node)
1484
+ throw new AtomWorkflowError("NODE_NOT_FOUND", `Node not found in frozen IR: ${nodeId}`);
1485
+ const current = snapshot.activationCounts[nodeId] ?? 0;
1486
+ const create = () => {
1487
+ const index = current;
1488
+ if (index >= snapshot.ir.effectiveRuntimePolicy.maxNodeActivations) {
1489
+ throw new AtomWorkflowError("WORKFLOW_NODE_ACTIVATION_BUDGET_EXHAUSTED", `Activation budget exhausted: ${nodeId}`);
1490
+ }
1491
+ const timestamp = nowIso(this.now);
1492
+ return {
1493
+ schemaVersion: "1.0",
1494
+ activationId: `${encodeURIComponent(snapshot.revision.workflowRevisionId)}:${nodeId}:${index}`,
1495
+ workflowRevisionId: snapshot.revision.workflowRevisionId,
1496
+ nodeId,
1497
+ activationIndex: index,
1498
+ status: "ready",
1499
+ attemptIds: [],
1500
+ inputOverlay: { ...overlay },
1501
+ controlLineage: [...controlLineage],
1502
+ ...(mapContext?.mapRunId ? {
1503
+ mapRunId: mapContext.mapRunId,
1504
+ ...(mapContext.mapItem !== undefined ? { mapItem: mapContext.mapItem } : {}),
1505
+ ...(mapContext.mapIndex !== undefined ? { mapIndex: mapContext.mapIndex } : {}),
1506
+ } : {}),
1507
+ ...(edgeId ? { triggeredByEdgeId: edgeId } : {}),
1508
+ artifacts: [],
1509
+ createdAt: timestamp,
1510
+ updatedAt: timestamp,
1511
+ };
1512
+ };
1513
+ const created = [create()];
1514
+ return {
1515
+ ...snapshot,
1516
+ nodeRuns: [...snapshot.nodeRuns, ...created],
1517
+ activationCounts: { ...snapshot.activationCounts, [nodeId]: current + created.length },
1518
+ };
1519
+ }
1520
+ flushReadyJoinsSync(snapshot) {
1521
+ let next = snapshot;
1522
+ const grouped = new Map();
1523
+ for (const pending of snapshot.pendingEdges) {
1524
+ const key = `${pending.targetNodeId}|${pending.mapRunId ?? ""}|${pending.mapIndex ?? ""}|${JSON.stringify(pending.controlLineage)}`;
1525
+ const current = grouped.get(key);
1526
+ grouped.set(key, { target: pending.targetNodeId, pending: [...(current?.pending ?? []), pending] });
1527
+ }
1528
+ for (const { target, pending } of grouped.values()) {
1529
+ const node = next.ir.nodes.find((candidate) => candidate.id === target);
1530
+ if (node.kind !== "join")
1531
+ throw new AtomWorkflowError("JOIN_NODE_INVALID", `Pending fan-in target is not a JoinNode: ${target}`);
1532
+ const first = pending[0];
1533
+ const arrived = new Set(pending.map((item) => item.edgeId));
1534
+ if (!node.incomingEdgeIds.every((edgeId) => arrived.has(edgeId)))
1535
+ continue;
1536
+ const overlay = {};
1537
+ for (const item of pending)
1538
+ for (const [key, value] of Object.entries(item.overlay)) {
1539
+ if (key in overlay && JSON.stringify(overlay[key]) !== JSON.stringify(value)) {
1540
+ throw new AtomWorkflowError("JOIN_OVERLAY_CONFLICT", `JoinNode received conflicting values for ${key}: ${target}`);
1541
+ }
1542
+ overlay[key] = value;
1543
+ }
1544
+ next = this.activate({
1545
+ ...next,
1546
+ pendingEdges: next.pendingEdges.filter((item) => !(item.targetNodeId === target
1547
+ && item.mapRunId === first?.mapRunId && item.mapIndex === first?.mapIndex
1548
+ && sameLineage(item.controlLineage, first?.controlLineage ?? []))),
1549
+ }, target, overlay, first?.edgeId, first?.mapRunId ? {
1550
+ mapRunId: first.mapRunId,
1551
+ ...(first.mapItem !== undefined ? { mapItem: first.mapItem } : {}),
1552
+ ...(first.mapIndex !== undefined ? { mapIndex: first.mapIndex } : {}),
1553
+ } : undefined, first?.controlLineage ?? []);
1554
+ }
1555
+ return next;
1556
+ }
1557
+ async flushReadyJoins(snapshot) {
1558
+ const next = this.flushReadyJoinsSync(snapshot);
1559
+ if (next === snapshot)
1560
+ return snapshot;
1561
+ await this.options.store.save(next);
1562
+ return next;
1563
+ }
1564
+ workflowOutputs(snapshot) {
1565
+ return Object.fromEntries(Object.entries(snapshot.ir.outputs).map(([name, reference]) => [name, resolveReference(reference.$ref, snapshot)]));
1566
+ }
1567
+ hostManager(snapshot) {
1568
+ const manager = new HostHandoffManager();
1569
+ manager.importRecords(snapshot.hostTasks, (id, version) => this.options.atoms.get(id, version).manifest);
1570
+ return manager;
1571
+ }
1572
+ event(snapshot, type, data, nodeId, activationId, attemptId, sessionId) {
1573
+ const event = {
1574
+ schemaVersion: "1.0",
1575
+ sequence: snapshot.eventSequence,
1576
+ timestamp: nowIso(this.now),
1577
+ runId: snapshot.run.runId,
1578
+ workflowRevisionId: snapshot.revision.workflowRevisionId,
1579
+ ...(nodeId ? { nodeId } : {}),
1580
+ ...(activationId ? { activationId } : {}),
1581
+ ...(attemptId ? { attemptId } : {}),
1582
+ ...(sessionId ? { sessionId } : {}),
1583
+ type,
1584
+ data,
1585
+ };
1586
+ try {
1587
+ this.options.onEvent?.(event);
1588
+ }
1589
+ catch { /* Observability must not break execution. */ }
1590
+ return { ...snapshot, events: [...snapshot.events, event], eventSequence: snapshot.eventSequence + 1 };
1591
+ }
1592
+ }
1593
+ //# sourceMappingURL=durable-workflow-engine.js.map