@melra/runtime-core 0.3.0-alpha.11 → 0.3.0-alpha.12

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 (39) hide show
  1. package/dist/task-controller.d.ts.map +1 -1
  2. package/dist/task-controller.js +7 -0
  3. package/dist/task-controller.js.map +1 -1
  4. package/dist/workflow-controller.d.ts.map +1 -1
  5. package/dist/workflow-controller.js +12 -1
  6. package/dist/workflow-controller.js.map +1 -1
  7. package/package.json +8 -8
  8. package/dist/circuit-breaker.test.d.ts +0 -2
  9. package/dist/circuit-breaker.test.d.ts.map +0 -1
  10. package/dist/circuit-breaker.test.js +0 -51
  11. package/dist/circuit-breaker.test.js.map +0 -1
  12. package/dist/payload-cipher.test.d.ts +0 -2
  13. package/dist/payload-cipher.test.d.ts.map +0 -1
  14. package/dist/payload-cipher.test.js +0 -33
  15. package/dist/payload-cipher.test.js.map +0 -1
  16. package/dist/retry-guard.test.d.ts +0 -2
  17. package/dist/retry-guard.test.d.ts.map +0 -1
  18. package/dist/retry-guard.test.js +0 -45
  19. package/dist/retry-guard.test.js.map +0 -1
  20. package/dist/task-controller.test.d.ts +0 -2
  21. package/dist/task-controller.test.d.ts.map +0 -1
  22. package/dist/task-controller.test.js +0 -667
  23. package/dist/task-controller.test.js.map +0 -1
  24. package/dist/verification-gate.test.d.ts +0 -2
  25. package/dist/verification-gate.test.d.ts.map +0 -1
  26. package/dist/verification-gate.test.js +0 -42
  27. package/dist/verification-gate.test.js.map +0 -1
  28. package/dist/workflow-controller.test.d.ts +0 -2
  29. package/dist/workflow-controller.test.d.ts.map +0 -1
  30. package/dist/workflow-controller.test.js +0 -911
  31. package/dist/workflow-controller.test.js.map +0 -1
  32. package/dist/workflow-events.test.d.ts +0 -2
  33. package/dist/workflow-events.test.d.ts.map +0 -1
  34. package/dist/workflow-events.test.js +0 -113
  35. package/dist/workflow-events.test.js.map +0 -1
  36. package/dist/workflow-graph.test.d.ts +0 -2
  37. package/dist/workflow-graph.test.d.ts.map +0 -1
  38. package/dist/workflow-graph.test.js +0 -170
  39. package/dist/workflow-graph.test.js.map +0 -1
@@ -1,667 +0,0 @@
1
- // Copyright 2026 XAGI Labs Private Limited
2
- // SPDX-License-Identifier: Apache-2.0
3
- import { mkdtemp, readFile, rm, writeFile } 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, outcomeUnknown, } from "@melra/protocol";
8
- import { createDefaultPolicy } from "@melra/policy-core";
9
- import { SqliteStore } from "@melra/storage-sqlite";
10
- import { Verifier } from "@melra/verifier-core";
11
- import { CircuitBreaker } from "./circuit-breaker.js";
12
- import { PayloadCipher } from "./payload-cipher.js";
13
- import { TaskController } from "./task-controller.js";
14
- const roots = [];
15
- const stores = [];
16
- afterEach(async () => {
17
- stores.splice(0).forEach((store) => store.close());
18
- await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
19
- });
20
- async function setup(executor = {
21
- async execute() {
22
- return { success: true, stored: true, value: "verified" };
23
- },
24
- }, probe) {
25
- const root = await mkdtemp(join(tmpdir(), "melra-controller-"));
26
- roots.push(root);
27
- const store = new SqliteStore(":memory:");
28
- stores.push(store);
29
- const controller = await createController(store, root, Buffer.alloc(32, 7), executor, probe);
30
- return { controller, store };
31
- }
32
- async function createController(store, root, key, executor, probe) {
33
- return new TaskController(store, createDefaultPolicy(root), executor, await Verifier.create(root, probe === undefined ? {} : { probe }), new PayloadCipher(key));
34
- }
35
- describe("TaskController", () => {
36
- it("executes a planned task after restart with the same key", async () => {
37
- const root = await mkdtemp(join(tmpdir(), "melra-controller-restart-"));
38
- roots.push(root);
39
- const databasePath = join(root, "melra.sqlite");
40
- const key = Buffer.alloc(32, 21);
41
- const executor = {
42
- execute: vi.fn(async () => ({ success: true, value: "verified" })),
43
- };
44
- const storeA = new SqliteStore(databasePath);
45
- stores.push(storeA);
46
- const controllerA = await createController(storeA, root, key, executor);
47
- const request = TaskRequestSchema.parse({
48
- goal: "Inspect the runtime after restart",
49
- operation: { kind: "system", action: "info" },
50
- });
51
- const planned = controllerA.plan(request);
52
- storeA.close();
53
- stores.splice(stores.indexOf(storeA), 1);
54
- const storeB = new SqliteStore(databasePath);
55
- stores.push(storeB);
56
- const controllerB = await createController(storeB, root, key, executor);
57
- const result = await controllerB.execute(planned.id);
58
- expect(result.task.status).toBe("verified_success");
59
- expect(executor.execute).toHaveBeenCalledWith(request.operation, expect.any(AbortSignal));
60
- });
61
- it("keeps exact requests out of SQLite and rejects the wrong key", async () => {
62
- const root = await mkdtemp(join(tmpdir(), "melra-controller-sealed-"));
63
- roots.push(root);
64
- const databasePath = join(root, "melra.sqlite");
65
- const storeA = new SqliteStore(databasePath);
66
- stores.push(storeA);
67
- const controllerA = await createController(storeA, root, Buffer.alloc(32, 23), { async execute() { return { success: true }; } });
68
- const planned = controllerA.plan(TaskRequestSchema.parse({
69
- goal: "one-time-secret",
70
- operation: { kind: "system", action: "info" },
71
- }));
72
- storeA.close();
73
- stores.splice(stores.indexOf(storeA), 1);
74
- expect((await readFile(databasePath)).toString()).not.toContain("one-time-secret");
75
- const storeB = new SqliteStore(databasePath);
76
- stores.push(storeB);
77
- const controllerB = await createController(storeB, root, Buffer.alloc(32, 29), { async execute() { return { success: true }; } });
78
- await expect(controllerB.execute(planned.id)).rejects.toThrow("task_payload_authentication_failed");
79
- });
80
- it("verifies an authenticated persisted adapter result", async () => {
81
- const { controller } = await setup();
82
- const planned = controller.plan(TaskRequestSchema.parse({
83
- goal: "Persist a result for later workflow conditions",
84
- operation: { kind: "system", action: "info" },
85
- }));
86
- await controller.execute(planned.id);
87
- await expect(controller.verifyPersisted(planned.id, [
88
- { type: "result_equals", path: "value", value: "verified" },
89
- ])).resolves.toMatchObject({ verified: true });
90
- });
91
- it("preflights installed capabilities without persisting a task", async () => {
92
- const { controller, store } = await setup({
93
- capabilities() {
94
- return new Set(["file"]);
95
- },
96
- async execute() {
97
- return { success: true };
98
- },
99
- });
100
- const request = TaskRequestSchema.parse({
101
- goal: "Require an installed system adapter",
102
- operation: { kind: "system", action: "info" },
103
- });
104
- expect(() => controller.preflight(request)).toThrow("operation_capability_unavailable:system");
105
- expect(store.listTasks()).toEqual([]);
106
- });
107
- it("rejects an approval whose stored action digest no longer matches", async () => {
108
- const { controller, store } = await setup();
109
- const planned = controller.plan(TaskRequestSchema.parse({
110
- goal: "Store a governed memory",
111
- operation: {
112
- kind: "memory",
113
- action: "put",
114
- key: "project",
115
- value: "MELRA",
116
- },
117
- requiredEvidence: [
118
- { type: "result_equals", path: "stored", value: true },
119
- ],
120
- }));
121
- const stored = store.getTask(planned.id);
122
- stored.approval = {
123
- ...stored.approval,
124
- actionDigest: "0".repeat(64),
125
- };
126
- store.saveTask(stored);
127
- await expect(controller.execute(planned.id, {
128
- approvalId: stored.approval.approvalId,
129
- phrase: stored.approval.phrase,
130
- })).rejects.toThrow("approval_action_digest_mismatch");
131
- });
132
- it("uses rechecked policy rather than a stale stored allow decision", async () => {
133
- const execute = vi.fn(async () => ({ success: true, stored: true }));
134
- const { controller, store } = await setup({ execute });
135
- const planned = controller.plan(TaskRequestSchema.parse({
136
- goal: "Store a governed memory",
137
- operation: {
138
- kind: "memory",
139
- action: "put",
140
- key: "project",
141
- value: "MELRA",
142
- },
143
- requiredEvidence: [
144
- { type: "result_equals", path: "stored", value: true },
145
- ],
146
- }));
147
- const stored = store.getTask(planned.id);
148
- stored.policyDecision = {
149
- ...stored.policyDecision,
150
- outcome: "allow",
151
- reason: "stale projection",
152
- };
153
- store.saveTask(stored);
154
- await expect(controller.execute(planned.id)).rejects.toThrow("approval_required");
155
- expect(execute).not.toHaveBeenCalled();
156
- });
157
- it("recovers interrupted reads for retry and quarantines mutations", async () => {
158
- const execute = vi.fn(async () => ({ success: true }));
159
- const { controller, store } = await setup({ execute });
160
- const read = controller.plan(TaskRequestSchema.parse({
161
- goal: "Recover an interrupted read",
162
- operation: { kind: "system", action: "info" },
163
- }));
164
- const mutation = controller.plan(TaskRequestSchema.parse({
165
- goal: "Recover an interrupted mutation",
166
- operation: {
167
- kind: "memory",
168
- action: "put",
169
- key: "project",
170
- value: "MELRA",
171
- },
172
- requiredEvidence: [
173
- { type: "result_equals", path: "stored", value: true },
174
- ],
175
- }));
176
- for (const item of [read, mutation]) {
177
- const stored = store.getTask(item.id);
178
- stored.status = "running";
179
- store.saveTask(stored);
180
- }
181
- const recovered = await controller.recoverInterrupted();
182
- expect(recovered).toHaveLength(2);
183
- expect(store.getTask(read.id)).toMatchObject({
184
- status: "planned",
185
- error: "interrupted_read_ready_for_retry",
186
- });
187
- expect(store.getTask(mutation.id)).toMatchObject({
188
- status: "recovery_required",
189
- error: "interrupted_mutation_requires_reconciliation",
190
- });
191
- expect(execute).not.toHaveBeenCalled();
192
- });
193
- it("reconciles a verifying file mutation from independent evidence", async () => {
194
- const execute = vi.fn(async () => ({ success: true }));
195
- const { controller, store } = await setup({ execute });
196
- const root = roots.at(-1);
197
- const planned = controller.plan(TaskRequestSchema.parse({
198
- goal: "Recover an already completed file write",
199
- operation: {
200
- kind: "file",
201
- action: "write",
202
- path: "recovered.txt",
203
- content: "complete",
204
- },
205
- requiredEvidence: [
206
- { type: "file_exists", path: "recovered.txt" },
207
- ],
208
- }), {
209
- idempotencyKey: "b".repeat(64),
210
- attempt: 1,
211
- });
212
- await writeFile(join(root, "recovered.txt"), "complete");
213
- const interrupted = store.getTask(planned.id);
214
- interrupted.status = "verifying";
215
- store.saveTask(interrupted);
216
- const [recovered] = await controller.recoverInterrupted();
217
- expect(recovered).toMatchObject({
218
- id: planned.id,
219
- status: "verified_success",
220
- });
221
- expect(controller.receipts({ taskId: planned.id }).certificate?.result).toBe("VERIFIED_SUCCESS");
222
- expect(store.getIdempotencyCommit("b".repeat(64))).toMatchObject({
223
- taskId: planned.id,
224
- attempt: 1,
225
- });
226
- expect(execute).not.toHaveBeenCalled();
227
- });
228
- it("prevents an already committed idempotency key from executing again", async () => {
229
- const execute = vi.fn(async () => ({ success: true }));
230
- const { controller } = await setup({ execute });
231
- const request = TaskRequestSchema.parse({
232
- goal: "Run one logical attempt",
233
- operation: { kind: "system", action: "info" },
234
- });
235
- const idempotencyKey = "a".repeat(64);
236
- const first = controller.plan(request, {
237
- idempotencyKey,
238
- attempt: 1,
239
- });
240
- const firstResult = await controller.execute(first.id);
241
- const duplicate = controller.plan(request, {
242
- idempotencyKey,
243
- attempt: 2,
244
- });
245
- const duplicateResult = await controller.execute(duplicate.id);
246
- expect(firstResult.task).toMatchObject({
247
- status: "verified_success",
248
- idempotencyKey,
249
- attempt: 1,
250
- });
251
- expect(duplicateResult.task).toMatchObject({
252
- status: "cancelled",
253
- error: "duplicate_attempt_prevented",
254
- idempotencyKey,
255
- attempt: 2,
256
- });
257
- expect(execute).toHaveBeenCalledTimes(1);
258
- });
259
- it("plans, executes, verifies, and persists a read task", async () => {
260
- const { controller, store } = await setup();
261
- const task = controller.plan(TaskRequestSchema.parse({
262
- goal: "Inspect the system",
263
- operation: { kind: "system", action: "info" },
264
- }));
265
- expect(task.status).toBe("planned");
266
- const execution = await controller.execute(task.id);
267
- expect(execution.task.status).toBe("verified_success");
268
- expect(execution.receipt?.success).toBe(true);
269
- expect(execution.certificate?.result).toBe("VERIFIED_SUCCESS");
270
- expect(store.getReceiptsForTask(task.id)).toHaveLength(1);
271
- });
272
- it("requires the exact approval for mutation", async () => {
273
- const { controller } = await setup();
274
- const task = controller.plan(TaskRequestSchema.parse({
275
- goal: "Store a memory",
276
- operation: {
277
- kind: "memory",
278
- action: "put",
279
- scope: "workspace",
280
- key: "project",
281
- value: "MELRA",
282
- },
283
- requiredEvidence: [
284
- { type: "result_equals", path: "stored", value: true },
285
- ],
286
- }));
287
- expect(task.status).toBe("awaiting_approval");
288
- await expect(controller.execute(task.id)).rejects.toThrow("approval_required");
289
- const result = await controller.execute(task.id, {
290
- approvalId: task.approval.approvalId,
291
- phrase: task.approval.phrase,
292
- });
293
- expect(result.task.status).toBe("verified_success");
294
- });
295
- it("derives evidence for a mutation that declared none", async () => {
296
- const { controller } = await setup();
297
- const task = controller.plan(TaskRequestSchema.parse({
298
- goal: "Write a file",
299
- operation: {
300
- kind: "file",
301
- action: "write",
302
- path: "result.txt",
303
- content: "unsafe",
304
- },
305
- }));
306
- // A write has an obvious post-condition, so the task proceeds to approval
307
- // rather than dead-ending — but it is still held to that post-condition.
308
- expect(task.status).toBe("awaiting_approval");
309
- expect(task.request.requiredEvidence).toEqual([
310
- { type: "file_exists", path: "result.txt" },
311
- ]);
312
- });
313
- it("still blocks a mutation whose evidence cannot be derived", async () => {
314
- const { controller } = await setup();
315
- const task = controller.plan(TaskRequestSchema.parse({
316
- goal: "Run a build",
317
- operation: {
318
- kind: "terminal",
319
- action: "run",
320
- command: "npm",
321
- args: ["run", "build"],
322
- },
323
- }));
324
- // Nothing about the request says what the command should leave behind, so
325
- // the mutation-requires-evidence guarantee still holds.
326
- expect(task.status).toBe("policy_blocked");
327
- expect(task.policyDecision.reason).toBe("mutation_requires_evidence");
328
- });
329
- it("stops calling an adapter that keeps failing, and still receipts the refusal", async () => {
330
- const root = await mkdtemp(join(tmpdir(), "melra-controller-breaker-"));
331
- roots.push(root);
332
- const store = new SqliteStore(":memory:");
333
- stores.push(store);
334
- let calls = 0;
335
- const controller = new TaskController(store, createDefaultPolicy(root), {
336
- async execute() {
337
- calls += 1;
338
- throw new Error("target_unreachable");
339
- },
340
- }, await Verifier.create(root), new PayloadCipher(Buffer.alloc(32, 9)), new CircuitBreaker({ threshold: 2, cooldownMs: 60_000 }));
341
- const run = async () => {
342
- const task = controller.plan(TaskRequestSchema.parse({
343
- goal: "Ask an unreachable target",
344
- operation: { kind: "system", action: "info" },
345
- budget: { maxSteps: 1, maxDurationMs: 5_000, maxRetries: 0 },
346
- }));
347
- return (await controller.execute(task.id)).task;
348
- };
349
- expect((await run()).status).toBe("failed");
350
- expect((await run()).status).toBe("failed");
351
- const refused = await run();
352
- // The point of the breaker: the third task never reaches the adapter.
353
- expect(calls).toBe(2);
354
- expect(refused.status).toBe("failed");
355
- expect(refused.error).toContain("circuit_open:local-system");
356
- // A refusal is still a governed outcome, so it owes the caller the same
357
- // receipt and certificate any other terminal task produces.
358
- expect(controller.receipts({ taskId: refused.id }).receipts).toHaveLength(1);
359
- });
360
- it("retries bounded read failures but never loops indefinitely", async () => {
361
- let attempts = 0;
362
- const { controller } = await setup({
363
- async execute() {
364
- attempts += 1;
365
- if (attempts < 2)
366
- throw new Error("transient_read_failure");
367
- return { success: true };
368
- },
369
- });
370
- const task = controller.plan(TaskRequestSchema.parse({
371
- goal: "Retry one transient read",
372
- operation: { kind: "system", action: "info" },
373
- budget: { maxRetries: 2, maxSteps: 3, maxDurationMs: 5_000 },
374
- }));
375
- const result = await controller.execute(task.id);
376
- expect(result.task.status).toBe("verified_success");
377
- expect(result.task.attempts).toBe(2);
378
- expect(attempts).toBe(2);
379
- });
380
- it("classifies a generic abort error as budget exhaustion when its timer fires", async () => {
381
- const { controller } = await setup({
382
- async execute(_operation, signal) {
383
- await new Promise((_resolve, reject) => {
384
- signal?.addEventListener("abort", () => reject(new Error("The operation was aborted")), { once: true });
385
- });
386
- return { success: true };
387
- },
388
- });
389
- const task = controller.plan(TaskRequestSchema.parse({
390
- goal: "Respect a strict execution budget",
391
- operation: { kind: "system", action: "info" },
392
- budget: { maxRetries: 0, maxSteps: 1, maxDurationMs: 100 },
393
- }));
394
- const result = await controller.execute(task.id);
395
- expect(result.task.status).toBe("budget_exhausted");
396
- expect(result.task.error).toBe("task_budget_exhausted");
397
- expect(result.certificate?.result).toBe("BUDGET_EXHAUSTED");
398
- });
399
- it("redacts executor secrets before task or receipt persistence", async () => {
400
- const { controller, store } = await setup({
401
- async execute() {
402
- return {
403
- success: true,
404
- stored: true,
405
- output: "password=hunter2",
406
- authorization: "Bearer raw-token-value",
407
- };
408
- },
409
- });
410
- const task = controller.plan(TaskRequestSchema.parse({
411
- goal: "Persist only redacted evidence",
412
- operation: { kind: "system", action: "info" },
413
- requiredEvidence: [
414
- { type: "result_equals", path: "stored", value: true },
415
- ],
416
- }));
417
- const result = await controller.execute(task.id);
418
- expect(JSON.stringify(result.task.result)).not.toContain("hunter2");
419
- expect(JSON.stringify(result.receipt?.observedEffect)).not.toContain("raw-token-value");
420
- expect(result.output?.output).toBe("password=hunter2");
421
- expect(result.receipt?.redactions.length).toBeGreaterThan(0);
422
- expect(JSON.stringify(store.getTask(task.id))).not.toContain("hunter2");
423
- });
424
- it("shows the live caller the approval input without retaining it", async () => {
425
- const { controller, store } = await setup({
426
- async execute() {
427
- return { success: true, typed: true };
428
- },
429
- });
430
- const secretInput = "one-time private form value";
431
- const task = controller.plan(TaskRequestSchema.parse({
432
- goal: "Type a reviewed value",
433
- operation: {
434
- kind: "browser",
435
- action: "type",
436
- target: { selector: "#field" },
437
- value: secretInput,
438
- },
439
- requiredEvidence: [
440
- { type: "result_equals", path: "typed", value: true },
441
- ],
442
- }));
443
- expect(task.request.operation.kind === "browser"
444
- ? task.request.operation.value
445
- : undefined).toBe(secretInput);
446
- expect(JSON.stringify(store.getTask(task.id))).not.toContain(secretInput);
447
- const execution = await controller.execute(task.id, {
448
- approvalId: task.approval.approvalId,
449
- phrase: task.approval.phrase,
450
- });
451
- expect(execution.task.status).toBe("verified_success");
452
- });
453
- });
454
- describe("effect contract and principal", () => {
455
- it("returns the contract with the plan and stamps the chain on the receipt", async () => {
456
- const { controller } = await setup();
457
- const identity = {
458
- principal: { kind: "agent", id: "claude-code" },
459
- onBehalfOf: [{ kind: "human", id: "dheeraj" }],
460
- };
461
- const task = controller.plan(TaskRequestSchema.parse({
462
- goal: "Read a file",
463
- operation: { kind: "file", action: "read", path: "notes.txt" },
464
- identity,
465
- }));
466
- expect(task.contract).toMatchObject({
467
- taskId: task.id,
468
- capability: "file.read",
469
- effect: "read",
470
- identity,
471
- metadata: { goal: "Read a file" },
472
- });
473
- const execution = await controller.execute(task.id);
474
- expect(execution.receipt?.principal).toBe("human:dheeraj/agent:claude-code");
475
- });
476
- it("records the local principal when the caller declares none", async () => {
477
- const { controller } = await setup();
478
- const task = controller.plan(TaskRequestSchema.parse({
479
- goal: "Read a file",
480
- operation: { kind: "file", action: "read", path: "notes.txt" },
481
- }));
482
- const execution = await controller.execute(task.id);
483
- expect(execution.receipt?.principal).toBe("agent:local");
484
- });
485
- describe("reconciliation", () => {
486
- /**
487
- * A charge whose request reached the wire and was never answered, plus the
488
- * probe that can settle it. The reconciliation URL is written against the
489
- * idempotency key rather than a response field on purpose: there is no
490
- * response to read on this path.
491
- */
492
- async function unansweredCharge(probe) {
493
- const execute = vi.fn(async () => {
494
- throw outcomeUnknown("socket hang up");
495
- });
496
- const { controller, store } = await setup({ execute }, probe);
497
- const task = controller.plan(TaskRequestSchema.parse({
498
- goal: "Charge the customer",
499
- operation: {
500
- kind: "http",
501
- action: "request",
502
- method: "POST",
503
- url: "http://localhost:9/charges",
504
- idempotencyKey: "charge-1",
505
- },
506
- requiredEvidence: [
507
- { type: "result_equals", path: "success", value: true },
508
- ],
509
- reconciliation: [
510
- {
511
- type: "http_resource_matches",
512
- url: "http://localhost:9/charges/{{idempotencyKey}}",
513
- method: "GET",
514
- path: "json.status",
515
- value: "applied",
516
- },
517
- ],
518
- }));
519
- const parked = await controller.execute(task.id, {
520
- approvalId: task.approval.approvalId,
521
- phrase: task.approval.phrase,
522
- });
523
- return { controller, store, task, parked, execute };
524
- }
525
- it("parks an unanswered mutation instead of claiming it failed", async () => {
526
- const { parked, controller } = await unansweredCharge();
527
- expect(parked.task.status).toBe("recovery_required");
528
- expect(parked.certificate?.result).toBe("RECOVERY_REQUIRED");
529
- // The receipt must not read as proof the charge did not happen.
530
- expect(parked.receipt?.evidence[0]).toMatchObject({
531
- type: "execution_error",
532
- passed: false,
533
- inconclusive: true,
534
- });
535
- expect(parked.receipt?.executionGuarantee).toBe("reconciliation-required");
536
- expect(controller.status(parked.task.id).status).toBe("recovery_required");
537
- });
538
- it("resolves a parked mutation the provider says did happen", async () => {
539
- const probe = vi.fn(async () => ({
540
- status: 200,
541
- content: JSON.stringify({ status: "applied" }),
542
- }));
543
- const { parked, controller, execute } = await unansweredCharge(probe);
544
- expect(parked.task.status).toBe("recovery_required");
545
- // Retrying the obvious way must not re-run the adapter: it may already
546
- // have charged the customer once.
547
- const settled = await controller.execute(parked.task.id);
548
- expect(settled.task.status).toBe("verified_success");
549
- expect(settled.certificate?.result).toBe("VERIFIED_SUCCESS");
550
- expect(probe).toHaveBeenCalledWith(expect.objectContaining({
551
- url: "http://localhost:9/charges/charge-1",
552
- }));
553
- expect(execute).toHaveBeenCalledTimes(1);
554
- });
555
- it("marks a parked mutation failed only once the provider denies it", async () => {
556
- const probe = vi.fn(async () => ({
557
- status: 404,
558
- content: JSON.stringify({ status: "none" }),
559
- }));
560
- const { parked, controller } = await unansweredCharge(probe);
561
- const settled = await controller.execute(parked.task.id);
562
- expect(settled.task).toMatchObject({
563
- status: "failed",
564
- error: "reconciliation_confirms_effect_not_applied",
565
- });
566
- expect(settled.certificate?.result).toBe("FAILED");
567
- });
568
- it("keeps a parked mutation parked when the provider cannot be reached", async () => {
569
- const probe = vi.fn(async () => {
570
- throw new Error("ECONNREFUSED");
571
- });
572
- const { parked, controller } = await unansweredCharge(probe);
573
- const settled = await controller.execute(parked.task.id);
574
- // An unreachable provider did not say no. Anything but `recovery_required`
575
- // here is the kernel inventing an outcome.
576
- expect(settled.task).toMatchObject({
577
- status: "recovery_required",
578
- error: "reconciliation_inconclusive",
579
- });
580
- expect(settled.receipt).toBeUndefined();
581
- expect(probe).toHaveBeenCalledTimes(1);
582
- });
583
- it("parks an unanswered mutation that declared no way to settle it", async () => {
584
- const execute = vi.fn(async () => {
585
- throw outcomeUnknown("socket hang up");
586
- });
587
- const { controller } = await setup({ execute });
588
- const task = controller.plan(TaskRequestSchema.parse({
589
- goal: "Charge the customer",
590
- operation: {
591
- kind: "http",
592
- action: "request",
593
- method: "POST",
594
- url: "http://localhost:9/charges",
595
- },
596
- requiredEvidence: [
597
- { type: "result_equals", path: "success", value: true },
598
- ],
599
- }));
600
- const parked = await controller.execute(task.id, {
601
- approvalId: task.approval.approvalId,
602
- phrase: task.approval.phrase,
603
- });
604
- expect(parked.task.status).toBe("recovery_required");
605
- expect(parked.receipt?.executionGuarantee).toBe("at-most-once");
606
- const settled = await controller.execute(task.id);
607
- expect(settled.task.error).toBe("reconciliation_not_declared");
608
- expect(execute).toHaveBeenCalledTimes(1);
609
- });
610
- it("leaves an unanswered read failed, since nothing changed either way", async () => {
611
- const execute = vi.fn(async () => {
612
- throw outcomeUnknown("socket hang up");
613
- });
614
- const { controller } = await setup({ execute });
615
- const task = controller.plan(TaskRequestSchema.parse({
616
- goal: "Read the charge",
617
- operation: {
618
- kind: "http",
619
- action: "request",
620
- method: "GET",
621
- url: "http://localhost:9/charges/charge-1",
622
- },
623
- }));
624
- expect((await controller.execute(task.id)).task.status).toBe("failed");
625
- });
626
- it("settles an interrupted mutation on recovery when it can", async () => {
627
- const probe = vi.fn(async () => ({
628
- status: 200,
629
- content: JSON.stringify({ status: "applied" }),
630
- }));
631
- const execute = vi.fn(async () => ({ success: true }));
632
- const { controller, store } = await setup({ execute }, probe);
633
- const task = controller.plan(TaskRequestSchema.parse({
634
- goal: "Charge the customer",
635
- operation: {
636
- kind: "http",
637
- action: "request",
638
- method: "POST",
639
- url: "http://localhost:9/charges",
640
- idempotencyKey: "charge-2",
641
- },
642
- requiredEvidence: [
643
- { type: "result_equals", path: "success", value: true },
644
- ],
645
- reconciliation: [
646
- {
647
- type: "http_resource_matches",
648
- url: "http://localhost:9/charges/{{idempotencyKey}}",
649
- method: "GET",
650
- path: "json.status",
651
- value: "applied",
652
- },
653
- ],
654
- }));
655
- const interrupted = store.getTask(task.id);
656
- interrupted.status = "running";
657
- store.saveTask(interrupted);
658
- const [recovered] = await controller.recoverInterrupted();
659
- expect(recovered).toMatchObject({
660
- id: task.id,
661
- status: "verified_success",
662
- });
663
- expect(execute).not.toHaveBeenCalled();
664
- });
665
- });
666
- });
667
- //# sourceMappingURL=task-controller.test.js.map