@melra/runtime-core 0.3.0-alpha.10

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 (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +45 -0
  3. package/dist/circuit-breaker.d.ts +35 -0
  4. package/dist/circuit-breaker.d.ts.map +1 -0
  5. package/dist/circuit-breaker.js +56 -0
  6. package/dist/circuit-breaker.js.map +1 -0
  7. package/dist/circuit-breaker.test.d.ts +2 -0
  8. package/dist/circuit-breaker.test.d.ts.map +1 -0
  9. package/dist/circuit-breaker.test.js +51 -0
  10. package/dist/circuit-breaker.test.js.map +1 -0
  11. package/dist/index.d.ts +9 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +11 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/payload-cipher.d.ts +8 -0
  16. package/dist/payload-cipher.d.ts.map +1 -0
  17. package/dist/payload-cipher.js +47 -0
  18. package/dist/payload-cipher.js.map +1 -0
  19. package/dist/payload-cipher.test.d.ts +2 -0
  20. package/dist/payload-cipher.test.d.ts.map +1 -0
  21. package/dist/payload-cipher.test.js +33 -0
  22. package/dist/payload-cipher.test.js.map +1 -0
  23. package/dist/retry-guard.d.ts +21 -0
  24. package/dist/retry-guard.d.ts.map +1 -0
  25. package/dist/retry-guard.js +56 -0
  26. package/dist/retry-guard.js.map +1 -0
  27. package/dist/retry-guard.test.d.ts +2 -0
  28. package/dist/retry-guard.test.d.ts.map +1 -0
  29. package/dist/retry-guard.test.js +45 -0
  30. package/dist/retry-guard.test.js.map +1 -0
  31. package/dist/task-controller.d.ts +60 -0
  32. package/dist/task-controller.d.ts.map +1 -0
  33. package/dist/task-controller.js +456 -0
  34. package/dist/task-controller.js.map +1 -0
  35. package/dist/task-controller.test.d.ts +2 -0
  36. package/dist/task-controller.test.d.ts.map +1 -0
  37. package/dist/task-controller.test.js +486 -0
  38. package/dist/task-controller.test.js.map +1 -0
  39. package/dist/verification-gate.d.ts +18 -0
  40. package/dist/verification-gate.d.ts.map +1 -0
  41. package/dist/verification-gate.js +29 -0
  42. package/dist/verification-gate.js.map +1 -0
  43. package/dist/verification-gate.test.d.ts +2 -0
  44. package/dist/verification-gate.test.d.ts.map +1 -0
  45. package/dist/verification-gate.test.js +42 -0
  46. package/dist/verification-gate.test.js.map +1 -0
  47. package/dist/workflow-controller.d.ts +40 -0
  48. package/dist/workflow-controller.d.ts.map +1 -0
  49. package/dist/workflow-controller.js +877 -0
  50. package/dist/workflow-controller.js.map +1 -0
  51. package/dist/workflow-controller.test.d.ts +2 -0
  52. package/dist/workflow-controller.test.d.ts.map +1 -0
  53. package/dist/workflow-controller.test.js +785 -0
  54. package/dist/workflow-controller.test.js.map +1 -0
  55. package/dist/workflow-events.d.ts +4 -0
  56. package/dist/workflow-events.d.ts.map +1 -0
  57. package/dist/workflow-events.js +121 -0
  58. package/dist/workflow-events.js.map +1 -0
  59. package/dist/workflow-events.test.d.ts +2 -0
  60. package/dist/workflow-events.test.d.ts.map +1 -0
  61. package/dist/workflow-events.test.js +113 -0
  62. package/dist/workflow-events.test.js.map +1 -0
  63. package/dist/workflow-graph.d.ts +8 -0
  64. package/dist/workflow-graph.d.ts.map +1 -0
  65. package/dist/workflow-graph.js +107 -0
  66. package/dist/workflow-graph.js.map +1 -0
  67. package/dist/workflow-graph.test.d.ts +2 -0
  68. package/dist/workflow-graph.test.d.ts.map +1 -0
  69. package/dist/workflow-graph.test.js +169 -0
  70. package/dist/workflow-graph.test.js.map +1 -0
  71. package/package.json +36 -0
@@ -0,0 +1,877 @@
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
+ leaseDurationMs;
13
+ // In-process advances serialize on this map; competing *processes* serialize
14
+ // on a SQLite lease (see `advance`). Both are needed: the map keeps one
15
+ // process from racing itself without touching the disk on every call.
16
+ advances = new Map();
17
+ owner = `${process.pid}:${randomUUID()}`;
18
+ constructor(store, tasks, payloadCipher, leaseDurationMs = 60_000) {
19
+ this.store = store;
20
+ this.tasks = tasks;
21
+ this.payloadCipher = payloadCipher;
22
+ this.leaseDurationMs = leaseDurationMs;
23
+ }
24
+ plan(definition) {
25
+ const parsed = WorkflowDefinitionSchema.parse(definition);
26
+ const graph = validateWorkflow(parsed);
27
+ for (const node of graph.nodes.values()) {
28
+ for (const request of requestsFor(node)) {
29
+ if (this.tasks.preflight(request).outcome === "deny") {
30
+ throw new Error(`workflow_policy_blocked:${node.id}`);
31
+ }
32
+ }
33
+ }
34
+ const createdAt = new Date().toISOString();
35
+ const id = randomUUID();
36
+ const traceId = randomUUID();
37
+ const draft = WorkflowRunSchema.parse({
38
+ schemaVersion: "1.0.0",
39
+ id,
40
+ definitionId: parsed.id,
41
+ definitionVersion: parsed.version,
42
+ status: "draft",
43
+ stateVersion: 1,
44
+ nodes: Object.fromEntries(parsed.nodes.map((node) => [node.id, { status: "pending" }])),
45
+ traceId,
46
+ createdAt,
47
+ updatedAt: createdAt,
48
+ });
49
+ const created = makeEvent(draft, 1, "workflow.created", { run: draft });
50
+ const plannedEvent = makeEvent(draft, 2, "workflow.status_changed", {
51
+ from: "draft",
52
+ to: "planned",
53
+ });
54
+ const planned = applyWorkflowEvent(draft, plannedEvent);
55
+ const redacted = WorkflowDefinitionSchema.parse(redactStructuredValue(parsed).value);
56
+ this.store.createWorkflow(redacted, this.payloadCipher.seal(parsed, `workflow:${parsed.id}:${parsed.version}:definition`), planned, [created, plannedEvent]);
57
+ return planned;
58
+ }
59
+ status(workflowId) {
60
+ const run = this.store.getWorkflowRun(workflowId);
61
+ if (run === undefined)
62
+ throw new Error("workflow_not_found");
63
+ return run;
64
+ }
65
+ events(workflowId, afterSequence = 0) {
66
+ this.status(workflowId);
67
+ return this.store.listWorkflowEvents(workflowId, afterSequence);
68
+ }
69
+ async advance(workflowId, approvals = [], inputs = []) {
70
+ const previous = this.advances.get(workflowId);
71
+ const ready = previous === undefined
72
+ ? Promise.resolve()
73
+ : previous.then(() => undefined, () => undefined);
74
+ const result = ready.then(() => this.leasedAdvance(workflowId, approvals, inputs));
75
+ this.advances.set(workflowId, result);
76
+ try {
77
+ return await result;
78
+ }
79
+ finally {
80
+ if (this.advances.get(workflowId) === result) {
81
+ this.advances.delete(workflowId);
82
+ }
83
+ }
84
+ }
85
+ // The lease is taken before any adapter runs, so a second process cannot
86
+ // start duplicate side effects while this one is mid-flight. `transitionWorkflow`
87
+ // still guards the commit; this guards the work leading up to it.
88
+ async leasedAdvance(workflowId, approvals, inputs) {
89
+ this.status(workflowId);
90
+ const now = Date.now();
91
+ if (!this.store.acquireWorkflowLease(workflowId, this.owner, new Date(now).toISOString(), new Date(now + this.leaseDurationMs).toISOString())) {
92
+ throw new Error("workflow_lease_held");
93
+ }
94
+ // A long advance must not let its own lease lapse and invite a second
95
+ // process in, so renew at half the duration while the adapters run.
96
+ const renewal = setInterval(() => {
97
+ this.store.renewWorkflowLease(workflowId, this.owner, new Date(Date.now() + this.leaseDurationMs).toISOString());
98
+ }, Math.max(1_000, Math.floor(this.leaseDurationMs / 2)));
99
+ renewal.unref();
100
+ try {
101
+ return await this.advanceOnce(workflowId, approvals, inputs);
102
+ }
103
+ finally {
104
+ clearInterval(renewal);
105
+ this.store.releaseWorkflowLease(workflowId, this.owner);
106
+ }
107
+ }
108
+ async advanceOnce(workflowId, approvals, inputs) {
109
+ const current = this.status(workflowId);
110
+ if (["verified_complete", "cancelled"].includes(current.status)) {
111
+ return { run: current, tasks: [], events: [] };
112
+ }
113
+ // A halted workflow is a deliberate operator decision. Failing loudly beats
114
+ // returning an unchanged run, which reads to a caller like "nothing to do".
115
+ if (["paused", "suspended"].includes(current.status)) {
116
+ throw new Error(`workflow_halted:${current.status}`);
117
+ }
118
+ const definition = this.loadDefinition(current);
119
+ const graph = validateWorkflow(definition);
120
+ const rollback = current.status === "failed"
121
+ ? definition.nodes
122
+ .filter((node) => node.type === "compensation" &&
123
+ ["pending", "awaiting_approval"].includes(current.nodes[node.id]?.status ?? "") &&
124
+ current.nodes[node.forNodeId]?.status ===
125
+ "verified_complete")
126
+ .map((node) => node.id)
127
+ .reverse()
128
+ : [];
129
+ if (current.status === "failed" && rollback.length === 0) {
130
+ return { run: current, tasks: [], events: [] };
131
+ }
132
+ // A node parked for a human — approval phrase or supplied input — is no
133
+ // longer `pending`, so `readyNodeIds` will not return it. Re-offer it here
134
+ // or the workflow would stall forever with the answer in hand.
135
+ const awaiting = [...graph.nodes.values()]
136
+ .filter((node) => node.type !== "compensation" &&
137
+ ["awaiting_approval", "awaiting_input"].includes(current.nodes[node.id]?.status ?? ""))
138
+ .map((node) => node.id)
139
+ .sort();
140
+ const nodeIds = rollback.length > 0
141
+ ? rollback
142
+ : awaiting.length > 0
143
+ ? awaiting
144
+ : readyNodeIds(graph, current.nodes);
145
+ if (nodeIds.length === 0) {
146
+ return { run: current, tasks: [], events: [] };
147
+ }
148
+ const results = await Promise.all(nodeIds.map(async (nodeId) => {
149
+ const node = graph.nodes.get(nodeId);
150
+ return {
151
+ nodeId,
152
+ result: await this.advanceNode(node, definition, current.nodes[nodeId], current, approvals, inputs),
153
+ };
154
+ }));
155
+ if (results.some(({ result }) => result.state.status === "failed")) {
156
+ for (const node of [...definition.nodes].reverse()) {
157
+ if (node.type !== "compensation" ||
158
+ current.nodes[node.id]?.status !== "pending") {
159
+ continue;
160
+ }
161
+ const targetState = results.find(({ nodeId }) => nodeId === node.forNodeId)?.result
162
+ .state ?? current.nodes[node.forNodeId];
163
+ if (targetState?.status !== "verified_complete") {
164
+ results.push({
165
+ nodeId: node.id,
166
+ result: {
167
+ state: { status: "skipped", taskIds: [] },
168
+ tasks: [],
169
+ },
170
+ });
171
+ continue;
172
+ }
173
+ const compensation = await this.runTask(node.request, current.nodes[node.id]?.taskIds[0], approvals, true, {
174
+ workflowId: current.id,
175
+ nodeId: node.id,
176
+ iteration: 0,
177
+ branch: "compensation",
178
+ });
179
+ results.push({
180
+ nodeId: node.id,
181
+ result: {
182
+ ...compensation,
183
+ state: compensation.state.status === "verified_complete"
184
+ ? { ...compensation.state, status: "compensated" }
185
+ : compensation.state,
186
+ },
187
+ });
188
+ }
189
+ }
190
+ let run = this.transition(current, (draft, emit) => {
191
+ for (const { nodeId, result } of results) {
192
+ const from = draft.nodes[nodeId].status;
193
+ draft.nodes[nodeId] = result.state;
194
+ emit("workflow.node_changed", {
195
+ nodeId,
196
+ from,
197
+ state: result.state,
198
+ });
199
+ }
200
+ let status = this.deriveStatus(draft, definition);
201
+ if (status === "verified_complete") {
202
+ for (const node of definition.nodes) {
203
+ const state = draft.nodes[node.id];
204
+ if (node.type !== "compensation" || state.status !== "pending") {
205
+ continue;
206
+ }
207
+ state.status = "skipped";
208
+ emit("workflow.node_changed", {
209
+ nodeId: node.id,
210
+ from: "pending",
211
+ state,
212
+ });
213
+ }
214
+ status = this.deriveStatus(draft, definition);
215
+ }
216
+ if (status !== draft.status) {
217
+ const from = draft.status;
218
+ draft.status = status;
219
+ emit("workflow.status_changed", { from, to: status });
220
+ }
221
+ });
222
+ if (results.some(({ result }) => result.checkpoint === true)) {
223
+ run = this.transition(run, (draft, emit) => {
224
+ emit("workflow.checkpoint_saved", {
225
+ sequence: draft.stateVersion + 1,
226
+ });
227
+ });
228
+ this.store.saveWorkflowSnapshot(WorkflowSnapshotSchema.parse({
229
+ schemaVersion: "1.0.0",
230
+ workflowId: run.id,
231
+ sequence: run.stateVersion,
232
+ run,
233
+ createdAt: run.updatedAt,
234
+ }));
235
+ }
236
+ return {
237
+ run,
238
+ tasks: results.flatMap(({ result }) => result.tasks),
239
+ events: this.events(workflowId, current.stateVersion),
240
+ };
241
+ }
242
+ async recoverInterrupted() {
243
+ await this.tasks.recoverInterrupted();
244
+ const recovered = [];
245
+ const allTasks = this.store.listTasks(100_000);
246
+ for (const stored of this.store.listWorkflowRuns([
247
+ "planned",
248
+ "running",
249
+ "awaiting_approval",
250
+ "recovery_required",
251
+ ])) {
252
+ const replayed = this.replay(stored);
253
+ if (replayed.stateVersion !== stored.stateVersion) {
254
+ throw new Error("workflow_event_history_invalid");
255
+ }
256
+ const definition = this.loadDefinition(stored);
257
+ let run = this.transition(stored, (draft, emit) => {
258
+ for (const node of definition.nodes) {
259
+ const state = recoveredNodeState(node, expectedTaskKeys(stored.id, node, definition)
260
+ .map((key) => preferredTask(allTasks, key))
261
+ .filter((task) => task !== undefined), replayed.nodes[node.id]);
262
+ if (canonicalJson(draft.nodes[node.id]) === canonicalJson(state)) {
263
+ continue;
264
+ }
265
+ const from = draft.nodes[node.id].status;
266
+ draft.nodes[node.id] = state;
267
+ emit("workflow.node_changed", {
268
+ nodeId: node.id,
269
+ from,
270
+ state,
271
+ });
272
+ }
273
+ let status = this.deriveStatus(draft, definition);
274
+ if (status === "verified_complete") {
275
+ for (const node of definition.nodes) {
276
+ const state = draft.nodes[node.id];
277
+ if (node.type !== "compensation" || state.status !== "pending") {
278
+ continue;
279
+ }
280
+ state.status = "skipped";
281
+ emit("workflow.node_changed", {
282
+ nodeId: node.id,
283
+ from: "pending",
284
+ state,
285
+ });
286
+ }
287
+ status = this.deriveStatus(draft, definition);
288
+ }
289
+ const from = draft.status;
290
+ draft.status = status;
291
+ if (status === "recovery_required") {
292
+ draft.error = "workflow_contains_unreconciled_mutation";
293
+ }
294
+ else {
295
+ delete draft.error;
296
+ }
297
+ emit(status === "recovery_required"
298
+ ? "workflow.recovery_required"
299
+ : "workflow.recovered", {
300
+ from,
301
+ to: status,
302
+ ...(draft.error === undefined ? {} : { error: draft.error }),
303
+ });
304
+ });
305
+ recovered.push(run);
306
+ }
307
+ return recovered;
308
+ }
309
+ // Operator controls. `pause` is a soft stop an operator lifts with `resume`;
310
+ // `suspend` is the same stop for an operator who wants the run parked
311
+ // indefinitely. Neither touches node state, so resuming picks the graph up
312
+ // exactly where it stopped — that is why `advance` refuses to run while a
313
+ // workflow sits in either status instead of quietly continuing.
314
+ pause(workflowId) {
315
+ return this.halt(workflowId, "paused", "workflow.paused");
316
+ }
317
+ suspend(workflowId) {
318
+ return this.halt(workflowId, "suspended", "workflow.suspended");
319
+ }
320
+ resume(workflowId) {
321
+ const current = this.status(workflowId);
322
+ if (!["paused", "suspended"].includes(current.status)) {
323
+ throw new Error("workflow_not_paused");
324
+ }
325
+ return this.transition(current, (draft, emit) => {
326
+ const from = draft.status;
327
+ // Restore whatever the graph was actually blocked on before the halt,
328
+ // so resuming a run parked on a human does not claim it is running.
329
+ const nodes = Object.values(draft.nodes);
330
+ draft.status = nodes.some((node) => node.status === "awaiting_approval")
331
+ ? "awaiting_approval"
332
+ : nodes.some((node) => node.status === "awaiting_input")
333
+ ? "awaiting_input"
334
+ : "running";
335
+ delete draft.error;
336
+ emit("workflow.resumed", { from, to: draft.status });
337
+ });
338
+ }
339
+ halt(workflowId, to, eventType) {
340
+ const current = this.status(workflowId);
341
+ if (current.status === to)
342
+ return current;
343
+ if (["verified_complete", "cancelled", "failed"].includes(current.status)) {
344
+ throw new Error("workflow_not_haltable");
345
+ }
346
+ return this.transition(current, (draft, emit) => {
347
+ const from = draft.status;
348
+ draft.status = to;
349
+ emit(eventType, { from, to });
350
+ });
351
+ }
352
+ cancel(workflowId) {
353
+ const current = this.status(workflowId);
354
+ if (current.status === "verified_complete" ||
355
+ current.status === "cancelled") {
356
+ return current;
357
+ }
358
+ return this.transition(current, (draft, emit) => {
359
+ for (const [nodeId, node] of Object.entries(draft.nodes)) {
360
+ if (!["pending", "ready", "awaiting_approval"].includes(node.status)) {
361
+ continue;
362
+ }
363
+ for (const taskId of node.taskIds)
364
+ this.tasks.cancel(taskId);
365
+ const from = node.status;
366
+ node.status = "cancelled";
367
+ emit("workflow.node_changed", {
368
+ nodeId,
369
+ from,
370
+ state: node,
371
+ });
372
+ }
373
+ const from = draft.status;
374
+ draft.status = "cancelled";
375
+ delete draft.error;
376
+ emit("workflow.cancelled", { from, to: "cancelled" });
377
+ });
378
+ }
379
+ transition(current, mutate) {
380
+ const draft = structuredClone(current);
381
+ const events = [];
382
+ const emit = (type, data) => {
383
+ events.push(makeEvent(current, current.stateVersion + events.length + 1, type, data));
384
+ };
385
+ mutate(draft, emit);
386
+ if (events.length === 0)
387
+ return current;
388
+ draft.stateVersion = current.stateVersion + events.length;
389
+ draft.updatedAt = events.at(-1).occurredAt;
390
+ const expected = WorkflowRunSchema.parse(draft);
391
+ let rebuilt = current;
392
+ for (const event of events)
393
+ rebuilt = applyWorkflowEvent(rebuilt, event);
394
+ if (canonicalJson(rebuilt) !== canonicalJson(expected)) {
395
+ throw new Error("workflow_projection_event_mismatch");
396
+ }
397
+ this.store.transitionWorkflow(current.id, current.stateVersion, rebuilt, events);
398
+ return rebuilt;
399
+ }
400
+ loadDefinition(run) {
401
+ const payload = this.store.getWorkflowPayload(run.definitionId, run.definitionVersion);
402
+ if (payload === undefined)
403
+ throw new Error("workflow_payload_not_found");
404
+ return WorkflowDefinitionSchema.parse(this.payloadCipher.open(payload, `workflow:${run.definitionId}:${run.definitionVersion}:definition`));
405
+ }
406
+ replay(run) {
407
+ try {
408
+ const snapshot = this.store.getLatestWorkflowSnapshot(run.id);
409
+ return rebuildWorkflow(snapshot, this.store.listWorkflowEvents(run.id, snapshot?.sequence ?? 0));
410
+ }
411
+ catch {
412
+ try {
413
+ return rebuildWorkflow(undefined, this.store.listWorkflowEvents(run.id));
414
+ }
415
+ catch {
416
+ throw new Error("workflow_event_history_invalid");
417
+ }
418
+ }
419
+ }
420
+ async advanceNode(node, definition, state, run, approvals, inputs) {
421
+ switch (node.type) {
422
+ case "operation": {
423
+ const approvalNode = definition.nodes.find((candidate) => candidate.type === "approval" &&
424
+ candidate.forNodeId === node.id);
425
+ const approvedTaskId = approvalNode === undefined
426
+ ? undefined
427
+ : run.nodes[approvalNode.id]?.taskIds[0];
428
+ return await this.runTask(node.request, approvedTaskId ?? state.taskIds[0], approvals, true, {
429
+ workflowId: run.id,
430
+ nodeId: node.id,
431
+ iteration: 0,
432
+ branch: "operation",
433
+ });
434
+ }
435
+ case "approval": {
436
+ const target = definition.nodes.find((candidate) => candidate.id === node.forNodeId);
437
+ if (target === undefined || target.type !== "operation") {
438
+ throw new Error(`workflow_approval_target_missing:${node.id}`);
439
+ }
440
+ return await this.runTask(target.request, state.taskIds[0], approvals, false, {
441
+ workflowId: run.id,
442
+ nodeId: target.id,
443
+ iteration: 0,
444
+ branch: "operation",
445
+ });
446
+ }
447
+ case "condition": {
448
+ const sourceTaskId = run.nodes[node.sourceNodeId]?.taskIds[0];
449
+ if (sourceTaskId === undefined) {
450
+ throw new Error(`workflow_condition_source_unavailable:${node.id}`);
451
+ }
452
+ const condition = await this.tasks.verifyPersisted(sourceTaskId, [
453
+ node.predicate,
454
+ ]);
455
+ return await this.runSequential(condition.verified ? node.whenTrue : node.whenFalse, state, approvals, {
456
+ workflowId: run.id,
457
+ nodeId: node.id,
458
+ iteration: 0,
459
+ branch: condition.verified ? "true" : "false",
460
+ });
461
+ }
462
+ case "parallel":
463
+ return await this.runParallel(node.branches, state, approvals, run.id, node.id);
464
+ case "bounded_loop":
465
+ return await this.runLoop(node, state, approvals, run.id);
466
+ case "compensation": {
467
+ const result = await this.runTask(node.request, state.taskIds[0], approvals, true, {
468
+ workflowId: run.id,
469
+ nodeId: node.id,
470
+ iteration: 0,
471
+ branch: "compensation",
472
+ });
473
+ return {
474
+ ...result,
475
+ state: result.state.status === "verified_complete"
476
+ ? { ...result.state, status: "compensated" }
477
+ : result.state,
478
+ };
479
+ }
480
+ case "checkpoint":
481
+ return {
482
+ state: { status: "verified_complete", taskIds: [] },
483
+ tasks: [],
484
+ checkpoint: true,
485
+ };
486
+ case "human_input": {
487
+ const supplied = inputs.find((item) => item.nodeId === node.id);
488
+ if (supplied === undefined) {
489
+ return {
490
+ state: {
491
+ status: "awaiting_input",
492
+ taskIds: [],
493
+ prompt: node.prompt,
494
+ },
495
+ tasks: [],
496
+ };
497
+ }
498
+ if (supplied.value.length > node.maxLength) {
499
+ throw new Error(`workflow_input_too_long:${node.id}`);
500
+ }
501
+ if (node.choices.length > 0 &&
502
+ !node.choices.includes(supplied.value)) {
503
+ throw new Error(`workflow_input_not_a_choice:${node.id}`);
504
+ }
505
+ return {
506
+ state: {
507
+ status: "verified_complete",
508
+ taskIds: [],
509
+ prompt: node.prompt,
510
+ input: recordedInput(supplied.value),
511
+ },
512
+ tasks: [],
513
+ };
514
+ }
515
+ case "delegation": {
516
+ const supplied = inputs.find((item) => item.nodeId === node.id);
517
+ // Node state caps `prompt` at 2,000 chars; assignee + goal can exceed
518
+ // that, and a truncated label is better than a schema rejection.
519
+ const prompt = `${node.assignee}: ${node.goal}`.slice(0, 2_000);
520
+ if (supplied === undefined) {
521
+ return {
522
+ state: { status: "awaiting_input", taskIds: [], prompt },
523
+ tasks: [],
524
+ };
525
+ }
526
+ // The delegate reporting "done" is not evidence. If the node declared
527
+ // predicates, they decide — a node whose evidence fails is `failed`,
528
+ // never complete, exactly as for an operation node.
529
+ if (node.requiredEvidence.length > 0) {
530
+ const verification = await this.tasks.verifyStandalone(node.requiredEvidence);
531
+ if (!verification.verified) {
532
+ return {
533
+ state: {
534
+ status: "failed",
535
+ taskIds: [],
536
+ prompt,
537
+ error: `workflow_delegation_unverified:${node.id}`,
538
+ },
539
+ tasks: [],
540
+ };
541
+ }
542
+ }
543
+ return {
544
+ state: {
545
+ status: "verified_complete",
546
+ taskIds: [],
547
+ prompt,
548
+ input: recordedInput(supplied.value),
549
+ },
550
+ tasks: [],
551
+ };
552
+ }
553
+ default:
554
+ throw new Error("workflow_node_not_executable");
555
+ }
556
+ }
557
+ async runTask(request, existingTaskId, approvals, executePlanned = true, identity) {
558
+ let task = existingTaskId === undefined
559
+ ? this.tasks.plan(request, {
560
+ idempotencyKey: taskIdempotencyKey(identity, request),
561
+ attempt: 1,
562
+ })
563
+ : this.tasks.status(existingTaskId);
564
+ if (task.status === "awaiting_approval") {
565
+ const approval = approvals.find((item) => item.approvalId === task.approval?.approvalId);
566
+ if (approval === undefined) {
567
+ return {
568
+ state: {
569
+ status: "awaiting_approval",
570
+ taskIds: [task.id],
571
+ ...(task.approval === undefined
572
+ ? {}
573
+ : { approval: task.approval }),
574
+ },
575
+ tasks: [task],
576
+ };
577
+ }
578
+ task = (await this.tasks.execute(task.id, approval)).task;
579
+ }
580
+ else if (task.status === "planned" && executePlanned) {
581
+ task = (await this.tasks.execute(task.id)).task;
582
+ }
583
+ return {
584
+ state: nodeStateForTask(task, !executePlanned),
585
+ tasks: [task],
586
+ };
587
+ }
588
+ async runSequential(requests, state, approvals, identity) {
589
+ const taskIds = [...state.taskIds];
590
+ const tasks = [];
591
+ for (const [index, request] of requests.entries()) {
592
+ const result = await this.runTask(request, taskIds[index], approvals, true, { ...identity, branch: `${identity.branch}:${index}` });
593
+ tasks.push(...result.tasks);
594
+ taskIds[index] = result.state.taskIds[0];
595
+ if (result.state.status !== "verified_complete") {
596
+ return {
597
+ state: { ...result.state, taskIds },
598
+ tasks,
599
+ };
600
+ }
601
+ }
602
+ return {
603
+ state: { status: "verified_complete", taskIds },
604
+ tasks,
605
+ };
606
+ }
607
+ async runParallel(branches, state, approvals, workflowId, nodeId) {
608
+ const requests = branches.flatMap((branch, branchIndex) => branch.map((request, requestIndex) => ({
609
+ request,
610
+ branch: `${branchIndex}:${requestIndex}`,
611
+ })));
612
+ const results = await Promise.all(requests.map((item, index) => this.runTask(item.request, state.taskIds[index], approvals, true, {
613
+ workflowId,
614
+ nodeId,
615
+ iteration: 0,
616
+ branch: item.branch,
617
+ })));
618
+ return {
619
+ state: aggregateNodeState(results.map((result) => result.state)),
620
+ tasks: results.flatMap((result) => result.tasks),
621
+ };
622
+ }
623
+ async runLoop(node, state, approvals, workflowId) {
624
+ const taskIds = [...state.taskIds];
625
+ const tasks = [];
626
+ let iterations = state.iterations ?? 0;
627
+ while (iterations < node.maxIterations) {
628
+ const offset = iterations * node.body.length;
629
+ for (const [index, request] of node.body.entries()) {
630
+ const result = await this.runTask(request, taskIds[offset + index], approvals, true, {
631
+ workflowId,
632
+ nodeId: node.id,
633
+ iteration: iterations,
634
+ branch: `loop:${index}`,
635
+ });
636
+ tasks.push(...result.tasks);
637
+ taskIds[offset + index] = result.state.taskIds[0];
638
+ if (result.state.status !== "verified_complete") {
639
+ return {
640
+ state: {
641
+ ...result.state,
642
+ taskIds,
643
+ iterations,
644
+ },
645
+ tasks,
646
+ };
647
+ }
648
+ }
649
+ iterations += 1;
650
+ const lastTaskId = taskIds[offset + node.body.length - 1];
651
+ if (node.until !== undefined &&
652
+ (await this.tasks.verifyPersisted(lastTaskId, [node.until])).verified) {
653
+ break;
654
+ }
655
+ }
656
+ return {
657
+ state: {
658
+ status: "verified_complete",
659
+ taskIds,
660
+ iterations,
661
+ },
662
+ tasks,
663
+ };
664
+ }
665
+ deriveStatus(run, definition) {
666
+ const required = definition.nodes.filter((node) => node.type !== "compensation");
667
+ if (required.some((node) => run.nodes[node.id]?.status === "failed")) {
668
+ return "failed";
669
+ }
670
+ if (required.some((node) => run.nodes[node.id]?.status === "recovery_required")) {
671
+ return "recovery_required";
672
+ }
673
+ if (required.some((node) => run.nodes[node.id]?.status === "awaiting_approval")) {
674
+ return "awaiting_approval";
675
+ }
676
+ // A run blocked on a person is not "running" — reporting it as running
677
+ // makes an operator wait for a machine that is waiting for them.
678
+ if (required.some((node) => run.nodes[node.id]?.status === "awaiting_input")) {
679
+ return "awaiting_input";
680
+ }
681
+ if (required.every((node) => {
682
+ const state = run.nodes[node.id];
683
+ return (state !== undefined &&
684
+ ["verified_complete", "skipped", "compensated"].includes(state.status) &&
685
+ state.taskIds.every((taskId) => this.tasks.receipts({ taskId }).certificate?.result ===
686
+ "VERIFIED_SUCCESS"));
687
+ })) {
688
+ return "verified_complete";
689
+ }
690
+ return "running";
691
+ }
692
+ }
693
+ function makeEvent(run, sequence, type, data) {
694
+ return WorkflowEventSchema.parse({
695
+ schemaVersion: "1.0.0",
696
+ id: randomUUID(),
697
+ aggregateId: run.id,
698
+ sequence,
699
+ traceId: run.traceId,
700
+ type,
701
+ data,
702
+ occurredAt: new Date().toISOString(),
703
+ });
704
+ }
705
+ // Human- and delegate-supplied text is untrusted like any other input, so it is
706
+ // redacted before it reaches persisted state. Redaction can lengthen a value
707
+ // (a short secret becomes a longer marker), hence the cap the state schema wants.
708
+ function recordedInput(value) {
709
+ return String(redactStructuredValue(value).value).slice(0, 10_000);
710
+ }
711
+ function requestsFor(node) {
712
+ switch (node.type) {
713
+ case "operation":
714
+ case "compensation":
715
+ return [node.request];
716
+ case "condition":
717
+ return [...node.whenTrue, ...node.whenFalse];
718
+ case "parallel":
719
+ return node.branches.flat();
720
+ case "bounded_loop":
721
+ return node.body;
722
+ case "approval":
723
+ case "checkpoint":
724
+ case "human_input":
725
+ return [];
726
+ // A delegate reports work done elsewhere. MELRA runs no adapter for it, so
727
+ // it contributes no task requests — only evidence to verify.
728
+ case "delegation":
729
+ return [];
730
+ }
731
+ }
732
+ function nodeStateForTask(task, plannedIsComplete) {
733
+ switch (task.status) {
734
+ case "verified_success":
735
+ return { status: "verified_complete", taskIds: [task.id] };
736
+ case "planned":
737
+ return {
738
+ status: plannedIsComplete ? "verified_complete" : "ready",
739
+ taskIds: [task.id],
740
+ };
741
+ case "awaiting_approval":
742
+ return {
743
+ status: "awaiting_approval",
744
+ taskIds: [task.id],
745
+ ...(task.approval === undefined ? {} : { approval: task.approval }),
746
+ };
747
+ case "recovery_required":
748
+ return {
749
+ status: "recovery_required",
750
+ taskIds: [task.id],
751
+ ...(task.error === undefined ? {} : { error: task.error }),
752
+ };
753
+ case "cancelled":
754
+ return { status: "cancelled", taskIds: [task.id] };
755
+ default:
756
+ return {
757
+ status: "failed",
758
+ taskIds: [task.id],
759
+ ...(task.error === undefined ? {} : { error: task.error }),
760
+ };
761
+ }
762
+ }
763
+ function aggregateNodeState(states) {
764
+ const taskIds = states.flatMap((state) => state.taskIds);
765
+ for (const status of [
766
+ "failed",
767
+ "recovery_required",
768
+ "awaiting_approval",
769
+ "cancelled",
770
+ ]) {
771
+ const state = states.find((candidate) => candidate.status === status);
772
+ if (state !== undefined)
773
+ return { ...state, taskIds };
774
+ }
775
+ return { status: "verified_complete", taskIds };
776
+ }
777
+ function taskIdempotencyKey(identity, request) {
778
+ return sha256({ ...identity, request });
779
+ }
780
+ function expectedTaskKeys(workflowId, node, definition) {
781
+ const key = (request, iteration, branch, nodeId = node.id) => taskIdempotencyKey({ workflowId, nodeId, iteration, branch }, request);
782
+ switch (node.type) {
783
+ case "operation":
784
+ return [key(node.request, 0, "operation")];
785
+ case "approval": {
786
+ const target = definition.nodes.find((candidate) => candidate.id === node.forNodeId);
787
+ return target?.type === "operation"
788
+ ? [key(target.request, 0, "operation", target.id)]
789
+ : [];
790
+ }
791
+ case "condition":
792
+ return [
793
+ ...node.whenTrue.map((request, index) => key(request, 0, `true:${index}`)),
794
+ ...node.whenFalse.map((request, index) => key(request, 0, `false:${index}`)),
795
+ ];
796
+ case "parallel":
797
+ return node.branches.flatMap((branch, branchIndex) => branch.map((request, requestIndex) => key(request, 0, `${branchIndex}:${requestIndex}`)));
798
+ case "bounded_loop":
799
+ return Array.from({ length: node.maxIterations }, (_, iteration) => node.body.map((request, index) => key(request, iteration, `loop:${index}`))).flat();
800
+ case "compensation":
801
+ return [key(node.request, 0, "compensation")];
802
+ // Neither runs an adapter, so neither reserves an idempotency key: a
803
+ // checkpoint only records a resume point, and human_input/delegation
804
+ // record what a person supplied or a delegate reported.
805
+ case "checkpoint":
806
+ case "human_input":
807
+ case "delegation":
808
+ return [];
809
+ }
810
+ }
811
+ function preferredTask(tasks, idempotencyKey) {
812
+ return tasks
813
+ .filter((task) => task.idempotencyKey === idempotencyKey)
814
+ .sort((left, right) => Number(right.status === "verified_success") -
815
+ Number(left.status === "verified_success") ||
816
+ (right.attempt ?? 1) - (left.attempt ?? 1) ||
817
+ right.updatedAt.localeCompare(left.updatedAt))[0];
818
+ }
819
+ function recoveredNodeState(node, tasks, previous) {
820
+ if (tasks.length === 0)
821
+ return previous;
822
+ const taskIds = tasks.map((task) => task.id);
823
+ const withStatus = (status) => tasks.find((task) => task.status === status);
824
+ const recovery = withStatus("recovery_required");
825
+ if (recovery !== undefined) {
826
+ return {
827
+ status: "recovery_required",
828
+ taskIds,
829
+ ...(recovery.error === undefined ? {} : { error: recovery.error }),
830
+ };
831
+ }
832
+ const awaiting = withStatus("awaiting_approval");
833
+ if (awaiting !== undefined) {
834
+ return {
835
+ status: "awaiting_approval",
836
+ taskIds,
837
+ ...(awaiting.approval === undefined
838
+ ? {}
839
+ : { approval: awaiting.approval }),
840
+ };
841
+ }
842
+ const failed = tasks.find((task) => [
843
+ "failed",
844
+ "partial",
845
+ "budget_exhausted",
846
+ "policy_blocked",
847
+ ].includes(task.status));
848
+ if (failed !== undefined) {
849
+ return {
850
+ status: "failed",
851
+ taskIds,
852
+ ...(failed.error === undefined ? {} : { error: failed.error }),
853
+ };
854
+ }
855
+ if (tasks.some((task) => task.status === "planned")) {
856
+ return {
857
+ status: "pending",
858
+ taskIds,
859
+ ...(tasks.find((task) => task.error !== undefined)?.error === undefined
860
+ ? {}
861
+ : { error: tasks.find((task) => task.error !== undefined).error }),
862
+ };
863
+ }
864
+ if (tasks.every((task) => task.status === "verified_success")) {
865
+ return {
866
+ status: node.type === "compensation" ? "compensated" : "verified_complete",
867
+ taskIds,
868
+ ...(node.type === "bounded_loop"
869
+ ? {
870
+ iterations: Math.ceil(tasks.length / node.body.length),
871
+ }
872
+ : {}),
873
+ };
874
+ }
875
+ return { status: "cancelled", taskIds };
876
+ }
877
+ //# sourceMappingURL=workflow-controller.js.map