@cleocode/playbooks 2026.10.18 → 2026.10.19
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/dist/approval.d.ts +2 -2
- package/dist/approval.js +11 -9
- package/dist/state.d.ts +5 -4
- package/dist/state.js +32 -22
- package/package.json +3 -3
- package/src/__tests__/approval.test.ts +5 -25
- package/src/__tests__/guarded-deterministic-dispatch.test.ts +4 -10
- package/src/__tests__/inputs-bindings.test.ts +2 -10
- package/src/__tests__/node-allowlist.test.ts +2 -10
- package/src/__tests__/node-profile.test.ts +2 -10
- package/src/__tests__/pi-cantbook-e2e.test.ts +5 -8
- package/src/__tests__/rcasd-one-envelope.test.ts +2 -5
- package/src/__tests__/runtime.test.ts +5 -20
- package/src/__tests__/starter.e2e.test.ts +2 -5
- package/src/__tests__/state.test.ts +3 -23
- package/src/__tests__/twin-schema.ts +46 -0
- package/src/approval.ts +12 -12
- package/src/state.ts +31 -22
package/dist/approval.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export declare function generateResumeToken(runId: string, nodeId: string, bindi
|
|
|
51
51
|
* Input for {@link createApprovalGate}.
|
|
52
52
|
*/
|
|
53
53
|
export interface CreateApprovalGateInput {
|
|
54
|
-
/** Run identifier (FK to `
|
|
54
|
+
/** Run identifier (FK to `tasks_playbook_runs.run_id`). */
|
|
55
55
|
runId: string;
|
|
56
56
|
/** Node identifier within the run graph. */
|
|
57
57
|
nodeId: string;
|
|
@@ -67,7 +67,7 @@ export interface CreateApprovalGateInput {
|
|
|
67
67
|
secret?: string;
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
* Create an HITL approval gate row in `
|
|
70
|
+
* Create an HITL approval gate row in `tasks_playbook_approvals`.
|
|
71
71
|
*
|
|
72
72
|
* If `autoPassed` is true, the gate is written with `status='approved'`
|
|
73
73
|
* and `auto_passed=1` — used by the policy engine to short-circuit gates
|
package/dist/approval.js
CHANGED
|
@@ -72,7 +72,7 @@ export function generateResumeToken(runId, nodeId, bindings, secret = getPlayboo
|
|
|
72
72
|
function narrowStatus(s) {
|
|
73
73
|
if (s === 'pending' || s === 'approved' || s === 'rejected')
|
|
74
74
|
return s;
|
|
75
|
-
throw new Error(`invariant: unknown
|
|
75
|
+
throw new Error(`invariant: unknown tasks_playbook_approvals.status '${s}'`);
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
78
78
|
* Read a required string column from a raw sqlite row.
|
|
@@ -115,7 +115,7 @@ function readOptionalString(row, key) {
|
|
|
115
115
|
return v;
|
|
116
116
|
}
|
|
117
117
|
/**
|
|
118
|
-
* Map a raw `
|
|
118
|
+
* Map a raw `tasks_playbook_approvals` row to the camelCase {@link PlaybookApproval}
|
|
119
119
|
* contract shape. Validates types, converts the `auto_passed` 0/1 integer to
|
|
120
120
|
* a boolean, and strips nullable fields rather than emitting `null`.
|
|
121
121
|
*
|
|
@@ -143,7 +143,7 @@ function rowToApproval(row) {
|
|
|
143
143
|
return approval;
|
|
144
144
|
}
|
|
145
145
|
/**
|
|
146
|
-
* Create an HITL approval gate row in `
|
|
146
|
+
* Create an HITL approval gate row in `tasks_playbook_approvals`.
|
|
147
147
|
*
|
|
148
148
|
* If `autoPassed` is true, the gate is written with `status='approved'`
|
|
149
149
|
* and `auto_passed=1` — used by the policy engine to short-circuit gates
|
|
@@ -160,13 +160,13 @@ export function createApprovalGate(db, input) {
|
|
|
160
160
|
const autoPassed = input.autoPassed ?? false;
|
|
161
161
|
const status = autoPassed ? 'approved' : 'pending';
|
|
162
162
|
const stmt = db.prepare(`
|
|
163
|
-
INSERT INTO
|
|
163
|
+
INSERT INTO tasks_playbook_approvals
|
|
164
164
|
(approval_id, run_id, node_id, token, status, auto_passed, approver, reason)
|
|
165
165
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
166
166
|
`);
|
|
167
167
|
stmt.run(approvalId, input.runId, input.nodeId, token, status, autoPassed ? 1 : 0, input.approver ?? null, input.reason ?? null);
|
|
168
168
|
const row = db
|
|
169
|
-
.prepare('SELECT * FROM
|
|
169
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
170
170
|
.get(approvalId);
|
|
171
171
|
if (row === undefined) {
|
|
172
172
|
throw new Error(`${E_APPROVAL_NOT_FOUND}: insert did not round-trip (approval_id=${approvalId})`);
|
|
@@ -209,7 +209,9 @@ export function rejectGate(db, token, approver, reason) {
|
|
|
209
209
|
* @internal
|
|
210
210
|
*/
|
|
211
211
|
function transitionGate(db, token, next, approver, reason) {
|
|
212
|
-
const existing = db
|
|
212
|
+
const existing = db
|
|
213
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?')
|
|
214
|
+
.get(token);
|
|
213
215
|
if (existing === undefined) {
|
|
214
216
|
throw new Error(`${E_APPROVAL_NOT_FOUND}: no approval gate for token`);
|
|
215
217
|
}
|
|
@@ -218,10 +220,10 @@ function transitionGate(db, token, next, approver, reason) {
|
|
|
218
220
|
const approvalId = readString(existing, 'approval_id');
|
|
219
221
|
throw new Error(`${E_APPROVAL_ALREADY_DECIDED}: gate ${approvalId} is already ${existingStatus}`);
|
|
220
222
|
}
|
|
221
|
-
db.prepare(`UPDATE
|
|
223
|
+
db.prepare(`UPDATE tasks_playbook_approvals
|
|
222
224
|
SET status = ?, approved_at = datetime('now'), approver = ?, reason = ?
|
|
223
225
|
WHERE token = ?`).run(next, approver, reason ?? null, token);
|
|
224
|
-
const row = db.prepare('SELECT * FROM
|
|
226
|
+
const row = db.prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?').get(token);
|
|
225
227
|
if (row === undefined) {
|
|
226
228
|
// Unreachable: UPDATE just succeeded on this token.
|
|
227
229
|
throw new Error(`${E_APPROVAL_NOT_FOUND}: row vanished after update (token=${token})`);
|
|
@@ -236,7 +238,7 @@ function transitionGate(db, token, next, approver, reason) {
|
|
|
236
238
|
*/
|
|
237
239
|
export function getPendingApprovals(db) {
|
|
238
240
|
const rows = db
|
|
239
|
-
.prepare(`SELECT * FROM
|
|
241
|
+
.prepare(`SELECT * FROM tasks_playbook_approvals
|
|
240
242
|
WHERE status = 'pending'
|
|
241
243
|
ORDER BY requested_at ASC, approval_id ASC`)
|
|
242
244
|
.all();
|
package/dist/state.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* State layer for playbook runtime — CRUD against
|
|
2
|
+
* State layer for playbook runtime — CRUD against tasks_playbook_runs + tasks_playbook_approvals.
|
|
3
3
|
* Uses node:sqlite DatabaseSync for consistency with rest of CLEO.
|
|
4
4
|
*
|
|
5
5
|
* All JSON-shaped columns (`bindings`, `iteration_counts`) are serialized to
|
|
@@ -67,9 +67,10 @@ export declare function updatePlaybookRun(db: DatabaseSync, runId: string, patch
|
|
|
67
67
|
*/
|
|
68
68
|
export declare function listPlaybookRuns(db: DatabaseSync, opts?: ListPlaybookRunsOptions): PlaybookRun[];
|
|
69
69
|
/**
|
|
70
|
-
* Deletes a playbook run by primary key
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* Deletes a playbook run by primary key, with its approvals, in one
|
|
71
|
+
* transaction. Returns `true` if the run was removed. `tasks_playbook_approvals`
|
|
72
|
+
* has no foreign key to the run (the bare twin cascaded), so the approvals are
|
|
73
|
+
* deleted first, explicitly: each delete is then a captured op of its own.
|
|
73
74
|
*/
|
|
74
75
|
export declare function deletePlaybookRun(db: DatabaseSync, runId: string): boolean;
|
|
75
76
|
/**
|
package/dist/state.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* State layer for playbook runtime — CRUD against
|
|
2
|
+
* State layer for playbook runtime — CRUD against tasks_playbook_runs + tasks_playbook_approvals.
|
|
3
3
|
* Uses node:sqlite DatabaseSync for consistency with rest of CLEO.
|
|
4
4
|
*
|
|
5
5
|
* All JSON-shaped columns (`bindings`, `iteration_counts`) are serialized to
|
|
@@ -30,7 +30,7 @@ function parseJsonColumn(raw, column, runId) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
* Maps a snake_case `
|
|
33
|
+
* Maps a snake_case `tasks_playbook_runs` row to the contract-shaped
|
|
34
34
|
* {@link PlaybookRun}. Performs strict JSON parsing and validates the status
|
|
35
35
|
* column against the enum.
|
|
36
36
|
*/
|
|
@@ -53,7 +53,7 @@ function rowToPlaybookRun(row) {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
|
-
* Maps a snake_case `
|
|
56
|
+
* Maps a snake_case `tasks_playbook_approvals` row to the contract-shaped
|
|
57
57
|
* {@link PlaybookApproval}. Converts the integer `auto_passed` column to a
|
|
58
58
|
* boolean at the boundary.
|
|
59
59
|
*/
|
|
@@ -83,12 +83,12 @@ function rowToPlaybookApproval(row) {
|
|
|
83
83
|
export function createPlaybookRun(db, input) {
|
|
84
84
|
const runId = randomUUID();
|
|
85
85
|
const bindingsJson = JSON.stringify(input.initialBindings ?? {});
|
|
86
|
-
const insert = db.prepare(`INSERT INTO
|
|
86
|
+
const insert = db.prepare(`INSERT INTO tasks_playbook_runs (
|
|
87
87
|
run_id, playbook_name, playbook_hash, bindings, status,
|
|
88
88
|
iteration_counts, epic_id, session_id
|
|
89
89
|
) VALUES (?, ?, ?, ?, 'running', '{}', ?, ?)`);
|
|
90
90
|
insert.run(runId, input.playbookName, input.playbookHash, bindingsJson, input.epicId ?? null, input.sessionId ?? null);
|
|
91
|
-
const row = db.prepare('SELECT * FROM
|
|
91
|
+
const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId);
|
|
92
92
|
if (!row) {
|
|
93
93
|
throw new Error(`playbook state: failed to read back run ${runId} after insert`);
|
|
94
94
|
}
|
|
@@ -99,7 +99,7 @@ export function createPlaybookRun(db, input) {
|
|
|
99
99
|
* does not exist. Never throws on missing rows.
|
|
100
100
|
*/
|
|
101
101
|
export function getPlaybookRun(db, runId) {
|
|
102
|
-
const row = db.prepare('SELECT * FROM
|
|
102
|
+
const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId);
|
|
103
103
|
return row ? rowToPlaybookRun(row) : null;
|
|
104
104
|
}
|
|
105
105
|
/**
|
|
@@ -159,7 +159,7 @@ export function updatePlaybookRun(db, runId, patch) {
|
|
|
159
159
|
}
|
|
160
160
|
db.exec('BEGIN');
|
|
161
161
|
try {
|
|
162
|
-
const stmt = db.prepare(`UPDATE
|
|
162
|
+
const stmt = db.prepare(`UPDATE tasks_playbook_runs SET ${sets.join(', ')} WHERE run_id = ?`);
|
|
163
163
|
values.push(runId);
|
|
164
164
|
const result = stmt.run(...values);
|
|
165
165
|
if (result.changes === 0) {
|
|
@@ -171,7 +171,7 @@ export function updatePlaybookRun(db, runId, patch) {
|
|
|
171
171
|
db.exec('ROLLBACK');
|
|
172
172
|
throw err;
|
|
173
173
|
}
|
|
174
|
-
const row = db.prepare('SELECT * FROM
|
|
174
|
+
const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId);
|
|
175
175
|
if (!row) {
|
|
176
176
|
throw new Error(`playbook state: run ${runId} disappeared after update`);
|
|
177
177
|
}
|
|
@@ -196,18 +196,28 @@ export function listPlaybookRuns(db, opts = {}) {
|
|
|
196
196
|
const limitClause = typeof opts.limit === 'number' ? 'LIMIT ?' : '';
|
|
197
197
|
if (limitClause)
|
|
198
198
|
values.push(opts.limit ?? 0);
|
|
199
|
-
const sql = `SELECT * FROM
|
|
199
|
+
const sql = `SELECT * FROM tasks_playbook_runs ${where} ORDER BY started_at DESC ${limitClause}`;
|
|
200
200
|
const rows = db.prepare(sql).all(...values);
|
|
201
201
|
return rows.map(rowToPlaybookRun);
|
|
202
202
|
}
|
|
203
203
|
/**
|
|
204
|
-
* Deletes a playbook run by primary key
|
|
205
|
-
*
|
|
206
|
-
*
|
|
204
|
+
* Deletes a playbook run by primary key, with its approvals, in one
|
|
205
|
+
* transaction. Returns `true` if the run was removed. `tasks_playbook_approvals`
|
|
206
|
+
* has no foreign key to the run (the bare twin cascaded), so the approvals are
|
|
207
|
+
* deleted first, explicitly: each delete is then a captured op of its own.
|
|
207
208
|
*/
|
|
208
209
|
export function deletePlaybookRun(db, runId) {
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
db.exec('BEGIN');
|
|
211
|
+
try {
|
|
212
|
+
db.prepare('DELETE FROM tasks_playbook_approvals WHERE run_id = ?').run(runId);
|
|
213
|
+
const result = db.prepare('DELETE FROM tasks_playbook_runs WHERE run_id = ?').run(runId);
|
|
214
|
+
db.exec('COMMIT');
|
|
215
|
+
return Number(result.changes) > 0;
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
db.exec('ROLLBACK');
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
211
221
|
}
|
|
212
222
|
// ---------------------------------------------------------------------------
|
|
213
223
|
// Playbook approval CRUD
|
|
@@ -220,11 +230,11 @@ export function deletePlaybookRun(db, runId) {
|
|
|
220
230
|
export function createPlaybookApproval(db, input) {
|
|
221
231
|
const approvalId = randomUUID();
|
|
222
232
|
const autoPassed = input.autoPassed ? 1 : 0;
|
|
223
|
-
db.prepare(`INSERT INTO
|
|
233
|
+
db.prepare(`INSERT INTO tasks_playbook_approvals (
|
|
224
234
|
approval_id, run_id, node_id, token, status, auto_passed
|
|
225
235
|
) VALUES (?, ?, ?, ?, 'pending', ?)`).run(approvalId, input.runId, input.nodeId, input.token, autoPassed);
|
|
226
236
|
const row = db
|
|
227
|
-
.prepare('SELECT * FROM
|
|
237
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
228
238
|
.get(approvalId);
|
|
229
239
|
if (!row) {
|
|
230
240
|
throw new Error(`playbook state: failed to read back approval ${approvalId} after insert`);
|
|
@@ -236,7 +246,7 @@ export function createPlaybookApproval(db, input) {
|
|
|
236
246
|
* The token column carries a UNIQUE constraint so at most one row is returned.
|
|
237
247
|
*/
|
|
238
248
|
export function getPlaybookApprovalByToken(db, token) {
|
|
239
|
-
const row = db.prepare('SELECT * FROM
|
|
249
|
+
const row = db.prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?').get(token);
|
|
240
250
|
return row ? rowToPlaybookApproval(row) : null;
|
|
241
251
|
}
|
|
242
252
|
/**
|
|
@@ -276,7 +286,7 @@ export function updatePlaybookApproval(db, approvalId, patch) {
|
|
|
276
286
|
// same guard transitionGate applies): a peer would refuse the move, so
|
|
277
287
|
// the origin refuses it too (T12944).
|
|
278
288
|
const current = db
|
|
279
|
-
.prepare('SELECT status FROM
|
|
289
|
+
.prepare('SELECT status FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
280
290
|
.get(approvalId);
|
|
281
291
|
if (current && current.status !== 'pending' && current.status !== patch.status) {
|
|
282
292
|
// @sync-invariant playbook.approval.once a decided gate keeps its decision locally, as every peer's merge keeps it
|
|
@@ -291,7 +301,7 @@ export function updatePlaybookApproval(db, approvalId, patch) {
|
|
|
291
301
|
}
|
|
292
302
|
if (sets.length === 0) {
|
|
293
303
|
const row = db
|
|
294
|
-
.prepare('SELECT * FROM
|
|
304
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
295
305
|
.get(approvalId);
|
|
296
306
|
if (!row) {
|
|
297
307
|
throw new Error(`playbook state: approval ${approvalId} not found for update`);
|
|
@@ -300,7 +310,7 @@ export function updatePlaybookApproval(db, approvalId, patch) {
|
|
|
300
310
|
}
|
|
301
311
|
db.exec('BEGIN');
|
|
302
312
|
try {
|
|
303
|
-
const stmt = db.prepare(`UPDATE
|
|
313
|
+
const stmt = db.prepare(`UPDATE tasks_playbook_approvals SET ${sets.join(', ')} WHERE approval_id = ?`);
|
|
304
314
|
values.push(approvalId);
|
|
305
315
|
const result = stmt.run(...values);
|
|
306
316
|
if (result.changes === 0) {
|
|
@@ -313,7 +323,7 @@ export function updatePlaybookApproval(db, approvalId, patch) {
|
|
|
313
323
|
throw err;
|
|
314
324
|
}
|
|
315
325
|
const row = db
|
|
316
|
-
.prepare('SELECT * FROM
|
|
326
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
317
327
|
.get(approvalId);
|
|
318
328
|
if (!row) {
|
|
319
329
|
throw new Error(`playbook state: approval ${approvalId} disappeared after update`);
|
|
@@ -326,7 +336,7 @@ export function updatePlaybookApproval(db, approvalId, patch) {
|
|
|
326
336
|
*/
|
|
327
337
|
export function listPlaybookApprovals(db, runId) {
|
|
328
338
|
const rows = db
|
|
329
|
-
.prepare('SELECT * FROM
|
|
339
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE run_id = ? ORDER BY requested_at ASC, approval_id ASC')
|
|
330
340
|
.all(runId);
|
|
331
341
|
return rows.map(rowToPlaybookApproval);
|
|
332
342
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleocode/playbooks",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.19",
|
|
4
4
|
"description": "Playbook DSL + runtime for CLEO — T889 Orchestration Coherence v3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"drizzle-orm": "1.0.0-rc.4",
|
|
21
21
|
"js-yaml": "^4.1.0",
|
|
22
|
-
"@cleocode/
|
|
23
|
-
"@cleocode/
|
|
22
|
+
"@cleocode/contracts": "2026.10.19",
|
|
23
|
+
"@cleocode/core": "2026.10.19"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/js-yaml": "^4.0.9",
|
|
@@ -7,11 +7,8 @@
|
|
|
7
7
|
* @task T889 / T908 / W4-16
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { readFileSync } from 'node:fs';
|
|
11
10
|
import { createRequire } from 'node:module';
|
|
12
|
-
import { dirname, resolve } from 'node:path';
|
|
13
11
|
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
14
|
-
import { fileURLToPath } from 'node:url';
|
|
15
12
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
13
|
import {
|
|
17
14
|
approveGate,
|
|
@@ -23,6 +20,7 @@ import {
|
|
|
23
20
|
getPlaybookSecret,
|
|
24
21
|
rejectGate,
|
|
25
22
|
} from '../approval.js';
|
|
23
|
+
import { applyPlaybookTwinSchema } from './twin-schema.js';
|
|
26
24
|
|
|
27
25
|
const _require = createRequire(import.meta.url);
|
|
28
26
|
type DatabaseSync = _DatabaseSyncType;
|
|
@@ -30,27 +28,9 @@ const { DatabaseSync } = _require('node:sqlite') as {
|
|
|
30
28
|
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
31
29
|
};
|
|
32
30
|
|
|
33
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
34
|
-
const MIGRATION_SQL = resolve(
|
|
35
|
-
__dirname,
|
|
36
|
-
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
40
|
-
const statements = sql
|
|
41
|
-
.split(/--> statement-breakpoint/)
|
|
42
|
-
.map((s) => s.trim())
|
|
43
|
-
.filter((s) => s.length > 0);
|
|
44
|
-
for (const stmt of statements) {
|
|
45
|
-
const lines = stmt.split('\n');
|
|
46
|
-
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
47
|
-
if (hasSql) db.exec(stmt);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
31
|
function seedRun(db: DatabaseSync, runId: string): void {
|
|
52
32
|
db.prepare(
|
|
53
|
-
`INSERT INTO
|
|
33
|
+
`INSERT INTO tasks_playbook_runs (run_id, playbook_name, playbook_hash)
|
|
54
34
|
VALUES (?, 'rcasd', 'hash')`,
|
|
55
35
|
).run(runId);
|
|
56
36
|
}
|
|
@@ -103,7 +83,7 @@ describe('W4-16: Approval DB operations', () => {
|
|
|
103
83
|
beforeEach(() => {
|
|
104
84
|
db = new DatabaseSync(':memory:');
|
|
105
85
|
db.exec('PRAGMA foreign_keys=ON');
|
|
106
|
-
|
|
86
|
+
applyPlaybookTwinSchema(db);
|
|
107
87
|
seedRun(db, 'run-1');
|
|
108
88
|
});
|
|
109
89
|
|
|
@@ -143,7 +123,7 @@ describe('W4-16: Approval DB operations', () => {
|
|
|
143
123
|
expect(approval.reason).toBe('auto-approve low-risk deterministic step');
|
|
144
124
|
|
|
145
125
|
const row = db
|
|
146
|
-
.prepare('SELECT auto_passed FROM
|
|
126
|
+
.prepare('SELECT auto_passed FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
147
127
|
.get(approval.approvalId) as { auto_passed: number };
|
|
148
128
|
expect(row.auto_passed).toBe(1);
|
|
149
129
|
});
|
|
@@ -266,7 +246,7 @@ describe('W4-16: Approval DB operations', () => {
|
|
|
266
246
|
|
|
267
247
|
// Query by token round-trips the same approvalId
|
|
268
248
|
const lookup = db
|
|
269
|
-
.prepare('SELECT approval_id FROM
|
|
249
|
+
.prepare('SELECT approval_id FROM tasks_playbook_approvals WHERE token = ?')
|
|
270
250
|
.get(a.token) as { approval_id: string };
|
|
271
251
|
expect(lookup.approval_id).toBe(a.approvalId);
|
|
272
252
|
|
|
@@ -33,12 +33,11 @@
|
|
|
33
33
|
* @saga T11387
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import { mkdtempSync,
|
|
36
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
37
37
|
import { createRequire } from 'node:module';
|
|
38
38
|
import { tmpdir } from 'node:os';
|
|
39
|
-
import {
|
|
39
|
+
import { join } from 'node:path';
|
|
40
40
|
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
41
|
-
import { fileURLToPath } from 'node:url';
|
|
42
41
|
import type { PlaybookDefinition, PlaybookDeterministicNode } from '@cleocode/contracts';
|
|
43
42
|
import {
|
|
44
43
|
createGuardedDeterministicRunner,
|
|
@@ -47,6 +46,7 @@ import {
|
|
|
47
46
|
} from '@cleocode/core/internal';
|
|
48
47
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
49
48
|
import { type DeterministicRunner, executePlaybook } from '../runtime.js';
|
|
49
|
+
import { playbookTwinSql } from './twin-schema.js';
|
|
50
50
|
|
|
51
51
|
const _require = createRequire(import.meta.url);
|
|
52
52
|
type DatabaseSync = _DatabaseSyncType;
|
|
@@ -54,13 +54,7 @@ const { DatabaseSync } = _require('node:sqlite') as {
|
|
|
54
54
|
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
58
|
-
|
|
59
57
|
/** Absolute path to the T889 playbook-tables migration SQL. */
|
|
60
|
-
const MIGRATION_SQL = resolve(
|
|
61
|
-
__dirname,
|
|
62
|
-
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
63
|
-
);
|
|
64
58
|
|
|
65
59
|
/** Apply a multi-statement Drizzle migration (split on the breakpoint token). */
|
|
66
60
|
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
@@ -87,7 +81,7 @@ describe('T11802 (M4): deterministic node dispatch routes through guard.ts', ()
|
|
|
87
81
|
beforeEach(() => {
|
|
88
82
|
db = new DatabaseSync(':memory:');
|
|
89
83
|
db.exec('PRAGMA foreign_keys=ON');
|
|
90
|
-
applyMigration(db,
|
|
84
|
+
applyMigration(db, playbookTwinSql());
|
|
91
85
|
projectRoot = mkdtempSync(join(tmpdir(), 't11802-guarded-shell-'));
|
|
92
86
|
});
|
|
93
87
|
|
|
@@ -18,11 +18,8 @@
|
|
|
18
18
|
* @task T1944 — wire PlaybookAgenticNode.inputs → AgentDispatchInput.bindings
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFileSync } from 'node:fs';
|
|
22
21
|
import { createRequire } from 'node:module';
|
|
23
|
-
import { dirname, resolve } from 'node:path';
|
|
24
22
|
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
25
|
-
import { fileURLToPath } from 'node:url';
|
|
26
23
|
import type { PlaybookAgenticNode, PlaybookDefinition } from '@cleocode/contracts';
|
|
27
24
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
28
25
|
import {
|
|
@@ -32,6 +29,7 @@ import {
|
|
|
32
29
|
executePlaybook,
|
|
33
30
|
resolveStepBindings,
|
|
34
31
|
} from '../runtime.js';
|
|
32
|
+
import { playbookTwinSql } from './twin-schema.js';
|
|
35
33
|
|
|
36
34
|
const _require = createRequire(import.meta.url);
|
|
37
35
|
type DatabaseSync = _DatabaseSyncType;
|
|
@@ -39,12 +37,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
|
|
|
39
37
|
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
40
38
|
};
|
|
41
39
|
|
|
42
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
-
const MIGRATION_SQL = resolve(
|
|
44
|
-
__dirname,
|
|
45
|
-
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
46
|
-
);
|
|
47
|
-
|
|
48
40
|
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
49
41
|
const statements = sql
|
|
50
42
|
.split(/--> statement-breakpoint/)
|
|
@@ -169,7 +161,7 @@ describe('T1944: AgentDispatchInput.bindings (in-process)', () => {
|
|
|
169
161
|
beforeEach(() => {
|
|
170
162
|
db = new DatabaseSync(':memory:');
|
|
171
163
|
db.exec('PRAGMA foreign_keys=ON');
|
|
172
|
-
applyMigration(db,
|
|
164
|
+
applyMigration(db, playbookTwinSql());
|
|
173
165
|
});
|
|
174
166
|
afterEach(() => db.close());
|
|
175
167
|
|
|
@@ -19,11 +19,8 @@
|
|
|
19
19
|
* @task T1947
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { readFileSync } from 'node:fs';
|
|
23
22
|
import { createRequire } from 'node:module';
|
|
24
|
-
import { dirname, resolve } from 'node:path';
|
|
25
23
|
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
26
|
-
import { fileURLToPath } from 'node:url';
|
|
27
24
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
28
25
|
import { parsePlaybook } from '../parser.js';
|
|
29
26
|
import {
|
|
@@ -32,6 +29,7 @@ import {
|
|
|
32
29
|
type AgentDispatchResult,
|
|
33
30
|
executePlaybook,
|
|
34
31
|
} from '../runtime.js';
|
|
32
|
+
import { playbookTwinSql } from './twin-schema.js';
|
|
35
33
|
|
|
36
34
|
const _require = createRequire(import.meta.url);
|
|
37
35
|
type DatabaseSync = _DatabaseSyncType;
|
|
@@ -39,12 +37,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
|
|
|
39
37
|
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
40
38
|
};
|
|
41
39
|
|
|
42
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
-
const MIGRATION_SQL = resolve(
|
|
44
|
-
__dirname,
|
|
45
|
-
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
46
|
-
);
|
|
47
|
-
|
|
48
40
|
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
49
41
|
const statements = sql
|
|
50
42
|
.split(/--> statement-breakpoint/)
|
|
@@ -125,7 +117,7 @@ describe('T1947: AC5 — allowlists reach the dispatch boundary', () => {
|
|
|
125
117
|
beforeEach(() => {
|
|
126
118
|
db = new DatabaseSync(':memory:');
|
|
127
119
|
db.exec('PRAGMA foreign_keys=ON');
|
|
128
|
-
applyMigration(db,
|
|
120
|
+
applyMigration(db, playbookTwinSql());
|
|
129
121
|
});
|
|
130
122
|
afterEach(() => db.close());
|
|
131
123
|
|
|
@@ -16,11 +16,8 @@
|
|
|
16
16
|
* @task T11759
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { readFileSync } from 'node:fs';
|
|
20
19
|
import { createRequire } from 'node:module';
|
|
21
|
-
import { dirname, resolve } from 'node:path';
|
|
22
20
|
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
23
|
-
import { fileURLToPath } from 'node:url';
|
|
24
21
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
25
22
|
import { parsePlaybook } from '../parser.js';
|
|
26
23
|
import {
|
|
@@ -29,6 +26,7 @@ import {
|
|
|
29
26
|
type AgentDispatchResult,
|
|
30
27
|
executePlaybook,
|
|
31
28
|
} from '../runtime.js';
|
|
29
|
+
import { playbookTwinSql } from './twin-schema.js';
|
|
32
30
|
|
|
33
31
|
const _require = createRequire(import.meta.url);
|
|
34
32
|
type DatabaseSync = _DatabaseSyncType;
|
|
@@ -36,12 +34,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
|
|
|
36
34
|
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
37
35
|
};
|
|
38
36
|
|
|
39
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
40
|
-
const MIGRATION_SQL = resolve(
|
|
41
|
-
__dirname,
|
|
42
|
-
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
43
|
-
);
|
|
44
|
-
|
|
45
37
|
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
46
38
|
const statements = sql
|
|
47
39
|
.split(/--> statement-breakpoint/)
|
|
@@ -72,7 +64,7 @@ describe('T11759: node profile pin reaches the dispatch boundary', () => {
|
|
|
72
64
|
beforeEach(() => {
|
|
73
65
|
db = new DatabaseSync(':memory:');
|
|
74
66
|
db.exec('PRAGMA foreign_keys=ON');
|
|
75
|
-
applyMigration(db,
|
|
67
|
+
applyMigration(db, playbookTwinSql());
|
|
76
68
|
});
|
|
77
69
|
afterEach(() => db.close());
|
|
78
70
|
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* ## What this proves (the M4 keystone)
|
|
7
7
|
*
|
|
8
|
-
* The
|
|
9
|
-
* `packages/cleo/src/dispatch/domains/playbook.ts`
|
|
10
|
-
* `
|
|
8
|
+
* The production cantbook dispatcher (`buildDefaultDispatcher` in
|
|
9
|
+
* `packages/cleo/src/dispatch/domains/playbook.ts`; `cleo go`'s
|
|
10
|
+
* `buildGoDispatcher` was retired in T13821) passes
|
|
11
11
|
* `runner: await maybeCreatePiRunner({ system, projectRoot })` into
|
|
12
12
|
* {@link runSkillNodeOrSpawn}. This test reconstructs that EXACT production
|
|
13
13
|
* dispatcher shape (`createToolGuard` + `runSkillNodeOrSpawn` + the
|
|
@@ -60,6 +60,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
60
60
|
import { parsePlaybook } from '../parser.js';
|
|
61
61
|
import { executePlaybook } from '../runtime.js';
|
|
62
62
|
import { getPlaybookRun } from '../state.js';
|
|
63
|
+
import { playbookTwinSql } from './twin-schema.js';
|
|
63
64
|
|
|
64
65
|
const _require = createRequire(import.meta.url);
|
|
65
66
|
type DatabaseSync = _DatabaseSyncType;
|
|
@@ -70,10 +71,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
|
|
|
70
71
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
71
72
|
|
|
72
73
|
/** Absolute path to the T889 playbook-tables migration SQL. */
|
|
73
|
-
const MIGRATION_SQL_PATH = resolve(
|
|
74
|
-
__dirname,
|
|
75
|
-
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
76
|
-
);
|
|
77
74
|
|
|
78
75
|
/** The skill-only fixture cantbook materialised for this suite. */
|
|
79
76
|
const FIXTURE_PATH = resolve(__dirname, 'fixtures/pi-skill-only.cantbook');
|
|
@@ -187,7 +184,7 @@ describe('T11945 (M4): skill-only .cantbook routes through the PiAgentAdapter',
|
|
|
187
184
|
beforeEach(() => {
|
|
188
185
|
db = new DatabaseSync(':memory:');
|
|
189
186
|
db.exec('PRAGMA foreign_keys=ON');
|
|
190
|
-
applyMigration(db,
|
|
187
|
+
applyMigration(db, playbookTwinSql());
|
|
191
188
|
|
|
192
189
|
projectRoot = mkdtempSync(join(tmpdir(), 't11945-pi-e2e-'));
|
|
193
190
|
writeFixtureSkill(projectRoot);
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
type ExecutePlaybookResult,
|
|
51
51
|
executePlaybook,
|
|
52
52
|
} from '../runtime.js';
|
|
53
|
+
import { playbookTwinSql } from './twin-schema.js';
|
|
53
54
|
|
|
54
55
|
const _require = createRequire(import.meta.url);
|
|
55
56
|
type DatabaseSync = _DatabaseSyncType;
|
|
@@ -60,10 +61,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
|
|
|
60
61
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
61
62
|
const PLAYBOOKS_SRC = resolve(__dirname, '..');
|
|
62
63
|
|
|
63
|
-
const MIGRATION_SQL = resolve(
|
|
64
|
-
__dirname,
|
|
65
|
-
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
66
|
-
);
|
|
67
64
|
const RCASD_CANTBOOK = resolve(__dirname, '../../starter/rcasd.cantbook');
|
|
68
65
|
|
|
69
66
|
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
@@ -155,7 +152,7 @@ describe('T11803 (M4): rcasd.cantbook → exactly ONE LAFS envelope, no GenKit',
|
|
|
155
152
|
beforeEach(() => {
|
|
156
153
|
db = new DatabaseSync(':memory:');
|
|
157
154
|
db.exec('PRAGMA foreign_keys=ON');
|
|
158
|
-
applyMigration(db,
|
|
155
|
+
applyMigration(db, playbookTwinSql());
|
|
159
156
|
});
|
|
160
157
|
afterEach(() => db.close());
|
|
161
158
|
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
resumePlaybook,
|
|
36
36
|
} from '../runtime.js';
|
|
37
37
|
import { getPlaybookRun, listPlaybookApprovals } from '../state.js';
|
|
38
|
+
import { applyPlaybookTwinSchema } from './twin-schema.js';
|
|
38
39
|
|
|
39
40
|
const _require = createRequire(import.meta.url);
|
|
40
41
|
type DatabaseSync = _DatabaseSyncType;
|
|
@@ -43,24 +44,8 @@ const { DatabaseSync } = _require('node:sqlite') as {
|
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
46
|
-
const MIGRATION_SQL = resolve(
|
|
47
|
-
__dirname,
|
|
48
|
-
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
49
|
-
);
|
|
50
47
|
const CONDITIONAL_BRANCH_CANTBOOK = resolve(__dirname, 'fixtures/conditional-branch.cantbook');
|
|
51
48
|
|
|
52
|
-
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
53
|
-
const statements = sql
|
|
54
|
-
.split(/--> statement-breakpoint/)
|
|
55
|
-
.map((s) => s.trim())
|
|
56
|
-
.filter((s) => s.length > 0);
|
|
57
|
-
for (const stmt of statements) {
|
|
58
|
-
const lines = stmt.split('\n');
|
|
59
|
-
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
60
|
-
if (hasSql) db.exec(stmt);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
49
|
// -- Dispatcher stub helpers -------------------------------------------------
|
|
65
50
|
|
|
66
51
|
interface RecordedCall {
|
|
@@ -158,7 +143,7 @@ describe('W4-10 / T930: playbook runtime state machine', () => {
|
|
|
158
143
|
beforeEach(() => {
|
|
159
144
|
db = new DatabaseSync(':memory:');
|
|
160
145
|
db.exec('PRAGMA foreign_keys=ON');
|
|
161
|
-
|
|
146
|
+
applyPlaybookTwinSchema(db);
|
|
162
147
|
});
|
|
163
148
|
afterEach(() => db.close());
|
|
164
149
|
|
|
@@ -674,7 +659,7 @@ describe('W4-10 / T930: playbook runtime state machine', () => {
|
|
|
674
659
|
// The SQLite migration stamps completedAt via JS, and updatePlaybookRun
|
|
675
660
|
// writes our supplied timestamp.
|
|
676
661
|
const runs = db
|
|
677
|
-
.prepare("SELECT completed_at FROM
|
|
662
|
+
.prepare("SELECT completed_at FROM tasks_playbook_runs WHERE playbook_name = 'clock'")
|
|
678
663
|
.all() as Array<{ completed_at: string }>;
|
|
679
664
|
expect(runs[0]?.completed_at).toBe('2026-04-17T22:30:00.000Z');
|
|
680
665
|
});
|
|
@@ -690,7 +675,7 @@ describe('T11806: cantbook runtime guarded branching', () => {
|
|
|
690
675
|
beforeEach(() => {
|
|
691
676
|
db = new DatabaseSync(':memory:');
|
|
692
677
|
db.exec('PRAGMA foreign_keys=ON');
|
|
693
|
-
|
|
678
|
+
applyPlaybookTwinSchema(db);
|
|
694
679
|
});
|
|
695
680
|
afterEach(() => db.close());
|
|
696
681
|
|
|
@@ -923,7 +908,7 @@ describe('T11762 ST-2: registry-driven ensures.schema enforcement', () => {
|
|
|
923
908
|
beforeEach(() => {
|
|
924
909
|
db = new DatabaseSync(':memory:');
|
|
925
910
|
db.exec('PRAGMA foreign_keys=ON');
|
|
926
|
-
|
|
911
|
+
applyPlaybookTwinSchema(db);
|
|
927
912
|
tmpRoot = mkdtempSync(join(tmpdir(), 'cleo-ensures-schema-'));
|
|
928
913
|
});
|
|
929
914
|
afterEach(() => {
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
resumePlaybook,
|
|
34
34
|
} from '../runtime.js';
|
|
35
35
|
import { getPlaybookRun, listPlaybookApprovals } from '../state.js';
|
|
36
|
+
import { playbookTwinSql } from './twin-schema.js';
|
|
36
37
|
|
|
37
38
|
const _require = createRequire(import.meta.url);
|
|
38
39
|
type DatabaseSync = _DatabaseSyncType;
|
|
@@ -43,10 +44,6 @@ const { DatabaseSync } = _require('node:sqlite') as {
|
|
|
43
44
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
44
45
|
|
|
45
46
|
/** Absolute path to the T889 playbook-tables migration SQL. */
|
|
46
|
-
const MIGRATION_SQL_PATH = resolve(
|
|
47
|
-
__dirname,
|
|
48
|
-
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
49
|
-
);
|
|
50
47
|
|
|
51
48
|
/** Absolute path to the `starter/` directory shipped with this package. */
|
|
52
49
|
const STARTER_DIR = resolve(__dirname, '../../starter');
|
|
@@ -205,7 +202,7 @@ describe('T934: starter playbooks — E2E against stubbed dispatcher', () => {
|
|
|
205
202
|
beforeEach(() => {
|
|
206
203
|
db = new DatabaseSync(':memory:');
|
|
207
204
|
db.exec('PRAGMA foreign_keys=ON');
|
|
208
|
-
applyMigration(db,
|
|
205
|
+
applyMigration(db, playbookTwinSql());
|
|
209
206
|
});
|
|
210
207
|
afterEach(() => db.close());
|
|
211
208
|
|
|
@@ -5,11 +5,8 @@
|
|
|
5
5
|
* @task T889 / T904 / W4-8
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync } from 'node:fs';
|
|
9
8
|
import { createRequire } from 'node:module';
|
|
10
|
-
import { dirname, resolve } from 'node:path';
|
|
11
9
|
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
12
|
-
import { fileURLToPath } from 'node:url';
|
|
13
10
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
11
|
import {
|
|
15
12
|
createPlaybookApproval,
|
|
@@ -22,6 +19,7 @@ import {
|
|
|
22
19
|
updatePlaybookApproval,
|
|
23
20
|
updatePlaybookRun,
|
|
24
21
|
} from '../state.js';
|
|
22
|
+
import { applyPlaybookTwinSchema } from './twin-schema.js';
|
|
25
23
|
|
|
26
24
|
const _require = createRequire(import.meta.url);
|
|
27
25
|
type DatabaseSync = _DatabaseSyncType;
|
|
@@ -29,31 +27,13 @@ const { DatabaseSync } = _require('node:sqlite') as {
|
|
|
29
27
|
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
30
28
|
};
|
|
31
29
|
|
|
32
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
33
|
-
const MIGRATION_SQL = resolve(
|
|
34
|
-
__dirname,
|
|
35
|
-
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
39
|
-
const statements = sql
|
|
40
|
-
.split(/--> statement-breakpoint/)
|
|
41
|
-
.map((s) => s.trim())
|
|
42
|
-
.filter((s) => s.length > 0);
|
|
43
|
-
for (const stmt of statements) {
|
|
44
|
-
const lines = stmt.split('\n');
|
|
45
|
-
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
46
|
-
if (hasSql) db.exec(stmt);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
30
|
describe('W4-8: playbook state CRUD', () => {
|
|
51
31
|
let db: DatabaseSync;
|
|
52
32
|
|
|
53
33
|
beforeEach(() => {
|
|
54
34
|
db = new DatabaseSync(':memory:');
|
|
55
35
|
db.exec('PRAGMA foreign_keys=ON');
|
|
56
|
-
|
|
36
|
+
applyPlaybookTwinSchema(db);
|
|
57
37
|
});
|
|
58
38
|
afterEach(() => db.close());
|
|
59
39
|
|
|
@@ -271,7 +251,7 @@ describe('W4-8: playbook state CRUD', () => {
|
|
|
271
251
|
playbookName: 'p',
|
|
272
252
|
playbookHash: 'h',
|
|
273
253
|
});
|
|
274
|
-
db.prepare('UPDATE
|
|
254
|
+
db.prepare('UPDATE tasks_playbook_runs SET bindings = ? WHERE run_id = ?').run(
|
|
275
255
|
'{not-json',
|
|
276
256
|
run.runId,
|
|
277
257
|
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helper: the playbook twins (`tasks_playbook_runs`,
|
|
3
|
+
* `tasks_playbook_approvals`) exactly as a project store creates them, read
|
|
4
|
+
* from the consolidation migration plus the uid migration, so the state layer
|
|
5
|
+
* is tested against the tables it writes (T12535).
|
|
6
|
+
*
|
|
7
|
+
* @task T12535
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, resolve } from 'node:path';
|
|
12
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const MIGRATIONS = resolve(
|
|
16
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
17
|
+
'../../../core/migrations/drizzle-cleo-project',
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The DDL of both playbook twins (with their uid columns), as statements
|
|
22
|
+
* separated by `--> statement-breakpoint`.
|
|
23
|
+
*/
|
|
24
|
+
export function playbookTwinSql(): string {
|
|
25
|
+
const consolidation = readFileSync(
|
|
26
|
+
resolve(MIGRATIONS, '20260531000001_t11363-consolidation-cleo-project/migration.sql'),
|
|
27
|
+
'utf8',
|
|
28
|
+
);
|
|
29
|
+
const tables = ['tasks_playbook_runs', 'tasks_playbook_approvals'].map((table) => {
|
|
30
|
+
const ddl = consolidation.match(new RegExp(`CREATE TABLE \`${table}\` \\([\\s\\S]*?\\n\\);`));
|
|
31
|
+
if (!ddl) throw new Error(`no CREATE TABLE for ${table} in the consolidation migration`);
|
|
32
|
+
return ddl[0];
|
|
33
|
+
});
|
|
34
|
+
const uids = readFileSync(
|
|
35
|
+
resolve(MIGRATIONS, '20261016120000_t12535-playbook-row-uids/migration.sql'),
|
|
36
|
+
'utf8',
|
|
37
|
+
);
|
|
38
|
+
return [...tables, uids].join('\n--> statement-breakpoint\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Create both playbook twins (with their uid columns) on `db`. */
|
|
42
|
+
export function applyPlaybookTwinSchema(db: DatabaseSync): void {
|
|
43
|
+
for (const stmt of playbookTwinSql().split('--> statement-breakpoint')) {
|
|
44
|
+
if (stmt.split('\n').some((l) => l.trim() !== '' && !l.trim().startsWith('--'))) db.exec(stmt);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/approval.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function generateResumeToken(
|
|
|
82
82
|
* Input for {@link createApprovalGate}.
|
|
83
83
|
*/
|
|
84
84
|
export interface CreateApprovalGateInput {
|
|
85
|
-
/** Run identifier (FK to `
|
|
85
|
+
/** Run identifier (FK to `tasks_playbook_runs.run_id`). */
|
|
86
86
|
runId: string;
|
|
87
87
|
/** Node identifier within the run graph. */
|
|
88
88
|
nodeId: string;
|
|
@@ -106,7 +106,7 @@ export interface CreateApprovalGateInput {
|
|
|
106
106
|
*/
|
|
107
107
|
function narrowStatus(s: string): PlaybookApprovalStatus {
|
|
108
108
|
if (s === 'pending' || s === 'approved' || s === 'rejected') return s;
|
|
109
|
-
throw new Error(`invariant: unknown
|
|
109
|
+
throw new Error(`invariant: unknown tasks_playbook_approvals.status '${s}'`);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
@@ -150,7 +150,7 @@ function readOptionalString(row: Record<string, unknown>, key: string): string |
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
* Map a raw `
|
|
153
|
+
* Map a raw `tasks_playbook_approvals` row to the camelCase {@link PlaybookApproval}
|
|
154
154
|
* contract shape. Validates types, converts the `auto_passed` 0/1 integer to
|
|
155
155
|
* a boolean, and strips nullable fields rather than emitting `null`.
|
|
156
156
|
*
|
|
@@ -176,7 +176,7 @@ function rowToApproval(row: Record<string, unknown>): PlaybookApproval {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
/**
|
|
179
|
-
* Create an HITL approval gate row in `
|
|
179
|
+
* Create an HITL approval gate row in `tasks_playbook_approvals`.
|
|
180
180
|
*
|
|
181
181
|
* If `autoPassed` is true, the gate is written with `status='approved'`
|
|
182
182
|
* and `auto_passed=1` — used by the policy engine to short-circuit gates
|
|
@@ -196,7 +196,7 @@ export function createApprovalGate(
|
|
|
196
196
|
const autoPassed = input.autoPassed ?? false;
|
|
197
197
|
const status: PlaybookApprovalStatus = autoPassed ? 'approved' : 'pending';
|
|
198
198
|
const stmt = db.prepare(`
|
|
199
|
-
INSERT INTO
|
|
199
|
+
INSERT INTO tasks_playbook_approvals
|
|
200
200
|
(approval_id, run_id, node_id, token, status, auto_passed, approver, reason)
|
|
201
201
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
202
202
|
`);
|
|
@@ -211,7 +211,7 @@ export function createApprovalGate(
|
|
|
211
211
|
input.reason ?? null,
|
|
212
212
|
);
|
|
213
213
|
const row = db
|
|
214
|
-
.prepare('SELECT * FROM
|
|
214
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
215
215
|
.get(approvalId) as Record<string, unknown> | undefined;
|
|
216
216
|
if (row === undefined) {
|
|
217
217
|
throw new Error(
|
|
@@ -275,9 +275,9 @@ function transitionGate(
|
|
|
275
275
|
approver: string,
|
|
276
276
|
reason?: string,
|
|
277
277
|
): PlaybookApproval {
|
|
278
|
-
const existing = db
|
|
279
|
-
|
|
280
|
-
| undefined;
|
|
278
|
+
const existing = db
|
|
279
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?')
|
|
280
|
+
.get(token) as Record<string, unknown> | undefined;
|
|
281
281
|
if (existing === undefined) {
|
|
282
282
|
throw new Error(`${E_APPROVAL_NOT_FOUND}: no approval gate for token`);
|
|
283
283
|
}
|
|
@@ -289,11 +289,11 @@ function transitionGate(
|
|
|
289
289
|
);
|
|
290
290
|
}
|
|
291
291
|
db.prepare(
|
|
292
|
-
`UPDATE
|
|
292
|
+
`UPDATE tasks_playbook_approvals
|
|
293
293
|
SET status = ?, approved_at = datetime('now'), approver = ?, reason = ?
|
|
294
294
|
WHERE token = ?`,
|
|
295
295
|
).run(next, approver, reason ?? null, token);
|
|
296
|
-
const row = db.prepare('SELECT * FROM
|
|
296
|
+
const row = db.prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?').get(token) as
|
|
297
297
|
| Record<string, unknown>
|
|
298
298
|
| undefined;
|
|
299
299
|
if (row === undefined) {
|
|
@@ -312,7 +312,7 @@ function transitionGate(
|
|
|
312
312
|
export function getPendingApprovals(db: DatabaseSync): PlaybookApproval[] {
|
|
313
313
|
const rows = db
|
|
314
314
|
.prepare(
|
|
315
|
-
`SELECT * FROM
|
|
315
|
+
`SELECT * FROM tasks_playbook_approvals
|
|
316
316
|
WHERE status = 'pending'
|
|
317
317
|
ORDER BY requested_at ASC, approval_id ASC`,
|
|
318
318
|
)
|
package/src/state.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* State layer for playbook runtime — CRUD against
|
|
2
|
+
* State layer for playbook runtime — CRUD against tasks_playbook_runs + tasks_playbook_approvals.
|
|
3
3
|
* Uses node:sqlite DatabaseSync for consistency with rest of CLEO.
|
|
4
4
|
*
|
|
5
5
|
* All JSON-shaped columns (`bindings`, `iteration_counts`) are serialized to
|
|
@@ -111,7 +111,7 @@ function parseJsonColumn<T>(raw: string, column: string, runId: string): T {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
|
-
* Maps a snake_case `
|
|
114
|
+
* Maps a snake_case `tasks_playbook_runs` row to the contract-shaped
|
|
115
115
|
* {@link PlaybookRun}. Performs strict JSON parsing and validates the status
|
|
116
116
|
* column against the enum.
|
|
117
117
|
*/
|
|
@@ -140,7 +140,7 @@ function rowToPlaybookRun(row: PlaybookRunRow): PlaybookRun {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
|
-
* Maps a snake_case `
|
|
143
|
+
* Maps a snake_case `tasks_playbook_approvals` row to the contract-shaped
|
|
144
144
|
* {@link PlaybookApproval}. Converts the integer `auto_passed` column to a
|
|
145
145
|
* boolean at the boundary.
|
|
146
146
|
*/
|
|
@@ -174,7 +174,7 @@ export function createPlaybookRun(db: DatabaseSync, input: CreatePlaybookRunInpu
|
|
|
174
174
|
const bindingsJson = JSON.stringify(input.initialBindings ?? {});
|
|
175
175
|
|
|
176
176
|
const insert = db.prepare(
|
|
177
|
-
`INSERT INTO
|
|
177
|
+
`INSERT INTO tasks_playbook_runs (
|
|
178
178
|
run_id, playbook_name, playbook_hash, bindings, status,
|
|
179
179
|
iteration_counts, epic_id, session_id
|
|
180
180
|
) VALUES (?, ?, ?, ?, 'running', '{}', ?, ?)`,
|
|
@@ -188,7 +188,7 @@ export function createPlaybookRun(db: DatabaseSync, input: CreatePlaybookRunInpu
|
|
|
188
188
|
input.sessionId ?? null,
|
|
189
189
|
);
|
|
190
190
|
|
|
191
|
-
const row = db.prepare('SELECT * FROM
|
|
191
|
+
const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId) as
|
|
192
192
|
| PlaybookRunRow
|
|
193
193
|
| undefined;
|
|
194
194
|
|
|
@@ -203,7 +203,7 @@ export function createPlaybookRun(db: DatabaseSync, input: CreatePlaybookRunInpu
|
|
|
203
203
|
* does not exist. Never throws on missing rows.
|
|
204
204
|
*/
|
|
205
205
|
export function getPlaybookRun(db: DatabaseSync, runId: string): PlaybookRun | null {
|
|
206
|
-
const row = db.prepare('SELECT * FROM
|
|
206
|
+
const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId) as
|
|
207
207
|
| PlaybookRunRow
|
|
208
208
|
| undefined;
|
|
209
209
|
return row ? rowToPlaybookRun(row) : null;
|
|
@@ -273,7 +273,7 @@ export function updatePlaybookRun(
|
|
|
273
273
|
|
|
274
274
|
db.exec('BEGIN');
|
|
275
275
|
try {
|
|
276
|
-
const stmt = db.prepare(`UPDATE
|
|
276
|
+
const stmt = db.prepare(`UPDATE tasks_playbook_runs SET ${sets.join(', ')} WHERE run_id = ?`);
|
|
277
277
|
values.push(runId);
|
|
278
278
|
const result = stmt.run(...values);
|
|
279
279
|
if (result.changes === 0) {
|
|
@@ -285,7 +285,7 @@ export function updatePlaybookRun(
|
|
|
285
285
|
throw err;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
const row = db.prepare('SELECT * FROM
|
|
288
|
+
const row = db.prepare('SELECT * FROM tasks_playbook_runs WHERE run_id = ?').get(runId) as
|
|
289
289
|
| PlaybookRunRow
|
|
290
290
|
| undefined;
|
|
291
291
|
if (!row) {
|
|
@@ -316,19 +316,28 @@ export function listPlaybookRuns(
|
|
|
316
316
|
const limitClause = typeof opts.limit === 'number' ? 'LIMIT ?' : '';
|
|
317
317
|
if (limitClause) values.push(opts.limit ?? 0);
|
|
318
318
|
|
|
319
|
-
const sql = `SELECT * FROM
|
|
319
|
+
const sql = `SELECT * FROM tasks_playbook_runs ${where} ORDER BY started_at DESC ${limitClause}`;
|
|
320
320
|
const rows = db.prepare(sql).all(...values) as unknown as PlaybookRunRow[];
|
|
321
321
|
return rows.map(rowToPlaybookRun);
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
/**
|
|
325
|
-
* Deletes a playbook run by primary key
|
|
326
|
-
*
|
|
327
|
-
*
|
|
325
|
+
* Deletes a playbook run by primary key, with its approvals, in one
|
|
326
|
+
* transaction. Returns `true` if the run was removed. `tasks_playbook_approvals`
|
|
327
|
+
* has no foreign key to the run (the bare twin cascaded), so the approvals are
|
|
328
|
+
* deleted first, explicitly: each delete is then a captured op of its own.
|
|
328
329
|
*/
|
|
329
330
|
export function deletePlaybookRun(db: DatabaseSync, runId: string): boolean {
|
|
330
|
-
|
|
331
|
-
|
|
331
|
+
db.exec('BEGIN');
|
|
332
|
+
try {
|
|
333
|
+
db.prepare('DELETE FROM tasks_playbook_approvals WHERE run_id = ?').run(runId);
|
|
334
|
+
const result = db.prepare('DELETE FROM tasks_playbook_runs WHERE run_id = ?').run(runId);
|
|
335
|
+
db.exec('COMMIT');
|
|
336
|
+
return Number(result.changes) > 0;
|
|
337
|
+
} catch (err) {
|
|
338
|
+
db.exec('ROLLBACK');
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
332
341
|
}
|
|
333
342
|
|
|
334
343
|
// ---------------------------------------------------------------------------
|
|
@@ -348,13 +357,13 @@ export function createPlaybookApproval(
|
|
|
348
357
|
const autoPassed = input.autoPassed ? 1 : 0;
|
|
349
358
|
|
|
350
359
|
db.prepare(
|
|
351
|
-
`INSERT INTO
|
|
360
|
+
`INSERT INTO tasks_playbook_approvals (
|
|
352
361
|
approval_id, run_id, node_id, token, status, auto_passed
|
|
353
362
|
) VALUES (?, ?, ?, ?, 'pending', ?)`,
|
|
354
363
|
).run(approvalId, input.runId, input.nodeId, input.token, autoPassed);
|
|
355
364
|
|
|
356
365
|
const row = db
|
|
357
|
-
.prepare('SELECT * FROM
|
|
366
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
358
367
|
.get(approvalId) as PlaybookApprovalRow | undefined;
|
|
359
368
|
if (!row) {
|
|
360
369
|
throw new Error(`playbook state: failed to read back approval ${approvalId} after insert`);
|
|
@@ -370,7 +379,7 @@ export function getPlaybookApprovalByToken(
|
|
|
370
379
|
db: DatabaseSync,
|
|
371
380
|
token: string,
|
|
372
381
|
): PlaybookApproval | null {
|
|
373
|
-
const row = db.prepare('SELECT * FROM
|
|
382
|
+
const row = db.prepare('SELECT * FROM tasks_playbook_approvals WHERE token = ?').get(token) as
|
|
374
383
|
| PlaybookApprovalRow
|
|
375
384
|
| undefined;
|
|
376
385
|
return row ? rowToPlaybookApproval(row) : null;
|
|
@@ -418,7 +427,7 @@ export function updatePlaybookApproval(
|
|
|
418
427
|
// same guard transitionGate applies): a peer would refuse the move, so
|
|
419
428
|
// the origin refuses it too (T12944).
|
|
420
429
|
const current = db
|
|
421
|
-
.prepare('SELECT status FROM
|
|
430
|
+
.prepare('SELECT status FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
422
431
|
.get(approvalId) as { status: string } | undefined;
|
|
423
432
|
if (current && current.status !== 'pending' && current.status !== patch.status) {
|
|
424
433
|
// @sync-invariant playbook.approval.once a decided gate keeps its decision locally, as every peer's merge keeps it
|
|
@@ -436,7 +445,7 @@ export function updatePlaybookApproval(
|
|
|
436
445
|
|
|
437
446
|
if (sets.length === 0) {
|
|
438
447
|
const row = db
|
|
439
|
-
.prepare('SELECT * FROM
|
|
448
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
440
449
|
.get(approvalId) as PlaybookApprovalRow | undefined;
|
|
441
450
|
if (!row) {
|
|
442
451
|
throw new Error(`playbook state: approval ${approvalId} not found for update`);
|
|
@@ -447,7 +456,7 @@ export function updatePlaybookApproval(
|
|
|
447
456
|
db.exec('BEGIN');
|
|
448
457
|
try {
|
|
449
458
|
const stmt = db.prepare(
|
|
450
|
-
`UPDATE
|
|
459
|
+
`UPDATE tasks_playbook_approvals SET ${sets.join(', ')} WHERE approval_id = ?`,
|
|
451
460
|
);
|
|
452
461
|
values.push(approvalId);
|
|
453
462
|
const result = stmt.run(...values);
|
|
@@ -461,7 +470,7 @@ export function updatePlaybookApproval(
|
|
|
461
470
|
}
|
|
462
471
|
|
|
463
472
|
const row = db
|
|
464
|
-
.prepare('SELECT * FROM
|
|
473
|
+
.prepare('SELECT * FROM tasks_playbook_approvals WHERE approval_id = ?')
|
|
465
474
|
.get(approvalId) as PlaybookApprovalRow | undefined;
|
|
466
475
|
if (!row) {
|
|
467
476
|
throw new Error(`playbook state: approval ${approvalId} disappeared after update`);
|
|
@@ -476,7 +485,7 @@ export function updatePlaybookApproval(
|
|
|
476
485
|
export function listPlaybookApprovals(db: DatabaseSync, runId: string): PlaybookApproval[] {
|
|
477
486
|
const rows = db
|
|
478
487
|
.prepare(
|
|
479
|
-
'SELECT * FROM
|
|
488
|
+
'SELECT * FROM tasks_playbook_approvals WHERE run_id = ? ORDER BY requested_at ASC, approval_id ASC',
|
|
480
489
|
)
|
|
481
490
|
.all(runId) as unknown as PlaybookApprovalRow[];
|
|
482
491
|
return rows.map(rowToPlaybookApproval);
|