@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,641 @@
1
+ // Copyright 2026 XAGI Labs Private Limited
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { TaskRequestSchema, WorkflowDefinitionSchema, WorkflowEventSchema, } from "@melra/protocol";
8
+ import { createDefaultPolicy } from "@melra/policy-core";
9
+ import { sha256 } from "@melra/receipt-schema";
10
+ import { SqliteStore } from "@melra/storage-sqlite";
11
+ import { Verifier } from "@melra/verifier-core";
12
+ import { PayloadCipher } from "./payload-cipher.js";
13
+ import { TaskController } from "./task-controller.js";
14
+ import { applyWorkflowEvent } from "./workflow-events.js";
15
+ import { WorkflowController } from "./workflow-controller.js";
16
+ const roots = [];
17
+ const stores = [];
18
+ const definitionId = "11111111-1111-4111-8111-111111111111";
19
+ afterEach(async () => {
20
+ vi.restoreAllMocks();
21
+ for (const store of stores.splice(0))
22
+ store.close();
23
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
24
+ });
25
+ async function setup(execute = async () => ({ success: true })) {
26
+ const root = await mkdtemp(join(tmpdir(), "melra-workflow-controller-"));
27
+ roots.push(root);
28
+ const store = new SqliteStore(":memory:");
29
+ stores.push(store);
30
+ const cipher = new PayloadCipher(Buffer.alloc(32, 17));
31
+ const tasks = new TaskController(store, createDefaultPolicy(root), { execute }, await Verifier.create(root), cipher);
32
+ return {
33
+ store,
34
+ cipher,
35
+ tasks,
36
+ controller: new WorkflowController(store, tasks, cipher),
37
+ };
38
+ }
39
+ function request(goal) {
40
+ return {
41
+ goal,
42
+ operation: { kind: "system", action: "info" },
43
+ constraints: [],
44
+ forbiddenEffects: [],
45
+ requiredEvidence: [],
46
+ budget: {
47
+ maxDurationMs: 120_000,
48
+ maxRetries: 2,
49
+ maxSteps: 10,
50
+ },
51
+ };
52
+ }
53
+ function mutationRequest(goal) {
54
+ return TaskRequestSchema.parse({
55
+ goal,
56
+ operation: {
57
+ kind: "memory",
58
+ action: "put",
59
+ key: "project",
60
+ value: "MELRA",
61
+ },
62
+ requiredEvidence: [
63
+ { type: "result_equals", path: "stored", value: true },
64
+ ],
65
+ });
66
+ }
67
+ function fileRequest(goal, path) {
68
+ return TaskRequestSchema.parse({
69
+ goal,
70
+ operation: { kind: "file", action: "read", path },
71
+ });
72
+ }
73
+ function definition(goal = "one-time-workflow-secret") {
74
+ return WorkflowDefinitionSchema.parse({
75
+ schemaVersion: "1.0.0",
76
+ id: definitionId,
77
+ version: 1,
78
+ name: "Durable workflow",
79
+ nodes: [
80
+ {
81
+ id: "inspect",
82
+ type: "operation",
83
+ request: request(goal),
84
+ },
85
+ ],
86
+ });
87
+ }
88
+ describe("WorkflowController planning", () => {
89
+ it("persists a redacted projection and sealed exact definition", async () => {
90
+ const { controller, store, cipher } = await setup();
91
+ const exact = definition();
92
+ const run = controller.plan(exact);
93
+ expect(run.status).toBe("planned");
94
+ expect(Object.values(run.nodes).every((node) => node.status === "pending")).toBe(true);
95
+ expect(store.listWorkflowEvents(run.id).map((event) => event.type)).toEqual([
96
+ "workflow.created",
97
+ "workflow.status_changed",
98
+ ]);
99
+ expect(store.getWorkflowDefinition(definitionId, 1)?.nodes[0]).not.toEqual(exact.nodes[0]);
100
+ expect(cipher.open(store.getWorkflowPayload(definitionId, 1), `workflow:${definitionId}:1:definition`)).toEqual(exact);
101
+ expect(JSON.stringify(store.listWorkflowEvents(run.id))).not.toContain("one-time-workflow-secret");
102
+ });
103
+ it("rejects a denied nested request before writing anything", async () => {
104
+ const { controller, store } = await setup();
105
+ const denied = WorkflowDefinitionSchema.parse({
106
+ schemaVersion: "1.0.0",
107
+ id: definitionId,
108
+ version: 1,
109
+ name: "Denied branch",
110
+ nodes: [
111
+ {
112
+ id: "branch",
113
+ type: "condition",
114
+ sourceNodeId: "inspect",
115
+ dependsOn: ["inspect"],
116
+ predicate: { type: "result_equals", path: "success", value: true },
117
+ whenTrue: [
118
+ {
119
+ goal: "Run a forbidden shell",
120
+ operation: {
121
+ kind: "terminal",
122
+ action: "run",
123
+ command: "bash",
124
+ },
125
+ },
126
+ ],
127
+ whenFalse: [],
128
+ },
129
+ {
130
+ id: "inspect",
131
+ type: "operation",
132
+ request: request("Inspect first"),
133
+ },
134
+ ],
135
+ });
136
+ expect(() => controller.plan(denied)).toThrow("workflow_policy_blocked:branch");
137
+ expect(store.getWorkflowDefinition(definitionId, 1)).toBeUndefined();
138
+ expect(store.getWorkflowPayload(definitionId, 1)).toBeUndefined();
139
+ expect(store.listTasks()).toEqual([]);
140
+ expect(store.database.prepare("SELECT count(*) AS count FROM workflow_runs").get()).toEqual({ count: 0 });
141
+ });
142
+ it("reads status and events and rejects unknown workflow IDs", async () => {
143
+ const { controller } = await setup();
144
+ const run = controller.plan(definition());
145
+ expect(controller.status(run.id)).toEqual(run);
146
+ expect(controller.events(run.id, 1)).toHaveLength(1);
147
+ expect(() => controller.status("99999999-9999-4999-8999-999999999999")).toThrow("workflow_not_found");
148
+ expect(() => controller.events("99999999-9999-4999-8999-999999999999")).toThrow("workflow_not_found");
149
+ });
150
+ it("cancels nonterminal nodes once and preserves completed workflows", async () => {
151
+ const { controller, store } = await setup();
152
+ const run = controller.plan(definition());
153
+ const cancelled = controller.cancel(run.id);
154
+ expect(cancelled.status).toBe("cancelled");
155
+ expect(cancelled.nodes.inspect?.status).toBe("cancelled");
156
+ expect(store.listWorkflowEvents(run.id).map((event) => event.type)).toEqual([
157
+ "workflow.created",
158
+ "workflow.status_changed",
159
+ "workflow.node_changed",
160
+ "workflow.cancelled",
161
+ ]);
162
+ expect(controller.cancel(run.id)).toEqual(cancelled);
163
+ const second = controller.plan(WorkflowDefinitionSchema.parse({
164
+ ...definition("another secret"),
165
+ id: "22222222-2222-4222-8222-222222222222",
166
+ }));
167
+ const completedEvent = WorkflowEventSchema.parse({
168
+ schemaVersion: "1.0.0",
169
+ id: "33333333-3333-4333-8333-333333333333",
170
+ aggregateId: second.id,
171
+ sequence: 3,
172
+ traceId: second.traceId,
173
+ type: "workflow.status_changed",
174
+ data: { from: "planned", to: "verified_complete" },
175
+ occurredAt: "2026-07-30T12:00:03.000Z",
176
+ });
177
+ const completed = applyWorkflowEvent(second, completedEvent);
178
+ store.transitionWorkflow(second.id, 2, completed, [completedEvent]);
179
+ expect(controller.cancel(second.id)).toEqual(completed);
180
+ expect(store.listWorkflowEvents(second.id)).toHaveLength(3);
181
+ });
182
+ it("surfaces a stale projection conflict without retrying", async () => {
183
+ const { controller, store } = await setup();
184
+ const run = controller.plan(definition());
185
+ const transition = store.transitionWorkflow.bind(store);
186
+ let injected = false;
187
+ vi.spyOn(store, "transitionWorkflow").mockImplementation((id, expectedVersion, next, events) => {
188
+ if (!injected) {
189
+ injected = true;
190
+ const externalEvent = WorkflowEventSchema.parse({
191
+ schemaVersion: "1.0.0",
192
+ id: "44444444-4444-4444-8444-444444444444",
193
+ aggregateId: run.id,
194
+ sequence: 3,
195
+ traceId: run.traceId,
196
+ type: "workflow.status_changed",
197
+ data: { from: "planned", to: "paused" },
198
+ occurredAt: "2026-07-30T12:00:03.000Z",
199
+ });
200
+ transition(run.id, run.stateVersion, applyWorkflowEvent(run, externalEvent), [externalEvent]);
201
+ }
202
+ transition(id, expectedVersion, next, events);
203
+ });
204
+ expect(() => controller.cancel(run.id)).toThrow("workflow_state_conflict");
205
+ expect(store.getWorkflowRun(run.id)?.status).toBe("paused");
206
+ expect(store.listWorkflowEvents(run.id)).toHaveLength(3);
207
+ });
208
+ });
209
+ describe("WorkflowController execution", () => {
210
+ it("advances one governed layer at a time through approval and checkpoint", async () => {
211
+ const { controller, store } = await setup(async (operation) => operation.kind === "memory"
212
+ ? { success: true, stored: true }
213
+ : { success: true });
214
+ const workflow = WorkflowDefinitionSchema.parse({
215
+ schemaVersion: "1.0.0",
216
+ id: definitionId,
217
+ version: 1,
218
+ name: "Governed checkpoint",
219
+ nodes: [
220
+ {
221
+ id: "inspect",
222
+ type: "operation",
223
+ request: request("Inspect"),
224
+ },
225
+ {
226
+ id: "approve-write",
227
+ type: "approval",
228
+ dependsOn: ["inspect"],
229
+ forNodeId: "write",
230
+ },
231
+ {
232
+ id: "write",
233
+ type: "operation",
234
+ dependsOn: ["approve-write"],
235
+ request: mutationRequest("Write"),
236
+ },
237
+ {
238
+ id: "checkpoint",
239
+ type: "checkpoint",
240
+ dependsOn: ["write"],
241
+ },
242
+ ],
243
+ });
244
+ const planned = controller.plan(workflow);
245
+ const inspected = await controller.advance(planned.id);
246
+ expect(inspected.run.nodes.inspect?.status).toBe("verified_complete");
247
+ expect(inspected.run.nodes.write?.status).toBe("pending");
248
+ const waiting = await controller.advance(planned.id);
249
+ expect(waiting.run.nodes["approve-write"]?.status).toBe("awaiting_approval");
250
+ expect(waiting.run.status).toBe("awaiting_approval");
251
+ const challenge = waiting.run.nodes["approve-write"].approval;
252
+ const approved = await controller.advance(planned.id, [
253
+ {
254
+ approvalId: challenge.approvalId,
255
+ phrase: challenge.phrase,
256
+ },
257
+ ]);
258
+ expect(approved.run.nodes["approve-write"]?.status).toBe("verified_complete");
259
+ const written = await controller.advance(planned.id);
260
+ expect(written.run.nodes.write?.status).toBe("verified_complete");
261
+ const completed = await controller.advance(planned.id);
262
+ expect(completed.run.nodes.checkpoint?.status).toBe("verified_complete");
263
+ expect(completed.run.status).toBe("verified_complete");
264
+ expect(store.getLatestWorkflowSnapshot(planned.id)?.run).toEqual(completed.run);
265
+ expect(store.listTasks()).toHaveLength(2);
266
+ expect(store
267
+ .listTasks()
268
+ .every((task) => task.idempotencyKey?.length === 64 && task.attempt === 1)).toBe(true);
269
+ });
270
+ it("executes only the condition branch selected from persisted evidence", async () => {
271
+ const executed = [];
272
+ const { controller } = await setup(async (operation) => {
273
+ if (operation.kind !== "file")
274
+ throw new Error("unexpected_operation");
275
+ executed.push(operation.path);
276
+ return {
277
+ success: true,
278
+ ...(operation.path === "inspect" ? { route: "yes" } : {}),
279
+ };
280
+ });
281
+ const planned = controller.plan(WorkflowDefinitionSchema.parse({
282
+ schemaVersion: "1.0.0",
283
+ id: definitionId,
284
+ version: 1,
285
+ name: "Conditional",
286
+ nodes: [
287
+ {
288
+ id: "inspect",
289
+ type: "operation",
290
+ request: fileRequest("Inspect", "inspect"),
291
+ },
292
+ {
293
+ id: "choose",
294
+ type: "condition",
295
+ dependsOn: ["inspect"],
296
+ sourceNodeId: "inspect",
297
+ predicate: {
298
+ type: "result_equals",
299
+ path: "route",
300
+ value: "yes",
301
+ },
302
+ whenTrue: [fileRequest("True branch", "true-branch")],
303
+ whenFalse: [fileRequest("False branch", "false-branch")],
304
+ },
305
+ ],
306
+ }));
307
+ await controller.advance(planned.id);
308
+ const result = await controller.advance(planned.id);
309
+ expect(executed).toEqual(["inspect", "true-branch"]);
310
+ expect(result.run.nodes.choose?.status).toBe("verified_complete");
311
+ expect(result.run.status).toBe("verified_complete");
312
+ });
313
+ it("executes independent parallel branches concurrently", async () => {
314
+ let active = 0;
315
+ let maxConcurrent = 0;
316
+ const { controller, store } = await setup(async () => {
317
+ active += 1;
318
+ maxConcurrent = Math.max(maxConcurrent, active);
319
+ await new Promise((resolve) => setTimeout(resolve, 10));
320
+ active -= 1;
321
+ return { success: true };
322
+ });
323
+ const planned = controller.plan(WorkflowDefinitionSchema.parse({
324
+ schemaVersion: "1.0.0",
325
+ id: definitionId,
326
+ version: 1,
327
+ name: "Parallel",
328
+ nodes: [
329
+ {
330
+ id: "parallel",
331
+ type: "parallel",
332
+ branches: [
333
+ [fileRequest("Branch A", "a")],
334
+ [fileRequest("Branch B", "b")],
335
+ ],
336
+ },
337
+ ],
338
+ }));
339
+ const result = await controller.advance(planned.id);
340
+ expect(maxConcurrent).toBe(2);
341
+ expect(result.run.nodes.parallel?.taskIds).toHaveLength(2);
342
+ expect(result.run.status).toBe("verified_complete");
343
+ expect(new Set(store.listTasks().map((task) => task.idempotencyKey)).size).toBe(2);
344
+ });
345
+ it("serializes concurrent advances for the same workflow", async () => {
346
+ let calls = 0;
347
+ const { controller, store } = await setup(async () => {
348
+ calls += 1;
349
+ await new Promise((resolve) => setTimeout(resolve, 10));
350
+ return { success: true };
351
+ });
352
+ const planned = controller.plan(definition("Concurrent advance"));
353
+ const results = await Promise.all([
354
+ controller.advance(planned.id),
355
+ controller.advance(planned.id),
356
+ ]);
357
+ expect(calls).toBe(1);
358
+ expect(results.every((result) => result.run.status === "verified_complete")).toBe(true);
359
+ expect(store.listTasks()).toHaveLength(1);
360
+ });
361
+ it("runs a bounded loop sequentially to its hard limit", async () => {
362
+ let calls = 0;
363
+ const { controller } = await setup(async () => {
364
+ calls += 1;
365
+ return { success: true };
366
+ });
367
+ const planned = controller.plan(WorkflowDefinitionSchema.parse({
368
+ schemaVersion: "1.0.0",
369
+ id: definitionId,
370
+ version: 1,
371
+ name: "Bounded loop",
372
+ nodes: [
373
+ {
374
+ id: "loop",
375
+ type: "bounded_loop",
376
+ body: [fileRequest("Loop body", "loop")],
377
+ maxIterations: 3,
378
+ },
379
+ ],
380
+ }));
381
+ const result = await controller.advance(planned.id);
382
+ expect(calls).toBe(3);
383
+ expect(result.run.nodes.loop?.iterations).toBe(3);
384
+ expect(result.run.status).toBe("verified_complete");
385
+ });
386
+ it("stops a bounded loop when its persisted result satisfies until", async () => {
387
+ let calls = 0;
388
+ const { controller } = await setup(async () => {
389
+ calls += 1;
390
+ return { success: true, done: calls >= 2 };
391
+ });
392
+ const planned = controller.plan(WorkflowDefinitionSchema.parse({
393
+ schemaVersion: "1.0.0",
394
+ id: definitionId,
395
+ version: 1,
396
+ name: "Bounded loop with condition",
397
+ nodes: [
398
+ {
399
+ id: "loop",
400
+ type: "bounded_loop",
401
+ body: [fileRequest("Loop body", "loop")],
402
+ maxIterations: 5,
403
+ until: { type: "result_equals", path: "done", value: true },
404
+ },
405
+ ],
406
+ }));
407
+ const result = await controller.advance(planned.id);
408
+ expect(calls).toBe(2);
409
+ expect(result.run.nodes.loop?.iterations).toBe(2);
410
+ expect(result.run.status).toBe("verified_complete");
411
+ });
412
+ it("compensates verified work once when a later operation fails", async () => {
413
+ const executed = [];
414
+ const { controller, tasks } = await setup(async (operation) => {
415
+ if (operation.kind !== "file")
416
+ throw new Error("unexpected_operation");
417
+ executed.push(operation.path);
418
+ return { success: operation.path !== "fail" };
419
+ });
420
+ const planned = controller.plan(WorkflowDefinitionSchema.parse({
421
+ schemaVersion: "1.0.0",
422
+ id: definitionId,
423
+ version: 1,
424
+ name: "Compensated failure",
425
+ nodes: [
426
+ {
427
+ id: "first",
428
+ type: "operation",
429
+ request: fileRequest("First", "first"),
430
+ },
431
+ {
432
+ id: "second",
433
+ type: "operation",
434
+ dependsOn: ["first"],
435
+ request: fileRequest("Fail", "fail"),
436
+ },
437
+ {
438
+ id: "undo-first",
439
+ type: "compensation",
440
+ forNodeId: "first",
441
+ request: fileRequest("Undo", "undo"),
442
+ },
443
+ ],
444
+ }));
445
+ await controller.advance(planned.id);
446
+ const result = await controller.advance(planned.id);
447
+ expect(executed).toEqual(["first", "fail", "undo"]);
448
+ expect(result.run.nodes["undo-first"]?.status).toBe("compensated");
449
+ expect(result.run.status).toBe("failed");
450
+ const compensationTaskId = result.run.nodes["undo-first"]?.taskIds[0];
451
+ expect(compensationTaskId).toBeDefined();
452
+ expect(tasks.receipts({ taskId: compensationTaskId }).receipts).toHaveLength(1);
453
+ });
454
+ it("skips compensation without execution after verified success", async () => {
455
+ const executed = [];
456
+ const { controller } = await setup(async (operation) => {
457
+ if (operation.kind !== "file")
458
+ throw new Error("unexpected_operation");
459
+ executed.push(operation.path);
460
+ return { success: true };
461
+ });
462
+ const planned = controller.plan(WorkflowDefinitionSchema.parse({
463
+ schemaVersion: "1.0.0",
464
+ id: definitionId,
465
+ version: 1,
466
+ name: "Successful compensation skip",
467
+ nodes: [
468
+ {
469
+ id: "first",
470
+ type: "operation",
471
+ request: fileRequest("First", "first"),
472
+ },
473
+ {
474
+ id: "undo-first",
475
+ type: "compensation",
476
+ forNodeId: "first",
477
+ request: fileRequest("Undo", "undo"),
478
+ },
479
+ ],
480
+ }));
481
+ const result = await controller.advance(planned.id);
482
+ expect(executed).toEqual(["first"]);
483
+ expect(result.run.nodes["undo-first"]?.status).toBe("skipped");
484
+ expect(result.run.status).toBe("verified_complete");
485
+ });
486
+ it("resumes approval-required compensation on a failed workflow", async () => {
487
+ let memoryCalls = 0;
488
+ const { controller } = await setup(async (operation) => {
489
+ if (operation.kind === "memory") {
490
+ memoryCalls += 1;
491
+ return { success: true, stored: true };
492
+ }
493
+ if (operation.kind !== "file")
494
+ throw new Error("unexpected_operation");
495
+ return { success: operation.path !== "fail" };
496
+ });
497
+ const planned = controller.plan(WorkflowDefinitionSchema.parse({
498
+ schemaVersion: "1.0.0",
499
+ id: definitionId,
500
+ version: 1,
501
+ name: "Governed compensation",
502
+ nodes: [
503
+ {
504
+ id: "first",
505
+ type: "operation",
506
+ request: fileRequest("First", "first"),
507
+ },
508
+ {
509
+ id: "second",
510
+ type: "operation",
511
+ dependsOn: ["first"],
512
+ request: fileRequest("Fail", "fail"),
513
+ },
514
+ {
515
+ id: "undo-first",
516
+ type: "compensation",
517
+ forNodeId: "first",
518
+ request: mutationRequest("Undo"),
519
+ },
520
+ ],
521
+ }));
522
+ await controller.advance(planned.id);
523
+ const failed = await controller.advance(planned.id);
524
+ expect(failed.run.status).toBe("failed");
525
+ expect(failed.run.nodes["undo-first"]?.status).toBe("awaiting_approval");
526
+ expect(memoryCalls).toBe(0);
527
+ const challenge = failed.run.nodes["undo-first"].approval;
528
+ const compensated = await controller.advance(planned.id, [
529
+ {
530
+ approvalId: challenge.approvalId,
531
+ phrase: challenge.phrase,
532
+ },
533
+ ]);
534
+ expect(compensated.run.status).toBe("failed");
535
+ expect(compensated.run.nodes["undo-first"]?.status).toBe("compensated");
536
+ expect(memoryCalls).toBe(1);
537
+ });
538
+ });
539
+ describe("WorkflowController recovery", () => {
540
+ it("retries an interrupted read through its original governed task", async () => {
541
+ const execute = vi.fn(async () => ({ success: true }));
542
+ const { controller, store, tasks, cipher } = await setup(execute);
543
+ const exact = definition("Retry read");
544
+ const planned = controller.plan(exact);
545
+ const operationNode = exact.nodes[0];
546
+ if (operationNode.type !== "operation") {
547
+ throw new Error("expected_operation_node");
548
+ }
549
+ const task = tasks.plan(operationNode.request, {
550
+ idempotencyKey: sha256({
551
+ workflowId: planned.id,
552
+ nodeId: operationNode.id,
553
+ iteration: 0,
554
+ branch: "operation",
555
+ request: operationNode.request,
556
+ }),
557
+ attempt: 1,
558
+ });
559
+ const interrupted = store.getTask(task.id);
560
+ interrupted.status = "running";
561
+ store.saveTask(interrupted);
562
+ const restarted = new WorkflowController(store, tasks, cipher);
563
+ const [recovered] = await restarted.recoverInterrupted();
564
+ expect(recovered?.nodes.inspect).toMatchObject({
565
+ status: "pending",
566
+ taskIds: [task.id],
567
+ });
568
+ const completed = await restarted.advance(planned.id);
569
+ expect(completed.run.status).toBe("verified_complete");
570
+ expect(execute).toHaveBeenCalledTimes(1);
571
+ });
572
+ it("repairs a projection after a verified task committed first", async () => {
573
+ const execute = vi.fn(async () => ({ success: true }));
574
+ const { controller, store, tasks, cipher } = await setup(execute);
575
+ const planned = controller.plan(definition("Recover projection"));
576
+ const crash = vi
577
+ .spyOn(store, "transitionWorkflow")
578
+ .mockImplementationOnce(() => {
579
+ throw new Error("simulated_projection_crash");
580
+ });
581
+ await expect(controller.advance(planned.id)).rejects.toThrow("simulated_projection_crash");
582
+ crash.mockRestore();
583
+ expect(store.listTasks()[0]?.status).toBe("verified_success");
584
+ expect(store.getWorkflowRun(planned.id)?.nodes.inspect?.status).toBe("pending");
585
+ const restarted = new WorkflowController(store, tasks, cipher);
586
+ const [recovered] = await restarted.recoverInterrupted();
587
+ expect(recovered?.nodes.inspect?.status).toBe("verified_complete");
588
+ expect(recovered?.status).toBe("verified_complete");
589
+ expect(store.listWorkflowEvents(planned.id).at(-1)?.type).toBe("workflow.recovered");
590
+ expect(execute).toHaveBeenCalledTimes(1);
591
+ });
592
+ it("raises an unresolved interrupted mutation to workflow recovery", async () => {
593
+ const execute = vi.fn(async () => ({ success: true, stored: true }));
594
+ const { controller, store, tasks, cipher } = await setup(execute);
595
+ const planned = controller.plan(WorkflowDefinitionSchema.parse({
596
+ schemaVersion: "1.0.0",
597
+ id: definitionId,
598
+ version: 1,
599
+ name: "Interrupted mutation",
600
+ nodes: [
601
+ {
602
+ id: "write",
603
+ type: "operation",
604
+ request: mutationRequest("Write"),
605
+ },
606
+ ],
607
+ }));
608
+ const waiting = await controller.advance(planned.id);
609
+ const taskId = waiting.run.nodes.write.taskIds[0];
610
+ const task = store.getTask(taskId);
611
+ task.status = "running";
612
+ store.saveTask(task);
613
+ const restarted = new WorkflowController(store, tasks, cipher);
614
+ const [recovered] = await restarted.recoverInterrupted();
615
+ expect(recovered?.nodes.write?.status).toBe("recovery_required");
616
+ expect(recovered?.status).toBe("recovery_required");
617
+ expect(store.listWorkflowEvents(planned.id).at(-1)?.type).toBe("workflow.recovery_required");
618
+ expect(execute).not.toHaveBeenCalled();
619
+ });
620
+ it("ignores a corrupt snapshot only when full event replay succeeds", async () => {
621
+ const { controller, store } = await setup();
622
+ const planned = controller.plan(definition("Snapshot fallback"));
623
+ store.database
624
+ .prepare(`
625
+ INSERT INTO workflow_snapshots(workflow_id, sequence, data, created_at)
626
+ VALUES (?, 999, '{}', ?)
627
+ `)
628
+ .run(planned.id, planned.updatedAt);
629
+ await expect(controller.recoverInterrupted()).resolves.toHaveLength(1);
630
+ expect(store.getWorkflowRun(planned.id)?.status).toBe("running");
631
+ });
632
+ it("fails closed when workflow event history is corrupt", async () => {
633
+ const { controller, store } = await setup();
634
+ const planned = controller.plan(definition("Corrupt history"));
635
+ store.database
636
+ .prepare("UPDATE workflow_events SET data = '{}' WHERE aggregate_id = ?")
637
+ .run(planned.id);
638
+ await expect(controller.recoverInterrupted()).rejects.toThrow("workflow_event_history_invalid");
639
+ });
640
+ });
641
+ //# sourceMappingURL=workflow-controller.test.js.map