@cleocode/playbooks 2026.4.93 → 2026.4.95

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.
@@ -0,0 +1,601 @@
1
+ /**
2
+ * Playbook runtime — deterministic state machine executor for `.cantbook` flows.
3
+ *
4
+ * This module is the executable heart of CLEO's T910 "Orchestration Coherence v4"
5
+ * pipeline. It walks a validated {@link PlaybookDefinition} one node at a time,
6
+ * merging node outputs into a shared `context`, enforcing per-node iteration
7
+ * caps, and pausing for HITL approval gates via the signed resume-token
8
+ * protocol (see `approval.ts`).
9
+ *
10
+ * Design constraints (non-negotiable):
11
+ *
12
+ * 1. Pure dependency injection — the runtime never imports or instantiates
13
+ * subprocess code. Callers pass an {@link AgentDispatcher} for `agentic`
14
+ * nodes and an optional {@link DeterministicRunner} for `deterministic`
15
+ * nodes. Tests can therefore exercise every branch without mocking any
16
+ * `@cleocode/*` module.
17
+ * 2. Deterministic ordering — a topological traversal is computed up front
18
+ * from the {@link PlaybookDefinition.edges} graph (with `depends[]`
19
+ * treated as reverse edges, exactly as the parser's cycle check does).
20
+ * Execution order is stable across runs for the same definition.
21
+ * 3. Fail-closed policy — unknown node kinds, missing successors, unresolved
22
+ * `inject_into` targets, or dispatcher errors all terminate the run with
23
+ * a typed `terminalStatus`. The runtime never silently swallows failures.
24
+ * 4. HITL gates persist — when an `approval` node executes, the run is
25
+ * marked `paused` in `playbook_runs`, a {@link PlaybookApproval} row is
26
+ * written with the HMAC-signed resume token, and the returned
27
+ * {@link ExecutePlaybookResult.approvalToken} is what the human reviewer
28
+ * must present via `resumePlaybook` to continue.
29
+ *
30
+ * @task T930 — Playbook Runtime State Machine
31
+ */
32
+ import { createApprovalGate, getPlaybookSecret } from './approval.js';
33
+ import { createPlaybookApproval, createPlaybookRun, getPlaybookApprovalByToken, getPlaybookRun, updatePlaybookRun, } from './state.js';
34
+ /**
35
+ * Error code stamped onto errors thrown by the runtime for invalid inputs.
36
+ * Exported for parity with the rest of the `@cleocode/playbooks` error codes.
37
+ */
38
+ export const E_PLAYBOOK_RUNTIME_INVALID = 'E_PLAYBOOK_RUNTIME_INVALID';
39
+ /**
40
+ * Error code thrown when {@link resumePlaybook} is called with a token that
41
+ * does not resolve to an `approved` gate.
42
+ */
43
+ export const E_PLAYBOOK_RESUME_BLOCKED = 'E_PLAYBOOK_RESUME_BLOCKED';
44
+ // ---------------------------------------------------------------------------
45
+ // Graph helpers
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * Build {@link EdgeIndex} from a validated playbook. The parser guarantees
49
+ * every `from`/`to` references a known node id, so lookups are safe.
50
+ *
51
+ * `depends[]` entries are treated as reverse edges (`dep → node`), matching
52
+ * the cycle-detection logic in `parser.ts::hasCycle`.
53
+ */
54
+ function buildEdgeIndex(def) {
55
+ const outgoing = new Map();
56
+ const incoming = new Map();
57
+ for (const n of def.nodes) {
58
+ outgoing.set(n.id, []);
59
+ incoming.set(n.id, []);
60
+ }
61
+ for (const e of def.edges) {
62
+ outgoing.get(e.from)?.push(e.to);
63
+ incoming.get(e.to)?.push(e.from);
64
+ }
65
+ for (const n of def.nodes) {
66
+ if (!n.depends)
67
+ continue;
68
+ for (const dep of n.depends) {
69
+ // dep -> n is a reverse edge; push onto outgoing(dep) and incoming(n)
70
+ // only if it is not already there (idempotent with explicit edges).
71
+ const out = outgoing.get(dep);
72
+ if (out && !out.includes(n.id))
73
+ out.push(n.id);
74
+ const inc = incoming.get(n.id);
75
+ if (inc && !inc.includes(dep))
76
+ inc.push(dep);
77
+ }
78
+ }
79
+ return {
80
+ outgoing: new Map([...outgoing].map(([k, v]) => [k, Object.freeze([...v])])),
81
+ incoming: new Map([...incoming].map(([k, v]) => [k, Object.freeze([...v])])),
82
+ };
83
+ }
84
+ /**
85
+ * Resolve the single entry node. An entry node is one with no incoming edges
86
+ * after `depends[]` is folded in. If multiple candidates exist, the first in
87
+ * {@link PlaybookDefinition.nodes} declaration order wins so execution is
88
+ * deterministic across process restarts.
89
+ *
90
+ * Throws if no entry node exists (every node has a predecessor — impossible
91
+ * for a DAG but we defensively check anyway).
92
+ */
93
+ function resolveEntryNode(def, idx) {
94
+ for (const n of def.nodes) {
95
+ const preds = idx.incoming.get(n.id);
96
+ if (preds && preds.length === 0)
97
+ return n;
98
+ }
99
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: no entry node (every node has a predecessor) in ${def.name}`);
100
+ }
101
+ /**
102
+ * Return the single successor node id for `nodeId`, or `null` if `nodeId` is
103
+ * terminal (no outgoing edges — the "end" state in the design contract).
104
+ *
105
+ * Throws on fan-out (> 1 successor) because the deterministic runtime does
106
+ * not support branching without an explicit `decide`-node contract. A
107
+ * follow-up task can add guarded branching here — see README.
108
+ */
109
+ function resolveNextNodeId(nodeId, idx) {
110
+ const outs = idx.outgoing.get(nodeId) ?? [];
111
+ if (outs.length === 0)
112
+ return null;
113
+ if (outs.length > 1) {
114
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has ${outs.length} successors; branching requires an approval/decide node`);
115
+ }
116
+ // Safe: length === 1
117
+ const [next] = outs;
118
+ if (next === undefined) {
119
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: node ${nodeId} has undefined successor`);
120
+ }
121
+ return next;
122
+ }
123
+ /**
124
+ * Look up the {@link PlaybookNode} by id. Throws on unknown id so callers
125
+ * surface invariant violations at the runtime boundary.
126
+ */
127
+ function resolveNode(nodeId, idx) {
128
+ const node = idx.get(nodeId);
129
+ if (node === undefined) {
130
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: unknown node id "${nodeId}"`);
131
+ }
132
+ return node;
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Per-node execution
136
+ // ---------------------------------------------------------------------------
137
+ /**
138
+ * Execute a single `agentic` node via the injected {@link AgentDispatcher}.
139
+ * The dispatcher receives the current context and must return a success /
140
+ * failure envelope — any thrown exception is normalized into a failure.
141
+ */
142
+ async function executeAgenticNode(node, runId, context, iteration, dispatcher) {
143
+ const agentId = node.agent ?? node.skill;
144
+ if (agentId === undefined) {
145
+ // Parser guarantees at-least-one, but narrow defensively.
146
+ return {
147
+ kind: 'failure',
148
+ error: `${E_PLAYBOOK_RUNTIME_INVALID}: node ${node.id} is agentic but has no skill or agent`,
149
+ };
150
+ }
151
+ const taskIdRaw = context['taskId'];
152
+ const taskId = typeof taskIdRaw === 'string' && taskIdRaw.length > 0 ? taskIdRaw : runId;
153
+ try {
154
+ const result = await dispatcher.dispatch({
155
+ runId,
156
+ nodeId: node.id,
157
+ agentId,
158
+ taskId,
159
+ context: { ...context },
160
+ iteration,
161
+ });
162
+ if (result.status === 'success') {
163
+ return { kind: 'success', output: result.output };
164
+ }
165
+ return { kind: 'failure', error: result.error ?? `agent ${agentId} returned failure` };
166
+ }
167
+ catch (err) {
168
+ return {
169
+ kind: 'failure',
170
+ error: err instanceof Error ? err.message : String(err),
171
+ };
172
+ }
173
+ }
174
+ /**
175
+ * Execute a single `deterministic` node. If the caller supplied a dedicated
176
+ * {@link DeterministicRunner}, it is used; otherwise the runtime falls back
177
+ * to {@link AgentDispatcher.dispatch} with a synthetic `agentId` so a single
178
+ * stub can cover both node kinds during unit tests.
179
+ */
180
+ async function executeDeterministicNode(node, runId, context, iteration, dispatcher, runner) {
181
+ try {
182
+ if (runner !== undefined) {
183
+ const input = {
184
+ runId,
185
+ nodeId: node.id,
186
+ command: node.command,
187
+ args: node.args,
188
+ context: { ...context },
189
+ iteration,
190
+ };
191
+ if (node.cwd !== undefined)
192
+ input.cwd = node.cwd;
193
+ if (node.env !== undefined)
194
+ input.env = node.env;
195
+ if (node.timeout_ms !== undefined)
196
+ input.timeout_ms = node.timeout_ms;
197
+ const result = await runner.run(input);
198
+ if (result.status === 'success') {
199
+ return { kind: 'success', output: result.output };
200
+ }
201
+ return {
202
+ kind: 'failure',
203
+ error: result.error ?? `command ${node.command} returned failure`,
204
+ };
205
+ }
206
+ // Fallback: dispatch as an agentic call with a synthetic agent id.
207
+ const taskIdRaw = context['taskId'];
208
+ const taskId = typeof taskIdRaw === 'string' && taskIdRaw.length > 0 ? taskIdRaw : runId;
209
+ const agentId = `deterministic:${node.command}`;
210
+ const result = await dispatcher.dispatch({
211
+ runId,
212
+ nodeId: node.id,
213
+ agentId,
214
+ taskId,
215
+ context: {
216
+ ...context,
217
+ __deterministic: {
218
+ command: node.command,
219
+ args: [...node.args],
220
+ cwd: node.cwd,
221
+ env: node.env,
222
+ timeout_ms: node.timeout_ms,
223
+ },
224
+ },
225
+ iteration,
226
+ });
227
+ if (result.status === 'success') {
228
+ return { kind: 'success', output: result.output };
229
+ }
230
+ return {
231
+ kind: 'failure',
232
+ error: result.error ?? `command ${node.command} returned failure`,
233
+ };
234
+ }
235
+ catch (err) {
236
+ return {
237
+ kind: 'failure',
238
+ error: err instanceof Error ? err.message : String(err),
239
+ };
240
+ }
241
+ }
242
+ /**
243
+ * Execute a single `approval` node. Writes a pending {@link PlaybookApproval}
244
+ * row and returns an `awaiting_approval` outcome — the main loop translates
245
+ * this into a `paused` run state.
246
+ */
247
+ function executeApprovalNode(node, runId, context, db, secret) {
248
+ const gate = createApprovalGate(db, {
249
+ runId,
250
+ nodeId: node.id,
251
+ bindings: context,
252
+ secret,
253
+ reason: node.prompt,
254
+ });
255
+ return { kind: 'awaiting_approval', token: gate.token, approvalId: gate.approvalId };
256
+ }
257
+ // ---------------------------------------------------------------------------
258
+ // Main execution loop
259
+ // ---------------------------------------------------------------------------
260
+ /**
261
+ * Determine the effective iteration cap for a node. Falls back to the
262
+ * runtime default (3) when `on_failure.max_iterations` is unset. The parser
263
+ * already validates the upper bound of 10.
264
+ */
265
+ function iterationCapFor(node, runtimeDefault) {
266
+ const cap = node.on_failure?.max_iterations;
267
+ if (typeof cap === 'number' && Number.isFinite(cap) && cap >= 0)
268
+ return cap;
269
+ return runtimeDefault;
270
+ }
271
+ /**
272
+ * Core step-by-step executor shared by {@link executePlaybook} and
273
+ * {@link resumePlaybook}. Starts at `startNodeId` and walks the graph until
274
+ * a terminal outcome is reached.
275
+ *
276
+ * Persists:
277
+ * - `playbook_runs.current_node` at every step so crash-resume is possible.
278
+ * - `playbook_runs.bindings` after every successful merge.
279
+ * - `playbook_runs.iteration_counts` after every attempt (success or failure).
280
+ * - `playbook_runs.status`/`error_context`/`completed_at` at termination.
281
+ *
282
+ * @internal
283
+ */
284
+ async function runFromNode(args) {
285
+ const { db, run, startNodeId, nodeIndex, edgeIndex, context, iterationCounts, dispatcher, deterministicRunner, approvalSecret, maxIterationsDefault, now, } = args;
286
+ let currentId = startNodeId;
287
+ let lastError;
288
+ let failedNodeId;
289
+ let exceededNodeId;
290
+ while (currentId !== null) {
291
+ const node = resolveNode(currentId, nodeIndex);
292
+ const cap = iterationCapFor(node, maxIterationsDefault);
293
+ // Advance iteration counter up front so a thrown dispatcher still bumps it.
294
+ const attempt = (iterationCounts[node.id] ?? 0) + 1;
295
+ iterationCounts[node.id] = attempt;
296
+ // Persist per-step bookkeeping before dispatch so crashes are recoverable.
297
+ updatePlaybookRun(db, run.runId, {
298
+ currentNode: node.id,
299
+ iterationCounts: { ...iterationCounts },
300
+ });
301
+ let outcome;
302
+ if (node.type === 'agentic') {
303
+ outcome = await executeAgenticNode(node, run.runId, context, attempt, dispatcher);
304
+ }
305
+ else if (node.type === 'deterministic') {
306
+ outcome = await executeDeterministicNode(node, run.runId, context, attempt, dispatcher, deterministicRunner);
307
+ }
308
+ else if (node.type === 'approval') {
309
+ outcome = executeApprovalNode(node, run.runId, context, db, approvalSecret);
310
+ }
311
+ else {
312
+ // Exhaustiveness guard — never type to force a compile-time error on
313
+ // future PlaybookNodeType additions.
314
+ const exhaustive = node;
315
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: unknown node kind ${JSON.stringify(exhaustive)}`);
316
+ }
317
+ if (outcome.kind === 'success') {
318
+ // Merge outputs into context and persist.
319
+ Object.assign(context, outcome.output);
320
+ updatePlaybookRun(db, run.runId, { bindings: { ...context } });
321
+ currentId = resolveNextNodeId(node.id, edgeIndex);
322
+ continue;
323
+ }
324
+ if (outcome.kind === 'awaiting_approval') {
325
+ // Persist pause + token and return. Caller resumes with the token.
326
+ const pausedAt = now().toISOString();
327
+ updatePlaybookRun(db, run.runId, {
328
+ status: 'paused',
329
+ errorContext: null,
330
+ bindings: { ...context },
331
+ iterationCounts: { ...iterationCounts },
332
+ });
333
+ return {
334
+ runId: run.runId,
335
+ terminalStatus: 'pending_approval',
336
+ finalContext: { ...context, __pausedAt: pausedAt },
337
+ approvalToken: outcome.token,
338
+ };
339
+ }
340
+ // outcome.kind === 'failure' — record the error and evaluate retry/escalate.
341
+ lastError = outcome.error;
342
+ const injectTarget = node.on_failure?.inject_into;
343
+ // Cap semantics: `cap === 0` disables retries entirely (first failure = fatal).
344
+ // For cap > 0 we allow up to `cap` total attempts per node.
345
+ if (attempt >= cap) {
346
+ if (injectTarget !== undefined && injectTarget !== node.id) {
347
+ // Escalate: hand control back to the inject target with the error in context.
348
+ if (!nodeIndex.has(injectTarget)) {
349
+ failedNodeId = node.id;
350
+ break;
351
+ }
352
+ context['__lastError'] = outcome.error;
353
+ context['__lastFailedNode'] = node.id;
354
+ updatePlaybookRun(db, run.runId, {
355
+ errorContext: outcome.error,
356
+ bindings: { ...context },
357
+ });
358
+ currentId = injectTarget;
359
+ // Reset the iteration counter on the injected target so it can retry
360
+ // with the enriched context without immediately tripping its own cap.
361
+ iterationCounts[injectTarget] = 0;
362
+ continue;
363
+ }
364
+ exceededNodeId = node.id;
365
+ break;
366
+ }
367
+ // Retry semantics: if `inject_into` points elsewhere, hand off control;
368
+ // otherwise re-execute the same node on the next loop iteration.
369
+ if (injectTarget !== undefined && injectTarget !== node.id) {
370
+ if (!nodeIndex.has(injectTarget)) {
371
+ failedNodeId = node.id;
372
+ break;
373
+ }
374
+ context['__lastError'] = outcome.error;
375
+ context['__lastFailedNode'] = node.id;
376
+ updatePlaybookRun(db, run.runId, {
377
+ errorContext: outcome.error,
378
+ bindings: { ...context },
379
+ });
380
+ currentId = injectTarget;
381
+ iterationCounts[injectTarget] = 0;
382
+ continue;
383
+ }
384
+ // Retry the same node (currentId stays the same).
385
+ updatePlaybookRun(db, run.runId, { errorContext: outcome.error });
386
+ }
387
+ // Terminal transition — completed vs failed vs exceeded.
388
+ const completedAt = now().toISOString();
389
+ if (exceededNodeId !== undefined) {
390
+ updatePlaybookRun(db, run.runId, {
391
+ status: 'failed',
392
+ errorContext: lastError ?? null,
393
+ completedAt,
394
+ bindings: { ...context },
395
+ iterationCounts: { ...iterationCounts },
396
+ });
397
+ const result = {
398
+ runId: run.runId,
399
+ terminalStatus: 'exceeded_iteration_cap',
400
+ finalContext: { ...context },
401
+ exceededNodeId,
402
+ };
403
+ if (lastError !== undefined)
404
+ result.errorContext = lastError;
405
+ return result;
406
+ }
407
+ if (failedNodeId !== undefined) {
408
+ updatePlaybookRun(db, run.runId, {
409
+ status: 'failed',
410
+ errorContext: lastError ?? null,
411
+ completedAt,
412
+ bindings: { ...context },
413
+ iterationCounts: { ...iterationCounts },
414
+ });
415
+ const result = {
416
+ runId: run.runId,
417
+ terminalStatus: 'failed',
418
+ finalContext: { ...context },
419
+ failedNodeId,
420
+ };
421
+ if (lastError !== undefined)
422
+ result.errorContext = lastError;
423
+ return result;
424
+ }
425
+ // Reached terminal "end" state (no outgoing edges) — run completed.
426
+ updatePlaybookRun(db, run.runId, {
427
+ status: 'completed',
428
+ currentNode: null,
429
+ completedAt,
430
+ bindings: { ...context },
431
+ iterationCounts: { ...iterationCounts },
432
+ errorContext: null,
433
+ });
434
+ return {
435
+ runId: run.runId,
436
+ terminalStatus: 'completed',
437
+ finalContext: { ...context },
438
+ };
439
+ }
440
+ // ---------------------------------------------------------------------------
441
+ // Public entry points
442
+ // ---------------------------------------------------------------------------
443
+ /**
444
+ * Execute a playbook from its entry node until a terminal state is reached
445
+ * (`completed`, `failed`, `exceeded_iteration_cap`, or `pending_approval`).
446
+ *
447
+ * Every execution is persisted to `playbook_runs` so that crashes or HITL
448
+ * pauses can resume via {@link resumePlaybook}. Returned
449
+ * {@link ExecutePlaybookResult.finalContext} is a fully-merged snapshot at
450
+ * the moment the runtime stopped.
451
+ *
452
+ * @param options - Runtime configuration, including the injected dispatcher.
453
+ * @returns Terminal envelope describing where the run stopped.
454
+ */
455
+ export async function executePlaybook(options) {
456
+ const now = options.now ?? (() => new Date());
457
+ const maxIterationsDefault = options.maxIterationsDefault ?? 3;
458
+ if (!Number.isInteger(maxIterationsDefault) || maxIterationsDefault < 0) {
459
+ throw new Error(`${E_PLAYBOOK_RUNTIME_INVALID}: maxIterationsDefault must be a non-negative integer (got ${maxIterationsDefault})`);
460
+ }
461
+ const approvalSecret = options.approvalSecret ?? getPlaybookSecret();
462
+ const nodeIndex = new Map(options.playbook.nodes.map((n) => [n.id, n]));
463
+ const edgeIndex = buildEdgeIndex(options.playbook);
464
+ const entry = resolveEntryNode(options.playbook, edgeIndex);
465
+ const createInput = {
466
+ playbookName: options.playbook.name,
467
+ playbookHash: options.playbookHash,
468
+ initialBindings: { ...options.initialContext },
469
+ };
470
+ if (options.epicId !== undefined)
471
+ createInput.epicId = options.epicId;
472
+ if (options.sessionId !== undefined)
473
+ createInput.sessionId = options.sessionId;
474
+ const run = createPlaybookRun(options.db, createInput);
475
+ const context = { ...options.initialContext };
476
+ const iterationCounts = {};
477
+ const runArgs = {
478
+ db: options.db,
479
+ playbook: options.playbook,
480
+ run,
481
+ startNodeId: entry.id,
482
+ nodeIndex,
483
+ edgeIndex,
484
+ context,
485
+ iterationCounts,
486
+ dispatcher: options.dispatcher,
487
+ deterministicRunner: options.deterministicRunner,
488
+ approvalSecret,
489
+ maxIterationsDefault,
490
+ now,
491
+ };
492
+ return runFromNode(runArgs);
493
+ }
494
+ /**
495
+ * Resume a paused playbook run using a HITL approval token. The runtime
496
+ * validates that the token maps to an `approved` {@link PlaybookApproval}
497
+ * row and that the associated run is in `paused` state, then continues from
498
+ * the approval node's single successor.
499
+ *
500
+ * @throws Error stamped with {@link E_PLAYBOOK_RESUME_BLOCKED} if the token
501
+ * is unknown, the gate is still `pending`, the gate was `rejected`, the
502
+ * run is not `paused`, or the approval node has no successor.
503
+ */
504
+ export async function resumePlaybook(options) {
505
+ const approval = getPlaybookApprovalByToken(options.db, options.approvalToken);
506
+ if (approval === null) {
507
+ throw new Error(`${E_PLAYBOOK_RESUME_BLOCKED}: no approval gate for token ${options.approvalToken}`);
508
+ }
509
+ if (approval.status === 'pending') {
510
+ throw new Error(`${E_PLAYBOOK_RESUME_BLOCKED}: gate ${approval.approvalId} is still pending — approve before resuming`);
511
+ }
512
+ if (approval.status === 'rejected') {
513
+ const run = getPlaybookRun(options.db, approval.runId);
514
+ // Mark run failed on resume-after-reject so dashboards stay consistent.
515
+ if (run !== null && run.status !== 'failed') {
516
+ updatePlaybookRun(options.db, approval.runId, {
517
+ status: 'failed',
518
+ errorContext: approval.reason ?? 'gate rejected',
519
+ completedAt: (options.now ?? (() => new Date()))().toISOString(),
520
+ });
521
+ }
522
+ throw new Error(`${E_PLAYBOOK_RESUME_BLOCKED}: gate ${approval.approvalId} was rejected` +
523
+ (approval.reason ? ` (${approval.reason})` : ''));
524
+ }
525
+ // approval.status === 'approved' past this point.
526
+ const run = getPlaybookRun(options.db, approval.runId);
527
+ if (run === null) {
528
+ throw new Error(`${E_PLAYBOOK_RESUME_BLOCKED}: run ${approval.runId} no longer exists (deleted?)`);
529
+ }
530
+ const validResumeStatuses = ['paused', 'running'];
531
+ if (!validResumeStatuses.includes(run.status)) {
532
+ throw new Error(`${E_PLAYBOOK_RESUME_BLOCKED}: run ${run.runId} is ${run.status}, expected paused|running`);
533
+ }
534
+ // Validate the approval node is present and resolve its single successor.
535
+ const nodeIndex = new Map(options.playbook.nodes.map((n) => [n.id, n]));
536
+ const edgeIndex = buildEdgeIndex(options.playbook);
537
+ const approvalNode = nodeIndex.get(approval.nodeId);
538
+ if (approvalNode === undefined || approvalNode.type !== 'approval') {
539
+ throw new Error(`${E_PLAYBOOK_RESUME_BLOCKED}: approval node ${approval.nodeId} not found in playbook ${options.playbook.name}`);
540
+ }
541
+ const successor = resolveNextNodeId(approvalNode.id, edgeIndex);
542
+ if (successor === null) {
543
+ // Approval at the tail of the graph completes the run immediately.
544
+ const completedAt = (options.now ?? (() => new Date()))().toISOString();
545
+ updatePlaybookRun(options.db, run.runId, {
546
+ status: 'completed',
547
+ currentNode: null,
548
+ completedAt,
549
+ errorContext: null,
550
+ });
551
+ return {
552
+ runId: run.runId,
553
+ terminalStatus: 'completed',
554
+ finalContext: { ...run.bindings },
555
+ };
556
+ }
557
+ // Return the run to `running` before proceeding so dashboards reflect activity.
558
+ updatePlaybookRun(options.db, run.runId, {
559
+ status: 'running',
560
+ currentNode: successor,
561
+ errorContext: null,
562
+ });
563
+ const now = options.now ?? (() => new Date());
564
+ const maxIterationsDefault = options.maxIterationsDefault ?? 3;
565
+ const approvalSecret = options.approvalSecret ?? getPlaybookSecret();
566
+ const context = { ...run.bindings };
567
+ const iterationCounts = { ...run.iterationCounts };
568
+ // Log the approval decision into the context so downstream nodes can act on it.
569
+ context['__lastApproval'] = {
570
+ nodeId: approval.nodeId,
571
+ approvalId: approval.approvalId,
572
+ approver: approval.approver,
573
+ reason: approval.reason,
574
+ approvedAt: approval.approvedAt,
575
+ };
576
+ // Persist an approval trace row for audit purposes. createPlaybookApproval
577
+ // is distinct from createApprovalGate — the latter generates the HMAC
578
+ // resume token, while this helper records arbitrary approval state.
579
+ createPlaybookApproval(options.db, {
580
+ runId: run.runId,
581
+ nodeId: approval.nodeId,
582
+ token: `resume:${approval.token}:${now().getTime()}`,
583
+ autoPassed: true,
584
+ });
585
+ const runArgs = {
586
+ db: options.db,
587
+ playbook: options.playbook,
588
+ run,
589
+ startNodeId: successor,
590
+ nodeIndex,
591
+ edgeIndex,
592
+ context,
593
+ iterationCounts,
594
+ dispatcher: options.dispatcher,
595
+ deterministicRunner: options.deterministicRunner,
596
+ approvalSecret,
597
+ maxIterationsDefault,
598
+ now,
599
+ };
600
+ return runFromNode(runArgs);
601
+ }