@melra/runtime-core 0.3.0-alpha.11 → 0.3.0-alpha.13
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.
- package/README.md +3 -2
- package/dist/task-controller.d.ts +81 -3
- package/dist/task-controller.d.ts.map +1 -1
- package/dist/task-controller.js +305 -52
- package/dist/task-controller.js.map +1 -1
- package/dist/workflow-controller.d.ts +19 -1
- package/dist/workflow-controller.d.ts.map +1 -1
- package/dist/workflow-controller.js +369 -92
- package/dist/workflow-controller.js.map +1 -1
- package/dist/workflow-graph.d.ts.map +1 -1
- package/dist/workflow-graph.js +25 -1
- package/dist/workflow-graph.js.map +1 -1
- package/package.json +8 -8
- package/dist/circuit-breaker.test.d.ts +0 -2
- package/dist/circuit-breaker.test.d.ts.map +0 -1
- package/dist/circuit-breaker.test.js +0 -51
- package/dist/circuit-breaker.test.js.map +0 -1
- package/dist/payload-cipher.test.d.ts +0 -2
- package/dist/payload-cipher.test.d.ts.map +0 -1
- package/dist/payload-cipher.test.js +0 -33
- package/dist/payload-cipher.test.js.map +0 -1
- package/dist/retry-guard.test.d.ts +0 -2
- package/dist/retry-guard.test.d.ts.map +0 -1
- package/dist/retry-guard.test.js +0 -45
- package/dist/retry-guard.test.js.map +0 -1
- package/dist/task-controller.test.d.ts +0 -2
- package/dist/task-controller.test.d.ts.map +0 -1
- package/dist/task-controller.test.js +0 -667
- package/dist/task-controller.test.js.map +0 -1
- package/dist/verification-gate.test.d.ts +0 -2
- package/dist/verification-gate.test.d.ts.map +0 -1
- package/dist/verification-gate.test.js +0 -42
- package/dist/verification-gate.test.js.map +0 -1
- package/dist/workflow-controller.test.d.ts +0 -2
- package/dist/workflow-controller.test.d.ts.map +0 -1
- package/dist/workflow-controller.test.js +0 -911
- package/dist/workflow-controller.test.js.map +0 -1
- package/dist/workflow-events.test.d.ts +0 -2
- package/dist/workflow-events.test.d.ts.map +0 -1
- package/dist/workflow-events.test.js +0 -113
- package/dist/workflow-events.test.js.map +0 -1
- package/dist/workflow-graph.test.d.ts +0 -2
- package/dist/workflow-graph.test.d.ts.map +0 -1
- package/dist/workflow-graph.test.js +0 -170
- package/dist/workflow-graph.test.js.map +0 -1
|
@@ -1,911 +0,0 @@
|
|
|
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
|
-
reconciliation: [],
|
|
47
|
-
budget: {
|
|
48
|
-
maxDurationMs: 120_000,
|
|
49
|
-
maxRetries: 2,
|
|
50
|
-
maxSteps: 10,
|
|
51
|
-
},
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
function mutationRequest(goal) {
|
|
55
|
-
return TaskRequestSchema.parse({
|
|
56
|
-
goal,
|
|
57
|
-
operation: {
|
|
58
|
-
kind: "memory",
|
|
59
|
-
action: "put",
|
|
60
|
-
key: "project",
|
|
61
|
-
value: "MELRA",
|
|
62
|
-
},
|
|
63
|
-
requiredEvidence: [
|
|
64
|
-
{ type: "result_equals", path: "stored", value: true },
|
|
65
|
-
],
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
function fileRequest(goal, path) {
|
|
69
|
-
return TaskRequestSchema.parse({
|
|
70
|
-
goal,
|
|
71
|
-
operation: { kind: "file", action: "read", path },
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
function definition(goal = "one-time-workflow-secret") {
|
|
75
|
-
return WorkflowDefinitionSchema.parse({
|
|
76
|
-
schemaVersion: "1.0.0",
|
|
77
|
-
id: definitionId,
|
|
78
|
-
version: 1,
|
|
79
|
-
name: "Durable workflow",
|
|
80
|
-
nodes: [
|
|
81
|
-
{
|
|
82
|
-
id: "inspect",
|
|
83
|
-
type: "operation",
|
|
84
|
-
request: request(goal),
|
|
85
|
-
},
|
|
86
|
-
],
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
describe("WorkflowController planning", () => {
|
|
90
|
-
it("persists a redacted projection and sealed exact definition", async () => {
|
|
91
|
-
const { controller, store, cipher } = await setup();
|
|
92
|
-
const exact = definition();
|
|
93
|
-
const run = controller.plan(exact);
|
|
94
|
-
expect(run.status).toBe("planned");
|
|
95
|
-
expect(Object.values(run.nodes).every((node) => node.status === "pending")).toBe(true);
|
|
96
|
-
expect(store.listWorkflowEvents(run.id).map((event) => event.type)).toEqual([
|
|
97
|
-
"workflow.created",
|
|
98
|
-
"workflow.status_changed",
|
|
99
|
-
]);
|
|
100
|
-
expect(store.getWorkflowDefinition(definitionId, 1)?.nodes[0]).not.toEqual(exact.nodes[0]);
|
|
101
|
-
expect(cipher.open(store.getWorkflowPayload(definitionId, 1), `workflow:${definitionId}:1:definition`)).toEqual(exact);
|
|
102
|
-
expect(JSON.stringify(store.listWorkflowEvents(run.id))).not.toContain("one-time-workflow-secret");
|
|
103
|
-
});
|
|
104
|
-
it("rejects a denied nested request before writing anything", async () => {
|
|
105
|
-
const { controller, store } = await setup();
|
|
106
|
-
const denied = WorkflowDefinitionSchema.parse({
|
|
107
|
-
schemaVersion: "1.0.0",
|
|
108
|
-
id: definitionId,
|
|
109
|
-
version: 1,
|
|
110
|
-
name: "Denied branch",
|
|
111
|
-
nodes: [
|
|
112
|
-
{
|
|
113
|
-
id: "branch",
|
|
114
|
-
type: "condition",
|
|
115
|
-
sourceNodeId: "inspect",
|
|
116
|
-
dependsOn: ["inspect"],
|
|
117
|
-
predicate: { type: "result_equals", path: "success", value: true },
|
|
118
|
-
whenTrue: [
|
|
119
|
-
{
|
|
120
|
-
goal: "Run a forbidden shell",
|
|
121
|
-
operation: {
|
|
122
|
-
kind: "terminal",
|
|
123
|
-
action: "run",
|
|
124
|
-
command: "bash",
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
],
|
|
128
|
-
whenFalse: [],
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
id: "inspect",
|
|
132
|
-
type: "operation",
|
|
133
|
-
request: request("Inspect first"),
|
|
134
|
-
},
|
|
135
|
-
],
|
|
136
|
-
});
|
|
137
|
-
expect(() => controller.plan(denied)).toThrow("workflow_policy_blocked:branch");
|
|
138
|
-
expect(store.getWorkflowDefinition(definitionId, 1)).toBeUndefined();
|
|
139
|
-
expect(store.getWorkflowPayload(definitionId, 1)).toBeUndefined();
|
|
140
|
-
expect(store.listTasks()).toEqual([]);
|
|
141
|
-
expect(store.database.prepare("SELECT count(*) AS count FROM workflow_runs").get()).toEqual({ count: 0 });
|
|
142
|
-
});
|
|
143
|
-
it("reads status and events and rejects unknown workflow IDs", async () => {
|
|
144
|
-
const { controller } = await setup();
|
|
145
|
-
const run = controller.plan(definition());
|
|
146
|
-
expect(controller.status(run.id)).toEqual(run);
|
|
147
|
-
expect(controller.events(run.id, 1)).toHaveLength(1);
|
|
148
|
-
expect(() => controller.status("99999999-9999-4999-8999-999999999999")).toThrow("workflow_not_found");
|
|
149
|
-
expect(() => controller.events("99999999-9999-4999-8999-999999999999")).toThrow("workflow_not_found");
|
|
150
|
-
});
|
|
151
|
-
it("cancels nonterminal nodes once and preserves completed workflows", async () => {
|
|
152
|
-
const { controller, store } = await setup();
|
|
153
|
-
const run = controller.plan(definition());
|
|
154
|
-
const cancelled = controller.cancel(run.id);
|
|
155
|
-
expect(cancelled.status).toBe("cancelled");
|
|
156
|
-
expect(cancelled.nodes.inspect?.status).toBe("cancelled");
|
|
157
|
-
expect(store.listWorkflowEvents(run.id).map((event) => event.type)).toEqual([
|
|
158
|
-
"workflow.created",
|
|
159
|
-
"workflow.status_changed",
|
|
160
|
-
"workflow.node_changed",
|
|
161
|
-
"workflow.cancelled",
|
|
162
|
-
]);
|
|
163
|
-
expect(controller.cancel(run.id)).toEqual(cancelled);
|
|
164
|
-
const second = controller.plan(WorkflowDefinitionSchema.parse({
|
|
165
|
-
...definition("another secret"),
|
|
166
|
-
id: "22222222-2222-4222-8222-222222222222",
|
|
167
|
-
}));
|
|
168
|
-
const completedEvent = WorkflowEventSchema.parse({
|
|
169
|
-
schemaVersion: "1.0.0",
|
|
170
|
-
id: "33333333-3333-4333-8333-333333333333",
|
|
171
|
-
aggregateId: second.id,
|
|
172
|
-
sequence: 3,
|
|
173
|
-
traceId: second.traceId,
|
|
174
|
-
type: "workflow.status_changed",
|
|
175
|
-
data: { from: "planned", to: "verified_complete" },
|
|
176
|
-
occurredAt: "2026-07-30T12:00:03.000Z",
|
|
177
|
-
});
|
|
178
|
-
const completed = applyWorkflowEvent(second, completedEvent);
|
|
179
|
-
store.transitionWorkflow(second.id, 2, completed, [completedEvent]);
|
|
180
|
-
expect(controller.cancel(second.id)).toEqual(completed);
|
|
181
|
-
expect(store.listWorkflowEvents(second.id)).toHaveLength(3);
|
|
182
|
-
});
|
|
183
|
-
it("surfaces a stale projection conflict without retrying", async () => {
|
|
184
|
-
const { controller, store } = await setup();
|
|
185
|
-
const run = controller.plan(definition());
|
|
186
|
-
const transition = store.transitionWorkflow.bind(store);
|
|
187
|
-
let injected = false;
|
|
188
|
-
vi.spyOn(store, "transitionWorkflow").mockImplementation((id, expectedVersion, next, events) => {
|
|
189
|
-
if (!injected) {
|
|
190
|
-
injected = true;
|
|
191
|
-
const externalEvent = WorkflowEventSchema.parse({
|
|
192
|
-
schemaVersion: "1.0.0",
|
|
193
|
-
id: "44444444-4444-4444-8444-444444444444",
|
|
194
|
-
aggregateId: run.id,
|
|
195
|
-
sequence: 3,
|
|
196
|
-
traceId: run.traceId,
|
|
197
|
-
type: "workflow.status_changed",
|
|
198
|
-
data: { from: "planned", to: "paused" },
|
|
199
|
-
occurredAt: "2026-07-30T12:00:03.000Z",
|
|
200
|
-
});
|
|
201
|
-
transition(run.id, run.stateVersion, applyWorkflowEvent(run, externalEvent), [externalEvent]);
|
|
202
|
-
}
|
|
203
|
-
transition(id, expectedVersion, next, events);
|
|
204
|
-
});
|
|
205
|
-
expect(() => controller.cancel(run.id)).toThrow("workflow_state_conflict");
|
|
206
|
-
expect(store.getWorkflowRun(run.id)?.status).toBe("paused");
|
|
207
|
-
expect(store.listWorkflowEvents(run.id)).toHaveLength(3);
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
describe("WorkflowController execution", () => {
|
|
211
|
-
it("advances one governed layer at a time through approval and checkpoint", async () => {
|
|
212
|
-
const { controller, store } = await setup(async (operation) => operation.kind === "memory"
|
|
213
|
-
? { success: true, stored: true }
|
|
214
|
-
: { success: true });
|
|
215
|
-
const workflow = WorkflowDefinitionSchema.parse({
|
|
216
|
-
schemaVersion: "1.0.0",
|
|
217
|
-
id: definitionId,
|
|
218
|
-
version: 1,
|
|
219
|
-
name: "Governed checkpoint",
|
|
220
|
-
nodes: [
|
|
221
|
-
{
|
|
222
|
-
id: "inspect",
|
|
223
|
-
type: "operation",
|
|
224
|
-
request: request("Inspect"),
|
|
225
|
-
},
|
|
226
|
-
{
|
|
227
|
-
id: "approve-write",
|
|
228
|
-
type: "approval",
|
|
229
|
-
dependsOn: ["inspect"],
|
|
230
|
-
forNodeId: "write",
|
|
231
|
-
},
|
|
232
|
-
{
|
|
233
|
-
id: "write",
|
|
234
|
-
type: "operation",
|
|
235
|
-
dependsOn: ["approve-write"],
|
|
236
|
-
request: mutationRequest("Write"),
|
|
237
|
-
},
|
|
238
|
-
{
|
|
239
|
-
id: "checkpoint",
|
|
240
|
-
type: "checkpoint",
|
|
241
|
-
dependsOn: ["write"],
|
|
242
|
-
},
|
|
243
|
-
],
|
|
244
|
-
});
|
|
245
|
-
const planned = controller.plan(workflow);
|
|
246
|
-
const inspected = await controller.advance(planned.id);
|
|
247
|
-
expect(inspected.run.nodes.inspect?.status).toBe("verified_complete");
|
|
248
|
-
expect(inspected.run.nodes.write?.status).toBe("pending");
|
|
249
|
-
const waiting = await controller.advance(planned.id);
|
|
250
|
-
expect(waiting.run.nodes["approve-write"]?.status).toBe("awaiting_approval");
|
|
251
|
-
expect(waiting.run.status).toBe("awaiting_approval");
|
|
252
|
-
const challenge = waiting.run.nodes["approve-write"].approval;
|
|
253
|
-
const approved = await controller.advance(planned.id, [
|
|
254
|
-
{
|
|
255
|
-
approvalId: challenge.approvalId,
|
|
256
|
-
phrase: challenge.phrase,
|
|
257
|
-
},
|
|
258
|
-
]);
|
|
259
|
-
expect(approved.run.nodes["approve-write"]?.status).toBe("verified_complete");
|
|
260
|
-
const written = await controller.advance(planned.id);
|
|
261
|
-
expect(written.run.nodes.write?.status).toBe("verified_complete");
|
|
262
|
-
const completed = await controller.advance(planned.id);
|
|
263
|
-
expect(completed.run.nodes.checkpoint?.status).toBe("verified_complete");
|
|
264
|
-
expect(completed.run.status).toBe("verified_complete");
|
|
265
|
-
expect(store.getLatestWorkflowSnapshot(planned.id)?.run).toEqual(completed.run);
|
|
266
|
-
expect(store.listTasks()).toHaveLength(2);
|
|
267
|
-
expect(store
|
|
268
|
-
.listTasks()
|
|
269
|
-
.every((task) => task.idempotencyKey?.length === 64 && task.attempt === 1)).toBe(true);
|
|
270
|
-
});
|
|
271
|
-
it("executes only the condition branch selected from persisted evidence", async () => {
|
|
272
|
-
const executed = [];
|
|
273
|
-
const { controller } = await setup(async (operation) => {
|
|
274
|
-
if (operation.kind !== "file")
|
|
275
|
-
throw new Error("unexpected_operation");
|
|
276
|
-
executed.push(operation.path);
|
|
277
|
-
return {
|
|
278
|
-
success: true,
|
|
279
|
-
...(operation.path === "inspect" ? { route: "yes" } : {}),
|
|
280
|
-
};
|
|
281
|
-
});
|
|
282
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
283
|
-
schemaVersion: "1.0.0",
|
|
284
|
-
id: definitionId,
|
|
285
|
-
version: 1,
|
|
286
|
-
name: "Conditional",
|
|
287
|
-
nodes: [
|
|
288
|
-
{
|
|
289
|
-
id: "inspect",
|
|
290
|
-
type: "operation",
|
|
291
|
-
request: fileRequest("Inspect", "inspect"),
|
|
292
|
-
},
|
|
293
|
-
{
|
|
294
|
-
id: "choose",
|
|
295
|
-
type: "condition",
|
|
296
|
-
dependsOn: ["inspect"],
|
|
297
|
-
sourceNodeId: "inspect",
|
|
298
|
-
predicate: {
|
|
299
|
-
type: "result_equals",
|
|
300
|
-
path: "route",
|
|
301
|
-
value: "yes",
|
|
302
|
-
},
|
|
303
|
-
whenTrue: [fileRequest("True branch", "true-branch")],
|
|
304
|
-
whenFalse: [fileRequest("False branch", "false-branch")],
|
|
305
|
-
},
|
|
306
|
-
],
|
|
307
|
-
}));
|
|
308
|
-
await controller.advance(planned.id);
|
|
309
|
-
const result = await controller.advance(planned.id);
|
|
310
|
-
expect(executed).toEqual(["inspect", "true-branch"]);
|
|
311
|
-
expect(result.run.nodes.choose?.status).toBe("verified_complete");
|
|
312
|
-
expect(result.run.status).toBe("verified_complete");
|
|
313
|
-
});
|
|
314
|
-
it("executes independent parallel branches concurrently", async () => {
|
|
315
|
-
let active = 0;
|
|
316
|
-
let maxConcurrent = 0;
|
|
317
|
-
const { controller, store } = await setup(async () => {
|
|
318
|
-
active += 1;
|
|
319
|
-
maxConcurrent = Math.max(maxConcurrent, active);
|
|
320
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
321
|
-
active -= 1;
|
|
322
|
-
return { success: true };
|
|
323
|
-
});
|
|
324
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
325
|
-
schemaVersion: "1.0.0",
|
|
326
|
-
id: definitionId,
|
|
327
|
-
version: 1,
|
|
328
|
-
name: "Parallel",
|
|
329
|
-
nodes: [
|
|
330
|
-
{
|
|
331
|
-
id: "parallel",
|
|
332
|
-
type: "parallel",
|
|
333
|
-
branches: [
|
|
334
|
-
[fileRequest("Branch A", "a")],
|
|
335
|
-
[fileRequest("Branch B", "b")],
|
|
336
|
-
],
|
|
337
|
-
},
|
|
338
|
-
],
|
|
339
|
-
}));
|
|
340
|
-
const result = await controller.advance(planned.id);
|
|
341
|
-
expect(maxConcurrent).toBe(2);
|
|
342
|
-
expect(result.run.nodes.parallel?.taskIds).toHaveLength(2);
|
|
343
|
-
expect(result.run.status).toBe("verified_complete");
|
|
344
|
-
expect(new Set(store.listTasks().map((task) => task.idempotencyKey)).size).toBe(2);
|
|
345
|
-
});
|
|
346
|
-
it("serializes concurrent advances for the same workflow", async () => {
|
|
347
|
-
let calls = 0;
|
|
348
|
-
const { controller, store } = await setup(async () => {
|
|
349
|
-
calls += 1;
|
|
350
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
351
|
-
return { success: true };
|
|
352
|
-
});
|
|
353
|
-
const planned = controller.plan(definition("Concurrent advance"));
|
|
354
|
-
const results = await Promise.all([
|
|
355
|
-
controller.advance(planned.id),
|
|
356
|
-
controller.advance(planned.id),
|
|
357
|
-
]);
|
|
358
|
-
expect(calls).toBe(1);
|
|
359
|
-
expect(results.every((result) => result.run.status === "verified_complete")).toBe(true);
|
|
360
|
-
expect(store.listTasks()).toHaveLength(1);
|
|
361
|
-
});
|
|
362
|
-
it("runs a bounded loop sequentially to its hard limit", async () => {
|
|
363
|
-
let calls = 0;
|
|
364
|
-
const { controller } = await setup(async () => {
|
|
365
|
-
calls += 1;
|
|
366
|
-
return { success: true };
|
|
367
|
-
});
|
|
368
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
369
|
-
schemaVersion: "1.0.0",
|
|
370
|
-
id: definitionId,
|
|
371
|
-
version: 1,
|
|
372
|
-
name: "Bounded loop",
|
|
373
|
-
nodes: [
|
|
374
|
-
{
|
|
375
|
-
id: "loop",
|
|
376
|
-
type: "bounded_loop",
|
|
377
|
-
body: [fileRequest("Loop body", "loop")],
|
|
378
|
-
maxIterations: 3,
|
|
379
|
-
},
|
|
380
|
-
],
|
|
381
|
-
}));
|
|
382
|
-
const result = await controller.advance(planned.id);
|
|
383
|
-
expect(calls).toBe(3);
|
|
384
|
-
expect(result.run.nodes.loop?.iterations).toBe(3);
|
|
385
|
-
expect(result.run.status).toBe("verified_complete");
|
|
386
|
-
});
|
|
387
|
-
it("stops a bounded loop when its persisted result satisfies until", async () => {
|
|
388
|
-
let calls = 0;
|
|
389
|
-
const { controller } = await setup(async () => {
|
|
390
|
-
calls += 1;
|
|
391
|
-
return { success: true, done: calls >= 2 };
|
|
392
|
-
});
|
|
393
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
394
|
-
schemaVersion: "1.0.0",
|
|
395
|
-
id: definitionId,
|
|
396
|
-
version: 1,
|
|
397
|
-
name: "Bounded loop with condition",
|
|
398
|
-
nodes: [
|
|
399
|
-
{
|
|
400
|
-
id: "loop",
|
|
401
|
-
type: "bounded_loop",
|
|
402
|
-
body: [fileRequest("Loop body", "loop")],
|
|
403
|
-
maxIterations: 5,
|
|
404
|
-
until: { type: "result_equals", path: "done", value: true },
|
|
405
|
-
},
|
|
406
|
-
],
|
|
407
|
-
}));
|
|
408
|
-
const result = await controller.advance(planned.id);
|
|
409
|
-
expect(calls).toBe(2);
|
|
410
|
-
expect(result.run.nodes.loop?.iterations).toBe(2);
|
|
411
|
-
expect(result.run.status).toBe("verified_complete");
|
|
412
|
-
});
|
|
413
|
-
it("compensates verified work once when a later operation fails", async () => {
|
|
414
|
-
const executed = [];
|
|
415
|
-
const { controller, tasks } = await setup(async (operation) => {
|
|
416
|
-
if (operation.kind !== "file")
|
|
417
|
-
throw new Error("unexpected_operation");
|
|
418
|
-
executed.push(operation.path);
|
|
419
|
-
return { success: operation.path !== "fail" };
|
|
420
|
-
});
|
|
421
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
422
|
-
schemaVersion: "1.0.0",
|
|
423
|
-
id: definitionId,
|
|
424
|
-
version: 1,
|
|
425
|
-
name: "Compensated failure",
|
|
426
|
-
nodes: [
|
|
427
|
-
{
|
|
428
|
-
id: "first",
|
|
429
|
-
type: "operation",
|
|
430
|
-
request: fileRequest("First", "first"),
|
|
431
|
-
},
|
|
432
|
-
{
|
|
433
|
-
id: "second",
|
|
434
|
-
type: "operation",
|
|
435
|
-
dependsOn: ["first"],
|
|
436
|
-
request: fileRequest("Fail", "fail"),
|
|
437
|
-
},
|
|
438
|
-
{
|
|
439
|
-
id: "undo-first",
|
|
440
|
-
type: "compensation",
|
|
441
|
-
forNodeId: "first",
|
|
442
|
-
request: fileRequest("Undo", "undo"),
|
|
443
|
-
},
|
|
444
|
-
],
|
|
445
|
-
}));
|
|
446
|
-
await controller.advance(planned.id);
|
|
447
|
-
const result = await controller.advance(planned.id);
|
|
448
|
-
expect(executed).toEqual(["first", "fail", "undo"]);
|
|
449
|
-
expect(result.run.nodes["undo-first"]?.status).toBe("compensated");
|
|
450
|
-
expect(result.run.status).toBe("failed");
|
|
451
|
-
const compensationTaskId = result.run.nodes["undo-first"]?.taskIds[0];
|
|
452
|
-
expect(compensationTaskId).toBeDefined();
|
|
453
|
-
expect(tasks.receipts({ taskId: compensationTaskId }).receipts).toHaveLength(1);
|
|
454
|
-
});
|
|
455
|
-
it("skips compensation without execution after verified success", async () => {
|
|
456
|
-
const executed = [];
|
|
457
|
-
const { controller } = await setup(async (operation) => {
|
|
458
|
-
if (operation.kind !== "file")
|
|
459
|
-
throw new Error("unexpected_operation");
|
|
460
|
-
executed.push(operation.path);
|
|
461
|
-
return { success: true };
|
|
462
|
-
});
|
|
463
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
464
|
-
schemaVersion: "1.0.0",
|
|
465
|
-
id: definitionId,
|
|
466
|
-
version: 1,
|
|
467
|
-
name: "Successful compensation skip",
|
|
468
|
-
nodes: [
|
|
469
|
-
{
|
|
470
|
-
id: "first",
|
|
471
|
-
type: "operation",
|
|
472
|
-
request: fileRequest("First", "first"),
|
|
473
|
-
},
|
|
474
|
-
{
|
|
475
|
-
id: "undo-first",
|
|
476
|
-
type: "compensation",
|
|
477
|
-
forNodeId: "first",
|
|
478
|
-
request: fileRequest("Undo", "undo"),
|
|
479
|
-
},
|
|
480
|
-
],
|
|
481
|
-
}));
|
|
482
|
-
const result = await controller.advance(planned.id);
|
|
483
|
-
expect(executed).toEqual(["first"]);
|
|
484
|
-
expect(result.run.nodes["undo-first"]?.status).toBe("skipped");
|
|
485
|
-
expect(result.run.status).toBe("verified_complete");
|
|
486
|
-
});
|
|
487
|
-
it("resumes approval-required compensation on a failed workflow", async () => {
|
|
488
|
-
let memoryCalls = 0;
|
|
489
|
-
const { controller } = await setup(async (operation) => {
|
|
490
|
-
if (operation.kind === "memory") {
|
|
491
|
-
memoryCalls += 1;
|
|
492
|
-
return { success: true, stored: true };
|
|
493
|
-
}
|
|
494
|
-
if (operation.kind !== "file")
|
|
495
|
-
throw new Error("unexpected_operation");
|
|
496
|
-
return { success: operation.path !== "fail" };
|
|
497
|
-
});
|
|
498
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
499
|
-
schemaVersion: "1.0.0",
|
|
500
|
-
id: definitionId,
|
|
501
|
-
version: 1,
|
|
502
|
-
name: "Governed compensation",
|
|
503
|
-
nodes: [
|
|
504
|
-
{
|
|
505
|
-
id: "first",
|
|
506
|
-
type: "operation",
|
|
507
|
-
request: fileRequest("First", "first"),
|
|
508
|
-
},
|
|
509
|
-
{
|
|
510
|
-
id: "second",
|
|
511
|
-
type: "operation",
|
|
512
|
-
dependsOn: ["first"],
|
|
513
|
-
request: fileRequest("Fail", "fail"),
|
|
514
|
-
},
|
|
515
|
-
{
|
|
516
|
-
id: "undo-first",
|
|
517
|
-
type: "compensation",
|
|
518
|
-
forNodeId: "first",
|
|
519
|
-
request: mutationRequest("Undo"),
|
|
520
|
-
},
|
|
521
|
-
],
|
|
522
|
-
}));
|
|
523
|
-
await controller.advance(planned.id);
|
|
524
|
-
const failed = await controller.advance(planned.id);
|
|
525
|
-
expect(failed.run.status).toBe("failed");
|
|
526
|
-
expect(failed.run.nodes["undo-first"]?.status).toBe("awaiting_approval");
|
|
527
|
-
expect(memoryCalls).toBe(0);
|
|
528
|
-
const challenge = failed.run.nodes["undo-first"].approval;
|
|
529
|
-
const compensated = await controller.advance(planned.id, [
|
|
530
|
-
{
|
|
531
|
-
approvalId: challenge.approvalId,
|
|
532
|
-
phrase: challenge.phrase,
|
|
533
|
-
},
|
|
534
|
-
]);
|
|
535
|
-
expect(compensated.run.status).toBe("failed");
|
|
536
|
-
expect(compensated.run.nodes["undo-first"]?.status).toBe("compensated");
|
|
537
|
-
expect(memoryCalls).toBe(1);
|
|
538
|
-
});
|
|
539
|
-
const PAYMENTS = "https://payments.example.test";
|
|
540
|
-
const LOGISTICS = "https://logistics.example.test";
|
|
541
|
-
function call(goal, url) {
|
|
542
|
-
return TaskRequestSchema.parse({
|
|
543
|
-
goal,
|
|
544
|
-
operation: { kind: "http", action: "request", method: "POST", url },
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* Advance until nothing changes, approving whatever the run stops on.
|
|
549
|
-
*
|
|
550
|
-
* Every step here is a mutation, so each one parks for approval — which is
|
|
551
|
-
* the point being preserved: an unwind that needed five approvals still ran
|
|
552
|
-
* in one order, and the helper never volunteers an approval for a node the
|
|
553
|
-
* run did not stop on.
|
|
554
|
-
*/
|
|
555
|
-
async function settle(controller, id) {
|
|
556
|
-
let result = await controller.advance(id);
|
|
557
|
-
for (let round = 0; round < 12; round += 1) {
|
|
558
|
-
const approvals = Object.values(result.run.nodes).flatMap((state) => state.status === "awaiting_approval" && state.approval !== undefined
|
|
559
|
-
? [
|
|
560
|
-
{
|
|
561
|
-
approvalId: state.approval.approvalId,
|
|
562
|
-
phrase: state.approval.phrase,
|
|
563
|
-
},
|
|
564
|
-
]
|
|
565
|
-
: []);
|
|
566
|
-
const next = await controller.advance(id, approvals);
|
|
567
|
-
if (next.events.length === 0)
|
|
568
|
-
return next;
|
|
569
|
-
result = next;
|
|
570
|
-
}
|
|
571
|
-
throw new Error("workflow_did_not_settle");
|
|
572
|
-
}
|
|
573
|
-
/** `host/path`, so an assertion reads as "which provider, which endpoint". */
|
|
574
|
-
function saga(failing) {
|
|
575
|
-
const calls = [];
|
|
576
|
-
return {
|
|
577
|
-
calls,
|
|
578
|
-
async execute(operation) {
|
|
579
|
-
if (operation.kind !== "http")
|
|
580
|
-
throw new Error("unexpected_operation");
|
|
581
|
-
const url = new URL(operation.url);
|
|
582
|
-
const seen = `${url.host}${url.pathname}`;
|
|
583
|
-
calls.push(seen);
|
|
584
|
-
return { success: seen !== failing };
|
|
585
|
-
},
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
function sagaDefinition() {
|
|
589
|
-
return WorkflowDefinitionSchema.parse({
|
|
590
|
-
schemaVersion: "1.0.0",
|
|
591
|
-
id: definitionId,
|
|
592
|
-
version: 1,
|
|
593
|
-
name: "Charge, ship, notify",
|
|
594
|
-
nodes: [
|
|
595
|
-
{
|
|
596
|
-
id: "charge",
|
|
597
|
-
type: "operation",
|
|
598
|
-
request: call("Charge", `${PAYMENTS}/charges`),
|
|
599
|
-
},
|
|
600
|
-
{
|
|
601
|
-
id: "ship",
|
|
602
|
-
type: "operation",
|
|
603
|
-
dependsOn: ["charge"],
|
|
604
|
-
request: call("Ship", `${LOGISTICS}/shipments`),
|
|
605
|
-
},
|
|
606
|
-
{
|
|
607
|
-
id: "notify",
|
|
608
|
-
type: "operation",
|
|
609
|
-
dependsOn: ["ship"],
|
|
610
|
-
request: call("Notify", `${PAYMENTS}/notify`),
|
|
611
|
-
},
|
|
612
|
-
// Declared in forward order; the unwind reverses them.
|
|
613
|
-
{
|
|
614
|
-
id: "undo-charge",
|
|
615
|
-
type: "compensation",
|
|
616
|
-
forNodeId: "charge",
|
|
617
|
-
request: call("Refund", `${PAYMENTS}/refunds`),
|
|
618
|
-
},
|
|
619
|
-
{
|
|
620
|
-
id: "undo-ship",
|
|
621
|
-
type: "compensation",
|
|
622
|
-
forNodeId: "ship",
|
|
623
|
-
request: call("Cancel shipment", `${LOGISTICS}/cancellations`),
|
|
624
|
-
},
|
|
625
|
-
],
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
|
-
it("unwinds two providers newest-effect-first when a later step fails", async () => {
|
|
629
|
-
const { calls, execute } = saga("payments.example.test/notify");
|
|
630
|
-
const { controller } = await setup(execute);
|
|
631
|
-
const planned = controller.plan(sagaDefinition());
|
|
632
|
-
const result = await settle(controller, planned.id);
|
|
633
|
-
// The shipment is cancelled before the charge that paid for it is refunded,
|
|
634
|
-
// and the two land at different hosts through the same governed path.
|
|
635
|
-
expect(calls).toEqual([
|
|
636
|
-
"payments.example.test/charges",
|
|
637
|
-
"logistics.example.test/shipments",
|
|
638
|
-
"payments.example.test/notify",
|
|
639
|
-
"logistics.example.test/cancellations",
|
|
640
|
-
"payments.example.test/refunds",
|
|
641
|
-
]);
|
|
642
|
-
expect(result.run.nodes["undo-ship"]?.status).toBe("compensated");
|
|
643
|
-
expect(result.run.nodes["undo-charge"]?.status).toBe("compensated");
|
|
644
|
-
expect(result.run.status).toBe("failed");
|
|
645
|
-
});
|
|
646
|
-
it("halts and flags the saga when a compensation cannot complete", async () => {
|
|
647
|
-
const { calls, execute } = saga("logistics.example.test/cancellations");
|
|
648
|
-
const { controller } = await setup(async (operation) => {
|
|
649
|
-
const result = await execute(operation);
|
|
650
|
-
if (operation.kind === "http" && operation.url.endsWith("/notify")) {
|
|
651
|
-
return { success: false };
|
|
652
|
-
}
|
|
653
|
-
return result;
|
|
654
|
-
});
|
|
655
|
-
const planned = controller.plan(sagaDefinition());
|
|
656
|
-
const result = await settle(controller, planned.id);
|
|
657
|
-
// Refunding the charge while the shipment is still outstanding would give
|
|
658
|
-
// away the goods. Stop, and say which inverse is missing.
|
|
659
|
-
expect(calls).not.toContain("payments.example.test/refunds");
|
|
660
|
-
expect(result.run.status).toBe("recovery_required");
|
|
661
|
-
expect(result.run.error).toBe("workflow_compensation_incomplete:undo-ship");
|
|
662
|
-
expect(result.run.nodes["undo-charge"]?.status).toBe("pending");
|
|
663
|
-
});
|
|
664
|
-
});
|
|
665
|
-
describe("WorkflowController recovery", () => {
|
|
666
|
-
it("retries an interrupted read through its original governed task", async () => {
|
|
667
|
-
const execute = vi.fn(async () => ({ success: true }));
|
|
668
|
-
const { controller, store, tasks, cipher } = await setup(execute);
|
|
669
|
-
const exact = definition("Retry read");
|
|
670
|
-
const planned = controller.plan(exact);
|
|
671
|
-
const operationNode = exact.nodes[0];
|
|
672
|
-
if (operationNode.type !== "operation") {
|
|
673
|
-
throw new Error("expected_operation_node");
|
|
674
|
-
}
|
|
675
|
-
const task = tasks.plan(operationNode.request, {
|
|
676
|
-
idempotencyKey: sha256({
|
|
677
|
-
workflowId: planned.id,
|
|
678
|
-
nodeId: operationNode.id,
|
|
679
|
-
iteration: 0,
|
|
680
|
-
branch: "operation",
|
|
681
|
-
request: operationNode.request,
|
|
682
|
-
}),
|
|
683
|
-
attempt: 1,
|
|
684
|
-
});
|
|
685
|
-
const interrupted = store.getTask(task.id);
|
|
686
|
-
interrupted.status = "running";
|
|
687
|
-
store.saveTask(interrupted);
|
|
688
|
-
const restarted = new WorkflowController(store, tasks, cipher);
|
|
689
|
-
const [recovered] = await restarted.recoverInterrupted();
|
|
690
|
-
expect(recovered?.nodes.inspect).toMatchObject({
|
|
691
|
-
status: "pending",
|
|
692
|
-
taskIds: [task.id],
|
|
693
|
-
});
|
|
694
|
-
const completed = await restarted.advance(planned.id);
|
|
695
|
-
expect(completed.run.status).toBe("verified_complete");
|
|
696
|
-
expect(execute).toHaveBeenCalledTimes(1);
|
|
697
|
-
});
|
|
698
|
-
it("repairs a projection after a verified task committed first", async () => {
|
|
699
|
-
const execute = vi.fn(async () => ({ success: true }));
|
|
700
|
-
const { controller, store, tasks, cipher } = await setup(execute);
|
|
701
|
-
const planned = controller.plan(definition("Recover projection"));
|
|
702
|
-
const crash = vi
|
|
703
|
-
.spyOn(store, "transitionWorkflow")
|
|
704
|
-
.mockImplementationOnce(() => {
|
|
705
|
-
throw new Error("simulated_projection_crash");
|
|
706
|
-
});
|
|
707
|
-
await expect(controller.advance(planned.id)).rejects.toThrow("simulated_projection_crash");
|
|
708
|
-
crash.mockRestore();
|
|
709
|
-
expect(store.listTasks()[0]?.status).toBe("verified_success");
|
|
710
|
-
expect(store.getWorkflowRun(planned.id)?.nodes.inspect?.status).toBe("pending");
|
|
711
|
-
const restarted = new WorkflowController(store, tasks, cipher);
|
|
712
|
-
const [recovered] = await restarted.recoverInterrupted();
|
|
713
|
-
expect(recovered?.nodes.inspect?.status).toBe("verified_complete");
|
|
714
|
-
expect(recovered?.status).toBe("verified_complete");
|
|
715
|
-
expect(store.listWorkflowEvents(planned.id).at(-1)?.type).toBe("workflow.recovered");
|
|
716
|
-
expect(execute).toHaveBeenCalledTimes(1);
|
|
717
|
-
});
|
|
718
|
-
it("raises an unresolved interrupted mutation to workflow recovery", async () => {
|
|
719
|
-
const execute = vi.fn(async () => ({ success: true, stored: true }));
|
|
720
|
-
const { controller, store, tasks, cipher } = await setup(execute);
|
|
721
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
722
|
-
schemaVersion: "1.0.0",
|
|
723
|
-
id: definitionId,
|
|
724
|
-
version: 1,
|
|
725
|
-
name: "Interrupted mutation",
|
|
726
|
-
nodes: [
|
|
727
|
-
{
|
|
728
|
-
id: "write",
|
|
729
|
-
type: "operation",
|
|
730
|
-
request: mutationRequest("Write"),
|
|
731
|
-
},
|
|
732
|
-
],
|
|
733
|
-
}));
|
|
734
|
-
const waiting = await controller.advance(planned.id);
|
|
735
|
-
const taskId = waiting.run.nodes.write.taskIds[0];
|
|
736
|
-
const task = store.getTask(taskId);
|
|
737
|
-
task.status = "running";
|
|
738
|
-
store.saveTask(task);
|
|
739
|
-
const restarted = new WorkflowController(store, tasks, cipher);
|
|
740
|
-
const [recovered] = await restarted.recoverInterrupted();
|
|
741
|
-
expect(recovered?.nodes.write?.status).toBe("recovery_required");
|
|
742
|
-
expect(recovered?.status).toBe("recovery_required");
|
|
743
|
-
expect(store.listWorkflowEvents(planned.id).at(-1)?.type).toBe("workflow.recovery_required");
|
|
744
|
-
expect(execute).not.toHaveBeenCalled();
|
|
745
|
-
});
|
|
746
|
-
it("ignores a corrupt snapshot only when full event replay succeeds", async () => {
|
|
747
|
-
const { controller, store } = await setup();
|
|
748
|
-
const planned = controller.plan(definition("Snapshot fallback"));
|
|
749
|
-
store.database
|
|
750
|
-
.prepare(`
|
|
751
|
-
INSERT INTO workflow_snapshots(workflow_id, sequence, data, created_at)
|
|
752
|
-
VALUES (?, 999, '{}', ?)
|
|
753
|
-
`)
|
|
754
|
-
.run(planned.id, planned.updatedAt);
|
|
755
|
-
await expect(controller.recoverInterrupted()).resolves.toHaveLength(1);
|
|
756
|
-
expect(store.getWorkflowRun(planned.id)?.status).toBe("running");
|
|
757
|
-
});
|
|
758
|
-
it("fails closed when workflow event history is corrupt", async () => {
|
|
759
|
-
const { controller, store } = await setup();
|
|
760
|
-
const planned = controller.plan(definition("Corrupt history"));
|
|
761
|
-
store.database
|
|
762
|
-
.prepare("UPDATE workflow_events SET data = '{}' WHERE aggregate_id = ?")
|
|
763
|
-
.run(planned.id);
|
|
764
|
-
await expect(controller.recoverInterrupted()).rejects.toThrow("workflow_event_history_invalid");
|
|
765
|
-
});
|
|
766
|
-
});
|
|
767
|
-
describe("WorkflowController operator controls", () => {
|
|
768
|
-
it("refuses to advance a paused workflow and resumes where it stopped", async () => {
|
|
769
|
-
const { controller } = await setup();
|
|
770
|
-
const planned = controller.plan(definition("Pause me"));
|
|
771
|
-
expect(controller.pause(planned.id).status).toBe("paused");
|
|
772
|
-
await expect(controller.advance(planned.id)).rejects.toThrow("workflow_halted:paused");
|
|
773
|
-
expect(controller.resume(planned.id).status).toBe("running");
|
|
774
|
-
const advanced = await controller.advance(planned.id);
|
|
775
|
-
expect(advanced.run.status).toBe("verified_complete");
|
|
776
|
-
});
|
|
777
|
-
it("suspends indefinitely and reports the halt in the event log", async () => {
|
|
778
|
-
const { controller } = await setup();
|
|
779
|
-
const planned = controller.plan(definition("Suspend me"));
|
|
780
|
-
expect(controller.suspend(planned.id).status).toBe("suspended");
|
|
781
|
-
await expect(controller.advance(planned.id)).rejects.toThrow("workflow_halted:suspended");
|
|
782
|
-
expect(controller.events(planned.id).map((event) => event.type)).toContain("workflow.suspended");
|
|
783
|
-
controller.resume(planned.id);
|
|
784
|
-
expect(controller.events(planned.id).map((event) => event.type)).toContain("workflow.resumed");
|
|
785
|
-
});
|
|
786
|
-
it("is idempotent on repeat halts and rejects resuming a live run", async () => {
|
|
787
|
-
const { controller } = await setup();
|
|
788
|
-
const planned = controller.plan(definition("Idempotent halt"));
|
|
789
|
-
expect(controller.pause(planned.id).status).toBe("paused");
|
|
790
|
-
expect(controller.pause(planned.id).status).toBe("paused");
|
|
791
|
-
controller.resume(planned.id);
|
|
792
|
-
expect(() => controller.resume(planned.id)).toThrow("workflow_not_paused");
|
|
793
|
-
});
|
|
794
|
-
it("refuses to halt a finished workflow", async () => {
|
|
795
|
-
const { controller } = await setup();
|
|
796
|
-
const planned = controller.plan(definition("Already done"));
|
|
797
|
-
await controller.advance(planned.id);
|
|
798
|
-
expect(() => controller.pause(planned.id)).toThrow("workflow_not_haltable");
|
|
799
|
-
});
|
|
800
|
-
});
|
|
801
|
-
describe("WorkflowController human input and delegation", () => {
|
|
802
|
-
it("waits on a human_input node and clears it when the answer arrives", async () => {
|
|
803
|
-
const { controller } = await setup();
|
|
804
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
805
|
-
schemaVersion: "1.0.0",
|
|
806
|
-
id: definitionId,
|
|
807
|
-
version: 1,
|
|
808
|
-
name: "Ask first",
|
|
809
|
-
nodes: [
|
|
810
|
-
{
|
|
811
|
-
id: "ask",
|
|
812
|
-
type: "human_input",
|
|
813
|
-
prompt: "Ship it?",
|
|
814
|
-
choices: ["yes", "no"],
|
|
815
|
-
},
|
|
816
|
-
{
|
|
817
|
-
id: "act",
|
|
818
|
-
type: "operation",
|
|
819
|
-
dependsOn: ["ask"],
|
|
820
|
-
request: request("after the answer"),
|
|
821
|
-
},
|
|
822
|
-
],
|
|
823
|
-
}));
|
|
824
|
-
const waiting = await controller.advance(planned.id);
|
|
825
|
-
expect(waiting.run.status).toBe("awaiting_input");
|
|
826
|
-
expect(waiting.run.nodes.ask?.status).toBe("awaiting_input");
|
|
827
|
-
expect(waiting.run.nodes.ask?.prompt).toBe("Ship it?");
|
|
828
|
-
expect(waiting.run.nodes.act?.status).toBe("pending");
|
|
829
|
-
// An unlisted answer is rejected, so a workflow can branch on a value it
|
|
830
|
-
// actually enumerated rather than on arbitrary prose.
|
|
831
|
-
await expect(controller.advance(planned.id, [], [{ nodeId: "ask", value: "maybe" }])).rejects.toThrow("workflow_input_not_a_choice:ask");
|
|
832
|
-
const answered = await controller.advance(planned.id, [], [{ nodeId: "ask", value: "yes" }]);
|
|
833
|
-
expect(answered.run.nodes.ask?.status).toBe("verified_complete");
|
|
834
|
-
expect(answered.run.nodes.ask?.input).toBe("yes");
|
|
835
|
-
const finished = await controller.advance(planned.id);
|
|
836
|
-
expect(finished.run.status).toBe("verified_complete");
|
|
837
|
-
});
|
|
838
|
-
it("fails a delegation whose declared evidence does not hold", async () => {
|
|
839
|
-
const { controller } = await setup();
|
|
840
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
841
|
-
schemaVersion: "1.0.0",
|
|
842
|
-
id: definitionId,
|
|
843
|
-
version: 1,
|
|
844
|
-
name: "Hand it off",
|
|
845
|
-
nodes: [
|
|
846
|
-
{
|
|
847
|
-
id: "handoff",
|
|
848
|
-
type: "delegation",
|
|
849
|
-
assignee: "outside-worker",
|
|
850
|
-
goal: "write the report",
|
|
851
|
-
requiredEvidence: [{ type: "file_exists", path: "report.md" }],
|
|
852
|
-
},
|
|
853
|
-
],
|
|
854
|
-
}));
|
|
855
|
-
expect((await controller.advance(planned.id)).run.status).toBe("awaiting_input");
|
|
856
|
-
// The delegate says done; the file it promised is not there. A delegate's
|
|
857
|
-
// word is not evidence, so the node fails rather than completing.
|
|
858
|
-
const reported = await controller.advance(planned.id, [], [{ nodeId: "handoff", value: "done" }]);
|
|
859
|
-
expect(reported.run.nodes.handoff?.status).toBe("failed");
|
|
860
|
-
expect(reported.run.nodes.handoff?.error).toBe("workflow_delegation_unverified:handoff");
|
|
861
|
-
expect(reported.run.status).toBe("failed");
|
|
862
|
-
});
|
|
863
|
-
it("refuses an answer longer than the node allows", async () => {
|
|
864
|
-
const { controller } = await setup();
|
|
865
|
-
const planned = controller.plan(WorkflowDefinitionSchema.parse({
|
|
866
|
-
schemaVersion: "1.0.0",
|
|
867
|
-
id: definitionId,
|
|
868
|
-
version: 1,
|
|
869
|
-
name: "Short answers only",
|
|
870
|
-
nodes: [
|
|
871
|
-
{
|
|
872
|
-
id: "ask",
|
|
873
|
-
type: "human_input",
|
|
874
|
-
prompt: "Initials?",
|
|
875
|
-
maxLength: 4,
|
|
876
|
-
},
|
|
877
|
-
],
|
|
878
|
-
}));
|
|
879
|
-
await controller.advance(planned.id);
|
|
880
|
-
await expect(controller.advance(planned.id, [], [{ nodeId: "ask", value: "far too long" }])).rejects.toThrow("workflow_input_too_long:ask");
|
|
881
|
-
});
|
|
882
|
-
});
|
|
883
|
-
describe("WorkflowController leases", () => {
|
|
884
|
-
it("refuses a second process while one holds the workflow lease", async () => {
|
|
885
|
-
let release = () => { };
|
|
886
|
-
const held = new Promise((resolve) => {
|
|
887
|
-
release = resolve;
|
|
888
|
-
});
|
|
889
|
-
let started = () => { };
|
|
890
|
-
const running = new Promise((resolve) => {
|
|
891
|
-
started = resolve;
|
|
892
|
-
});
|
|
893
|
-
const { store, tasks, cipher, controller } = await setup(async () => {
|
|
894
|
-
started();
|
|
895
|
-
await held;
|
|
896
|
-
return { success: true };
|
|
897
|
-
});
|
|
898
|
-
// A distinct controller stands in for a second process: separate owner id,
|
|
899
|
-
// separate in-process advance map, same SQLite file.
|
|
900
|
-
const other = new WorkflowController(store, tasks, cipher);
|
|
901
|
-
const planned = controller.plan(definition("Leased work"));
|
|
902
|
-
const first = controller.advance(planned.id);
|
|
903
|
-
await running;
|
|
904
|
-
await expect(other.advance(planned.id)).rejects.toThrow("workflow_lease_held");
|
|
905
|
-
release();
|
|
906
|
-
expect((await first).run.status).toBe("verified_complete");
|
|
907
|
-
// The lease is released with the advance, so the next process gets in.
|
|
908
|
-
expect(store.getWorkflowLease(planned.id)).toBeUndefined();
|
|
909
|
-
});
|
|
910
|
-
});
|
|
911
|
-
//# sourceMappingURL=workflow-controller.test.js.map
|