@melra/runtime-core 0.3.0-alpha.14 → 0.3.0-alpha.15
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 +89 -1
- package/dist/task-controller.d.ts.map +1 -1
- package/dist/task-controller.js +351 -73
- package/dist/task-controller.js.map +1 -1
- package/dist/workflow-controller.d.ts +87 -0
- package/dist/workflow-controller.d.ts.map +1 -1
- package/dist/workflow-controller.js +1062 -258
- package/dist/workflow-controller.js.map +1 -1
- package/package.json +9 -6
|
@@ -5,12 +5,29 @@ 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
|
+
/**
|
|
9
|
+
* A node one of whose tasks refused (a wrong approval phrase, say) after the
|
|
10
|
+
* others had run, or whose check could not be asked after its steps had: what
|
|
11
|
+
* they did is recorded with the node before the refusal is reported, as a
|
|
12
|
+
* sibling node's would be.
|
|
13
|
+
*/
|
|
14
|
+
class NodeRefused extends Error {
|
|
15
|
+
reason;
|
|
16
|
+
advance;
|
|
17
|
+
constructor(reason, advance) {
|
|
18
|
+
super(reason instanceof Error ? reason.message : String(reason));
|
|
19
|
+
this.reason = reason;
|
|
20
|
+
this.advance = advance;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
8
23
|
const RECOVERABLE = [
|
|
9
24
|
"planned",
|
|
10
25
|
"running",
|
|
11
26
|
"awaiting_approval",
|
|
12
27
|
"recovery_required",
|
|
13
28
|
];
|
|
29
|
+
// A condition whose other branch holds a task that may have had an effect.
|
|
30
|
+
const BRANCH_CONFLICT = "workflow_condition_branch_conflict";
|
|
14
31
|
// A failed run's advance runs its compensations, so an advance that died on
|
|
15
32
|
// one leaves the same hazard as any other: a compensation it started, planned
|
|
16
33
|
// and run again. Repaired too, but only once an expired lease says an advance
|
|
@@ -26,9 +43,13 @@ export class WorkflowController {
|
|
|
26
43
|
// process from racing itself without touching the disk on every call.
|
|
27
44
|
advances = new Map();
|
|
28
45
|
owner = `${process.pid}:${randomUUID()}`;
|
|
46
|
+
/** Whether a task's effect happened and verified, for `targetDone`. */
|
|
47
|
+
ran = (taskId) => this.tasks.status(taskId).status === "verified_success";
|
|
29
48
|
// Distinct from `owner`, so this process's own advance in flight also keeps
|
|
30
49
|
// its recovery away from the run.
|
|
31
50
|
recoveryOwner = `${this.owner}:recovery`;
|
|
51
|
+
// An operator's halt or cancel, likewise refused by this process's own advance.
|
|
52
|
+
controlOwner = `${this.owner}:control`;
|
|
32
53
|
// Runs an advance in this process holds the lease for. Recovery leaves them
|
|
33
54
|
// alone even once the lease has lapsed, say across a laptop's sleep.
|
|
34
55
|
leased = new Set();
|
|
@@ -42,11 +63,26 @@ export class WorkflowController {
|
|
|
42
63
|
const parsed = WorkflowDefinitionSchema.parse(definition);
|
|
43
64
|
const graph = validateWorkflow(parsed);
|
|
44
65
|
for (const node of graph.nodes.values()) {
|
|
66
|
+
// No node state exists for a task waiting on a verdict: a waiting task
|
|
67
|
+
// would read as failed and have an effect that held compensated, and a
|
|
68
|
+
// condition, loop or delegation would read an unanswered question as
|
|
69
|
+
// false. Refused until workflows can wait on one (#54).
|
|
70
|
+
if (declaresSemantic(node))
|
|
71
|
+
throw new Error(`workflow_semantic_evidence_unsupported:${node.id}`);
|
|
45
72
|
for (const request of requestsFor(node)) {
|
|
46
73
|
if (this.tasks.preflight(request).outcome === "deny") {
|
|
47
74
|
throw new Error(`workflow_policy_blocked:${node.id}`);
|
|
48
75
|
}
|
|
49
76
|
}
|
|
77
|
+
// A check no registered verifier can ask is never answered. Refused
|
|
78
|
+
// here, as a step's evidence is, not found out mid-run with effects done.
|
|
79
|
+
this.tasks.assertVerifiable(node.type === "condition"
|
|
80
|
+
? [node.predicate]
|
|
81
|
+
: node.type === "bounded_loop"
|
|
82
|
+
? (node.until === undefined ? [] : [node.until])
|
|
83
|
+
: node.type === "delegation"
|
|
84
|
+
? node.requiredEvidence
|
|
85
|
+
: []);
|
|
50
86
|
}
|
|
51
87
|
const createdAt = new Date().toISOString();
|
|
52
88
|
const id = randomUUID();
|
|
@@ -69,7 +105,9 @@ export class WorkflowController {
|
|
|
69
105
|
to: "planned",
|
|
70
106
|
});
|
|
71
107
|
const planned = applyWorkflowEvent(draft, plannedEvent);
|
|
72
|
-
|
|
108
|
+
// Not parsed again: redaction may rewrite a node id or URL shaped like a
|
|
109
|
+
// key, and the stored copy is only for reading.
|
|
110
|
+
const redacted = redactStructuredValue(parsed).value;
|
|
73
111
|
// A definition is stored once per id and version, and any number of runs
|
|
74
112
|
// may use it: planning the same file twice is a second run, not an error.
|
|
75
113
|
// The same id and version with different content is refused, because
|
|
@@ -155,7 +193,21 @@ export class WorkflowController {
|
|
|
155
193
|
// A long advance must not let its own lease lapse and invite a second
|
|
156
194
|
// process in, so renew at half the duration while the adapters run.
|
|
157
195
|
const renewal = setInterval(() => {
|
|
158
|
-
|
|
196
|
+
// Thrown from a timer, nothing catches it and the process dies: a store
|
|
197
|
+
// closed under a long advance (a shutdown) stops the renewing instead,
|
|
198
|
+
// and the advance fails on its own next write. Any other failure (a busy
|
|
199
|
+
// database) is tried again next tick: stopping would let the lease lapse
|
|
200
|
+
// mid-advance and a second process run the same step.
|
|
201
|
+
try {
|
|
202
|
+
const renewed = this.store.renewWorkflowLease(workflowId, this.owner, new Date(Date.now() + this.leaseDurationMs).toISOString());
|
|
203
|
+
// Taken over: the next step's own renewal finds that and stops.
|
|
204
|
+
if (!renewed)
|
|
205
|
+
clearInterval(renewal);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (error.code === "ERR_INVALID_STATE")
|
|
209
|
+
clearInterval(renewal);
|
|
210
|
+
}
|
|
159
211
|
}, Math.max(1_000, Math.floor(this.leaseDurationMs / 2)));
|
|
160
212
|
renewal.unref();
|
|
161
213
|
this.leased.add(workflowId);
|
|
@@ -169,52 +221,91 @@ export class WorkflowController {
|
|
|
169
221
|
}
|
|
170
222
|
}
|
|
171
223
|
async advanceOnce(workflowId, approvals, inputs) {
|
|
172
|
-
const
|
|
173
|
-
if (["verified_complete", "cancelled"].includes(
|
|
174
|
-
return { run:
|
|
224
|
+
const stored = this.status(workflowId);
|
|
225
|
+
if (["verified_complete", "cancelled"].includes(stored.status)) {
|
|
226
|
+
return { run: stored, tasks: [], events: [] };
|
|
175
227
|
}
|
|
176
228
|
// A halted workflow is a deliberate operator decision. Failing loudly beats
|
|
177
229
|
// returning an unchanged run, which reads to a caller like "nothing to do".
|
|
178
|
-
if (["paused", "suspended"].includes(
|
|
179
|
-
throw new Error(`workflow_halted:${
|
|
230
|
+
if (["paused", "suspended"].includes(stored.status)) {
|
|
231
|
+
throw new Error(`workflow_halted:${stored.status}`);
|
|
180
232
|
}
|
|
181
|
-
const definition = this.loadDefinition(
|
|
233
|
+
const definition = this.loadDefinition(stored);
|
|
182
234
|
const graph = validateWorkflow(definition);
|
|
235
|
+
const since = stored.stateVersion;
|
|
236
|
+
// An earlier version failed a run beside a step whose outcome is unknown
|
|
237
|
+
// and would now undo what that step may depend on. Halt it as a run that
|
|
238
|
+
// gets there now halts, so recovery settles the step first. Repaired
|
|
239
|
+
// before any parked step is settled: settled first, the step no longer
|
|
240
|
+
// reads unknown, and its skipped compensation is never offered again.
|
|
241
|
+
const repaired = this.repairLegacy(definition, stored);
|
|
242
|
+
if (repaired !== undefined) {
|
|
243
|
+
return { run: repaired, tasks: [], events: this.events(workflowId, since) };
|
|
244
|
+
}
|
|
245
|
+
// A step parked as recovery_required is settled outside the run, by
|
|
246
|
+
// melra_execute reconciling its task against the provider. Its tasks are
|
|
247
|
+
// read again here, as recovery reads them, so the run moves on in this
|
|
248
|
+
// process instead of at the next restart.
|
|
249
|
+
const current = this.resettleParked(stored, definition);
|
|
183
250
|
const rollback = current.status === "failed"
|
|
184
251
|
? definition.nodes
|
|
185
252
|
.filter((node) => node.type === "compensation" &&
|
|
186
253
|
["pending", "awaiting_approval"].includes(current.nodes[node.id]?.status ?? "") &&
|
|
187
|
-
current.nodes[node.forNodeId
|
|
188
|
-
"verified_complete")
|
|
254
|
+
targetDone(definition, (id) => current.nodes[id], node.forNodeId, this.ran))
|
|
189
255
|
.map((node) => node.id)
|
|
190
256
|
.reverse()
|
|
191
257
|
: [];
|
|
192
|
-
if (current.status === "failed" && rollback.length === 0) {
|
|
193
|
-
return { run: current, tasks: [], events: [] };
|
|
194
|
-
}
|
|
195
258
|
// An unwind that stopped half-done is not a base to build more effects on:
|
|
196
259
|
// a forward effect happened, its declared inverse did not, and no node
|
|
197
260
|
// describes that. Refused here rather than left to `readyNodeIds`, which
|
|
198
261
|
// would happily start an independent branch on top of it.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
262
|
+
const stalled = stalledCompensation(definition, current.nodes);
|
|
263
|
+
if (stalled !== undefined) {
|
|
264
|
+
if (current.status === "recovery_required") {
|
|
265
|
+
return { run: current, tasks: [], events: this.events(workflowId, since) };
|
|
266
|
+
}
|
|
267
|
+
// An earlier version recorded a denied compensation and left the run
|
|
268
|
+
// `failed`; say so, as a run that stalls now does, rather than return it
|
|
269
|
+
// unchanged as if nothing were outstanding.
|
|
270
|
+
const run = this.transition(current, (draft, emit) => {
|
|
271
|
+
const from = draft.status;
|
|
272
|
+
draft.status = "recovery_required";
|
|
273
|
+
draft.error = `workflow_compensation_incomplete:${stalled.id}`;
|
|
274
|
+
emit("workflow.status_changed", { from, to: draft.status, error: draft.error });
|
|
275
|
+
});
|
|
276
|
+
return { run, tasks: [], events: this.events(workflowId, since) };
|
|
277
|
+
}
|
|
278
|
+
// A run with a failed or denied step unwinds once nothing is unknown. Until
|
|
279
|
+
// then it is halted, and offers no forward step: not one newly ready, nor
|
|
280
|
+
// one parked for approval, whose effect would only be undone.
|
|
281
|
+
if (this.hasFailure(definition, current.nodes) && rollback.length === 0) {
|
|
282
|
+
return { run: current, tasks: [], events: this.events(workflowId, since) };
|
|
202
283
|
}
|
|
203
284
|
// A node parked for a human — approval or supplied input — is no
|
|
204
285
|
// longer `pending`, so `readyNodeIds` will not return it. Re-offer it here
|
|
205
|
-
// or the workflow would stall forever with the answer in hand.
|
|
286
|
+
// or the workflow would stall forever with the answer in hand. Beside the
|
|
287
|
+
// ready nodes, not instead of them: a branch that does not wait on it
|
|
288
|
+
// carries on (#247).
|
|
206
289
|
const awaiting = [...graph.nodes.values()]
|
|
207
290
|
.filter((node) => node.type !== "compensation" &&
|
|
208
291
|
["awaiting_approval", "awaiting_input"].includes(current.nodes[node.id]?.status ?? ""))
|
|
209
292
|
.map((node) => node.id)
|
|
210
293
|
.sort();
|
|
294
|
+
// Unless a parked task has settled since (an operator's Deny, a peer's
|
|
295
|
+
// execute): the run still reads it as waiting, so hasFailure cannot see a
|
|
296
|
+
// denial yet. Those nodes alone, then, so this wave records it before any
|
|
297
|
+
// newly ready step is challenged or run on a run it fails.
|
|
298
|
+
const parkedSettled = awaiting.some((nodeId) => current.nodes[nodeId].taskIds.some((taskId) => !["planned", "awaiting_approval", "verified_success"].includes(this.store.getTask(taskId)?.status ?? "")));
|
|
211
299
|
const nodeIds = rollback.length > 0
|
|
212
300
|
? rollback
|
|
213
|
-
:
|
|
301
|
+
: parkedSettled
|
|
214
302
|
? awaiting
|
|
215
|
-
: readyNodeIds(graph, current.nodes);
|
|
303
|
+
: [...awaiting, ...readyNodeIds(graph, current.nodes)];
|
|
216
304
|
if (nodeIds.length === 0) {
|
|
217
|
-
|
|
305
|
+
// Nothing to run, but a step's certificate the last advance could not
|
|
306
|
+
// write may be written now, and a run with nothing left is complete.
|
|
307
|
+
const run = this.transition(current, (draft, emit) => this.settleStatus(draft, definition, emit));
|
|
308
|
+
return { run, tasks: [], events: this.events(workflowId, since) };
|
|
218
309
|
}
|
|
219
310
|
// A sibling's refusal is reported only after the others' changes are
|
|
220
311
|
// committed: a node another sibling cancelled and planned again in this
|
|
@@ -241,23 +332,44 @@ export class WorkflowController {
|
|
|
241
332
|
// Every sibling finishes before one's error is reported. Rejecting
|
|
242
333
|
// at the first throw released the lease while other adapters still
|
|
243
334
|
// ran, and a retry then read their tasks as failed.
|
|
244
|
-
|
|
245
|
-
|
|
335
|
+
const rejected = settled.find((outcome) => outcome.status === "rejected");
|
|
336
|
+
if (rejected !== undefined) {
|
|
337
|
+
refused = {
|
|
338
|
+
reason: rejected.reason instanceof NodeRefused ? rejected.reason.reason : rejected.reason,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return settled.flatMap((outcome, index) => outcome.status === "fulfilled"
|
|
342
|
+
? [outcome.value]
|
|
343
|
+
: outcome.reason instanceof NodeRefused
|
|
344
|
+
? [{ nodeId: nodeIds[index], result: outcome.reason.advance }]
|
|
345
|
+
: []);
|
|
246
346
|
}));
|
|
247
347
|
// A *compensation* that failed must not open a second unwind. The saga is
|
|
248
348
|
// already stranded, and undoing the next step while this inverse is still
|
|
249
349
|
// outstanding is precisely what the halt exists to prevent — so only a
|
|
250
|
-
// forward node failing starts compensating.
|
|
251
|
-
|
|
252
|
-
|
|
350
|
+
// forward node failing, denied or cancelled starts compensating. A denied
|
|
351
|
+
// step is never offered again, so the run can only end by unwinding.
|
|
352
|
+
const statusAfter = (nodeId) => results.find((entry) => entry.nodeId === nodeId)?.result.state.status ??
|
|
353
|
+
current.nodes[nodeId]?.status;
|
|
354
|
+
// Not past a step whose outcome is unknown or still unsettled: the run
|
|
355
|
+
// halts for an operator, as deriveStatus says, rather than undo what that
|
|
356
|
+
// effect may depend on.
|
|
357
|
+
if (!hasUnknownOutcome(definition, statusAfter) &&
|
|
358
|
+
results.some(({ nodeId, result }) => ["failed", "cancelled"].includes(result.state.status) &&
|
|
359
|
+
graph.nodes.get(nodeId)?.type !== "compensation")) {
|
|
253
360
|
for (const node of [...definition.nodes].reverse()) {
|
|
254
361
|
if (node.type !== "compensation" ||
|
|
255
362
|
current.nodes[node.id]?.status !== "pending") {
|
|
256
363
|
continue;
|
|
257
364
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
365
|
+
// Not once the run has moved on under this advance (cancelled,
|
|
366
|
+
// paused): its commit is going to fail, and a compensation planned now
|
|
367
|
+
// would be a live challenge for a run that no longer offers it.
|
|
368
|
+
// Checked before each one, since one that ran gives a cancel time.
|
|
369
|
+
if (this.status(workflowId).stateVersion !== current.stateVersion)
|
|
370
|
+
break;
|
|
371
|
+
const stateOf = (id) => results.find(({ nodeId }) => nodeId === id)?.result.state ?? current.nodes[id];
|
|
372
|
+
if (!targetDone(definition, stateOf, node.forNodeId, this.ran)) {
|
|
261
373
|
results.push({
|
|
262
374
|
nodeId: node.id,
|
|
263
375
|
result: {
|
|
@@ -288,8 +400,14 @@ export class WorkflowController {
|
|
|
288
400
|
break;
|
|
289
401
|
}
|
|
290
402
|
}
|
|
403
|
+
this.assertAdvanceLease(workflowId);
|
|
291
404
|
let run = this.transition(current, (draft, emit) => {
|
|
292
405
|
for (const { nodeId, result } of results) {
|
|
406
|
+
// A node offered again and still waiting (an approval nobody has
|
|
407
|
+
// answered) did not change. Recorded anyway, every poll bumped the
|
|
408
|
+
// run's version and added an event.
|
|
409
|
+
if (canonicalJson(result.state) === canonicalJson(draft.nodes[nodeId]))
|
|
410
|
+
continue;
|
|
293
411
|
const from = draft.nodes[nodeId].status;
|
|
294
412
|
draft.nodes[nodeId] = result.state;
|
|
295
413
|
emit("workflow.node_changed", {
|
|
@@ -298,43 +416,7 @@ export class WorkflowController {
|
|
|
298
416
|
state: result.state,
|
|
299
417
|
});
|
|
300
418
|
}
|
|
301
|
-
|
|
302
|
-
if (status === "verified_complete") {
|
|
303
|
-
for (const node of definition.nodes) {
|
|
304
|
-
const state = draft.nodes[node.id];
|
|
305
|
-
if (node.type !== "compensation" || state.status !== "pending") {
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
state.status = "skipped";
|
|
309
|
-
emit("workflow.node_changed", {
|
|
310
|
-
nodeId: node.id,
|
|
311
|
-
from: "pending",
|
|
312
|
-
state,
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
status = this.deriveStatus(draft, definition);
|
|
316
|
-
}
|
|
317
|
-
if (status !== draft.status) {
|
|
318
|
-
const from = draft.status;
|
|
319
|
-
draft.status = status;
|
|
320
|
-
// Name the step, or "recovery_required" tells an operator that
|
|
321
|
-
// something needs hands without saying which effect is half-undone.
|
|
322
|
-
const stalled = definition.nodes.find((node) => node.type === "compensation" &&
|
|
323
|
-
["failed", "recovery_required"].includes(draft.nodes[node.id]?.status ?? ""));
|
|
324
|
-
if (status === "recovery_required" && stalled !== undefined) {
|
|
325
|
-
draft.error = `workflow_compensation_incomplete:${stalled.id}`;
|
|
326
|
-
}
|
|
327
|
-
else {
|
|
328
|
-
// The reducer clears `error` on every status change, so the draft has
|
|
329
|
-
// to as well or replay no longer reproduces the projection.
|
|
330
|
-
delete draft.error;
|
|
331
|
-
}
|
|
332
|
-
emit("workflow.status_changed", {
|
|
333
|
-
from,
|
|
334
|
-
to: status,
|
|
335
|
-
...(draft.error === undefined ? {} : { error: draft.error }),
|
|
336
|
-
});
|
|
337
|
-
}
|
|
419
|
+
this.settleStatus(draft, definition, emit);
|
|
338
420
|
});
|
|
339
421
|
if (results.some(({ result }) => result.checkpoint === true)) {
|
|
340
422
|
run = this.transition(run, (draft, emit) => {
|
|
@@ -355,7 +437,7 @@ export class WorkflowController {
|
|
|
355
437
|
return {
|
|
356
438
|
run,
|
|
357
439
|
tasks: results.flatMap(({ result }) => result.tasks),
|
|
358
|
-
events: this.events(workflowId,
|
|
440
|
+
events: this.events(workflowId, since),
|
|
359
441
|
};
|
|
360
442
|
}
|
|
361
443
|
/**
|
|
@@ -401,7 +483,6 @@ export class WorkflowController {
|
|
|
401
483
|
if (runs.length === 0)
|
|
402
484
|
return [];
|
|
403
485
|
const recovered = [];
|
|
404
|
-
const allTasks = this.store.listTasks(100_000);
|
|
405
486
|
for (const stored of runs) {
|
|
406
487
|
if (this.leased.has(stored.id))
|
|
407
488
|
continue;
|
|
@@ -416,9 +497,9 @@ export class WorkflowController {
|
|
|
416
497
|
const keys = new Set(definition.nodes.flatMap((node) => expectedTaskKeys(stored.id, node, definition)));
|
|
417
498
|
// Skipped runs keep any abandoned lease, which is how the sweep finds
|
|
418
499
|
// them again once that task settles.
|
|
419
|
-
const unsettled =
|
|
420
|
-
|
|
421
|
-
(task.status === "running" || task.status === "verifying")
|
|
500
|
+
const unsettled = [...keys]
|
|
501
|
+
.flatMap((key) => this.store.listTasksByKey(key))
|
|
502
|
+
.filter((task) => task.status === "running" || task.status === "verifying");
|
|
422
503
|
if (unsettled.length > 0) {
|
|
423
504
|
const failed = unsettled.find((task) => taskFailures.has(task.id));
|
|
424
505
|
if (failed !== undefined)
|
|
@@ -434,7 +515,7 @@ export class WorkflowController {
|
|
|
434
515
|
// Changed since the listing, by an advance or by an operator's pause,
|
|
435
516
|
// so the tasks read above may be stale.
|
|
436
517
|
if (this.store.getWorkflowRun(stored.id)?.stateVersion === stored.stateVersion) {
|
|
437
|
-
recovered.push(this.recoverRun(stored, definition
|
|
518
|
+
recovered.push(this.recoverRun(stored, definition));
|
|
438
519
|
repaired = true;
|
|
439
520
|
}
|
|
440
521
|
}
|
|
@@ -459,75 +540,316 @@ export class WorkflowController {
|
|
|
459
540
|
}
|
|
460
541
|
return recovered;
|
|
461
542
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
543
|
+
/**
|
|
544
|
+
* A node's state read again from its tasks: what recovery does at startup,
|
|
545
|
+
* and what an advance does for a step parked as recovery_required.
|
|
546
|
+
*/
|
|
547
|
+
recoveredNode(runId, node, definition, recorded) {
|
|
548
|
+
const keys = expectedTaskKeys(runId, node, definition);
|
|
549
|
+
const ignored = new Set();
|
|
550
|
+
if (node.type === "condition") {
|
|
551
|
+
// Only one branch ever runs (#268). The one with a committed step, as
|
|
552
|
+
// the live path decides; else the run's own record: a condition
|
|
553
|
+
// finished with no task took its empty branch, and its first task names
|
|
554
|
+
// its branch (not a conflict's, whose tasks are the other branch's).
|
|
555
|
+
const trueKeys = keys.slice(0, node.whenTrue.length);
|
|
556
|
+
const committed = (branch) => branch.some((key) => this.store.getIdempotencyCommit(key) !== undefined);
|
|
557
|
+
const first = recorded.error === BRANCH_CONFLICT || recorded.taskIds[0] === undefined
|
|
558
|
+
? undefined
|
|
559
|
+
: this.store.getTask(recorded.taskIds[0])?.idempotencyKey;
|
|
560
|
+
const chosen = committed(trueKeys)
|
|
561
|
+
? true
|
|
562
|
+
: committed(keys.slice(node.whenTrue.length))
|
|
563
|
+
? false
|
|
564
|
+
: recorded.status === "verified_complete" && recorded.taskIds.length === 0
|
|
565
|
+
? node.whenTrue.length === 0
|
|
566
|
+
: first === undefined || !keys.includes(first)
|
|
567
|
+
? undefined
|
|
568
|
+
: trueKeys.includes(first);
|
|
569
|
+
const all = keys.map((key) => preferredTask(this.store.listTasksByKey(key), key));
|
|
570
|
+
if (chosen === undefined) {
|
|
571
|
+
// Nothing decided it. A conflict stands while a task in it may have
|
|
572
|
+
// had an effect.
|
|
573
|
+
const cleared = recorded.error === BRANCH_CONFLICT &&
|
|
574
|
+
recorded.taskIds.every((taskId) => {
|
|
575
|
+
const task = this.store.getTask(taskId);
|
|
576
|
+
return task !== undefined && hadNoEffect(task);
|
|
577
|
+
});
|
|
578
|
+
if (recorded.error === BRANCH_CONFLICT && !cleared)
|
|
579
|
+
return { state: recorded, found: all };
|
|
580
|
+
// A step a lost advance ran whose outcome is unknown, on either
|
|
581
|
+
// branch, is read as the node's, so it halts the run here rather than
|
|
582
|
+
// a failed sibling unwinding past it (#270). Anything else it left (a
|
|
583
|
+
// challenge, or a step that took effect but failed its evidence) waits
|
|
584
|
+
// for the next advance, which decides as it would with no restart.
|
|
585
|
+
const unknown = this.unsettledUnder(keys);
|
|
586
|
+
if (unknown.length > 0)
|
|
587
|
+
return { state: recoveredNodeState(node, unknown, recorded), found: all };
|
|
588
|
+
return { state: cleared ? { status: "pending", taskIds: [] } : recorded, found: all };
|
|
589
|
+
}
|
|
590
|
+
const halted = this.abandonBranch(node, keys, chosen);
|
|
591
|
+
if (halted !== undefined)
|
|
592
|
+
return { state: halted, found: all };
|
|
593
|
+
for (const key of chosen ? keys.slice(node.whenTrue.length) : trueKeys)
|
|
594
|
+
ignored.add(key);
|
|
466
595
|
}
|
|
467
|
-
|
|
596
|
+
const found = keys.map((key) => ignored.has(key) ? undefined : preferredTask(this.store.listTasksByKey(key), key));
|
|
597
|
+
let state = recoveredNodeState(node, found.filter((task) => task !== undefined), recorded);
|
|
598
|
+
// An operation behind an approval shares its task with the approval
|
|
599
|
+
// node. Waiting for the approval is that node's state; mirrored onto
|
|
600
|
+
// the operation, both were re-offered, the approval ran the effect,
|
|
601
|
+
// and the operation read the running task as failed.
|
|
602
|
+
if (node.type === "operation" &&
|
|
603
|
+
state.status === "awaiting_approval" &&
|
|
604
|
+
definition.nodes.some((other) => other.type === "approval" && other.forNodeId === node.id)) {
|
|
605
|
+
state = recorded;
|
|
606
|
+
}
|
|
607
|
+
// A sequence whose finished steps all verified is not finished if
|
|
608
|
+
// steps remain: a crash between a loop's iterations, or between a
|
|
609
|
+
// branch's steps, recovered as complete with the rest never run.
|
|
610
|
+
// Pending, so the next advance carries on from the tasks it has.
|
|
611
|
+
// Unless the projection recorded it finished in a way only the live
|
|
612
|
+
// path finishes one short: a loop stopped by `until` at a whole
|
|
613
|
+
// iteration, or a condition whose chosen branch is empty. Reopened,
|
|
614
|
+
// `until` or the condition would be asked again of a world that has
|
|
615
|
+
// moved on. Anything else recorded finished but short was written by
|
|
616
|
+
// a recovery that never downgraded (alpha.12), and is downgraded now.
|
|
617
|
+
const finishedShort = recorded.status === "verified_complete" &&
|
|
618
|
+
canonicalJson(recorded.taskIds) === canonicalJson(state.taskIds) &&
|
|
619
|
+
((node.type === "bounded_loop" &&
|
|
620
|
+
node.until !== undefined &&
|
|
621
|
+
state.taskIds.length > 0 &&
|
|
622
|
+
state.taskIds.length % node.body.length === 0) ||
|
|
623
|
+
(node.type === "condition" && state.taskIds.length === 0));
|
|
624
|
+
if (state.status === "verified_complete" && !sequenceFinished(node, found) && !finishedShort) {
|
|
625
|
+
state = {
|
|
626
|
+
status: "pending",
|
|
627
|
+
taskIds: state.taskIds,
|
|
628
|
+
// Whole iterations only: the loop resumes at the one in progress.
|
|
629
|
+
...(node.type === "bounded_loop"
|
|
630
|
+
? { iterations: Math.floor(state.taskIds.length / node.body.length) }
|
|
631
|
+
: {}),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
// A loop that stopped on a step keeps the iteration it stopped in, as
|
|
635
|
+
// runLoop records it. Without it the next advance starts again at
|
|
636
|
+
// iteration 0 and asks `until` of a task long finished.
|
|
637
|
+
if (node.type === "bounded_loop" && state.iterations === undefined && state.taskIds.length > 0) {
|
|
638
|
+
state = { ...state, iterations: Math.floor((state.taskIds.length - 1) / node.body.length) };
|
|
639
|
+
}
|
|
640
|
+
return { state, found };
|
|
641
|
+
}
|
|
642
|
+
/** Tasks under these keys whose outcome is unknown or still unsettled. */
|
|
643
|
+
unsettledUnder(keys) {
|
|
644
|
+
return keys
|
|
645
|
+
.map((key) => preferredTask(this.store.listTasksByKey(key), key))
|
|
646
|
+
.filter((task) => task !== undefined && ["recovery_required", "running", "verifying"].includes(task.status));
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* The branch a condition did not take. An advance that lost its commit (a
|
|
650
|
+
* pause landed) can have left tasks there. A challenge is cancelled, and
|
|
651
|
+
* the cancel read back, so nobody approves it later. A task that may have
|
|
652
|
+
* had an effect, or one started before its cancel landed, halts the node:
|
|
653
|
+
* both branches would stand. Reconciled as never applied, it no longer
|
|
654
|
+
* counts, and the node moves on.
|
|
655
|
+
*/
|
|
656
|
+
abandonBranch(node, keys, chosen) {
|
|
657
|
+
const other = chosen ? keys.slice(node.whenTrue.length) : keys.slice(0, node.whenTrue.length);
|
|
658
|
+
const conflicting = new Set();
|
|
659
|
+
for (const key of other) {
|
|
660
|
+
const commit = this.store.getIdempotencyCommit(key);
|
|
661
|
+
if (commit !== undefined)
|
|
662
|
+
conflicting.add(commit.taskId);
|
|
663
|
+
}
|
|
664
|
+
for (const task of this.cancelChallenges(other)) {
|
|
665
|
+
if (!hadNoEffect(task))
|
|
666
|
+
conflicting.add(task.id);
|
|
667
|
+
}
|
|
668
|
+
return conflicting.size === 0
|
|
669
|
+
? undefined
|
|
670
|
+
: // Sorted, so reading it again in another order records nothing new.
|
|
671
|
+
{ status: "recovery_required", taskIds: [...conflicting].sort(), error: BRANCH_CONFLICT };
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Every task under these keys, read again, with each challenge among them
|
|
675
|
+
* cancelled and the cancel read back, so nobody approves it later.
|
|
676
|
+
*/
|
|
677
|
+
cancelChallenges(keys) {
|
|
678
|
+
// Each key once: an approval node's is its operation's.
|
|
679
|
+
return [...new Set(keys)]
|
|
680
|
+
.flatMap((key) => this.store.listTasksByKey(key))
|
|
681
|
+
.map((listed) => {
|
|
682
|
+
// Read again: the listing can predate an approver starting it.
|
|
683
|
+
const task = this.tasks.status(listed.id);
|
|
684
|
+
if (task.status !== "planned" && task.status !== "awaiting_approval")
|
|
685
|
+
return task;
|
|
686
|
+
this.tasks.cancel(task.id);
|
|
687
|
+
return this.tasks.status(task.id);
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* settleInFlight once a cancel's stopped tasks have ended. Another holder
|
|
692
|
+
* of the run's lease is brief on a cancelled run (an advance returning
|
|
693
|
+
* early, a cancel's sweep, a halt refused), or lapses and is taken over, so
|
|
694
|
+
* it is tried again for about as long as a lease lasts. Any other failure
|
|
695
|
+
* (the store closed) leaves the steps in flight, as the cancel recorded
|
|
696
|
+
* them; cancelling again settles them (#325).
|
|
697
|
+
*/
|
|
698
|
+
settleStopped(workflowId, definition, waited = 0) {
|
|
699
|
+
try {
|
|
700
|
+
this.underLease(workflowId, (run) => this.settleInFlight(run, definition));
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
if (error.message !== "workflow_lease_held" || waited > this.leaseDurationMs)
|
|
704
|
+
return;
|
|
705
|
+
const delay = Math.min(1_000, Math.max(10, waited));
|
|
706
|
+
setTimeout(() => this.settleStopped(workflowId, definition, waited + delay), delay).unref();
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* A cancelled run's steps recorded still in flight, read again from their
|
|
711
|
+
* tasks once these have ended, as its cancel reads a step. One whose task
|
|
712
|
+
* runs on is left as it is.
|
|
713
|
+
*/
|
|
714
|
+
settleInFlight(run, definition) {
|
|
715
|
+
if (run.status !== "cancelled")
|
|
716
|
+
return run;
|
|
717
|
+
return this.transition(run, (draft, emit) => {
|
|
468
718
|
for (const node of definition.nodes) {
|
|
469
|
-
|
|
470
|
-
|
|
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")
|
|
719
|
+
const recorded = draft.nodes[node.id];
|
|
720
|
+
if (recorded.error !== "task_still_in_flight")
|
|
474
721
|
continue;
|
|
475
|
-
const found =
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
722
|
+
const { state, found } = this.recoveredNode(run.id, node, definition, recorded);
|
|
723
|
+
if (found.some((task) => task !== undefined && ["running", "verifying"].includes(task.status)))
|
|
724
|
+
continue;
|
|
725
|
+
const settled = ["pending", "ready", "awaiting_approval"].includes(state.status)
|
|
726
|
+
? { ...state, status: "cancelled" }
|
|
727
|
+
: state;
|
|
728
|
+
if (canonicalJson(settled) === canonicalJson(recorded))
|
|
729
|
+
continue;
|
|
730
|
+
draft.nodes[node.id] = settled;
|
|
731
|
+
emit("workflow.node_changed", { nodeId: node.id, from: recorded.status, state: settled });
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Parked steps whose tasks have since settled, recorded as such: forward
|
|
737
|
+
* steps, and compensations an unwind stopped at. One still in flight is left
|
|
738
|
+
* for recovery, which waits for its owner as at startup.
|
|
739
|
+
*/
|
|
740
|
+
resettleParked(run, definition) {
|
|
741
|
+
const parked = definition.nodes.filter((node) => {
|
|
742
|
+
const state = run.nodes[node.id];
|
|
743
|
+
if (state?.status !== "recovery_required")
|
|
744
|
+
return false;
|
|
745
|
+
// Read again only once its recorded tasks would move it: a harness polls
|
|
746
|
+
// a parked run, and reading every task each time is not free. A parallel
|
|
747
|
+
// branch that settled beside one still unknown moves nothing.
|
|
748
|
+
if (state.taskIds.length === 0)
|
|
749
|
+
return true;
|
|
750
|
+
const recorded = state.taskIds.map((taskId) => this.store.getTask(taskId));
|
|
751
|
+
if (recorded.some((task) => task === undefined))
|
|
752
|
+
return true;
|
|
753
|
+
if (recorded.some((task) => ["running", "verifying"].includes(task.status)))
|
|
754
|
+
return false;
|
|
755
|
+
return recoveredNodeState(node, recorded, state).status !== "recovery_required";
|
|
756
|
+
});
|
|
757
|
+
if (parked.length === 0)
|
|
758
|
+
return run;
|
|
759
|
+
const changed = parked.flatMap((node) => {
|
|
760
|
+
const { state, found } = this.recoveredNode(run.id, node, definition, run.nodes[node.id]);
|
|
761
|
+
if (found.some((task) => task !== undefined && ["running", "verifying"].includes(task.status)))
|
|
762
|
+
return [];
|
|
763
|
+
return canonicalJson(state) === canonicalJson(run.nodes[node.id]) ? [] : [{ nodeId: node.id, state }];
|
|
764
|
+
});
|
|
765
|
+
if (changed.length === 0)
|
|
766
|
+
return run;
|
|
767
|
+
return this.transition(run, (draft, emit) => {
|
|
768
|
+
for (const { nodeId, state } of changed) {
|
|
769
|
+
const from = draft.nodes[nodeId].status;
|
|
770
|
+
draft.nodes[nodeId] = state;
|
|
771
|
+
emit("workflow.node_changed", { nodeId, from, state });
|
|
772
|
+
}
|
|
773
|
+
this.settleStatus(draft, definition, emit);
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
/** The run's status from its nodes, with a finished run's unused compensations skipped. */
|
|
777
|
+
settleStatus(draft, definition, emit) {
|
|
778
|
+
let status = this.deriveStatus(draft, definition);
|
|
779
|
+
if (status === "verified_complete") {
|
|
780
|
+
for (const node of definition.nodes) {
|
|
781
|
+
const state = draft.nodes[node.id];
|
|
782
|
+
if (node.type !== "compensation" || state.status !== "pending") {
|
|
521
783
|
continue;
|
|
522
784
|
}
|
|
523
|
-
|
|
524
|
-
draft.nodes[node.id] = state;
|
|
785
|
+
state.status = "skipped";
|
|
525
786
|
emit("workflow.node_changed", {
|
|
526
787
|
nodeId: node.id,
|
|
527
|
-
from,
|
|
788
|
+
from: "pending",
|
|
528
789
|
state,
|
|
529
790
|
});
|
|
530
791
|
}
|
|
792
|
+
status = this.deriveStatus(draft, definition);
|
|
793
|
+
}
|
|
794
|
+
if (status !== draft.status) {
|
|
795
|
+
const from = draft.status;
|
|
796
|
+
draft.status = status;
|
|
797
|
+
// Name the step, or "recovery_required" tells an operator that
|
|
798
|
+
// something needs hands without saying which effect is half-undone.
|
|
799
|
+
const reason = status === "recovery_required" ? haltReason(definition, draft.nodes) : undefined;
|
|
800
|
+
if (reason !== undefined) {
|
|
801
|
+
draft.error = reason;
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
// The reducer clears `error` on every status change, so the draft has
|
|
805
|
+
// to as well or replay no longer reproduces the projection.
|
|
806
|
+
delete draft.error;
|
|
807
|
+
}
|
|
808
|
+
emit("workflow.status_changed", {
|
|
809
|
+
from,
|
|
810
|
+
to: status,
|
|
811
|
+
...(draft.error === undefined ? {} : { error: draft.error }),
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Each node of `stored` read again from the tasks under its keys, as
|
|
817
|
+
* recovery reads it, into `draft`. Returns the ids of the tasks now on the
|
|
818
|
+
* nodes it changed.
|
|
819
|
+
*/
|
|
820
|
+
recoverNodes(stored, definition, draft, emit) {
|
|
821
|
+
const replayed = this.replay(stored);
|
|
822
|
+
if (replayed.stateVersion !== stored.stateVersion) {
|
|
823
|
+
throw new Error("workflow_event_history_invalid");
|
|
824
|
+
}
|
|
825
|
+
const found = [];
|
|
826
|
+
for (const node of definition.nodes) {
|
|
827
|
+
// An advance on a failed run only ever runs compensations, so only
|
|
828
|
+
// they can be stale. Re-deriving the forward nodes would rank their
|
|
829
|
+
// tasks differently from the live path and could take the run out of
|
|
830
|
+
// failed: a branch awaiting approval beside a failed one reopened.
|
|
831
|
+
if (stored.status === "failed" && node.type !== "compensation")
|
|
832
|
+
continue;
|
|
833
|
+
const { state } = this.recoveredNode(stored.id, node, definition, replayed.nodes[node.id]);
|
|
834
|
+
if (canonicalJson(draft.nodes[node.id]) === canonicalJson(state)) {
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
const from = draft.nodes[node.id].status;
|
|
838
|
+
// A copy: the caller goes on to change the node in the draft (cancels
|
|
839
|
+
// it, or skips a compensation), which must not rewrite this event.
|
|
840
|
+
draft.nodes[node.id] = structuredClone(state);
|
|
841
|
+
emit("workflow.node_changed", {
|
|
842
|
+
nodeId: node.id,
|
|
843
|
+
from,
|
|
844
|
+
state,
|
|
845
|
+
});
|
|
846
|
+
found.push(...state.taskIds);
|
|
847
|
+
}
|
|
848
|
+
return found;
|
|
849
|
+
}
|
|
850
|
+
recoverRun(stored, definition) {
|
|
851
|
+
return this.transition(stored, (draft, emit) => {
|
|
852
|
+
this.recoverNodes(stored, definition, draft, emit);
|
|
531
853
|
let status = this.deriveStatus(draft, definition);
|
|
532
854
|
if (status === "verified_complete") {
|
|
533
855
|
for (const node of definition.nodes) {
|
|
@@ -548,13 +870,8 @@ export class WorkflowController {
|
|
|
548
870
|
draft.status = status;
|
|
549
871
|
// Named the way advanceOnce names a stalled unwind, so a run reads the
|
|
550
872
|
// 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
873
|
if (status === "recovery_required") {
|
|
554
|
-
draft.error =
|
|
555
|
-
stalled === undefined
|
|
556
|
-
? "workflow_contains_unreconciled_mutation"
|
|
557
|
-
: `workflow_compensation_incomplete:${stalled.id}`;
|
|
874
|
+
draft.error = haltReason(definition, draft.nodes) ?? "workflow_contains_unreconciled_mutation";
|
|
558
875
|
}
|
|
559
876
|
else {
|
|
560
877
|
delete draft.error;
|
|
@@ -580,70 +897,169 @@ export class WorkflowController {
|
|
|
580
897
|
return this.halt(workflowId, "suspended", "workflow.suspended");
|
|
581
898
|
}
|
|
582
899
|
resume(workflowId) {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
900
|
+
return this.underLease(workflowId, (current) => {
|
|
901
|
+
if (!["paused", "suspended"].includes(current.status)) {
|
|
902
|
+
throw new Error("workflow_not_paused");
|
|
903
|
+
}
|
|
904
|
+
const definition = this.loadDefinition(current);
|
|
905
|
+
return this.transition(current, (draft, emit) => {
|
|
906
|
+
const from = draft.status;
|
|
907
|
+
// Restore whatever the graph was actually blocked on before the halt,
|
|
908
|
+
// so resuming a run parked on a human, or on an effect whose outcome is
|
|
909
|
+
// unknown, does not claim it is running.
|
|
910
|
+
draft.status = this.deriveStatus(draft, definition);
|
|
911
|
+
if (draft.status !== "recovery_required")
|
|
912
|
+
delete draft.error;
|
|
913
|
+
else
|
|
914
|
+
draft.error ??= "workflow_contains_unreconciled_mutation";
|
|
915
|
+
emit("workflow.resumed", {
|
|
916
|
+
from,
|
|
917
|
+
to: draft.status,
|
|
918
|
+
...(draft.error === undefined ? {} : { error: draft.error }),
|
|
919
|
+
});
|
|
602
920
|
});
|
|
603
921
|
});
|
|
604
922
|
}
|
|
605
923
|
halt(workflowId, to, eventType) {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
924
|
+
return this.underLease(workflowId, (current) => {
|
|
925
|
+
if (current.status === to)
|
|
926
|
+
return current;
|
|
927
|
+
if (["verified_complete", "cancelled", "failed"].includes(current.status)) {
|
|
928
|
+
throw new Error("workflow_not_haltable");
|
|
929
|
+
}
|
|
930
|
+
return this.transition(current, (draft, emit) => {
|
|
931
|
+
const from = draft.status;
|
|
932
|
+
draft.status = to;
|
|
933
|
+
// Kept: a run halted while it needed recovery still needs it, and the
|
|
934
|
+
// reducer drops an error the event does not carry.
|
|
935
|
+
emit(eventType, { from, to, ...(draft.error === undefined ? {} : { error: draft.error }) });
|
|
936
|
+
});
|
|
618
937
|
});
|
|
619
938
|
}
|
|
620
939
|
cancel(workflowId) {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
940
|
+
return this.underLease(workflowId, (current) => {
|
|
941
|
+
if (current.status === "verified_complete")
|
|
942
|
+
return current;
|
|
943
|
+
let definition;
|
|
944
|
+
try {
|
|
945
|
+
definition = this.loadDefinition(current);
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
definition = undefined;
|
|
949
|
+
}
|
|
950
|
+
const stopped = [];
|
|
951
|
+
// Cancelled again: a step recorded in flight whose task has since ended
|
|
952
|
+
// (here, after its settle gave up, or in another process) is recorded
|
|
953
|
+
// as it ended.
|
|
954
|
+
const run = current.status === "cancelled"
|
|
955
|
+
? definition === undefined
|
|
956
|
+
? current
|
|
957
|
+
: this.settleInFlight(current, definition)
|
|
958
|
+
: this.transition(current, (draft, emit) => {
|
|
959
|
+
// After an advance that stalled, died or lost its commit, the run
|
|
960
|
+
// names none of the steps that advance planned or ran, and
|
|
961
|
+
// nothing recovers a cancelled run: read again from the tasks
|
|
962
|
+
// under its keys first, as the next advance would, so they are on
|
|
963
|
+
// its nodes. A run already whole reads the same and records
|
|
964
|
+
// nothing more, but for an operation whose approval node ran its
|
|
965
|
+
// task, which reads that task on the next wave: recorded done
|
|
966
|
+
// here. A task still running there is stopped as the run's own,
|
|
967
|
+
// its step recorded as still in flight. One that cannot be
|
|
968
|
+
// recovered (its history no longer replays) is cancelled as it
|
|
969
|
+
// reads, as for a definition that no longer opens: refused, it
|
|
970
|
+
// could never end.
|
|
971
|
+
if (definition !== undefined) {
|
|
972
|
+
try {
|
|
973
|
+
stopped.push(...this.recoverNodes(current, definition, draft, emit));
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
// Cancelled as recorded.
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
// A step an earlier advance already recorded in flight reads the
|
|
980
|
+
// same and is not among those, yet its task may run here (a
|
|
981
|
+
// direct melra_execute): stopped too. One in another process is
|
|
982
|
+
// left running by tasks.cancel.
|
|
983
|
+
for (const node of Object.values(draft.nodes)) {
|
|
984
|
+
if (node.error === "task_still_in_flight")
|
|
985
|
+
stopped.push(...node.taskIds);
|
|
986
|
+
}
|
|
987
|
+
for (const [nodeId, node] of Object.entries(draft.nodes)) {
|
|
988
|
+
if (!["pending", "ready", "awaiting_approval"].includes(node.status)) {
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
stopped.push(...node.taskIds);
|
|
992
|
+
const from = node.status;
|
|
993
|
+
node.status = "cancelled";
|
|
994
|
+
emit("workflow.node_changed", {
|
|
995
|
+
nodeId,
|
|
996
|
+
from,
|
|
997
|
+
state: node,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
const from = draft.status;
|
|
1001
|
+
draft.status = "cancelled";
|
|
1002
|
+
delete draft.error;
|
|
1003
|
+
emit("workflow.cancelled", { from, to: "cancelled" });
|
|
639
1004
|
});
|
|
1005
|
+
// Tasks are cancelled only once the run is: cancelled before a commit
|
|
1006
|
+
// that then failed, a step's challenge read as a denial on a run that
|
|
1007
|
+
// went on, and the run unwound.
|
|
1008
|
+
const here = stopped.filter((taskId) => this.store.inFlight.has(taskId));
|
|
1009
|
+
for (const taskId of stopped)
|
|
1010
|
+
this.tasks.cancel(taskId);
|
|
1011
|
+
// One running in this process is only told to stop. Its step is read
|
|
1012
|
+
// again once it has ended, as recorded: cancelled, or done all the same.
|
|
1013
|
+
// One running in another process, or whose process died, stays in
|
|
1014
|
+
// flight: nothing here learns how it ends until the run is cancelled
|
|
1015
|
+
// again.
|
|
1016
|
+
if (here.length > 0 && definition !== undefined) {
|
|
1017
|
+
const opened = definition;
|
|
1018
|
+
void Promise.all(here.map((taskId) => this.tasks.settled(taskId)))
|
|
1019
|
+
.then(() => this.settleStopped(workflowId, opened))
|
|
1020
|
+
.catch(() => undefined);
|
|
640
1021
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
1022
|
+
// A challenge a lost advance left is in no node's taskIds: a condition's
|
|
1023
|
+
// is never adopted (it would follow its branch without asking, #268),
|
|
1024
|
+
// and another step's is taken up only when the run advances again
|
|
1025
|
+
// (#319). So the run cancels what is planned or waiting under every
|
|
1026
|
+
// node's keys too, and cancelling a cancelled run sweeps again.
|
|
1027
|
+
// A store failure is reported, so the operator cancels again and that
|
|
1028
|
+
// sweeps; only a definition that can no longer be opened goes unswept,
|
|
1029
|
+
// and still lets the run end.
|
|
1030
|
+
if (definition !== undefined) {
|
|
1031
|
+
const opened = definition;
|
|
1032
|
+
this.cancelChallenges(opened.nodes.flatMap((node) => expectedTaskKeys(current.id, node, opened)));
|
|
1033
|
+
}
|
|
1034
|
+
return run;
|
|
645
1035
|
});
|
|
646
1036
|
}
|
|
1037
|
+
/**
|
|
1038
|
+
* An operator's change to a run, made under its lease as recovery repairs
|
|
1039
|
+
* one, so it never lands under an advance: the advance's commit would fail
|
|
1040
|
+
* and the effects it ran drop out of the run (#244). Refused with
|
|
1041
|
+
* workflow_lease_held while an advance in any process holds the lease. A
|
|
1042
|
+
* lapsed lease is taken over, so the advance that held it stops with
|
|
1043
|
+
* workflow_lease_lost before its next step, and is left expired, as it was:
|
|
1044
|
+
* whoever advances the run next recovers it first.
|
|
1045
|
+
*/
|
|
1046
|
+
underLease(workflowId, change) {
|
|
1047
|
+
this.status(workflowId);
|
|
1048
|
+
const at = Date.now();
|
|
1049
|
+
const taken = this.store.takeWorkflowLease(workflowId, this.controlOwner, new Date(at).toISOString(), new Date(at + this.leaseDurationMs).toISOString());
|
|
1050
|
+
if (taken === "refused")
|
|
1051
|
+
throw new Error("workflow_lease_held");
|
|
1052
|
+
try {
|
|
1053
|
+
// Read under the lease: an advance may have committed since.
|
|
1054
|
+
return change(this.status(workflowId));
|
|
1055
|
+
}
|
|
1056
|
+
finally {
|
|
1057
|
+
if (taken === "created")
|
|
1058
|
+
this.store.releaseWorkflowLease(workflowId, this.controlOwner);
|
|
1059
|
+
else
|
|
1060
|
+
this.store.renewWorkflowLease(workflowId, this.controlOwner, new Date(0).toISOString());
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
647
1063
|
transition(current, mutate) {
|
|
648
1064
|
const draft = structuredClone(current);
|
|
649
1065
|
const events = [];
|
|
@@ -699,6 +1115,10 @@ export class WorkflowController {
|
|
|
699
1115
|
async unwind(nodeIds, graph, definition, run, approvals, inputs) {
|
|
700
1116
|
const results = [];
|
|
701
1117
|
for (const nodeId of nodeIds) {
|
|
1118
|
+
// A cancel or pause landed under this advance: its commit will fail, and
|
|
1119
|
+
// a compensation started now would run for a run that no longer asks.
|
|
1120
|
+
if (this.status(run.id).stateVersion !== run.stateVersion)
|
|
1121
|
+
break;
|
|
702
1122
|
const result = await this.advanceNode(graph.nodes.get(nodeId), definition, run.nodes[nodeId], run, approvals, inputs);
|
|
703
1123
|
results.push({ nodeId, result });
|
|
704
1124
|
if (result.state.status !== "compensated")
|
|
@@ -739,14 +1159,51 @@ export class WorkflowController {
|
|
|
739
1159
|
throw new Error(`workflow_condition_source_unavailable:${node.id}`);
|
|
740
1160
|
}
|
|
741
1161
|
// Decided once. A branch that has started keeps going: re-evaluating a
|
|
742
|
-
// live predicate on a later advance could flip it and
|
|
743
|
-
// branch
|
|
1162
|
+
// live predicate on a later advance could flip it and run the other
|
|
1163
|
+
// branch as well.
|
|
744
1164
|
const identity = { workflowId: run.id, nodeId: node.id, iteration: 0 };
|
|
745
|
-
const
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1165
|
+
const keys = expectedTaskKeys(run.id, node, definition);
|
|
1166
|
+
const trueKeys = keys.slice(0, node.whenTrue.length);
|
|
1167
|
+
const falseKeys = keys.slice(node.whenTrue.length);
|
|
1168
|
+
// A branch with a committed step was chosen, though the advance that
|
|
1169
|
+
// chose it may have lost its commit (a pause landed) and left no task
|
|
1170
|
+
// here. Asking the predicate again would read a workspace that branch
|
|
1171
|
+
// already changed, and could run the other one as well.
|
|
1172
|
+
const committed = (branch) => branch.some((key) => this.store.getIdempotencyCommit(key) !== undefined);
|
|
1173
|
+
const trueCommitted = committed(trueKeys);
|
|
1174
|
+
const falseCommitted = committed(falseKeys);
|
|
1175
|
+
const recorded = state.taskIds.map((taskId) => this.tasks.status(taskId).idempotencyKey ?? "");
|
|
1176
|
+
const chosen = trueCommitted
|
|
1177
|
+
? true
|
|
1178
|
+
: falseCommitted
|
|
1179
|
+
? false
|
|
1180
|
+
: recorded.length > 0
|
|
1181
|
+
? trueKeys.includes(recorded[0])
|
|
1182
|
+
: await answered(node.id, this.tasks.verifyPersisted(sourceTaskId, [node.predicate]), {
|
|
1183
|
+
state,
|
|
1184
|
+
tasks: [],
|
|
1185
|
+
}).catch((error) => {
|
|
1186
|
+
// Not asked, so nothing decided it, but an advance that lost
|
|
1187
|
+
// its commit may have run a step under it whose outcome is
|
|
1188
|
+
// unknown. The node halts on that step, as recovery reads it
|
|
1189
|
+
// (#270), rather than wait or fail naming nothing while a
|
|
1190
|
+
// failed sibling unwinds past it (#318).
|
|
1191
|
+
const unknown = this.unsettledUnder(keys);
|
|
1192
|
+
if (!(error instanceof NodeRefused) || unknown.length === 0)
|
|
1193
|
+
throw error;
|
|
1194
|
+
throw new NodeRefused(error.reason, { state: recoveredNodeState(node, unknown, state), tasks: [] });
|
|
1195
|
+
});
|
|
1196
|
+
// The other branch is settled by the advance that decides, or one that
|
|
1197
|
+
// finds the run naming it: only then can an advance that lost its
|
|
1198
|
+
// commit have left a task there.
|
|
1199
|
+
const own = chosen ? trueKeys : falseKeys;
|
|
1200
|
+
if ((chosen ? falseCommitted : trueCommitted) ||
|
|
1201
|
+
recorded.length === 0 ||
|
|
1202
|
+
recorded.some((key) => !own.includes(key))) {
|
|
1203
|
+
const halted = this.abandonBranch(node, keys, chosen);
|
|
1204
|
+
if (halted !== undefined)
|
|
1205
|
+
return { state: halted, tasks: [] };
|
|
1206
|
+
}
|
|
750
1207
|
return await this.runSequential(chosen ? node.whenTrue : node.whenFalse, state, approvals, { ...identity, branch: chosen ? "true" : "false" });
|
|
751
1208
|
}
|
|
752
1209
|
case "parallel":
|
|
@@ -803,11 +1260,13 @@ export class WorkflowController {
|
|
|
803
1260
|
};
|
|
804
1261
|
}
|
|
805
1262
|
case "delegation": {
|
|
806
|
-
|
|
1263
|
+
// A report whose evidence went unanswered waits on the node, so the
|
|
1264
|
+
// next advance asks again without the worker reporting twice.
|
|
1265
|
+
const report = inputs.find((item) => item.nodeId === node.id)?.value ?? state.input;
|
|
807
1266
|
// Node state caps `prompt` at 2,000 chars; assignee + goal can exceed
|
|
808
1267
|
// that, and a truncated label is better than a schema rejection.
|
|
809
1268
|
const prompt = `${node.assignee}: ${node.goal}`.slice(0, 2_000);
|
|
810
|
-
if (
|
|
1269
|
+
if (report === undefined) {
|
|
811
1270
|
return {
|
|
812
1271
|
state: { status: "awaiting_input", taskIds: [], prompt },
|
|
813
1272
|
tasks: [],
|
|
@@ -817,8 +1276,11 @@ export class WorkflowController {
|
|
|
817
1276
|
// predicates, they decide — a node whose evidence fails is `failed`,
|
|
818
1277
|
// never complete, exactly as for an operation node.
|
|
819
1278
|
if (node.requiredEvidence.length > 0) {
|
|
820
|
-
const
|
|
821
|
-
|
|
1279
|
+
const verified = await answered(node.id, this.tasks.verifyStandalone(node.requiredEvidence), {
|
|
1280
|
+
state: { status: "awaiting_input", taskIds: [], prompt, input: recordedInput(report) },
|
|
1281
|
+
tasks: [],
|
|
1282
|
+
});
|
|
1283
|
+
if (!verified) {
|
|
822
1284
|
return {
|
|
823
1285
|
state: {
|
|
824
1286
|
status: "failed",
|
|
@@ -835,7 +1297,7 @@ export class WorkflowController {
|
|
|
835
1297
|
status: "verified_complete",
|
|
836
1298
|
taskIds: [],
|
|
837
1299
|
prompt,
|
|
838
|
-
input: recordedInput(
|
|
1300
|
+
input: recordedInput(report),
|
|
839
1301
|
},
|
|
840
1302
|
tasks: [],
|
|
841
1303
|
};
|
|
@@ -845,29 +1307,51 @@ export class WorkflowController {
|
|
|
845
1307
|
}
|
|
846
1308
|
}
|
|
847
1309
|
async runTask(request, existingTaskId, approvals, executePlanned = true, identity) {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1310
|
+
this.assertAdvanceLease(identity.workflowId);
|
|
1311
|
+
const key = taskIdempotencyKey(identity, request);
|
|
1312
|
+
let task;
|
|
1313
|
+
if (existingTaskId !== undefined) {
|
|
1314
|
+
task = this.tasks.status(existingTaskId);
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
// A step the run names no task for may still have one: an advance that
|
|
1318
|
+
// lost its commit planned or ran it (#319). Planned afresh, a leftover
|
|
1319
|
+
// challenge stayed approvable beside the new one, a step that took
|
|
1320
|
+
// effect but failed its evidence ran again, and one that failed or was
|
|
1321
|
+
// denied ran after all, past a compensation already run for it.
|
|
1322
|
+
// Planned only when its key holds no task at all.
|
|
1323
|
+
task =
|
|
1324
|
+
this.takenUp(key) ??
|
|
1325
|
+
this.tasks.plan(request, { idempotencyKey: key, attempt: 1, stored: true });
|
|
1326
|
+
}
|
|
1327
|
+
// A challenge this run asked again after it expired leaves its first
|
|
1328
|
+
// attempt cancelled; when the advance that asked again did not commit, the
|
|
1329
|
+
// run still names that attempt. Take the newest attempt for the key, as
|
|
1330
|
+
// recovery does, so only a cancel nobody followed reads as a denial.
|
|
1331
|
+
if (task.status === "cancelled" && task.idempotencyKey !== undefined) {
|
|
1332
|
+
task = preferredTask(this.store.listTasksByKey(task.idempotencyKey), task.idempotencyKey) ?? task;
|
|
1333
|
+
}
|
|
855
1334
|
// A challenge that expired before anyone answered it can never be
|
|
856
1335
|
// answered, and reusing it left the node unapprovable for good. Ask again:
|
|
857
1336
|
// the expired task is cancelled and the same request planned afresh.
|
|
858
1337
|
// A challenge an approver answered in time is not asked again, however
|
|
859
|
-
// long the answer took to act on.
|
|
1338
|
+
// long the answer took to act on. One started meanwhile by another
|
|
1339
|
+
// process (an execute right at expiry) keeps its cancel from landing: it
|
|
1340
|
+
// is the step's task, read as it now stands, not one to ask beside.
|
|
860
1341
|
const decided = task.status === "awaiting_approval" && this.tasks.approvalDecision(task) !== undefined;
|
|
861
1342
|
if (task.status === "awaiting_approval" &&
|
|
862
1343
|
task.approval !== undefined &&
|
|
863
1344
|
Date.parse(task.approval.expiresAt) <= Date.now() &&
|
|
864
1345
|
!decided) {
|
|
865
|
-
this.tasks.cancel(task.id);
|
|
866
|
-
task =
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1346
|
+
const expired = this.tasks.cancel(task.id);
|
|
1347
|
+
task =
|
|
1348
|
+
expired.status !== "cancelled"
|
|
1349
|
+
? expired
|
|
1350
|
+
: this.tasks.plan(request, {
|
|
1351
|
+
idempotencyKey: key,
|
|
1352
|
+
attempt: (task.attempt ?? 1) + 1,
|
|
1353
|
+
stored: true,
|
|
1354
|
+
});
|
|
871
1355
|
}
|
|
872
1356
|
if (task.status === "awaiting_approval") {
|
|
873
1357
|
const approval = approvals.find((item) => item.approvalId === task.approval?.approvalId);
|
|
@@ -893,13 +1377,44 @@ export class WorkflowController {
|
|
|
893
1377
|
tasks: [task],
|
|
894
1378
|
};
|
|
895
1379
|
}
|
|
1380
|
+
// The renewal itself, not a read of the lease: a read passes a lapsed lease
|
|
1381
|
+
// nobody has taken yet, and a takeover landing just after it runs the same
|
|
1382
|
+
// step a second time. The update matches on owner and serializes with
|
|
1383
|
+
// `takeWorkflowLease`, so a takeover either lands first (nothing renewed:
|
|
1384
|
+
// stop) or after, and then finds a fresh lease and is refused. A renewal
|
|
1385
|
+
// that throws (a busy database) fails the advance here too: a lease this
|
|
1386
|
+
// advance cannot confirm is not one to start a step under.
|
|
1387
|
+
assertAdvanceLease(workflowId) {
|
|
1388
|
+
if (!this.store.renewWorkflowLease(workflowId, this.owner, new Date(Date.now() + this.leaseDurationMs).toISOString())) {
|
|
1389
|
+
throw new Error("workflow_lease_lost");
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* The task a step takes up rather than plan one: the one committed, else
|
|
1394
|
+
* the one recovery prefers of every task under the key, one that did not
|
|
1395
|
+
* happen (failed, denied, out of budget, refused) included, so the step
|
|
1396
|
+
* reads as it would after a restart. Undefined only when there is none.
|
|
1397
|
+
*/
|
|
1398
|
+
takenUp(key) {
|
|
1399
|
+
const committed = this.store.getIdempotencyCommit(key)?.taskId;
|
|
1400
|
+
return committed === undefined ? preferredTask(this.store.listTasksByKey(key), key) : this.tasks.status(committed);
|
|
1401
|
+
}
|
|
896
1402
|
async runSequential(requests, state, approvals, identity) {
|
|
897
|
-
const taskIds = [
|
|
1403
|
+
const taskIds = [];
|
|
898
1404
|
const tasks = [];
|
|
899
1405
|
for (const [index, request] of requests.entries()) {
|
|
900
|
-
const
|
|
1406
|
+
const taskIdentity = { ...identity, branch: `${identity.branch}:${index}` };
|
|
1407
|
+
// The step's task is the one under its key. The run may name another
|
|
1408
|
+
// branch's or step's (an earlier version merged both branches), or none
|
|
1409
|
+
// (the advance that planned it lost its commit): runTask then takes up
|
|
1410
|
+
// the one under its key.
|
|
1411
|
+
const named = state.taskIds[index];
|
|
1412
|
+
const existing = named !== undefined && this.tasks.status(named).idempotencyKey === taskIdempotencyKey(taskIdentity, request)
|
|
1413
|
+
? named
|
|
1414
|
+
: undefined;
|
|
1415
|
+
const result = await this.runTask(request, existing, approvals, true, taskIdentity);
|
|
901
1416
|
tasks.push(...result.tasks);
|
|
902
|
-
taskIds
|
|
1417
|
+
taskIds.push(result.state.taskIds[0]);
|
|
903
1418
|
if (result.state.status !== "verified_complete") {
|
|
904
1419
|
return {
|
|
905
1420
|
state: { ...result.state, taskIds },
|
|
@@ -913,25 +1428,65 @@ export class WorkflowController {
|
|
|
913
1428
|
};
|
|
914
1429
|
}
|
|
915
1430
|
async runParallel(branches, state, approvals, workflowId, nodeId) {
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1431
|
+
// Branches side by side, each one's steps in order (#245). A branch's
|
|
1432
|
+
// slice of the node's tasks is only a hint: runSequential checks each
|
|
1433
|
+
// against its step's key, so one shifted by a branch that stopped short
|
|
1434
|
+
// is taken up by key instead.
|
|
1435
|
+
let offset = 0;
|
|
1436
|
+
// Every branch finishes before one's refusal is reported. Rejecting at
|
|
1437
|
+
// the first throw left a sibling's adapter running and the node recorded
|
|
1438
|
+
// as it was, so a failure beside it unwound past whatever that sibling did.
|
|
1439
|
+
const settled = await Promise.allSettled(branches.map((branch, index) => {
|
|
1440
|
+
const taskIds = state.taskIds.slice(offset, offset + branch.length);
|
|
1441
|
+
offset += branch.length;
|
|
1442
|
+
return this.runSequential(branch, { ...state, taskIds }, approvals, {
|
|
1443
|
+
workflowId,
|
|
1444
|
+
nodeId,
|
|
1445
|
+
iteration: 0,
|
|
1446
|
+
branch: String(index),
|
|
1447
|
+
});
|
|
1448
|
+
}));
|
|
1449
|
+
const refused = settled.find((outcome) => outcome.status === "rejected");
|
|
1450
|
+
// A refused branch stays as the attempts it acted on stand, found by key
|
|
1451
|
+
// as runTask finds them: one planned in this call is not recorded yet,
|
|
1452
|
+
// and the run may still name a cancelled attempt that was asked again.
|
|
1453
|
+
// Read as runSequential reads it, up to the first step not complete; one
|
|
1454
|
+
// it never reached leaves it pending, not complete.
|
|
1455
|
+
const states = settled.flatMap((outcome, branchIndex) => {
|
|
1456
|
+
if (outcome.status === "fulfilled")
|
|
1457
|
+
return [outcome.value.state];
|
|
1458
|
+
const steps = [];
|
|
1459
|
+
for (const [index, request] of branches[branchIndex].entries()) {
|
|
1460
|
+
const key = taskIdempotencyKey({ workflowId, nodeId, iteration: 0, branch: `${branchIndex}:${index}` }, request);
|
|
1461
|
+
const task = preferredTask(this.store.listTasksByKey(key), key);
|
|
1462
|
+
const step = task === undefined ? { status: "pending", taskIds: [] } : nodeStateForTask(task, false);
|
|
1463
|
+
steps.push(step);
|
|
1464
|
+
if (step.status !== "verified_complete")
|
|
1465
|
+
break;
|
|
1466
|
+
}
|
|
1467
|
+
return steps;
|
|
1468
|
+
});
|
|
1469
|
+
const advance = {
|
|
1470
|
+
state: aggregateNodeState(states),
|
|
1471
|
+
tasks: settled.flatMap((outcome) => (outcome.status === "fulfilled" ? outcome.value.tasks : [])),
|
|
929
1472
|
};
|
|
1473
|
+
if (refused !== undefined)
|
|
1474
|
+
throw new NodeRefused(refused.reason, advance);
|
|
1475
|
+
return advance;
|
|
930
1476
|
}
|
|
931
1477
|
async runLoop(node, state, approvals, workflowId) {
|
|
932
1478
|
const taskIds = [...state.taskIds];
|
|
933
1479
|
const tasks = [];
|
|
934
1480
|
let iterations = state.iterations ?? 0;
|
|
1481
|
+
// An advance that lost its commit may have gone on to this iteration, so
|
|
1482
|
+
// `until` said no before it: asked again, of a workspace that iteration
|
|
1483
|
+
// changed, it could stop the loop short of an iteration that ran.
|
|
1484
|
+
const wentOn = (iteration) => {
|
|
1485
|
+
if (iteration >= node.maxIterations)
|
|
1486
|
+
return false;
|
|
1487
|
+
const key = taskIdempotencyKey({ workflowId, nodeId: node.id, iteration, branch: "loop:0" }, node.body[0]);
|
|
1488
|
+
return this.takenUp(key) !== undefined;
|
|
1489
|
+
};
|
|
935
1490
|
// Resumed after recovery between iterations: the last one finished but its
|
|
936
1491
|
// `until` was never checked, and running another would be one too many.
|
|
937
1492
|
const finished = taskIds[iterations * node.body.length - 1];
|
|
@@ -939,7 +1494,8 @@ export class WorkflowController {
|
|
|
939
1494
|
taskIds.length === iterations * node.body.length &&
|
|
940
1495
|
finished !== undefined &&
|
|
941
1496
|
node.until !== undefined &&
|
|
942
|
-
(
|
|
1497
|
+
!wentOn(iterations) &&
|
|
1498
|
+
(await answered(node.id, this.tasks.verifyPersisted(finished, [node.until]), { state: { status: "pending", taskIds, iterations }, tasks }, "no"))) {
|
|
943
1499
|
return { state: { status: "verified_complete", taskIds, iterations }, tasks };
|
|
944
1500
|
}
|
|
945
1501
|
while (iterations < node.maxIterations) {
|
|
@@ -966,8 +1522,13 @@ export class WorkflowController {
|
|
|
966
1522
|
}
|
|
967
1523
|
iterations += 1;
|
|
968
1524
|
const lastTaskId = taskIds[offset + node.body.length - 1];
|
|
1525
|
+
// Unanswered, the iteration is recorded and `until` asked again on the
|
|
1526
|
+
// next advance, where the check above picks it up. Not asked after the
|
|
1527
|
+
// last iteration, which ends the loop whatever the answer.
|
|
969
1528
|
if (node.until !== undefined &&
|
|
970
|
-
|
|
1529
|
+
iterations < node.maxIterations &&
|
|
1530
|
+
!wentOn(iterations) &&
|
|
1531
|
+
(await answered(node.id, this.tasks.verifyPersisted(lastTaskId, [node.until]), { state: { status: "pending", taskIds, iterations }, tasks }, "no"))) {
|
|
971
1532
|
break;
|
|
972
1533
|
}
|
|
973
1534
|
}
|
|
@@ -980,6 +1541,99 @@ export class WorkflowController {
|
|
|
980
1541
|
tasks,
|
|
981
1542
|
};
|
|
982
1543
|
}
|
|
1544
|
+
/**
|
|
1545
|
+
* What an earlier version recorded beside a step whose outcome is unknown.
|
|
1546
|
+
* It ranked a failure above an unknown outcome, so a parallel, condition or
|
|
1547
|
+
* loop node holding both read `failed`, and the run failed and unwound. That
|
|
1548
|
+
* unwind skipped the unknown step's own compensation, the step not being
|
|
1549
|
+
* done; offered again, it runs if the step settles as done. Nothing records
|
|
1550
|
+
* either now, so a current run is never changed here.
|
|
1551
|
+
*/
|
|
1552
|
+
repairLegacy(definition, current) {
|
|
1553
|
+
if (current.status !== "failed" && current.status !== "recovery_required")
|
|
1554
|
+
return undefined;
|
|
1555
|
+
const reranked = definition.nodes.flatMap((node) => {
|
|
1556
|
+
const state = current.nodes[node.id];
|
|
1557
|
+
if (node.type === "compensation" || (state?.status !== "failed" && state?.status !== "cancelled"))
|
|
1558
|
+
return [];
|
|
1559
|
+
const unknown = state.taskIds
|
|
1560
|
+
.map((taskId) => this.store.getTask(taskId))
|
|
1561
|
+
.find((task) => task?.status === "recovery_required");
|
|
1562
|
+
return unknown === undefined ? [] : [{ node, state, unknown }];
|
|
1563
|
+
});
|
|
1564
|
+
const isUnknown = (nodeId) => current.nodes[nodeId]?.status === "recovery_required" || reranked.some(({ node }) => node.id === nodeId);
|
|
1565
|
+
// An approval node runs its step's task itself, so the step may still
|
|
1566
|
+
// read `pending` while the approval holds the unknown outcome. A target
|
|
1567
|
+
// that has since settled as done counts too: recovery at startup may have
|
|
1568
|
+
// settled it before this ran, and this version skips a compensation only
|
|
1569
|
+
// when its target is not done.
|
|
1570
|
+
const unskipped = definition.nodes.filter((node) => node.type === "compensation" &&
|
|
1571
|
+
current.nodes[node.id]?.status === "skipped" &&
|
|
1572
|
+
(isUnknown(node.forNodeId) ||
|
|
1573
|
+
targetDone(definition, (id) => current.nodes[id], node.forNodeId, this.ran) ||
|
|
1574
|
+
definition.nodes.some((other) => other.type === "approval" && other.forNodeId === node.forNodeId && isUnknown(other.id))));
|
|
1575
|
+
const halt = current.status === "failed" &&
|
|
1576
|
+
(reranked.length > 0 || hasUnknownOutcome(definition, (id) => current.nodes[id]?.status));
|
|
1577
|
+
if (reranked.length === 0 && unskipped.length === 0 && !halt)
|
|
1578
|
+
return undefined;
|
|
1579
|
+
return this.transition(current, (draft, emit) => {
|
|
1580
|
+
for (const { node, state: previous, unknown } of reranked) {
|
|
1581
|
+
const state = {
|
|
1582
|
+
status: "recovery_required",
|
|
1583
|
+
taskIds: previous.taskIds,
|
|
1584
|
+
...(unknown.error === undefined ? {} : { error: unknown.error }),
|
|
1585
|
+
};
|
|
1586
|
+
emit("workflow.node_changed", { nodeId: node.id, from: previous.status, state });
|
|
1587
|
+
draft.nodes[node.id] = state;
|
|
1588
|
+
}
|
|
1589
|
+
for (const node of unskipped) {
|
|
1590
|
+
const state = { status: "pending", taskIds: [] };
|
|
1591
|
+
emit("workflow.node_changed", { nodeId: node.id, from: "skipped", state });
|
|
1592
|
+
draft.nodes[node.id] = state;
|
|
1593
|
+
}
|
|
1594
|
+
// A run with nothing unknown stays failed, so its unwind takes up what
|
|
1595
|
+
// was offered again.
|
|
1596
|
+
if (halt) {
|
|
1597
|
+
const from = draft.status;
|
|
1598
|
+
draft.status = "recovery_required";
|
|
1599
|
+
const stalled = stalledCompensation(definition, draft.nodes);
|
|
1600
|
+
if (stalled === undefined)
|
|
1601
|
+
delete draft.error;
|
|
1602
|
+
else
|
|
1603
|
+
draft.error = `workflow_compensation_incomplete:${stalled.id}`;
|
|
1604
|
+
emit("workflow.status_changed", {
|
|
1605
|
+
from,
|
|
1606
|
+
to: draft.status,
|
|
1607
|
+
...(draft.error === undefined ? {} : { error: draft.error }),
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Whether a forward step failed or was denied. Also inside a node that an
|
|
1614
|
+
* unknown branch ranks as recovery_required: a parallel node with one branch
|
|
1615
|
+
* unknown and one failed reads recovery_required, and its failure is in its
|
|
1616
|
+
* tasks.
|
|
1617
|
+
*/
|
|
1618
|
+
hasFailure(definition, nodes) {
|
|
1619
|
+
return definition.nodes.some((node) => {
|
|
1620
|
+
if (node.type === "compensation")
|
|
1621
|
+
return false;
|
|
1622
|
+
const state = nodes[node.id];
|
|
1623
|
+
if (state === undefined)
|
|
1624
|
+
return false;
|
|
1625
|
+
if (state.status === "failed" || state.status === "cancelled")
|
|
1626
|
+
return true;
|
|
1627
|
+
if (state.status !== "recovery_required")
|
|
1628
|
+
return false;
|
|
1629
|
+
return state.taskIds.some((taskId) => {
|
|
1630
|
+
const task = this.store.getTask(taskId);
|
|
1631
|
+
return (task !== undefined &&
|
|
1632
|
+
(["failed", "partial", "budget_exhausted", "policy_blocked"].includes(task.status) ||
|
|
1633
|
+
(task.status === "cancelled" && task.error !== "duplicate_attempt_prevented")));
|
|
1634
|
+
});
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
983
1637
|
deriveStatus(run, definition) {
|
|
984
1638
|
const required = definition.nodes.filter((node) => node.type !== "compensation");
|
|
985
1639
|
// Compensation nodes are excluded from every check below, because a
|
|
@@ -987,15 +1641,25 @@ export class WorkflowController {
|
|
|
987
1641
|
// the forward effect happened and its declared inverse did not, which
|
|
988
1642
|
// leaves the world in a state no single node describes. Checked first, so
|
|
989
1643
|
// an incomplete unwind is never reported as a clean failure.
|
|
990
|
-
if (definition.nodes
|
|
991
|
-
|
|
1644
|
+
if (stalledCompensation(definition, run.nodes) !== undefined) {
|
|
1645
|
+
return "recovery_required";
|
|
1646
|
+
}
|
|
1647
|
+
// Above a failure: unwinding now could undo what an effect that may have
|
|
1648
|
+
// happened depends on (refund a charge whose goods may be in transit). The
|
|
1649
|
+
// run halts until that step is settled, and only then fails and unwinds.
|
|
1650
|
+
if (hasUnknownOutcome(definition, (id) => run.nodes[id]?.status)) {
|
|
992
1651
|
return "recovery_required";
|
|
993
1652
|
}
|
|
994
1653
|
if (required.some((node) => run.nodes[node.id]?.status === "failed")) {
|
|
995
1654
|
return "failed";
|
|
996
1655
|
}
|
|
997
|
-
|
|
998
|
-
|
|
1656
|
+
// A step whose task was denied or cancelled did not happen and is never
|
|
1657
|
+
// offered again (`readyNodeIds` takes pending nodes, the re-offer awaiting
|
|
1658
|
+
// ones), so the run cannot finish: it fails, and unwinds as for a failure.
|
|
1659
|
+
// A run-level `cancelled` belongs to `cancel()`, whose runs are not
|
|
1660
|
+
// derived.
|
|
1661
|
+
if (required.some((node) => run.nodes[node.id]?.status === "cancelled")) {
|
|
1662
|
+
return "failed";
|
|
999
1663
|
}
|
|
1000
1664
|
if (required.some((node) => run.nodes[node.id]?.status === "awaiting_approval")) {
|
|
1001
1665
|
return "awaiting_approval";
|
|
@@ -1009,8 +1673,9 @@ export class WorkflowController {
|
|
|
1009
1673
|
const state = run.nodes[node.id];
|
|
1010
1674
|
return (state !== undefined &&
|
|
1011
1675
|
["verified_complete", "skipped", "compensated"].includes(state.status) &&
|
|
1012
|
-
|
|
1013
|
-
|
|
1676
|
+
// Every caller writes the run, so a step's certificate that
|
|
1677
|
+
// failed to write is written here rather than on a read.
|
|
1678
|
+
state.taskIds.every((taskId) => this.tasks.certify(taskId)?.result === "VERIFIED_SUCCESS"));
|
|
1014
1679
|
})) {
|
|
1015
1680
|
return "verified_complete";
|
|
1016
1681
|
}
|
|
@@ -1029,9 +1694,49 @@ function makeEvent(run, sequence, type, data) {
|
|
|
1029
1694
|
occurredAt: new Date().toISOString(),
|
|
1030
1695
|
});
|
|
1031
1696
|
}
|
|
1697
|
+
/**
|
|
1698
|
+
* A workflow check's answer. One the verifier could not ask (a provider that
|
|
1699
|
+
* answered 503, or not at all) is not a "no": nothing is decided on it. The
|
|
1700
|
+
* node is recorded as `waiting`, with the check as its error, and the next
|
|
1701
|
+
* advance asks again; the same check again writes nothing. Read as a no, a
|
|
1702
|
+
* condition took its other branch for good, a loop ran another iteration, and
|
|
1703
|
+
* a delegation failed its run. One the kernel refuses to ask (`unanswerable`)
|
|
1704
|
+
* is refused again of the same result, so the node fails; `until` passes
|
|
1705
|
+
* `refusedIs: "no"`, since the next iteration's result may answer it.
|
|
1706
|
+
*/
|
|
1707
|
+
async function answered(nodeId, check, waiting, refusedIs = "failure") {
|
|
1708
|
+
const failed = (await check).evidence.filter((item) => !item.passed);
|
|
1709
|
+
const refused = (code, items, status) => new NodeRefused(new Error(code), {
|
|
1710
|
+
...waiting,
|
|
1711
|
+
state: { ...waiting.state, status, error: recordedInput(`${code}: ${items.map(asked).join("; ")}`) },
|
|
1712
|
+
});
|
|
1713
|
+
const unanswerable = failed.filter((item) => item.unanswerable === true);
|
|
1714
|
+
if (unanswerable.length > 0 && refusedIs === "failure") {
|
|
1715
|
+
throw refused(`workflow_check_unresolvable:${nodeId}`, unanswerable, "failed");
|
|
1716
|
+
}
|
|
1717
|
+
// Every item must pass, so a definite no decides, whatever an unanswered
|
|
1718
|
+
// item would have said.
|
|
1719
|
+
if (failed.some((item) => item.inconclusive !== true || item.unanswerable === true))
|
|
1720
|
+
return false;
|
|
1721
|
+
if (failed.length === 0)
|
|
1722
|
+
return true;
|
|
1723
|
+
throw refused(`workflow_check_inconclusive:${nodeId}`, failed, waiting.state.status);
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* A check's item as a node's error names it: its type, its source redacted as
|
|
1727
|
+
* a receipt's URL is (a query can carry a token), and the kernel's own
|
|
1728
|
+
* refusal. Not a provider's words, which can change between polls (a
|
|
1729
|
+
* transport message listing addresses in resolver order) and would write a
|
|
1730
|
+
* waiting run again on each.
|
|
1731
|
+
*/
|
|
1732
|
+
function asked(item) {
|
|
1733
|
+
const source = item.source === undefined ? "" : ` ${redactStructuredValue({ url: item.source }).value.url}`;
|
|
1734
|
+
return `${item.type}${source}${item.unanswerable === true ? `: ${item.summary}` : ""}`;
|
|
1735
|
+
}
|
|
1032
1736
|
// Human- and delegate-supplied text is untrusted like any other input, so it is
|
|
1033
|
-
// redacted before it reaches persisted state
|
|
1034
|
-
// (a short secret becomes a longer
|
|
1737
|
+
// redacted before it reaches persisted state, as is the check a node waits
|
|
1738
|
+
// on. Redaction can lengthen a value (a short secret becomes a longer
|
|
1739
|
+
// marker), hence the cap the state schema wants.
|
|
1035
1740
|
function recordedInput(value) {
|
|
1036
1741
|
return String(redactStructuredValue(value).value).slice(0, 10_000);
|
|
1037
1742
|
}
|
|
@@ -1056,6 +1761,15 @@ function requestsFor(node) {
|
|
|
1056
1761
|
return [];
|
|
1057
1762
|
}
|
|
1058
1763
|
}
|
|
1764
|
+
/** Whether a node asks a semantic question anywhere (#54). */
|
|
1765
|
+
function declaresSemantic(node) {
|
|
1766
|
+
return [
|
|
1767
|
+
...requestsFor(node).flatMap((request) => [...request.requiredEvidence, ...request.reconciliation]),
|
|
1768
|
+
...(node.type === "condition" ? [node.predicate] : []),
|
|
1769
|
+
...(node.type === "bounded_loop" && node.until !== undefined ? [node.until] : []),
|
|
1770
|
+
...(node.type === "delegation" ? node.requiredEvidence : []),
|
|
1771
|
+
].some((predicate) => predicate.type === "semantic");
|
|
1772
|
+
}
|
|
1059
1773
|
function nodeStateForTask(task, plannedIsComplete) {
|
|
1060
1774
|
switch (task.status) {
|
|
1061
1775
|
case "verified_success":
|
|
@@ -1078,7 +1792,20 @@ function nodeStateForTask(task, plannedIsComplete) {
|
|
|
1078
1792
|
...(task.error === undefined ? {} : { error: task.error }),
|
|
1079
1793
|
};
|
|
1080
1794
|
case "cancelled":
|
|
1081
|
-
|
|
1795
|
+
// Cancelled as a duplicate, the effect committed under an attempt this
|
|
1796
|
+
// run does not record: an advance lost its commit (a pause landed while
|
|
1797
|
+
// the step ran). Not a denial. The run halts, and recovery, which reads
|
|
1798
|
+
// every attempt of every slot, settles it as it always has.
|
|
1799
|
+
return task.error === "duplicate_attempt_prevented"
|
|
1800
|
+
? { status: "recovery_required", taskIds: [task.id], error: task.error }
|
|
1801
|
+
: { status: "cancelled", taskIds: [task.id] };
|
|
1802
|
+
case "running":
|
|
1803
|
+
case "verifying":
|
|
1804
|
+
// Still in flight elsewhere, or interrupted after its effect (its key may
|
|
1805
|
+
// be committed). Not a failure of its own: read as one, its run would
|
|
1806
|
+
// unwind past an effect that may stand. Task recovery settles it, and
|
|
1807
|
+
// the run is derived again.
|
|
1808
|
+
return { status: "recovery_required", taskIds: [task.id], error: "task_still_in_flight" };
|
|
1082
1809
|
default:
|
|
1083
1810
|
return {
|
|
1084
1811
|
status: "failed",
|
|
@@ -1089,17 +1816,79 @@ function nodeStateForTask(task, plannedIsComplete) {
|
|
|
1089
1816
|
}
|
|
1090
1817
|
function aggregateNodeState(states) {
|
|
1091
1818
|
const taskIds = states.flatMap((state) => state.taskIds);
|
|
1819
|
+
// A failed or denied branch ends the node before another branch's approval
|
|
1820
|
+
// can run a step on a run that is already failing. An unknown outcome ranks
|
|
1821
|
+
// above both: the run halts for an operator rather than unwind past an
|
|
1822
|
+
// effect that may have happened.
|
|
1092
1823
|
for (const status of [
|
|
1093
|
-
"failed",
|
|
1094
1824
|
"recovery_required",
|
|
1095
|
-
"
|
|
1825
|
+
"failed",
|
|
1096
1826
|
"cancelled",
|
|
1827
|
+
"awaiting_approval",
|
|
1097
1828
|
]) {
|
|
1098
1829
|
const state = states.find((candidate) => candidate.status === status);
|
|
1099
1830
|
if (state !== undefined)
|
|
1100
1831
|
return { ...state, taskIds };
|
|
1101
1832
|
}
|
|
1102
|
-
|
|
1833
|
+
// Complete only when every branch is: one that has not run yet (a refused
|
|
1834
|
+
// branch whose task is still planned) leaves the node to be offered again.
|
|
1835
|
+
return states.every((state) => state.status === "verified_complete")
|
|
1836
|
+
? { status: "verified_complete", taskIds }
|
|
1837
|
+
: { status: "pending", taskIds };
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* Whether a compensation's target step happened. An approval node runs its
|
|
1841
|
+
* operation's task itself, so the operation still reads `pending` for a wave
|
|
1842
|
+
* after its effect; skipping the compensation then would undo the steps
|
|
1843
|
+
* before it while this one stands. Only when that task did run, though: an
|
|
1844
|
+
* approval that policy let through (unsafe-local, or a read) reads
|
|
1845
|
+
* `verified_complete` with the task merely planned, and the effect is not
|
|
1846
|
+
* there to undo.
|
|
1847
|
+
*/
|
|
1848
|
+
function targetDone(definition, stateOf, nodeId, ran) {
|
|
1849
|
+
return (stateOf(nodeId)?.status === "verified_complete" ||
|
|
1850
|
+
definition.nodes.some((node) => node.type === "approval" &&
|
|
1851
|
+
node.forNodeId === nodeId &&
|
|
1852
|
+
stateOf(node.id)?.status === "verified_complete" &&
|
|
1853
|
+
(stateOf(node.id)?.taskIds ?? []).some(ran)));
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Whether a forward step's outcome is unknown or unsettled (an effect that may
|
|
1857
|
+
* have happened, a task still in flight, or one cancelled as a duplicate of an
|
|
1858
|
+
* attempt that committed). Nothing is undone past one.
|
|
1859
|
+
*/
|
|
1860
|
+
function hasUnknownOutcome(definition, statusOf) {
|
|
1861
|
+
return definition.nodes.some((node) => node.type !== "compensation" && statusOf(node.id) === "recovery_required");
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* The compensation an unwind stopped at: it failed, its outcome is unknown, or
|
|
1865
|
+
* an operator denied it (Deny cancels its task). Its step was not undone, so
|
|
1866
|
+
* nothing older is undone either.
|
|
1867
|
+
*/
|
|
1868
|
+
function stalledCompensation(definition, nodes) {
|
|
1869
|
+
return definition.nodes.find((node) => node.type === "compensation" &&
|
|
1870
|
+
["failed", "recovery_required", "cancelled"].includes(nodes[node.id]?.status ?? ""));
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Why a halted run needs hands, naming the step: an unwind that stopped, or a
|
|
1874
|
+
* condition both of whose branches may have had an effect.
|
|
1875
|
+
*/
|
|
1876
|
+
function haltReason(definition, nodes) {
|
|
1877
|
+
const stalled = stalledCompensation(definition, nodes);
|
|
1878
|
+
if (stalled !== undefined)
|
|
1879
|
+
return `workflow_compensation_incomplete:${stalled.id}`;
|
|
1880
|
+
const conflict = definition.nodes.find((node) => nodes[node.id]?.error === BRANCH_CONFLICT);
|
|
1881
|
+
return conflict === undefined ? undefined : `${BRANCH_CONFLICT}:${conflict.id}`;
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* Whether a task is known to have changed nothing, as the task layer records
|
|
1885
|
+
* it: an adapter that may have acted reports an unknown outcome, which parks
|
|
1886
|
+
* the task as recovery_required, so failed (the adapter said it failed, or
|
|
1887
|
+
* reconciliation found it never applied), budget_exhausted, cancelled and
|
|
1888
|
+
* policy_blocked all say it did not happen. Anything else may have.
|
|
1889
|
+
*/
|
|
1890
|
+
function hadNoEffect(task) {
|
|
1891
|
+
return ["failed", "budget_exhausted", "cancelled", "policy_blocked"].includes(task.status);
|
|
1103
1892
|
}
|
|
1104
1893
|
function taskIdempotencyKey(identity, request) {
|
|
1105
1894
|
return sha256({ ...identity, request });
|
|
@@ -1152,6 +1941,9 @@ function sequenceFinished(node, found) {
|
|
|
1152
1941
|
// left for the loop itself to decide.
|
|
1153
1942
|
return found.every(Boolean);
|
|
1154
1943
|
}
|
|
1944
|
+
// Every branch runs to its end, each step only after the one before it.
|
|
1945
|
+
if (node.type === "parallel")
|
|
1946
|
+
return found.every(Boolean);
|
|
1155
1947
|
return true;
|
|
1156
1948
|
}
|
|
1157
1949
|
function preferredTask(tasks, idempotencyKey) {
|
|
@@ -1160,17 +1952,33 @@ function preferredTask(tasks, idempotencyKey) {
|
|
|
1160
1952
|
.sort((left, right) => Number(right.status === "verified_success") -
|
|
1161
1953
|
Number(left.status === "verified_success") ||
|
|
1162
1954
|
(right.attempt ?? 1) - (left.attempt ?? 1) ||
|
|
1163
|
-
|
|
1955
|
+
// The newest planned, not the newest touched: cancelling a stale
|
|
1956
|
+
// duplicate challenge must not make it the one the run names.
|
|
1957
|
+
right.createdAt.localeCompare(left.createdAt))[0];
|
|
1164
1958
|
}
|
|
1165
1959
|
function recoveredNodeState(node, tasks, previous) {
|
|
1166
1960
|
if (tasks.length === 0)
|
|
1167
1961
|
return previous;
|
|
1168
1962
|
const taskIds = tasks.map((task) => task.id);
|
|
1169
1963
|
const withStatus = (status) => tasks.find((task) => task.status === status);
|
|
1170
|
-
// Ranked as aggregateNodeState ranks a live node
|
|
1171
|
-
// node with one failed branch and one awaiting approval
|
|
1172
|
-
// came back from recovery awaiting approval, reopening
|
|
1173
|
-
// a run whose rollback had begun.
|
|
1964
|
+
// Ranked as aggregateNodeState ranks a live node: an unknown outcome, then a
|
|
1965
|
+
// failure. A parallel node with one failed branch and one awaiting approval
|
|
1966
|
+
// was failed live and came back from recovery awaiting approval, reopening
|
|
1967
|
+
// the waiting branch on a run whose rollback had begun.
|
|
1968
|
+
const recovery = withStatus("recovery_required");
|
|
1969
|
+
if (recovery !== undefined) {
|
|
1970
|
+
return {
|
|
1971
|
+
status: "recovery_required",
|
|
1972
|
+
taskIds,
|
|
1973
|
+
...(recovery.error === undefined ? {} : { error: recovery.error }),
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
// Still running, in a live process or one that died mid-effect: unknown, as
|
|
1977
|
+
// nodeStateForTask reads it. Read as cancelled, a cancel (which reads every
|
|
1978
|
+
// step again) recorded the effect as cancelled on its run for good.
|
|
1979
|
+
if (tasks.some((task) => task.status === "running" || task.status === "verifying")) {
|
|
1980
|
+
return { status: "recovery_required", taskIds, error: "task_still_in_flight" };
|
|
1981
|
+
}
|
|
1174
1982
|
const failed = tasks.find((task) => [
|
|
1175
1983
|
"failed",
|
|
1176
1984
|
"partial",
|
|
@@ -1184,14 +1992,10 @@ function recoveredNodeState(node, tasks, previous) {
|
|
|
1184
1992
|
...(failed.error === undefined ? {} : { error: failed.error }),
|
|
1185
1993
|
};
|
|
1186
1994
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
taskIds,
|
|
1192
|
-
...(recovery.error === undefined ? {} : { error: recovery.error }),
|
|
1193
|
-
};
|
|
1194
|
-
}
|
|
1995
|
+
// Ranked as aggregateNodeState ranks it, so a denied branch reads the same
|
|
1996
|
+
// after a restart and its sibling's challenge is not offered again.
|
|
1997
|
+
if (withStatus("cancelled") !== undefined)
|
|
1998
|
+
return { status: "cancelled", taskIds };
|
|
1195
1999
|
const awaiting = withStatus("awaiting_approval");
|
|
1196
2000
|
if (awaiting !== undefined) {
|
|
1197
2001
|
return {
|