@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
|
@@ -5,6 +5,17 @@ import { WorkflowDefinitionSchema, WorkflowEventSchema, WorkflowRunSchema, Workf
|
|
|
5
5
|
import { canonicalJson, redactStructuredValue, sha256, } from "@melra/receipt-schema";
|
|
6
6
|
import { applyWorkflowEvent, rebuildWorkflow, } from "./workflow-events.js";
|
|
7
7
|
import { readyNodeIds, validateWorkflow, } from "./workflow-graph.js";
|
|
8
|
+
const RECOVERABLE = [
|
|
9
|
+
"planned",
|
|
10
|
+
"running",
|
|
11
|
+
"awaiting_approval",
|
|
12
|
+
"recovery_required",
|
|
13
|
+
];
|
|
14
|
+
// A failed run's advance runs its compensations, so an advance that died on
|
|
15
|
+
// one leaves the same hazard as any other: a compensation it started, planned
|
|
16
|
+
// and run again. Repaired too, but only once an expired lease says an advance
|
|
17
|
+
// died there; the full startup pass leaves failed runs as they are.
|
|
18
|
+
const ABANDONABLE = [...RECOVERABLE, "failed"];
|
|
8
19
|
export class WorkflowController {
|
|
9
20
|
store;
|
|
10
21
|
tasks;
|
|
@@ -15,6 +26,12 @@ export class WorkflowController {
|
|
|
15
26
|
// process from racing itself without touching the disk on every call.
|
|
16
27
|
advances = new Map();
|
|
17
28
|
owner = `${process.pid}:${randomUUID()}`;
|
|
29
|
+
// Distinct from `owner`, so this process's own advance in flight also keeps
|
|
30
|
+
// its recovery away from the run.
|
|
31
|
+
recoveryOwner = `${this.owner}:recovery`;
|
|
32
|
+
// Runs an advance in this process holds the lease for. Recovery leaves them
|
|
33
|
+
// alone even once the lease has lapsed, say across a laptop's sleep.
|
|
34
|
+
leased = new Set();
|
|
18
35
|
constructor(store, tasks, payloadCipher, leaseDurationMs = 60_000) {
|
|
19
36
|
this.store = store;
|
|
20
37
|
this.tasks = tasks;
|
|
@@ -53,7 +70,18 @@ export class WorkflowController {
|
|
|
53
70
|
});
|
|
54
71
|
const planned = applyWorkflowEvent(draft, plannedEvent);
|
|
55
72
|
const redacted = WorkflowDefinitionSchema.parse(redactStructuredValue(parsed).value);
|
|
56
|
-
|
|
73
|
+
// A definition is stored once per id and version, and any number of runs
|
|
74
|
+
// may use it: planning the same file twice is a second run, not an error.
|
|
75
|
+
// The same id and version with different content is refused, because
|
|
76
|
+
// runs already planned from the stored one must keep meaning what they did.
|
|
77
|
+
const purpose = `workflow:${parsed.id}:${parsed.version}:definition`;
|
|
78
|
+
const stored = this.store.getWorkflowPayload(parsed.id, parsed.version);
|
|
79
|
+
if (stored !== undefined &&
|
|
80
|
+
canonicalJson(WorkflowDefinitionSchema.parse(this.payloadCipher.open(stored, purpose))) !==
|
|
81
|
+
canonicalJson(parsed)) {
|
|
82
|
+
throw new Error(`workflow_definition_conflict:${parsed.id}@${parsed.version}: a different definition is stored under this id and version; bump "version"`);
|
|
83
|
+
}
|
|
84
|
+
this.store.createWorkflow(redacted, stored === undefined ? this.payloadCipher.seal(parsed, purpose) : undefined, planned, [created, plannedEvent]);
|
|
57
85
|
return planned;
|
|
58
86
|
}
|
|
59
87
|
status(workflowId) {
|
|
@@ -86,7 +114,40 @@ export class WorkflowController {
|
|
|
86
114
|
// start duplicate side effects while this one is mid-flight. `transitionWorkflow`
|
|
87
115
|
// still guards the commit; this guards the work leading up to it.
|
|
88
116
|
async leasedAdvance(workflowId, approvals, inputs) {
|
|
89
|
-
this.status(workflowId);
|
|
117
|
+
const current = this.status(workflowId);
|
|
118
|
+
// Refused before the lease is touched: taking it would replace, and the
|
|
119
|
+
// release then delete, an expired lease a dead advance left, which is the
|
|
120
|
+
// only sign the run still needs recovering once it is resumed.
|
|
121
|
+
if (["paused", "suspended"].includes(current.status)) {
|
|
122
|
+
throw new Error(`workflow_halted:${current.status}`);
|
|
123
|
+
}
|
|
124
|
+
// Every task would refuse these anyway, but only once its node's turn came,
|
|
125
|
+
// after siblings in the same wave had already been cancelled, planned
|
|
126
|
+
// again or run on the strength of a request that is refused. Refused
|
|
127
|
+
// before anything moves.
|
|
128
|
+
if (this.tasks.enforced && approvals.length > 0)
|
|
129
|
+
throw new Error("approval_in_band_refused");
|
|
130
|
+
// An expired lease someone else holds is an advance that died mid-flight.
|
|
131
|
+
// Taking it over would read a projection from before that advance's tasks
|
|
132
|
+
// and plan them again under keys that were never committed, so the run is
|
|
133
|
+
// recovered first. A lease left exactly as it was means a task of that
|
|
134
|
+
// advance is still running somewhere: wait, as for a live holder.
|
|
135
|
+
const left = this.store.getWorkflowLease(workflowId);
|
|
136
|
+
if (left !== undefined &&
|
|
137
|
+
left.owner !== this.owner &&
|
|
138
|
+
left.expiresAt <= new Date().toISOString() &&
|
|
139
|
+
ABANDONABLE.includes(current.status)) {
|
|
140
|
+
// A repaired run has no lease left. One still there is a task still
|
|
141
|
+
// running, a live process that got in first, or a repair that failed.
|
|
142
|
+
// Only this run's own failure is reported as itself; the rest of the
|
|
143
|
+
// pass's belong to other runs or tasks and reach the sweep's warnings.
|
|
144
|
+
const pass = await this.recoveryPass(true);
|
|
145
|
+
if (this.store.getWorkflowLease(workflowId) !== undefined) {
|
|
146
|
+
if (pass.runFailures.has(workflowId))
|
|
147
|
+
throw pass.runFailures.get(workflowId);
|
|
148
|
+
throw new Error("workflow_lease_held");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
90
151
|
const now = Date.now();
|
|
91
152
|
if (!this.store.acquireWorkflowLease(workflowId, this.owner, new Date(now).toISOString(), new Date(now + this.leaseDurationMs).toISOString())) {
|
|
92
153
|
throw new Error("workflow_lease_held");
|
|
@@ -97,10 +158,12 @@ export class WorkflowController {
|
|
|
97
158
|
this.store.renewWorkflowLease(workflowId, this.owner, new Date(Date.now() + this.leaseDurationMs).toISOString());
|
|
98
159
|
}, Math.max(1_000, Math.floor(this.leaseDurationMs / 2)));
|
|
99
160
|
renewal.unref();
|
|
161
|
+
this.leased.add(workflowId);
|
|
100
162
|
try {
|
|
101
163
|
return await this.advanceOnce(workflowId, approvals, inputs);
|
|
102
164
|
}
|
|
103
165
|
finally {
|
|
166
|
+
this.leased.delete(workflowId);
|
|
104
167
|
clearInterval(renewal);
|
|
105
168
|
this.store.releaseWorkflowLease(workflowId, this.owner);
|
|
106
169
|
}
|
|
@@ -137,7 +200,7 @@ export class WorkflowController {
|
|
|
137
200
|
["failed", "recovery_required"].includes(current.nodes[node.id]?.status ?? ""))) {
|
|
138
201
|
return { run: current, tasks: [], events: [] };
|
|
139
202
|
}
|
|
140
|
-
// A node parked for a human — approval
|
|
203
|
+
// A node parked for a human — approval or supplied input — is no
|
|
141
204
|
// longer `pending`, so `readyNodeIds` will not return it. Re-offer it here
|
|
142
205
|
// or the workflow would stall forever with the answer in hand.
|
|
143
206
|
const awaiting = [...graph.nodes.values()]
|
|
@@ -153,6 +216,11 @@ export class WorkflowController {
|
|
|
153
216
|
if (nodeIds.length === 0) {
|
|
154
217
|
return { run: current, tasks: [], events: [] };
|
|
155
218
|
}
|
|
219
|
+
// A sibling's refusal is reported only after the others' changes are
|
|
220
|
+
// committed: a node another sibling cancelled and planned again in this
|
|
221
|
+
// wave would otherwise keep pointing at the task it cancelled, and stay
|
|
222
|
+
// cancelled for good.
|
|
223
|
+
let refused;
|
|
156
224
|
const results = await (rollback.length >
|
|
157
225
|
0
|
|
158
226
|
? // Compensations undo each other's preconditions, so the order they run
|
|
@@ -163,13 +231,19 @@ export class WorkflowController {
|
|
|
163
231
|
// continuing past a failed unwind would strand the saga in a state no
|
|
164
232
|
// node describes.
|
|
165
233
|
this.unwind(rollback, graph, definition, current, approvals, inputs)
|
|
166
|
-
: Promise.
|
|
234
|
+
: Promise.allSettled(nodeIds.map(async (nodeId) => {
|
|
167
235
|
const node = graph.nodes.get(nodeId);
|
|
168
236
|
return {
|
|
169
237
|
nodeId,
|
|
170
238
|
result: await this.advanceNode(node, definition, current.nodes[nodeId], current, approvals, inputs),
|
|
171
239
|
};
|
|
172
|
-
})))
|
|
240
|
+
})).then((settled) => {
|
|
241
|
+
// Every sibling finishes before one's error is reported. Rejecting
|
|
242
|
+
// at the first throw released the lease while other adapters still
|
|
243
|
+
// ran, and a retry then read their tasks as failed.
|
|
244
|
+
refused = settled.find((outcome) => outcome.status === "rejected");
|
|
245
|
+
return settled.flatMap((outcome) => (outcome.status === "fulfilled" ? [outcome.value] : []));
|
|
246
|
+
}));
|
|
173
247
|
// A *compensation* that failed must not open a second unwind. The saga is
|
|
174
248
|
// already stranded, and undoing the next step while this inverse is still
|
|
175
249
|
// outstanding is precisely what the halt exists to prevent — so only a
|
|
@@ -276,78 +350,223 @@ export class WorkflowController {
|
|
|
276
350
|
createdAt: run.updatedAt,
|
|
277
351
|
}));
|
|
278
352
|
}
|
|
353
|
+
if (refused !== undefined)
|
|
354
|
+
throw refused.reason;
|
|
279
355
|
return {
|
|
280
356
|
run,
|
|
281
357
|
tasks: results.flatMap(({ result }) => result.tasks),
|
|
282
358
|
events: this.events(workflowId, current.stateVersion),
|
|
283
359
|
};
|
|
284
360
|
}
|
|
285
|
-
|
|
286
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Repairs runs whose advance is not coming back. A run is touched only under
|
|
363
|
+
* its lease, so a live advance in any process (this one included) keeps its
|
|
364
|
+
* run; and only when none of its tasks is still running, which after the
|
|
365
|
+
* task pass means a live process owns that task. `abandonedOnly` limits the
|
|
366
|
+
* pass to runs whose advance died holding the lease — the periodic sweep.
|
|
367
|
+
*/
|
|
368
|
+
async recoverInterrupted({ abandonedOnly = false } = {}) {
|
|
369
|
+
// A task or run that cannot be recovered is reported once everything else
|
|
370
|
+
// has been: one bad record must not hold up the rest.
|
|
371
|
+
const pass = await this.recoveryPass(abandonedOnly);
|
|
372
|
+
if (pass.failures.length > 0)
|
|
373
|
+
throw pass.failures[0];
|
|
374
|
+
return pass.recovered;
|
|
375
|
+
}
|
|
376
|
+
async recoveryPass(abandonedOnly) {
|
|
377
|
+
const taskFailures = new Map();
|
|
378
|
+
const failures = [];
|
|
379
|
+
await this.tasks.recoverInterrupted(taskFailures).catch((error) => {
|
|
380
|
+
failures.push(error);
|
|
381
|
+
});
|
|
382
|
+
const runFailures = new Map();
|
|
383
|
+
const recovered = this.repairRuns(abandonedOnly, taskFailures, runFailures);
|
|
384
|
+
failures.push(...taskFailures.values(), ...runFailures.values());
|
|
385
|
+
return { recovered, failures, runFailures };
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Repairs each run under its lease. `runFailures` gets each run's own reason
|
|
389
|
+
* for not being repaired, including a task of its that could not be.
|
|
390
|
+
*/
|
|
391
|
+
repairRuns(abandonedOnly, taskFailures, runFailures) {
|
|
392
|
+
const at = Date.now();
|
|
393
|
+
// The sweep reads only runs with an expired lease, so a home's history of
|
|
394
|
+
// finished and failed runs costs it nothing.
|
|
395
|
+
const runs = abandonedOnly
|
|
396
|
+
? this.store
|
|
397
|
+
.expiredWorkflowLeases(new Date(at).toISOString())
|
|
398
|
+
.map((id) => this.store.getWorkflowRun(id))
|
|
399
|
+
.filter((run) => run !== undefined && ABANDONABLE.includes(run.status))
|
|
400
|
+
: this.store.listWorkflowRuns(RECOVERABLE);
|
|
401
|
+
if (runs.length === 0)
|
|
402
|
+
return [];
|
|
287
403
|
const recovered = [];
|
|
288
404
|
const allTasks = this.store.listTasks(100_000);
|
|
289
|
-
for (const stored of
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
405
|
+
for (const stored of runs) {
|
|
406
|
+
if (this.leased.has(stored.id))
|
|
407
|
+
continue;
|
|
408
|
+
let definition;
|
|
409
|
+
try {
|
|
410
|
+
definition = this.loadDefinition(stored);
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
runFailures.set(stored.id, error);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const keys = new Set(definition.nodes.flatMap((node) => expectedTaskKeys(stored.id, node, definition)));
|
|
417
|
+
// Skipped runs keep any abandoned lease, which is how the sweep finds
|
|
418
|
+
// them again once that task settles.
|
|
419
|
+
const unsettled = allTasks.filter((task) => task.idempotencyKey !== undefined &&
|
|
420
|
+
keys.has(task.idempotencyKey) &&
|
|
421
|
+
(task.status === "running" || task.status === "verifying"));
|
|
422
|
+
if (unsettled.length > 0) {
|
|
423
|
+
const failed = unsettled.find((task) => taskFailures.has(task.id));
|
|
424
|
+
if (failed !== undefined)
|
|
425
|
+
runFailures.set(stored.id, taskFailures.get(failed.id));
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const taken = this.store.takeWorkflowLease(stored.id, this.recoveryOwner, new Date(at).toISOString(), new Date(at + this.leaseDurationMs).toISOString());
|
|
429
|
+
if (taken === "refused")
|
|
430
|
+
continue;
|
|
431
|
+
let repaired = false;
|
|
432
|
+
let failed = false;
|
|
433
|
+
try {
|
|
434
|
+
// Changed since the listing, by an advance or by an operator's pause,
|
|
435
|
+
// so the tasks read above may be stale.
|
|
436
|
+
if (this.store.getWorkflowRun(stored.id)?.stateVersion === stored.stateVersion) {
|
|
437
|
+
recovered.push(this.recoverRun(stored, definition, allTasks));
|
|
438
|
+
repaired = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
failed = true;
|
|
443
|
+
runFailures.set(stored.id, error);
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
// A run not repaired keeps a lease, expired rather than deleted, when
|
|
447
|
+
// it still needs one: its repair failed, or the lease it replaced was
|
|
448
|
+
// a dead advance's or an earlier repair's. That lease is how the sweep
|
|
449
|
+
// and the next advance know, and without it an advance would plan its
|
|
450
|
+
// tasks again. A lease this pass created replaced nothing: whoever
|
|
451
|
+
// held one last released it with the run left straight.
|
|
452
|
+
if (repaired || (!failed && taken === "created")) {
|
|
453
|
+
this.store.releaseWorkflowLease(stored.id, this.recoveryOwner);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
this.store.renewWorkflowLease(stored.id, this.recoveryOwner, new Date(0).toISOString());
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return recovered;
|
|
461
|
+
}
|
|
462
|
+
recoverRun(stored, definition, allTasks) {
|
|
463
|
+
const replayed = this.replay(stored);
|
|
464
|
+
if (replayed.stateVersion !== stored.stateVersion) {
|
|
465
|
+
throw new Error("workflow_event_history_invalid");
|
|
466
|
+
}
|
|
467
|
+
return this.transition(stored, (draft, emit) => {
|
|
468
|
+
for (const node of definition.nodes) {
|
|
469
|
+
// An advance on a failed run only ever runs compensations, so only
|
|
470
|
+
// they can be stale. Re-deriving the forward nodes would rank their
|
|
471
|
+
// tasks differently from the live path and could take the run out of
|
|
472
|
+
// failed: a branch awaiting approval beside a failed one reopened.
|
|
473
|
+
if (stored.status === "failed" && node.type !== "compensation")
|
|
474
|
+
continue;
|
|
475
|
+
const found = expectedTaskKeys(stored.id, node, definition).map((key) => preferredTask(allTasks, key));
|
|
476
|
+
let state = recoveredNodeState(node, found.filter((task) => task !== undefined), replayed.nodes[node.id]);
|
|
477
|
+
// An operation behind an approval shares its task with the approval
|
|
478
|
+
// node. Waiting for the approval is that node's state; mirrored onto
|
|
479
|
+
// the operation, both were re-offered, the approval ran the effect,
|
|
480
|
+
// and the operation read the running task as failed.
|
|
481
|
+
if (node.type === "operation" &&
|
|
482
|
+
state.status === "awaiting_approval" &&
|
|
483
|
+
definition.nodes.some((other) => other.type === "approval" && other.forNodeId === node.id)) {
|
|
484
|
+
state = replayed.nodes[node.id];
|
|
485
|
+
}
|
|
486
|
+
// A sequence whose finished steps all verified is not finished if
|
|
487
|
+
// steps remain: a crash between a loop's iterations, or between a
|
|
488
|
+
// branch's steps, recovered as complete with the rest never run.
|
|
489
|
+
// Pending, so the next advance carries on from the tasks it has.
|
|
490
|
+
// Unless the projection recorded it finished in a way only the live
|
|
491
|
+
// path finishes one short: a loop stopped by `until` at a whole
|
|
492
|
+
// iteration, or a condition whose chosen branch is empty. Reopened,
|
|
493
|
+
// `until` or the condition would be asked again of a world that has
|
|
494
|
+
// moved on. Anything else recorded finished but short was written by
|
|
495
|
+
// a recovery that never downgraded (alpha.12), and is downgraded now.
|
|
496
|
+
const recorded = replayed.nodes[node.id];
|
|
497
|
+
const finishedShort = recorded.status === "verified_complete" &&
|
|
498
|
+
canonicalJson(recorded.taskIds) === canonicalJson(state.taskIds) &&
|
|
499
|
+
((node.type === "bounded_loop" &&
|
|
500
|
+
node.until !== undefined &&
|
|
501
|
+
state.taskIds.length > 0 &&
|
|
502
|
+
state.taskIds.length % node.body.length === 0) ||
|
|
503
|
+
(node.type === "condition" && state.taskIds.length === 0));
|
|
504
|
+
if (state.status === "verified_complete" && !sequenceFinished(node, found) && !finishedShort) {
|
|
505
|
+
state = {
|
|
506
|
+
status: "pending",
|
|
507
|
+
taskIds: state.taskIds,
|
|
508
|
+
// Whole iterations only: the loop resumes at the one in progress.
|
|
509
|
+
...(node.type === "bounded_loop"
|
|
510
|
+
? { iterations: Math.floor(state.taskIds.length / node.body.length) }
|
|
511
|
+
: {}),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
// A loop that stopped on a step keeps the iteration it stopped in, as
|
|
515
|
+
// runLoop records it. Without it the next advance starts again at
|
|
516
|
+
// iteration 0 and asks `until` of a task long finished.
|
|
517
|
+
if (node.type === "bounded_loop" && state.iterations === undefined && state.taskIds.length > 0) {
|
|
518
|
+
state = { ...state, iterations: Math.floor((state.taskIds.length - 1) / node.body.length) };
|
|
519
|
+
}
|
|
520
|
+
if (canonicalJson(draft.nodes[node.id]) === canonicalJson(state)) {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const from = draft.nodes[node.id].status;
|
|
524
|
+
draft.nodes[node.id] = state;
|
|
525
|
+
emit("workflow.node_changed", {
|
|
526
|
+
nodeId: node.id,
|
|
527
|
+
from,
|
|
528
|
+
state,
|
|
529
|
+
});
|
|
298
530
|
}
|
|
299
|
-
|
|
300
|
-
|
|
531
|
+
let status = this.deriveStatus(draft, definition);
|
|
532
|
+
if (status === "verified_complete") {
|
|
301
533
|
for (const node of definition.nodes) {
|
|
302
|
-
const state =
|
|
303
|
-
|
|
304
|
-
.filter((task) => task !== undefined), replayed.nodes[node.id]);
|
|
305
|
-
if (canonicalJson(draft.nodes[node.id]) === canonicalJson(state)) {
|
|
534
|
+
const state = draft.nodes[node.id];
|
|
535
|
+
if (node.type !== "compensation" || state.status !== "pending") {
|
|
306
536
|
continue;
|
|
307
537
|
}
|
|
308
|
-
|
|
309
|
-
draft.nodes[node.id] = state;
|
|
538
|
+
state.status = "skipped";
|
|
310
539
|
emit("workflow.node_changed", {
|
|
311
540
|
nodeId: node.id,
|
|
312
|
-
from,
|
|
541
|
+
from: "pending",
|
|
313
542
|
state,
|
|
314
543
|
});
|
|
315
544
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
emit(status === "recovery_required"
|
|
341
|
-
? "workflow.recovery_required"
|
|
342
|
-
: "workflow.recovered", {
|
|
343
|
-
from,
|
|
344
|
-
to: status,
|
|
345
|
-
...(draft.error === undefined ? {} : { error: draft.error }),
|
|
346
|
-
});
|
|
545
|
+
status = this.deriveStatus(draft, definition);
|
|
546
|
+
}
|
|
547
|
+
const from = draft.status;
|
|
548
|
+
draft.status = status;
|
|
549
|
+
// Named the way advanceOnce names a stalled unwind, so a run reads the
|
|
550
|
+
// same whether its process died or not.
|
|
551
|
+
const stalled = definition.nodes.find((node) => node.type === "compensation" &&
|
|
552
|
+
["failed", "recovery_required"].includes(draft.nodes[node.id]?.status ?? ""));
|
|
553
|
+
if (status === "recovery_required") {
|
|
554
|
+
draft.error =
|
|
555
|
+
stalled === undefined
|
|
556
|
+
? "workflow_contains_unreconciled_mutation"
|
|
557
|
+
: `workflow_compensation_incomplete:${stalled.id}`;
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
delete draft.error;
|
|
561
|
+
}
|
|
562
|
+
emit(status === "recovery_required"
|
|
563
|
+
? "workflow.recovery_required"
|
|
564
|
+
: "workflow.recovered", {
|
|
565
|
+
from,
|
|
566
|
+
to: status,
|
|
567
|
+
...(draft.error === undefined ? {} : { error: draft.error }),
|
|
347
568
|
});
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
return recovered;
|
|
569
|
+
});
|
|
351
570
|
}
|
|
352
571
|
// Operator controls. `pause` is a soft stop an operator lifts with `resume`;
|
|
353
572
|
// `suspend` is the same stop for an operator who wants the run parked
|
|
@@ -365,18 +584,22 @@ export class WorkflowController {
|
|
|
365
584
|
if (!["paused", "suspended"].includes(current.status)) {
|
|
366
585
|
throw new Error("workflow_not_paused");
|
|
367
586
|
}
|
|
587
|
+
const definition = this.loadDefinition(current);
|
|
368
588
|
return this.transition(current, (draft, emit) => {
|
|
369
589
|
const from = draft.status;
|
|
370
590
|
// Restore whatever the graph was actually blocked on before the halt,
|
|
371
|
-
// so resuming a run parked on a human
|
|
372
|
-
|
|
373
|
-
draft.status =
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
591
|
+
// so resuming a run parked on a human, or on an effect whose outcome is
|
|
592
|
+
// unknown, does not claim it is running.
|
|
593
|
+
draft.status = this.deriveStatus(draft, definition);
|
|
594
|
+
if (draft.status !== "recovery_required")
|
|
595
|
+
delete draft.error;
|
|
596
|
+
else
|
|
597
|
+
draft.error ??= "workflow_contains_unreconciled_mutation";
|
|
598
|
+
emit("workflow.resumed", {
|
|
599
|
+
from,
|
|
600
|
+
to: draft.status,
|
|
601
|
+
...(draft.error === undefined ? {} : { error: draft.error }),
|
|
602
|
+
});
|
|
380
603
|
});
|
|
381
604
|
}
|
|
382
605
|
halt(workflowId, to, eventType) {
|
|
@@ -389,7 +612,9 @@ export class WorkflowController {
|
|
|
389
612
|
return this.transition(current, (draft, emit) => {
|
|
390
613
|
const from = draft.status;
|
|
391
614
|
draft.status = to;
|
|
392
|
-
|
|
615
|
+
// Kept: a run halted while it needed recovery still needs it, and the
|
|
616
|
+
// reducer drops an error the event does not carry.
|
|
617
|
+
emit(eventType, { from, to, ...(draft.error === undefined ? {} : { error: draft.error }) });
|
|
393
618
|
});
|
|
394
619
|
}
|
|
395
620
|
cancel(workflowId) {
|
|
@@ -513,15 +738,16 @@ export class WorkflowController {
|
|
|
513
738
|
if (sourceTaskId === undefined) {
|
|
514
739
|
throw new Error(`workflow_condition_source_unavailable:${node.id}`);
|
|
515
740
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
741
|
+
// Decided once. A branch that has started keeps going: re-evaluating a
|
|
742
|
+
// live predicate on a later advance could flip it and reuse the other
|
|
743
|
+
// branch's tasks by position.
|
|
744
|
+
const identity = { workflowId: run.id, nodeId: node.id, iteration: 0 };
|
|
745
|
+
const started = state.taskIds[0] === undefined ? undefined : this.tasks.status(state.taskIds[0]).idempotencyKey;
|
|
746
|
+
const chosen = started === undefined
|
|
747
|
+
? (await this.tasks.verifyPersisted(sourceTaskId, [node.predicate])).verified
|
|
748
|
+
: node.whenTrue.length > 0 &&
|
|
749
|
+
started === taskIdempotencyKey({ ...identity, branch: "true:0" }, node.whenTrue[0]);
|
|
750
|
+
return await this.runSequential(chosen ? node.whenTrue : node.whenFalse, state, approvals, { ...identity, branch: chosen ? "true" : "false" });
|
|
525
751
|
}
|
|
526
752
|
case "parallel":
|
|
527
753
|
return await this.runParallel(node.branches, state, approvals, run.id, node.id);
|
|
@@ -623,11 +849,29 @@ export class WorkflowController {
|
|
|
623
849
|
? this.tasks.plan(request, {
|
|
624
850
|
idempotencyKey: taskIdempotencyKey(identity, request),
|
|
625
851
|
attempt: 1,
|
|
852
|
+
stored: true,
|
|
626
853
|
})
|
|
627
854
|
: this.tasks.status(existingTaskId);
|
|
855
|
+
// A challenge that expired before anyone answered it can never be
|
|
856
|
+
// answered, and reusing it left the node unapprovable for good. Ask again:
|
|
857
|
+
// the expired task is cancelled and the same request planned afresh.
|
|
858
|
+
// A challenge an approver answered in time is not asked again, however
|
|
859
|
+
// long the answer took to act on.
|
|
860
|
+
const decided = task.status === "awaiting_approval" && this.tasks.approvalDecision(task) !== undefined;
|
|
861
|
+
if (task.status === "awaiting_approval" &&
|
|
862
|
+
task.approval !== undefined &&
|
|
863
|
+
Date.parse(task.approval.expiresAt) <= Date.now() &&
|
|
864
|
+
!decided) {
|
|
865
|
+
this.tasks.cancel(task.id);
|
|
866
|
+
task = this.tasks.plan(request, {
|
|
867
|
+
idempotencyKey: taskIdempotencyKey(identity, request),
|
|
868
|
+
attempt: (task.attempt ?? 1) + 1,
|
|
869
|
+
stored: true,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
628
872
|
if (task.status === "awaiting_approval") {
|
|
629
873
|
const approval = approvals.find((item) => item.approvalId === task.approval?.approvalId);
|
|
630
|
-
if (approval === undefined) {
|
|
874
|
+
if (approval === undefined && !decided) {
|
|
631
875
|
return {
|
|
632
876
|
state: {
|
|
633
877
|
status: "awaiting_approval",
|
|
@@ -688,6 +932,16 @@ export class WorkflowController {
|
|
|
688
932
|
const taskIds = [...state.taskIds];
|
|
689
933
|
const tasks = [];
|
|
690
934
|
let iterations = state.iterations ?? 0;
|
|
935
|
+
// Resumed after recovery between iterations: the last one finished but its
|
|
936
|
+
// `until` was never checked, and running another would be one too many.
|
|
937
|
+
const finished = taskIds[iterations * node.body.length - 1];
|
|
938
|
+
if (iterations > 0 &&
|
|
939
|
+
taskIds.length === iterations * node.body.length &&
|
|
940
|
+
finished !== undefined &&
|
|
941
|
+
node.until !== undefined &&
|
|
942
|
+
(await this.tasks.verifyPersisted(finished, [node.until])).verified) {
|
|
943
|
+
return { state: { status: "verified_complete", taskIds, iterations }, tasks };
|
|
944
|
+
}
|
|
691
945
|
while (iterations < node.maxIterations) {
|
|
692
946
|
const offset = iterations * node.body.length;
|
|
693
947
|
for (const [index, request] of node.body.entries()) {
|
|
@@ -881,6 +1135,25 @@ function expectedTaskKeys(workflowId, node, definition) {
|
|
|
881
1135
|
return [];
|
|
882
1136
|
}
|
|
883
1137
|
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Whether a recovered node that runs steps in sequence ran all of them, given
|
|
1140
|
+
* its expected tasks in `expectedTaskKeys` order (missing ones undefined).
|
|
1141
|
+
*/
|
|
1142
|
+
function sequenceFinished(node, found) {
|
|
1143
|
+
if (node.type === "condition") {
|
|
1144
|
+
// Only one branch ever runs; finished means the branch that ran is whole.
|
|
1145
|
+
const whenTrue = found.slice(0, node.whenTrue.length);
|
|
1146
|
+
const whenFalse = found.slice(node.whenTrue.length);
|
|
1147
|
+
const whole = (steps) => steps.length > 0 && steps.every(Boolean);
|
|
1148
|
+
return whole(whenTrue) || whole(whenFalse);
|
|
1149
|
+
}
|
|
1150
|
+
if (node.type === "bounded_loop") {
|
|
1151
|
+
// Recovery cannot evaluate `until`, so short of the last iteration it is
|
|
1152
|
+
// left for the loop itself to decide.
|
|
1153
|
+
return found.every(Boolean);
|
|
1154
|
+
}
|
|
1155
|
+
return true;
|
|
1156
|
+
}
|
|
884
1157
|
function preferredTask(tasks, idempotencyKey) {
|
|
885
1158
|
return tasks
|
|
886
1159
|
.filter((task) => task.idempotencyKey === idempotencyKey)
|
|
@@ -894,6 +1167,23 @@ function recoveredNodeState(node, tasks, previous) {
|
|
|
894
1167
|
return previous;
|
|
895
1168
|
const taskIds = tasks.map((task) => task.id);
|
|
896
1169
|
const withStatus = (status) => tasks.find((task) => task.status === status);
|
|
1170
|
+
// Ranked as aggregateNodeState ranks a live node, failed first. A parallel
|
|
1171
|
+
// node with one failed branch and one awaiting approval was failed live and
|
|
1172
|
+
// came back from recovery awaiting approval, reopening the waiting branch on
|
|
1173
|
+
// a run whose rollback had begun.
|
|
1174
|
+
const failed = tasks.find((task) => [
|
|
1175
|
+
"failed",
|
|
1176
|
+
"partial",
|
|
1177
|
+
"budget_exhausted",
|
|
1178
|
+
"policy_blocked",
|
|
1179
|
+
].includes(task.status));
|
|
1180
|
+
if (failed !== undefined) {
|
|
1181
|
+
return {
|
|
1182
|
+
status: "failed",
|
|
1183
|
+
taskIds,
|
|
1184
|
+
...(failed.error === undefined ? {} : { error: failed.error }),
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
897
1187
|
const recovery = withStatus("recovery_required");
|
|
898
1188
|
if (recovery !== undefined) {
|
|
899
1189
|
return {
|
|
@@ -912,19 +1202,6 @@ function recoveredNodeState(node, tasks, previous) {
|
|
|
912
1202
|
: { approval: awaiting.approval }),
|
|
913
1203
|
};
|
|
914
1204
|
}
|
|
915
|
-
const failed = tasks.find((task) => [
|
|
916
|
-
"failed",
|
|
917
|
-
"partial",
|
|
918
|
-
"budget_exhausted",
|
|
919
|
-
"policy_blocked",
|
|
920
|
-
].includes(task.status));
|
|
921
|
-
if (failed !== undefined) {
|
|
922
|
-
return {
|
|
923
|
-
status: "failed",
|
|
924
|
-
taskIds,
|
|
925
|
-
...(failed.error === undefined ? {} : { error: failed.error }),
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
1205
|
if (tasks.some((task) => task.status === "planned")) {
|
|
929
1206
|
return {
|
|
930
1207
|
status: "pending",
|