@melra/runtime-core 0.3.0-alpha.3

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 (62) hide show
  1. package/LICENSE +202 -0
  2. package/dist/index.d.ts +8 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +10 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/payload-cipher.d.ts +8 -0
  7. package/dist/payload-cipher.d.ts.map +1 -0
  8. package/dist/payload-cipher.js +47 -0
  9. package/dist/payload-cipher.js.map +1 -0
  10. package/dist/payload-cipher.test.d.ts +2 -0
  11. package/dist/payload-cipher.test.d.ts.map +1 -0
  12. package/dist/payload-cipher.test.js +33 -0
  13. package/dist/payload-cipher.test.js.map +1 -0
  14. package/dist/retry-guard.d.ts +21 -0
  15. package/dist/retry-guard.d.ts.map +1 -0
  16. package/dist/retry-guard.js +56 -0
  17. package/dist/retry-guard.js.map +1 -0
  18. package/dist/retry-guard.test.d.ts +2 -0
  19. package/dist/retry-guard.test.d.ts.map +1 -0
  20. package/dist/retry-guard.test.js +45 -0
  21. package/dist/retry-guard.test.js.map +1 -0
  22. package/dist/task-controller.d.ts +52 -0
  23. package/dist/task-controller.d.ts.map +1 -0
  24. package/dist/task-controller.js +416 -0
  25. package/dist/task-controller.js.map +1 -0
  26. package/dist/task-controller.test.d.ts +2 -0
  27. package/dist/task-controller.test.d.ts.map +1 -0
  28. package/dist/task-controller.test.js +422 -0
  29. package/dist/task-controller.test.js.map +1 -0
  30. package/dist/verification-gate.d.ts +18 -0
  31. package/dist/verification-gate.d.ts.map +1 -0
  32. package/dist/verification-gate.js +29 -0
  33. package/dist/verification-gate.js.map +1 -0
  34. package/dist/verification-gate.test.d.ts +2 -0
  35. package/dist/verification-gate.test.d.ts.map +1 -0
  36. package/dist/verification-gate.test.js +42 -0
  37. package/dist/verification-gate.test.js.map +1 -0
  38. package/dist/workflow-controller.d.ts +33 -0
  39. package/dist/workflow-controller.d.ts.map +1 -0
  40. package/dist/workflow-controller.js +711 -0
  41. package/dist/workflow-controller.js.map +1 -0
  42. package/dist/workflow-controller.test.d.ts +2 -0
  43. package/dist/workflow-controller.test.d.ts.map +1 -0
  44. package/dist/workflow-controller.test.js +641 -0
  45. package/dist/workflow-controller.test.js.map +1 -0
  46. package/dist/workflow-events.d.ts +4 -0
  47. package/dist/workflow-events.d.ts.map +1 -0
  48. package/dist/workflow-events.js +112 -0
  49. package/dist/workflow-events.js.map +1 -0
  50. package/dist/workflow-events.test.d.ts +2 -0
  51. package/dist/workflow-events.test.d.ts.map +1 -0
  52. package/dist/workflow-events.test.js +113 -0
  53. package/dist/workflow-events.test.js.map +1 -0
  54. package/dist/workflow-graph.d.ts +8 -0
  55. package/dist/workflow-graph.d.ts.map +1 -0
  56. package/dist/workflow-graph.js +107 -0
  57. package/dist/workflow-graph.js.map +1 -0
  58. package/dist/workflow-graph.test.d.ts +2 -0
  59. package/dist/workflow-graph.test.d.ts.map +1 -0
  60. package/dist/workflow-graph.test.js +169 -0
  61. package/dist/workflow-graph.test.js.map +1 -0
  62. package/package.json +36 -0
@@ -0,0 +1,711 @@
1
+ // Copyright 2026 XAGI Labs Private Limited
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { randomUUID } from "node:crypto";
4
+ import { WorkflowDefinitionSchema, WorkflowEventSchema, WorkflowRunSchema, WorkflowSnapshotSchema, } from "@melra/protocol";
5
+ import { canonicalJson, redactStructuredValue, sha256, } from "@melra/receipt-schema";
6
+ import { applyWorkflowEvent, rebuildWorkflow, } from "./workflow-events.js";
7
+ import { readyNodeIds, validateWorkflow } from "./workflow-graph.js";
8
+ export class WorkflowController {
9
+ store;
10
+ tasks;
11
+ payloadCipher;
12
+ // ponytail: process-local serialization; add SQLite leases before supporting
13
+ // multiple MELRA server processes against one data directory.
14
+ advances = new Map();
15
+ constructor(store, tasks, payloadCipher) {
16
+ this.store = store;
17
+ this.tasks = tasks;
18
+ this.payloadCipher = payloadCipher;
19
+ }
20
+ plan(definition) {
21
+ const parsed = WorkflowDefinitionSchema.parse(definition);
22
+ const graph = validateWorkflow(parsed);
23
+ for (const node of graph.nodes.values()) {
24
+ for (const request of requestsFor(node)) {
25
+ if (this.tasks.preflight(request).outcome === "deny") {
26
+ throw new Error(`workflow_policy_blocked:${node.id}`);
27
+ }
28
+ }
29
+ }
30
+ const createdAt = new Date().toISOString();
31
+ const id = randomUUID();
32
+ const traceId = randomUUID();
33
+ const draft = WorkflowRunSchema.parse({
34
+ schemaVersion: "1.0.0",
35
+ id,
36
+ definitionId: parsed.id,
37
+ definitionVersion: parsed.version,
38
+ status: "draft",
39
+ stateVersion: 1,
40
+ nodes: Object.fromEntries(parsed.nodes.map((node) => [node.id, { status: "pending" }])),
41
+ traceId,
42
+ createdAt,
43
+ updatedAt: createdAt,
44
+ });
45
+ const created = makeEvent(draft, 1, "workflow.created", { run: draft });
46
+ const plannedEvent = makeEvent(draft, 2, "workflow.status_changed", {
47
+ from: "draft",
48
+ to: "planned",
49
+ });
50
+ const planned = applyWorkflowEvent(draft, plannedEvent);
51
+ const redacted = WorkflowDefinitionSchema.parse(redactStructuredValue(parsed).value);
52
+ this.store.createWorkflow(redacted, this.payloadCipher.seal(parsed, `workflow:${parsed.id}:${parsed.version}:definition`), planned, [created, plannedEvent]);
53
+ return planned;
54
+ }
55
+ status(workflowId) {
56
+ const run = this.store.getWorkflowRun(workflowId);
57
+ if (run === undefined)
58
+ throw new Error("workflow_not_found");
59
+ return run;
60
+ }
61
+ events(workflowId, afterSequence = 0) {
62
+ this.status(workflowId);
63
+ return this.store.listWorkflowEvents(workflowId, afterSequence);
64
+ }
65
+ async advance(workflowId, approvals = []) {
66
+ const previous = this.advances.get(workflowId);
67
+ const ready = previous === undefined
68
+ ? Promise.resolve()
69
+ : previous.then(() => undefined, () => undefined);
70
+ const result = ready.then(() => this.advanceOnce(workflowId, approvals));
71
+ this.advances.set(workflowId, result);
72
+ try {
73
+ return await result;
74
+ }
75
+ finally {
76
+ if (this.advances.get(workflowId) === result) {
77
+ this.advances.delete(workflowId);
78
+ }
79
+ }
80
+ }
81
+ async advanceOnce(workflowId, approvals) {
82
+ const current = this.status(workflowId);
83
+ if (["verified_complete", "cancelled"].includes(current.status)) {
84
+ return { run: current, tasks: [], events: [] };
85
+ }
86
+ const definition = this.loadDefinition(current);
87
+ const graph = validateWorkflow(definition);
88
+ const rollback = current.status === "failed"
89
+ ? definition.nodes
90
+ .filter((node) => node.type === "compensation" &&
91
+ ["pending", "awaiting_approval"].includes(current.nodes[node.id]?.status ?? "") &&
92
+ current.nodes[node.forNodeId]?.status ===
93
+ "verified_complete")
94
+ .map((node) => node.id)
95
+ .reverse()
96
+ : [];
97
+ if (current.status === "failed" && rollback.length === 0) {
98
+ return { run: current, tasks: [], events: [] };
99
+ }
100
+ const awaiting = [...graph.nodes.values()]
101
+ .filter((node) => node.type !== "compensation" &&
102
+ current.nodes[node.id]?.status === "awaiting_approval")
103
+ .map((node) => node.id)
104
+ .sort();
105
+ const nodeIds = rollback.length > 0
106
+ ? rollback
107
+ : awaiting.length > 0
108
+ ? awaiting
109
+ : readyNodeIds(graph, current.nodes);
110
+ if (nodeIds.length === 0) {
111
+ return { run: current, tasks: [], events: [] };
112
+ }
113
+ const results = await Promise.all(nodeIds.map(async (nodeId) => {
114
+ const node = graph.nodes.get(nodeId);
115
+ return {
116
+ nodeId,
117
+ result: await this.advanceNode(node, definition, current.nodes[nodeId], current, approvals),
118
+ };
119
+ }));
120
+ if (results.some(({ result }) => result.state.status === "failed")) {
121
+ for (const node of [...definition.nodes].reverse()) {
122
+ if (node.type !== "compensation" ||
123
+ current.nodes[node.id]?.status !== "pending") {
124
+ continue;
125
+ }
126
+ const targetState = results.find(({ nodeId }) => nodeId === node.forNodeId)?.result
127
+ .state ?? current.nodes[node.forNodeId];
128
+ if (targetState?.status !== "verified_complete") {
129
+ results.push({
130
+ nodeId: node.id,
131
+ result: {
132
+ state: { status: "skipped", taskIds: [] },
133
+ tasks: [],
134
+ },
135
+ });
136
+ continue;
137
+ }
138
+ const compensation = await this.runTask(node.request, current.nodes[node.id]?.taskIds[0], approvals, true, {
139
+ workflowId: current.id,
140
+ nodeId: node.id,
141
+ iteration: 0,
142
+ branch: "compensation",
143
+ });
144
+ results.push({
145
+ nodeId: node.id,
146
+ result: {
147
+ ...compensation,
148
+ state: compensation.state.status === "verified_complete"
149
+ ? { ...compensation.state, status: "compensated" }
150
+ : compensation.state,
151
+ },
152
+ });
153
+ }
154
+ }
155
+ let run = this.transition(current, (draft, emit) => {
156
+ for (const { nodeId, result } of results) {
157
+ const from = draft.nodes[nodeId].status;
158
+ draft.nodes[nodeId] = result.state;
159
+ emit("workflow.node_changed", {
160
+ nodeId,
161
+ from,
162
+ state: result.state,
163
+ });
164
+ }
165
+ let status = this.deriveStatus(draft, definition);
166
+ if (status === "verified_complete") {
167
+ for (const node of definition.nodes) {
168
+ const state = draft.nodes[node.id];
169
+ if (node.type !== "compensation" || state.status !== "pending") {
170
+ continue;
171
+ }
172
+ state.status = "skipped";
173
+ emit("workflow.node_changed", {
174
+ nodeId: node.id,
175
+ from: "pending",
176
+ state,
177
+ });
178
+ }
179
+ status = this.deriveStatus(draft, definition);
180
+ }
181
+ if (status !== draft.status) {
182
+ const from = draft.status;
183
+ draft.status = status;
184
+ emit("workflow.status_changed", { from, to: status });
185
+ }
186
+ });
187
+ if (results.some(({ result }) => result.checkpoint === true)) {
188
+ run = this.transition(run, (draft, emit) => {
189
+ emit("workflow.checkpoint_saved", {
190
+ sequence: draft.stateVersion + 1,
191
+ });
192
+ });
193
+ this.store.saveWorkflowSnapshot(WorkflowSnapshotSchema.parse({
194
+ schemaVersion: "1.0.0",
195
+ workflowId: run.id,
196
+ sequence: run.stateVersion,
197
+ run,
198
+ createdAt: run.updatedAt,
199
+ }));
200
+ }
201
+ return {
202
+ run,
203
+ tasks: results.flatMap(({ result }) => result.tasks),
204
+ events: this.events(workflowId, current.stateVersion),
205
+ };
206
+ }
207
+ async recoverInterrupted() {
208
+ await this.tasks.recoverInterrupted();
209
+ const recovered = [];
210
+ const allTasks = this.store.listTasks(100_000);
211
+ for (const stored of this.store.listWorkflowRuns([
212
+ "planned",
213
+ "running",
214
+ "awaiting_approval",
215
+ "recovery_required",
216
+ ])) {
217
+ const replayed = this.replay(stored);
218
+ if (replayed.stateVersion !== stored.stateVersion) {
219
+ throw new Error("workflow_event_history_invalid");
220
+ }
221
+ const definition = this.loadDefinition(stored);
222
+ let run = this.transition(stored, (draft, emit) => {
223
+ for (const node of definition.nodes) {
224
+ const state = recoveredNodeState(node, expectedTaskKeys(stored.id, node, definition)
225
+ .map((key) => preferredTask(allTasks, key))
226
+ .filter((task) => task !== undefined), replayed.nodes[node.id]);
227
+ if (canonicalJson(draft.nodes[node.id]) === canonicalJson(state)) {
228
+ continue;
229
+ }
230
+ const from = draft.nodes[node.id].status;
231
+ draft.nodes[node.id] = state;
232
+ emit("workflow.node_changed", {
233
+ nodeId: node.id,
234
+ from,
235
+ state,
236
+ });
237
+ }
238
+ let status = this.deriveStatus(draft, definition);
239
+ if (status === "verified_complete") {
240
+ for (const node of definition.nodes) {
241
+ const state = draft.nodes[node.id];
242
+ if (node.type !== "compensation" || state.status !== "pending") {
243
+ continue;
244
+ }
245
+ state.status = "skipped";
246
+ emit("workflow.node_changed", {
247
+ nodeId: node.id,
248
+ from: "pending",
249
+ state,
250
+ });
251
+ }
252
+ status = this.deriveStatus(draft, definition);
253
+ }
254
+ const from = draft.status;
255
+ draft.status = status;
256
+ if (status === "recovery_required") {
257
+ draft.error = "workflow_contains_unreconciled_mutation";
258
+ }
259
+ else {
260
+ delete draft.error;
261
+ }
262
+ emit(status === "recovery_required"
263
+ ? "workflow.recovery_required"
264
+ : "workflow.recovered", {
265
+ from,
266
+ to: status,
267
+ ...(draft.error === undefined ? {} : { error: draft.error }),
268
+ });
269
+ });
270
+ recovered.push(run);
271
+ }
272
+ return recovered;
273
+ }
274
+ cancel(workflowId) {
275
+ const current = this.status(workflowId);
276
+ if (current.status === "verified_complete" ||
277
+ current.status === "cancelled") {
278
+ return current;
279
+ }
280
+ return this.transition(current, (draft, emit) => {
281
+ for (const [nodeId, node] of Object.entries(draft.nodes)) {
282
+ if (!["pending", "ready", "awaiting_approval"].includes(node.status)) {
283
+ continue;
284
+ }
285
+ for (const taskId of node.taskIds)
286
+ this.tasks.cancel(taskId);
287
+ const from = node.status;
288
+ node.status = "cancelled";
289
+ emit("workflow.node_changed", {
290
+ nodeId,
291
+ from,
292
+ state: node,
293
+ });
294
+ }
295
+ const from = draft.status;
296
+ draft.status = "cancelled";
297
+ delete draft.error;
298
+ emit("workflow.cancelled", { from, to: "cancelled" });
299
+ });
300
+ }
301
+ transition(current, mutate) {
302
+ const draft = structuredClone(current);
303
+ const events = [];
304
+ const emit = (type, data) => {
305
+ events.push(makeEvent(current, current.stateVersion + events.length + 1, type, data));
306
+ };
307
+ mutate(draft, emit);
308
+ if (events.length === 0)
309
+ return current;
310
+ draft.stateVersion = current.stateVersion + events.length;
311
+ draft.updatedAt = events.at(-1).occurredAt;
312
+ const expected = WorkflowRunSchema.parse(draft);
313
+ let rebuilt = current;
314
+ for (const event of events)
315
+ rebuilt = applyWorkflowEvent(rebuilt, event);
316
+ if (canonicalJson(rebuilt) !== canonicalJson(expected)) {
317
+ throw new Error("workflow_projection_event_mismatch");
318
+ }
319
+ this.store.transitionWorkflow(current.id, current.stateVersion, rebuilt, events);
320
+ return rebuilt;
321
+ }
322
+ loadDefinition(run) {
323
+ const payload = this.store.getWorkflowPayload(run.definitionId, run.definitionVersion);
324
+ if (payload === undefined)
325
+ throw new Error("workflow_payload_not_found");
326
+ return WorkflowDefinitionSchema.parse(this.payloadCipher.open(payload, `workflow:${run.definitionId}:${run.definitionVersion}:definition`));
327
+ }
328
+ replay(run) {
329
+ try {
330
+ const snapshot = this.store.getLatestWorkflowSnapshot(run.id);
331
+ return rebuildWorkflow(snapshot, this.store.listWorkflowEvents(run.id, snapshot?.sequence ?? 0));
332
+ }
333
+ catch {
334
+ try {
335
+ return rebuildWorkflow(undefined, this.store.listWorkflowEvents(run.id));
336
+ }
337
+ catch {
338
+ throw new Error("workflow_event_history_invalid");
339
+ }
340
+ }
341
+ }
342
+ async advanceNode(node, definition, state, run, approvals) {
343
+ switch (node.type) {
344
+ case "operation": {
345
+ const approvalNode = definition.nodes.find((candidate) => candidate.type === "approval" &&
346
+ candidate.forNodeId === node.id);
347
+ const approvedTaskId = approvalNode === undefined
348
+ ? undefined
349
+ : run.nodes[approvalNode.id]?.taskIds[0];
350
+ return await this.runTask(node.request, approvedTaskId ?? state.taskIds[0], approvals, true, {
351
+ workflowId: run.id,
352
+ nodeId: node.id,
353
+ iteration: 0,
354
+ branch: "operation",
355
+ });
356
+ }
357
+ case "approval": {
358
+ const target = definition.nodes.find((candidate) => candidate.id === node.forNodeId);
359
+ if (target === undefined || target.type !== "operation") {
360
+ throw new Error(`workflow_approval_target_missing:${node.id}`);
361
+ }
362
+ return await this.runTask(target.request, state.taskIds[0], approvals, false, {
363
+ workflowId: run.id,
364
+ nodeId: target.id,
365
+ iteration: 0,
366
+ branch: "operation",
367
+ });
368
+ }
369
+ case "condition": {
370
+ const sourceTaskId = run.nodes[node.sourceNodeId]?.taskIds[0];
371
+ if (sourceTaskId === undefined) {
372
+ throw new Error(`workflow_condition_source_unavailable:${node.id}`);
373
+ }
374
+ const condition = await this.tasks.verifyPersisted(sourceTaskId, [
375
+ node.predicate,
376
+ ]);
377
+ return await this.runSequential(condition.verified ? node.whenTrue : node.whenFalse, state, approvals, {
378
+ workflowId: run.id,
379
+ nodeId: node.id,
380
+ iteration: 0,
381
+ branch: condition.verified ? "true" : "false",
382
+ });
383
+ }
384
+ case "parallel":
385
+ return await this.runParallel(node.branches, state, approvals, run.id, node.id);
386
+ case "bounded_loop":
387
+ return await this.runLoop(node, state, approvals, run.id);
388
+ case "compensation": {
389
+ const result = await this.runTask(node.request, state.taskIds[0], approvals, true, {
390
+ workflowId: run.id,
391
+ nodeId: node.id,
392
+ iteration: 0,
393
+ branch: "compensation",
394
+ });
395
+ return {
396
+ ...result,
397
+ state: result.state.status === "verified_complete"
398
+ ? { ...result.state, status: "compensated" }
399
+ : result.state,
400
+ };
401
+ }
402
+ case "checkpoint":
403
+ return {
404
+ state: { status: "verified_complete", taskIds: [] },
405
+ tasks: [],
406
+ checkpoint: true,
407
+ };
408
+ default:
409
+ throw new Error("workflow_node_not_executable");
410
+ }
411
+ }
412
+ async runTask(request, existingTaskId, approvals, executePlanned = true, identity) {
413
+ let task = existingTaskId === undefined
414
+ ? this.tasks.plan(request, {
415
+ idempotencyKey: taskIdempotencyKey(identity, request),
416
+ attempt: 1,
417
+ })
418
+ : this.tasks.status(existingTaskId);
419
+ if (task.status === "awaiting_approval") {
420
+ const approval = approvals.find((item) => item.approvalId === task.approval?.approvalId);
421
+ if (approval === undefined) {
422
+ return {
423
+ state: {
424
+ status: "awaiting_approval",
425
+ taskIds: [task.id],
426
+ ...(task.approval === undefined
427
+ ? {}
428
+ : { approval: task.approval }),
429
+ },
430
+ tasks: [task],
431
+ };
432
+ }
433
+ task = (await this.tasks.execute(task.id, approval)).task;
434
+ }
435
+ else if (task.status === "planned" && executePlanned) {
436
+ task = (await this.tasks.execute(task.id)).task;
437
+ }
438
+ return {
439
+ state: nodeStateForTask(task, !executePlanned),
440
+ tasks: [task],
441
+ };
442
+ }
443
+ async runSequential(requests, state, approvals, identity) {
444
+ const taskIds = [...state.taskIds];
445
+ const tasks = [];
446
+ for (const [index, request] of requests.entries()) {
447
+ const result = await this.runTask(request, taskIds[index], approvals, true, { ...identity, branch: `${identity.branch}:${index}` });
448
+ tasks.push(...result.tasks);
449
+ taskIds[index] = result.state.taskIds[0];
450
+ if (result.state.status !== "verified_complete") {
451
+ return {
452
+ state: { ...result.state, taskIds },
453
+ tasks,
454
+ };
455
+ }
456
+ }
457
+ return {
458
+ state: { status: "verified_complete", taskIds },
459
+ tasks,
460
+ };
461
+ }
462
+ async runParallel(branches, state, approvals, workflowId, nodeId) {
463
+ const requests = branches.flatMap((branch, branchIndex) => branch.map((request, requestIndex) => ({
464
+ request,
465
+ branch: `${branchIndex}:${requestIndex}`,
466
+ })));
467
+ const results = await Promise.all(requests.map((item, index) => this.runTask(item.request, state.taskIds[index], approvals, true, {
468
+ workflowId,
469
+ nodeId,
470
+ iteration: 0,
471
+ branch: item.branch,
472
+ })));
473
+ return {
474
+ state: aggregateNodeState(results.map((result) => result.state)),
475
+ tasks: results.flatMap((result) => result.tasks),
476
+ };
477
+ }
478
+ async runLoop(node, state, approvals, workflowId) {
479
+ const taskIds = [...state.taskIds];
480
+ const tasks = [];
481
+ let iterations = state.iterations ?? 0;
482
+ while (iterations < node.maxIterations) {
483
+ const offset = iterations * node.body.length;
484
+ for (const [index, request] of node.body.entries()) {
485
+ const result = await this.runTask(request, taskIds[offset + index], approvals, true, {
486
+ workflowId,
487
+ nodeId: node.id,
488
+ iteration: iterations,
489
+ branch: `loop:${index}`,
490
+ });
491
+ tasks.push(...result.tasks);
492
+ taskIds[offset + index] = result.state.taskIds[0];
493
+ if (result.state.status !== "verified_complete") {
494
+ return {
495
+ state: {
496
+ ...result.state,
497
+ taskIds,
498
+ iterations,
499
+ },
500
+ tasks,
501
+ };
502
+ }
503
+ }
504
+ iterations += 1;
505
+ const lastTaskId = taskIds[offset + node.body.length - 1];
506
+ if (node.until !== undefined &&
507
+ (await this.tasks.verifyPersisted(lastTaskId, [node.until])).verified) {
508
+ break;
509
+ }
510
+ }
511
+ return {
512
+ state: {
513
+ status: "verified_complete",
514
+ taskIds,
515
+ iterations,
516
+ },
517
+ tasks,
518
+ };
519
+ }
520
+ deriveStatus(run, definition) {
521
+ const required = definition.nodes.filter((node) => node.type !== "compensation");
522
+ if (required.some((node) => run.nodes[node.id]?.status === "failed")) {
523
+ return "failed";
524
+ }
525
+ if (required.some((node) => run.nodes[node.id]?.status === "recovery_required")) {
526
+ return "recovery_required";
527
+ }
528
+ if (required.some((node) => run.nodes[node.id]?.status === "awaiting_approval")) {
529
+ return "awaiting_approval";
530
+ }
531
+ if (required.every((node) => {
532
+ const state = run.nodes[node.id];
533
+ return (state !== undefined &&
534
+ ["verified_complete", "skipped", "compensated"].includes(state.status) &&
535
+ state.taskIds.every((taskId) => this.tasks.receipts({ taskId }).certificate?.result ===
536
+ "VERIFIED_SUCCESS"));
537
+ })) {
538
+ return "verified_complete";
539
+ }
540
+ return "running";
541
+ }
542
+ }
543
+ function makeEvent(run, sequence, type, data) {
544
+ return WorkflowEventSchema.parse({
545
+ schemaVersion: "1.0.0",
546
+ id: randomUUID(),
547
+ aggregateId: run.id,
548
+ sequence,
549
+ traceId: run.traceId,
550
+ type,
551
+ data,
552
+ occurredAt: new Date().toISOString(),
553
+ });
554
+ }
555
+ function requestsFor(node) {
556
+ switch (node.type) {
557
+ case "operation":
558
+ case "compensation":
559
+ return [node.request];
560
+ case "condition":
561
+ return [...node.whenTrue, ...node.whenFalse];
562
+ case "parallel":
563
+ return node.branches.flat();
564
+ case "bounded_loop":
565
+ return node.body;
566
+ case "approval":
567
+ case "checkpoint":
568
+ return [];
569
+ }
570
+ }
571
+ function nodeStateForTask(task, plannedIsComplete) {
572
+ switch (task.status) {
573
+ case "verified_success":
574
+ return { status: "verified_complete", taskIds: [task.id] };
575
+ case "planned":
576
+ return {
577
+ status: plannedIsComplete ? "verified_complete" : "ready",
578
+ taskIds: [task.id],
579
+ };
580
+ case "awaiting_approval":
581
+ return {
582
+ status: "awaiting_approval",
583
+ taskIds: [task.id],
584
+ ...(task.approval === undefined ? {} : { approval: task.approval }),
585
+ };
586
+ case "recovery_required":
587
+ return {
588
+ status: "recovery_required",
589
+ taskIds: [task.id],
590
+ ...(task.error === undefined ? {} : { error: task.error }),
591
+ };
592
+ case "cancelled":
593
+ return { status: "cancelled", taskIds: [task.id] };
594
+ default:
595
+ return {
596
+ status: "failed",
597
+ taskIds: [task.id],
598
+ ...(task.error === undefined ? {} : { error: task.error }),
599
+ };
600
+ }
601
+ }
602
+ function aggregateNodeState(states) {
603
+ const taskIds = states.flatMap((state) => state.taskIds);
604
+ for (const status of [
605
+ "failed",
606
+ "recovery_required",
607
+ "awaiting_approval",
608
+ "cancelled",
609
+ ]) {
610
+ const state = states.find((candidate) => candidate.status === status);
611
+ if (state !== undefined)
612
+ return { ...state, taskIds };
613
+ }
614
+ return { status: "verified_complete", taskIds };
615
+ }
616
+ function taskIdempotencyKey(identity, request) {
617
+ return sha256({ ...identity, request });
618
+ }
619
+ function expectedTaskKeys(workflowId, node, definition) {
620
+ const key = (request, iteration, branch, nodeId = node.id) => taskIdempotencyKey({ workflowId, nodeId, iteration, branch }, request);
621
+ switch (node.type) {
622
+ case "operation":
623
+ return [key(node.request, 0, "operation")];
624
+ case "approval": {
625
+ const target = definition.nodes.find((candidate) => candidate.id === node.forNodeId);
626
+ return target?.type === "operation"
627
+ ? [key(target.request, 0, "operation", target.id)]
628
+ : [];
629
+ }
630
+ case "condition":
631
+ return [
632
+ ...node.whenTrue.map((request, index) => key(request, 0, `true:${index}`)),
633
+ ...node.whenFalse.map((request, index) => key(request, 0, `false:${index}`)),
634
+ ];
635
+ case "parallel":
636
+ return node.branches.flatMap((branch, branchIndex) => branch.map((request, requestIndex) => key(request, 0, `${branchIndex}:${requestIndex}`)));
637
+ case "bounded_loop":
638
+ return Array.from({ length: node.maxIterations }, (_, iteration) => node.body.map((request, index) => key(request, iteration, `loop:${index}`))).flat();
639
+ case "compensation":
640
+ return [key(node.request, 0, "compensation")];
641
+ case "checkpoint":
642
+ return [];
643
+ }
644
+ }
645
+ function preferredTask(tasks, idempotencyKey) {
646
+ return tasks
647
+ .filter((task) => task.idempotencyKey === idempotencyKey)
648
+ .sort((left, right) => Number(right.status === "verified_success") -
649
+ Number(left.status === "verified_success") ||
650
+ (right.attempt ?? 1) - (left.attempt ?? 1) ||
651
+ right.updatedAt.localeCompare(left.updatedAt))[0];
652
+ }
653
+ function recoveredNodeState(node, tasks, previous) {
654
+ if (tasks.length === 0)
655
+ return previous;
656
+ const taskIds = tasks.map((task) => task.id);
657
+ const withStatus = (status) => tasks.find((task) => task.status === status);
658
+ const recovery = withStatus("recovery_required");
659
+ if (recovery !== undefined) {
660
+ return {
661
+ status: "recovery_required",
662
+ taskIds,
663
+ ...(recovery.error === undefined ? {} : { error: recovery.error }),
664
+ };
665
+ }
666
+ const awaiting = withStatus("awaiting_approval");
667
+ if (awaiting !== undefined) {
668
+ return {
669
+ status: "awaiting_approval",
670
+ taskIds,
671
+ ...(awaiting.approval === undefined
672
+ ? {}
673
+ : { approval: awaiting.approval }),
674
+ };
675
+ }
676
+ const failed = tasks.find((task) => [
677
+ "failed",
678
+ "partial",
679
+ "budget_exhausted",
680
+ "policy_blocked",
681
+ ].includes(task.status));
682
+ if (failed !== undefined) {
683
+ return {
684
+ status: "failed",
685
+ taskIds,
686
+ ...(failed.error === undefined ? {} : { error: failed.error }),
687
+ };
688
+ }
689
+ if (tasks.some((task) => task.status === "planned")) {
690
+ return {
691
+ status: "pending",
692
+ taskIds,
693
+ ...(tasks.find((task) => task.error !== undefined)?.error === undefined
694
+ ? {}
695
+ : { error: tasks.find((task) => task.error !== undefined).error }),
696
+ };
697
+ }
698
+ if (tasks.every((task) => task.status === "verified_success")) {
699
+ return {
700
+ status: node.type === "compensation" ? "compensated" : "verified_complete",
701
+ taskIds,
702
+ ...(node.type === "bounded_loop"
703
+ ? {
704
+ iterations: Math.ceil(tasks.length / node.body.length),
705
+ }
706
+ : {}),
707
+ };
708
+ }
709
+ return { status: "cancelled", taskIds };
710
+ }
711
+ //# sourceMappingURL=workflow-controller.js.map