@cleocode/playbooks 2026.4.88

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/src/policy.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * HITL auto-policy — evaluates whether a deterministic command requires
3
+ * human approval before execution. Conservative defaults per OpenProse standard.
4
+ *
5
+ * `require-human` rules are evaluated FIRST and cannot be bypassed by
6
+ * `auto-approve` rules even when callers append custom rules to the list.
7
+ * The default decision for an unmatched command is `require-human` so the
8
+ * runtime fails closed when confronted with unknown surface area.
9
+ *
10
+ * @task T889 / T908 / W4-9
11
+ */
12
+
13
+ /**
14
+ * One policy rule in the auto-approval evaluation order. Rules are matched by
15
+ * applying `pattern.test(command)` against the fully-resolved command string.
16
+ */
17
+ export interface PolicyRule {
18
+ pattern: RegExp;
19
+ action: 'auto-approve' | 'require-human';
20
+ reason: string;
21
+ }
22
+
23
+ /**
24
+ * Result of {@link evaluatePolicy}. `matchedPattern` carries the source of
25
+ * the regex that produced the decision so operators can audit the trail;
26
+ * it is absent for the terminal `default` fallthrough.
27
+ */
28
+ export interface EvaluatePolicyResult {
29
+ action: 'auto-approve' | 'require-human';
30
+ reason: string;
31
+ matchedPattern?: string;
32
+ }
33
+
34
+ /**
35
+ * Conservative default policy. `require-human` rules come first for ordering
36
+ * clarity, but evaluation order in {@link evaluatePolicy} enforces the
37
+ * priority regardless of array position.
38
+ */
39
+ export const DEFAULT_POLICY_RULES: readonly PolicyRule[] = Object.freeze([
40
+ { pattern: /\bnpm\s+publish\b/, action: 'require-human', reason: 'publish' },
41
+ { pattern: /\bpnpm\s+publish\b/, action: 'require-human', reason: 'publish' },
42
+ { pattern: /\byarn\s+publish\b/, action: 'require-human', reason: 'publish' },
43
+ { pattern: /\bgit\s+push\b/, action: 'require-human', reason: 'push' },
44
+ { pattern: /\bgit\s+tag\b/, action: 'require-human', reason: 'tag' },
45
+ { pattern: /\bgh\s+release\s+create\b/, action: 'require-human', reason: 'release' },
46
+ { pattern: /\bgh\s+workflow\s+run\b/, action: 'require-human', reason: 'workflow-trigger' },
47
+ {
48
+ pattern: /\b(rm\s+-rf|drop\s+table|truncate|delete\s+from)\b/i,
49
+ action: 'require-human',
50
+ reason: 'destructive',
51
+ },
52
+ {
53
+ pattern: /\b(curl|wget|fetch)\b.*\bhttps?:/i,
54
+ action: 'require-human',
55
+ reason: 'external-api',
56
+ },
57
+ { pattern: /\b(ssh|scp|rsync)\b.+@/, action: 'require-human', reason: 'remote-access' },
58
+ {
59
+ pattern: /^pnpm\s+(test|run\s+test|biome|tsc)\b/,
60
+ action: 'auto-approve',
61
+ reason: 'safe-qa-tool',
62
+ },
63
+ {
64
+ pattern: /^cleo\s+(verify|check|show|find|list|status|current|next)\b/,
65
+ action: 'auto-approve',
66
+ reason: 'safe-cleo-read',
67
+ },
68
+ ]);
69
+
70
+ /**
71
+ * Evaluates a command against the supplied policy rules and returns the
72
+ * resolved approval decision.
73
+ *
74
+ * Priority:
75
+ * 1. Every `require-human` rule across the list is tested first.
76
+ * 2. `auto-approve` rules are tested only if no block fired.
77
+ * 3. Fallback is `{ action: 'require-human', reason: 'default' }` so
78
+ * unknown commands never auto-execute.
79
+ *
80
+ * Callers MAY pass a custom rule list, but they CANNOT relax default blocks —
81
+ * any rule elsewhere in the list that matches with `require-human` wins over
82
+ * any auto-approve match, regardless of order.
83
+ *
84
+ * @param command The fully-resolved command string (executable plus arguments).
85
+ * @param rules The ordered rule list. Defaults to {@link DEFAULT_POLICY_RULES}.
86
+ * @returns The approval decision including the matched reason.
87
+ */
88
+ export function evaluatePolicy(
89
+ command: string,
90
+ rules: readonly PolicyRule[] = DEFAULT_POLICY_RULES,
91
+ ): EvaluatePolicyResult {
92
+ for (const rule of rules) {
93
+ if (rule.action === 'require-human' && rule.pattern.test(command)) {
94
+ return {
95
+ action: 'require-human',
96
+ reason: rule.reason,
97
+ matchedPattern: rule.pattern.source,
98
+ };
99
+ }
100
+ }
101
+ for (const rule of rules) {
102
+ if (rule.action === 'auto-approve' && rule.pattern.test(command)) {
103
+ return {
104
+ action: 'auto-approve',
105
+ reason: rule.reason,
106
+ matchedPattern: rule.pattern.source,
107
+ };
108
+ }
109
+ }
110
+ return { action: 'require-human', reason: 'default' };
111
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Drizzle ORM table definitions for playbook state.
3
+ * Both tables are added to tasks.db via migration at
4
+ * packages/core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/.
5
+ *
6
+ * @task T889 / T904 / W4-6
7
+ */
8
+
9
+ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
10
+
11
+ export const playbookRuns = sqliteTable('playbook_runs', {
12
+ runId: text('run_id').primaryKey(),
13
+ playbookName: text('playbook_name').notNull(),
14
+ playbookHash: text('playbook_hash').notNull(),
15
+ currentNode: text('current_node'),
16
+ bindings: text('bindings').notNull().default('{}'),
17
+ errorContext: text('error_context'),
18
+ status: text('status').notNull().default('running'),
19
+ iterationCounts: text('iteration_counts').notNull().default('{}'),
20
+ epicId: text('epic_id'),
21
+ sessionId: text('session_id'),
22
+ startedAt: text('started_at').notNull().default("(datetime('now'))"),
23
+ completedAt: text('completed_at'),
24
+ });
25
+
26
+ export const playbookApprovals = sqliteTable('playbook_approvals', {
27
+ approvalId: text('approval_id').primaryKey(),
28
+ runId: text('run_id').notNull(),
29
+ nodeId: text('node_id').notNull(),
30
+ token: text('token').notNull().unique(),
31
+ requestedAt: text('requested_at').notNull().default("(datetime('now'))"),
32
+ approvedAt: text('approved_at'),
33
+ approver: text('approver'),
34
+ reason: text('reason'),
35
+ status: text('status').notNull().default('pending'),
36
+ autoPassed: integer('auto_passed').notNull().default(0),
37
+ });
38
+
39
+ export type {
40
+ PlaybookApproval,
41
+ PlaybookApprovalStatus,
42
+ PlaybookRun,
43
+ PlaybookRunStatus,
44
+ } from '@cleocode/contracts';
package/src/state.ts ADDED
@@ -0,0 +1,471 @@
1
+ /**
2
+ * State layer for playbook runtime — CRUD against playbook_runs + playbook_approvals.
3
+ * Uses node:sqlite DatabaseSync for consistency with rest of CLEO.
4
+ *
5
+ * All JSON-shaped columns (`bindings`, `iteration_counts`) are serialized to
6
+ * text at the write boundary and strictly parsed on read. Parse failures throw
7
+ * rather than silently reset state, per the data-integrity contract of ADR-013.
8
+ *
9
+ * Multi-column updates and cross-table operations run inside a BEGIN/COMMIT
10
+ * transaction so partial failures cannot leave the run in a half-mutated state.
11
+ *
12
+ * @task T889 / T904 / W4-8
13
+ */
14
+
15
+ import { randomUUID } from 'node:crypto';
16
+ import type { DatabaseSync } from 'node:sqlite';
17
+ import type {
18
+ PlaybookApproval,
19
+ PlaybookApprovalStatus,
20
+ PlaybookRun,
21
+ PlaybookRunStatus,
22
+ } from '@cleocode/contracts';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Row shapes (snake_case — mirror of SQLite PRAGMA table_info output)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ interface PlaybookRunRow {
29
+ run_id: string;
30
+ playbook_name: string;
31
+ playbook_hash: string;
32
+ current_node: string | null;
33
+ bindings: string;
34
+ error_context: string | null;
35
+ status: string;
36
+ iteration_counts: string;
37
+ epic_id: string | null;
38
+ session_id: string | null;
39
+ started_at: string;
40
+ completed_at: string | null;
41
+ }
42
+
43
+ interface PlaybookApprovalRow {
44
+ approval_id: string;
45
+ run_id: string;
46
+ node_id: string;
47
+ token: string;
48
+ requested_at: string;
49
+ approved_at: string | null;
50
+ approver: string | null;
51
+ reason: string | null;
52
+ status: string;
53
+ auto_passed: number;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Input types
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Input payload for {@link createPlaybookRun}. `playbookHash` MUST be a stable
62
+ * digest of the `.cantbook` source so replays can prove definition parity.
63
+ */
64
+ export interface CreatePlaybookRunInput {
65
+ playbookName: string;
66
+ playbookHash: string;
67
+ epicId?: string;
68
+ sessionId?: string;
69
+ initialBindings?: Record<string, unknown>;
70
+ }
71
+
72
+ /**
73
+ * Input payload for {@link createPlaybookApproval}. Callers MUST supply an
74
+ * opaque `token` — approval resume flows look up runs by this value.
75
+ */
76
+ export interface CreatePlaybookApprovalInput {
77
+ runId: string;
78
+ nodeId: string;
79
+ token: string;
80
+ autoPassed?: boolean;
81
+ }
82
+
83
+ /**
84
+ * Filter options for {@link listPlaybookRuns}. All fields are optional; when
85
+ * omitted the call returns the most recent runs ordered by `started_at DESC`.
86
+ */
87
+ export interface ListPlaybookRunsOptions {
88
+ status?: PlaybookRunStatus;
89
+ epicId?: string;
90
+ limit?: number;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Row mappers
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Strictly parses a JSON payload stored in a playbook column. Throws a
99
+ * descriptive error on malformed JSON so state corruption surfaces at the
100
+ * boundary rather than mutating downstream logic.
101
+ */
102
+ function parseJsonColumn<T>(raw: string, column: string, runId: string): T {
103
+ try {
104
+ return JSON.parse(raw) as T;
105
+ } catch (err) {
106
+ const message = err instanceof Error ? err.message : String(err);
107
+ throw new Error(
108
+ `playbook state: failed to parse JSON column "${column}" for run ${runId}: ${message}`,
109
+ );
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Maps a snake_case `playbook_runs` row to the contract-shaped
115
+ * {@link PlaybookRun}. Performs strict JSON parsing and validates the status
116
+ * column against the enum.
117
+ */
118
+ function rowToPlaybookRun(row: PlaybookRunRow): PlaybookRun {
119
+ const bindings = parseJsonColumn<Record<string, unknown>>(row.bindings, 'bindings', row.run_id);
120
+ const iterationCounts = parseJsonColumn<Record<string, number>>(
121
+ row.iteration_counts,
122
+ 'iteration_counts',
123
+ row.run_id,
124
+ );
125
+
126
+ return {
127
+ runId: row.run_id,
128
+ playbookName: row.playbook_name,
129
+ playbookHash: row.playbook_hash,
130
+ currentNode: row.current_node,
131
+ bindings,
132
+ errorContext: row.error_context,
133
+ status: row.status as PlaybookRunStatus,
134
+ iterationCounts,
135
+ epicId: row.epic_id ?? undefined,
136
+ sessionId: row.session_id ?? undefined,
137
+ startedAt: row.started_at,
138
+ completedAt: row.completed_at ?? undefined,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Maps a snake_case `playbook_approvals` row to the contract-shaped
144
+ * {@link PlaybookApproval}. Converts the integer `auto_passed` column to a
145
+ * boolean at the boundary.
146
+ */
147
+ function rowToPlaybookApproval(row: PlaybookApprovalRow): PlaybookApproval {
148
+ return {
149
+ approvalId: row.approval_id,
150
+ runId: row.run_id,
151
+ nodeId: row.node_id,
152
+ token: row.token,
153
+ requestedAt: row.requested_at,
154
+ approvedAt: row.approved_at ?? undefined,
155
+ approver: row.approver ?? undefined,
156
+ reason: row.reason ?? undefined,
157
+ status: row.status as PlaybookApprovalStatus,
158
+ autoPassed: row.auto_passed === 1,
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Playbook run CRUD
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Inserts a new playbook run with a freshly generated UUID, `status='running'`,
168
+ * and the provided initial bindings. Returns the hydrated {@link PlaybookRun}
169
+ * read back from the row so callers see all server-defaulted columns
170
+ * (`started_at`, empty `iteration_counts`, etc.).
171
+ */
172
+ export function createPlaybookRun(db: DatabaseSync, input: CreatePlaybookRunInput): PlaybookRun {
173
+ const runId = randomUUID();
174
+ const bindingsJson = JSON.stringify(input.initialBindings ?? {});
175
+
176
+ const insert = db.prepare(
177
+ `INSERT INTO playbook_runs (
178
+ run_id, playbook_name, playbook_hash, bindings, status,
179
+ iteration_counts, epic_id, session_id
180
+ ) VALUES (?, ?, ?, ?, 'running', '{}', ?, ?)`,
181
+ );
182
+ insert.run(
183
+ runId,
184
+ input.playbookName,
185
+ input.playbookHash,
186
+ bindingsJson,
187
+ input.epicId ?? null,
188
+ input.sessionId ?? null,
189
+ );
190
+
191
+ const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId) as
192
+ | PlaybookRunRow
193
+ | undefined;
194
+
195
+ if (!row) {
196
+ throw new Error(`playbook state: failed to read back run ${runId} after insert`);
197
+ }
198
+ return rowToPlaybookRun(row);
199
+ }
200
+
201
+ /**
202
+ * Fetches a playbook run by its primary key. Returns `null` when the run
203
+ * does not exist. Never throws on missing rows.
204
+ */
205
+ export function getPlaybookRun(db: DatabaseSync, runId: string): PlaybookRun | null {
206
+ const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId) as
207
+ | PlaybookRunRow
208
+ | undefined;
209
+ return row ? rowToPlaybookRun(row) : null;
210
+ }
211
+
212
+ /**
213
+ * Applies a partial patch to a playbook run inside a transaction so mixed
214
+ * column updates (e.g. `status` + `currentNode`) commit atomically. Returns
215
+ * the fully-hydrated run read back after commit.
216
+ */
217
+ export function updatePlaybookRun(
218
+ db: DatabaseSync,
219
+ runId: string,
220
+ patch: Partial<Omit<PlaybookRun, 'runId' | 'startedAt'>>,
221
+ ): PlaybookRun {
222
+ const sets: string[] = [];
223
+ const values: Array<string | number | null> = [];
224
+
225
+ if ('playbookName' in patch && patch.playbookName !== undefined) {
226
+ sets.push('playbook_name = ?');
227
+ values.push(patch.playbookName);
228
+ }
229
+ if ('playbookHash' in patch && patch.playbookHash !== undefined) {
230
+ sets.push('playbook_hash = ?');
231
+ values.push(patch.playbookHash);
232
+ }
233
+ if ('currentNode' in patch) {
234
+ sets.push('current_node = ?');
235
+ values.push(patch.currentNode ?? null);
236
+ }
237
+ if ('bindings' in patch && patch.bindings !== undefined) {
238
+ sets.push('bindings = ?');
239
+ values.push(JSON.stringify(patch.bindings));
240
+ }
241
+ if ('errorContext' in patch) {
242
+ sets.push('error_context = ?');
243
+ values.push(patch.errorContext ?? null);
244
+ }
245
+ if ('status' in patch && patch.status !== undefined) {
246
+ sets.push('status = ?');
247
+ values.push(patch.status);
248
+ }
249
+ if ('iterationCounts' in patch && patch.iterationCounts !== undefined) {
250
+ sets.push('iteration_counts = ?');
251
+ values.push(JSON.stringify(patch.iterationCounts));
252
+ }
253
+ if ('epicId' in patch) {
254
+ sets.push('epic_id = ?');
255
+ values.push(patch.epicId ?? null);
256
+ }
257
+ if ('sessionId' in patch) {
258
+ sets.push('session_id = ?');
259
+ values.push(patch.sessionId ?? null);
260
+ }
261
+ if ('completedAt' in patch) {
262
+ sets.push('completed_at = ?');
263
+ values.push(patch.completedAt ?? null);
264
+ }
265
+
266
+ if (sets.length === 0) {
267
+ const existing = getPlaybookRun(db, runId);
268
+ if (!existing) {
269
+ throw new Error(`playbook state: run ${runId} not found for update`);
270
+ }
271
+ return existing;
272
+ }
273
+
274
+ db.exec('BEGIN');
275
+ try {
276
+ const stmt = db.prepare(`UPDATE playbook_runs SET ${sets.join(', ')} WHERE run_id = ?`);
277
+ values.push(runId);
278
+ const result = stmt.run(...values);
279
+ if (result.changes === 0) {
280
+ throw new Error(`playbook state: run ${runId} not found for update`);
281
+ }
282
+ db.exec('COMMIT');
283
+ } catch (err) {
284
+ db.exec('ROLLBACK');
285
+ throw err;
286
+ }
287
+
288
+ const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId) as
289
+ | PlaybookRunRow
290
+ | undefined;
291
+ if (!row) {
292
+ throw new Error(`playbook state: run ${runId} disappeared after update`);
293
+ }
294
+ return rowToPlaybookRun(row);
295
+ }
296
+
297
+ /**
298
+ * Lists playbook runs filtered by status and/or epic. Defaults to `ORDER BY
299
+ * started_at DESC` so the newest runs surface first for dashboards.
300
+ */
301
+ export function listPlaybookRuns(
302
+ db: DatabaseSync,
303
+ opts: ListPlaybookRunsOptions = {},
304
+ ): PlaybookRun[] {
305
+ const clauses: string[] = [];
306
+ const values: Array<string | number> = [];
307
+ if (opts.status) {
308
+ clauses.push('status = ?');
309
+ values.push(opts.status);
310
+ }
311
+ if (opts.epicId) {
312
+ clauses.push('epic_id = ?');
313
+ values.push(opts.epicId);
314
+ }
315
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
316
+ const limitClause = typeof opts.limit === 'number' ? 'LIMIT ?' : '';
317
+ if (limitClause) values.push(opts.limit ?? 0);
318
+
319
+ const sql = `SELECT * FROM playbook_runs ${where} ORDER BY started_at DESC ${limitClause}`;
320
+ const rows = db.prepare(sql).all(...values) as unknown as PlaybookRunRow[];
321
+ return rows.map(rowToPlaybookRun);
322
+ }
323
+
324
+ /**
325
+ * Deletes a playbook run by primary key. Returns `true` if a row was removed.
326
+ * CASCADE wipes all associated approvals via the foreign-key constraint on
327
+ * `playbook_approvals.run_id`.
328
+ */
329
+ export function deletePlaybookRun(db: DatabaseSync, runId: string): boolean {
330
+ const result = db.prepare('DELETE FROM playbook_runs WHERE run_id = ?').run(runId);
331
+ return Number(result.changes) > 0;
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Playbook approval CRUD
336
+ // ---------------------------------------------------------------------------
337
+
338
+ /**
339
+ * Inserts a new approval record with a freshly generated UUID, `status='pending'`,
340
+ * and the caller-supplied opaque token. Returns the hydrated
341
+ * {@link PlaybookApproval} so callers see the server-defaulted `requested_at`.
342
+ */
343
+ export function createPlaybookApproval(
344
+ db: DatabaseSync,
345
+ input: CreatePlaybookApprovalInput,
346
+ ): PlaybookApproval {
347
+ const approvalId = randomUUID();
348
+ const autoPassed = input.autoPassed ? 1 : 0;
349
+
350
+ db.prepare(
351
+ `INSERT INTO playbook_approvals (
352
+ approval_id, run_id, node_id, token, status, auto_passed
353
+ ) VALUES (?, ?, ?, ?, 'pending', ?)`,
354
+ ).run(approvalId, input.runId, input.nodeId, input.token, autoPassed);
355
+
356
+ const row = db
357
+ .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
358
+ .get(approvalId) as PlaybookApprovalRow | undefined;
359
+ if (!row) {
360
+ throw new Error(`playbook state: failed to read back approval ${approvalId} after insert`);
361
+ }
362
+ return rowToPlaybookApproval(row);
363
+ }
364
+
365
+ /**
366
+ * Fetches an approval by its opaque token. Returns `null` if no row matches.
367
+ * The token column carries a UNIQUE constraint so at most one row is returned.
368
+ */
369
+ export function getPlaybookApprovalByToken(
370
+ db: DatabaseSync,
371
+ token: string,
372
+ ): PlaybookApproval | null {
373
+ const row = db.prepare('SELECT * FROM playbook_approvals WHERE token = ?').get(token) as
374
+ | PlaybookApprovalRow
375
+ | undefined;
376
+ return row ? rowToPlaybookApproval(row) : null;
377
+ }
378
+
379
+ /**
380
+ * Applies a partial patch to an approval record inside a transaction. Used by
381
+ * the approval-resume flow to transactionally set both `status` and
382
+ * `approved_at` when a human resolves a HITL checkpoint.
383
+ */
384
+ export function updatePlaybookApproval(
385
+ db: DatabaseSync,
386
+ approvalId: string,
387
+ patch: Partial<Omit<PlaybookApproval, 'approvalId' | 'requestedAt'>>,
388
+ ): PlaybookApproval {
389
+ const sets: string[] = [];
390
+ const values: Array<string | number | null> = [];
391
+
392
+ if ('runId' in patch && patch.runId !== undefined) {
393
+ sets.push('run_id = ?');
394
+ values.push(patch.runId);
395
+ }
396
+ if ('nodeId' in patch && patch.nodeId !== undefined) {
397
+ sets.push('node_id = ?');
398
+ values.push(patch.nodeId);
399
+ }
400
+ if ('token' in patch && patch.token !== undefined) {
401
+ sets.push('token = ?');
402
+ values.push(patch.token);
403
+ }
404
+ if ('approvedAt' in patch) {
405
+ sets.push('approved_at = ?');
406
+ values.push(patch.approvedAt ?? null);
407
+ }
408
+ if ('approver' in patch) {
409
+ sets.push('approver = ?');
410
+ values.push(patch.approver ?? null);
411
+ }
412
+ if ('reason' in patch) {
413
+ sets.push('reason = ?');
414
+ values.push(patch.reason ?? null);
415
+ }
416
+ if ('status' in patch && patch.status !== undefined) {
417
+ sets.push('status = ?');
418
+ values.push(patch.status);
419
+ }
420
+ if ('autoPassed' in patch && patch.autoPassed !== undefined) {
421
+ sets.push('auto_passed = ?');
422
+ values.push(patch.autoPassed ? 1 : 0);
423
+ }
424
+
425
+ if (sets.length === 0) {
426
+ const row = db
427
+ .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
428
+ .get(approvalId) as PlaybookApprovalRow | undefined;
429
+ if (!row) {
430
+ throw new Error(`playbook state: approval ${approvalId} not found for update`);
431
+ }
432
+ return rowToPlaybookApproval(row);
433
+ }
434
+
435
+ db.exec('BEGIN');
436
+ try {
437
+ const stmt = db.prepare(
438
+ `UPDATE playbook_approvals SET ${sets.join(', ')} WHERE approval_id = ?`,
439
+ );
440
+ values.push(approvalId);
441
+ const result = stmt.run(...values);
442
+ if (result.changes === 0) {
443
+ throw new Error(`playbook state: approval ${approvalId} not found for update`);
444
+ }
445
+ db.exec('COMMIT');
446
+ } catch (err) {
447
+ db.exec('ROLLBACK');
448
+ throw err;
449
+ }
450
+
451
+ const row = db
452
+ .prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
453
+ .get(approvalId) as PlaybookApprovalRow | undefined;
454
+ if (!row) {
455
+ throw new Error(`playbook state: approval ${approvalId} disappeared after update`);
456
+ }
457
+ return rowToPlaybookApproval(row);
458
+ }
459
+
460
+ /**
461
+ * Lists all approvals for a given run, ordered by `requested_at ASC` so the
462
+ * first HITL checkpoint surfaces first.
463
+ */
464
+ export function listPlaybookApprovals(db: DatabaseSync, runId: string): PlaybookApproval[] {
465
+ const rows = db
466
+ .prepare(
467
+ 'SELECT * FROM playbook_approvals WHERE run_id = ? ORDER BY requested_at ASC, approval_id ASC',
468
+ )
469
+ .all(runId) as unknown as PlaybookApprovalRow[];
470
+ return rows.map(rowToPlaybookApproval);
471
+ }