@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/LICENSE +21 -0
- package/dist/approval.d.ts +113 -0
- package/dist/approval.js +244 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +32 -0
- package/dist/parser.d.ts +60 -0
- package/dist/parser.js +509 -0
- package/dist/policy.d.ts +55 -0
- package/dist/policy.js +85 -0
- package/dist/schema.d.ts +374 -0
- package/dist/schema.js +34 -0
- package/dist/state.d.ts +96 -0
- package/dist/state.js +322 -0
- package/package.json +51 -0
- package/src/__tests__/approval.test.ts +295 -0
- package/src/__tests__/parser.test.ts +456 -0
- package/src/__tests__/policy.test.ts +91 -0
- package/src/__tests__/schema.test.ts +209 -0
- package/src/__tests__/smoke.test.ts +9 -0
- package/src/__tests__/state.test.ts +258 -0
- package/src/approval.ts +321 -0
- package/src/index.ts +66 -0
- package/src/parser.ts +712 -0
- package/src/policy.ts +111 -0
- package/src/schema.ts +44 -0
- package/src/state.ts +471 -0
package/dist/state.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
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
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Row mappers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* Strictly parses a JSON payload stored in a playbook column. Throws a
|
|
20
|
+
* descriptive error on malformed JSON so state corruption surfaces at the
|
|
21
|
+
* boundary rather than mutating downstream logic.
|
|
22
|
+
*/
|
|
23
|
+
function parseJsonColumn(raw, column, runId) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
29
|
+
throw new Error(`playbook state: failed to parse JSON column "${column}" for run ${runId}: ${message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Maps a snake_case `playbook_runs` row to the contract-shaped
|
|
34
|
+
* {@link PlaybookRun}. Performs strict JSON parsing and validates the status
|
|
35
|
+
* column against the enum.
|
|
36
|
+
*/
|
|
37
|
+
function rowToPlaybookRun(row) {
|
|
38
|
+
const bindings = parseJsonColumn(row.bindings, 'bindings', row.run_id);
|
|
39
|
+
const iterationCounts = parseJsonColumn(row.iteration_counts, 'iteration_counts', row.run_id);
|
|
40
|
+
return {
|
|
41
|
+
runId: row.run_id,
|
|
42
|
+
playbookName: row.playbook_name,
|
|
43
|
+
playbookHash: row.playbook_hash,
|
|
44
|
+
currentNode: row.current_node,
|
|
45
|
+
bindings,
|
|
46
|
+
errorContext: row.error_context,
|
|
47
|
+
status: row.status,
|
|
48
|
+
iterationCounts,
|
|
49
|
+
epicId: row.epic_id ?? undefined,
|
|
50
|
+
sessionId: row.session_id ?? undefined,
|
|
51
|
+
startedAt: row.started_at,
|
|
52
|
+
completedAt: row.completed_at ?? undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Maps a snake_case `playbook_approvals` row to the contract-shaped
|
|
57
|
+
* {@link PlaybookApproval}. Converts the integer `auto_passed` column to a
|
|
58
|
+
* boolean at the boundary.
|
|
59
|
+
*/
|
|
60
|
+
function rowToPlaybookApproval(row) {
|
|
61
|
+
return {
|
|
62
|
+
approvalId: row.approval_id,
|
|
63
|
+
runId: row.run_id,
|
|
64
|
+
nodeId: row.node_id,
|
|
65
|
+
token: row.token,
|
|
66
|
+
requestedAt: row.requested_at,
|
|
67
|
+
approvedAt: row.approved_at ?? undefined,
|
|
68
|
+
approver: row.approver ?? undefined,
|
|
69
|
+
reason: row.reason ?? undefined,
|
|
70
|
+
status: row.status,
|
|
71
|
+
autoPassed: row.auto_passed === 1,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Playbook run CRUD
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
/**
|
|
78
|
+
* Inserts a new playbook run with a freshly generated UUID, `status='running'`,
|
|
79
|
+
* and the provided initial bindings. Returns the hydrated {@link PlaybookRun}
|
|
80
|
+
* read back from the row so callers see all server-defaulted columns
|
|
81
|
+
* (`started_at`, empty `iteration_counts`, etc.).
|
|
82
|
+
*/
|
|
83
|
+
export function createPlaybookRun(db, input) {
|
|
84
|
+
const runId = randomUUID();
|
|
85
|
+
const bindingsJson = JSON.stringify(input.initialBindings ?? {});
|
|
86
|
+
const insert = db.prepare(`INSERT INTO playbook_runs (
|
|
87
|
+
run_id, playbook_name, playbook_hash, bindings, status,
|
|
88
|
+
iteration_counts, epic_id, session_id
|
|
89
|
+
) VALUES (?, ?, ?, ?, 'running', '{}', ?, ?)`);
|
|
90
|
+
insert.run(runId, input.playbookName, input.playbookHash, bindingsJson, input.epicId ?? null, input.sessionId ?? null);
|
|
91
|
+
const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId);
|
|
92
|
+
if (!row) {
|
|
93
|
+
throw new Error(`playbook state: failed to read back run ${runId} after insert`);
|
|
94
|
+
}
|
|
95
|
+
return rowToPlaybookRun(row);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Fetches a playbook run by its primary key. Returns `null` when the run
|
|
99
|
+
* does not exist. Never throws on missing rows.
|
|
100
|
+
*/
|
|
101
|
+
export function getPlaybookRun(db, runId) {
|
|
102
|
+
const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId);
|
|
103
|
+
return row ? rowToPlaybookRun(row) : null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Applies a partial patch to a playbook run inside a transaction so mixed
|
|
107
|
+
* column updates (e.g. `status` + `currentNode`) commit atomically. Returns
|
|
108
|
+
* the fully-hydrated run read back after commit.
|
|
109
|
+
*/
|
|
110
|
+
export function updatePlaybookRun(db, runId, patch) {
|
|
111
|
+
const sets = [];
|
|
112
|
+
const values = [];
|
|
113
|
+
if ('playbookName' in patch && patch.playbookName !== undefined) {
|
|
114
|
+
sets.push('playbook_name = ?');
|
|
115
|
+
values.push(patch.playbookName);
|
|
116
|
+
}
|
|
117
|
+
if ('playbookHash' in patch && patch.playbookHash !== undefined) {
|
|
118
|
+
sets.push('playbook_hash = ?');
|
|
119
|
+
values.push(patch.playbookHash);
|
|
120
|
+
}
|
|
121
|
+
if ('currentNode' in patch) {
|
|
122
|
+
sets.push('current_node = ?');
|
|
123
|
+
values.push(patch.currentNode ?? null);
|
|
124
|
+
}
|
|
125
|
+
if ('bindings' in patch && patch.bindings !== undefined) {
|
|
126
|
+
sets.push('bindings = ?');
|
|
127
|
+
values.push(JSON.stringify(patch.bindings));
|
|
128
|
+
}
|
|
129
|
+
if ('errorContext' in patch) {
|
|
130
|
+
sets.push('error_context = ?');
|
|
131
|
+
values.push(patch.errorContext ?? null);
|
|
132
|
+
}
|
|
133
|
+
if ('status' in patch && patch.status !== undefined) {
|
|
134
|
+
sets.push('status = ?');
|
|
135
|
+
values.push(patch.status);
|
|
136
|
+
}
|
|
137
|
+
if ('iterationCounts' in patch && patch.iterationCounts !== undefined) {
|
|
138
|
+
sets.push('iteration_counts = ?');
|
|
139
|
+
values.push(JSON.stringify(patch.iterationCounts));
|
|
140
|
+
}
|
|
141
|
+
if ('epicId' in patch) {
|
|
142
|
+
sets.push('epic_id = ?');
|
|
143
|
+
values.push(patch.epicId ?? null);
|
|
144
|
+
}
|
|
145
|
+
if ('sessionId' in patch) {
|
|
146
|
+
sets.push('session_id = ?');
|
|
147
|
+
values.push(patch.sessionId ?? null);
|
|
148
|
+
}
|
|
149
|
+
if ('completedAt' in patch) {
|
|
150
|
+
sets.push('completed_at = ?');
|
|
151
|
+
values.push(patch.completedAt ?? null);
|
|
152
|
+
}
|
|
153
|
+
if (sets.length === 0) {
|
|
154
|
+
const existing = getPlaybookRun(db, runId);
|
|
155
|
+
if (!existing) {
|
|
156
|
+
throw new Error(`playbook state: run ${runId} not found for update`);
|
|
157
|
+
}
|
|
158
|
+
return existing;
|
|
159
|
+
}
|
|
160
|
+
db.exec('BEGIN');
|
|
161
|
+
try {
|
|
162
|
+
const stmt = db.prepare(`UPDATE playbook_runs SET ${sets.join(', ')} WHERE run_id = ?`);
|
|
163
|
+
values.push(runId);
|
|
164
|
+
const result = stmt.run(...values);
|
|
165
|
+
if (result.changes === 0) {
|
|
166
|
+
throw new Error(`playbook state: run ${runId} not found for update`);
|
|
167
|
+
}
|
|
168
|
+
db.exec('COMMIT');
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
db.exec('ROLLBACK');
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id = ?').get(runId);
|
|
175
|
+
if (!row) {
|
|
176
|
+
throw new Error(`playbook state: run ${runId} disappeared after update`);
|
|
177
|
+
}
|
|
178
|
+
return rowToPlaybookRun(row);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Lists playbook runs filtered by status and/or epic. Defaults to `ORDER BY
|
|
182
|
+
* started_at DESC` so the newest runs surface first for dashboards.
|
|
183
|
+
*/
|
|
184
|
+
export function listPlaybookRuns(db, opts = {}) {
|
|
185
|
+
const clauses = [];
|
|
186
|
+
const values = [];
|
|
187
|
+
if (opts.status) {
|
|
188
|
+
clauses.push('status = ?');
|
|
189
|
+
values.push(opts.status);
|
|
190
|
+
}
|
|
191
|
+
if (opts.epicId) {
|
|
192
|
+
clauses.push('epic_id = ?');
|
|
193
|
+
values.push(opts.epicId);
|
|
194
|
+
}
|
|
195
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
196
|
+
const limitClause = typeof opts.limit === 'number' ? 'LIMIT ?' : '';
|
|
197
|
+
if (limitClause)
|
|
198
|
+
values.push(opts.limit ?? 0);
|
|
199
|
+
const sql = `SELECT * FROM playbook_runs ${where} ORDER BY started_at DESC ${limitClause}`;
|
|
200
|
+
const rows = db.prepare(sql).all(...values);
|
|
201
|
+
return rows.map(rowToPlaybookRun);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Deletes a playbook run by primary key. Returns `true` if a row was removed.
|
|
205
|
+
* CASCADE wipes all associated approvals via the foreign-key constraint on
|
|
206
|
+
* `playbook_approvals.run_id`.
|
|
207
|
+
*/
|
|
208
|
+
export function deletePlaybookRun(db, runId) {
|
|
209
|
+
const result = db.prepare('DELETE FROM playbook_runs WHERE run_id = ?').run(runId);
|
|
210
|
+
return Number(result.changes) > 0;
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Playbook approval CRUD
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
/**
|
|
216
|
+
* Inserts a new approval record with a freshly generated UUID, `status='pending'`,
|
|
217
|
+
* and the caller-supplied opaque token. Returns the hydrated
|
|
218
|
+
* {@link PlaybookApproval} so callers see the server-defaulted `requested_at`.
|
|
219
|
+
*/
|
|
220
|
+
export function createPlaybookApproval(db, input) {
|
|
221
|
+
const approvalId = randomUUID();
|
|
222
|
+
const autoPassed = input.autoPassed ? 1 : 0;
|
|
223
|
+
db.prepare(`INSERT INTO playbook_approvals (
|
|
224
|
+
approval_id, run_id, node_id, token, status, auto_passed
|
|
225
|
+
) VALUES (?, ?, ?, ?, 'pending', ?)`).run(approvalId, input.runId, input.nodeId, input.token, autoPassed);
|
|
226
|
+
const row = db
|
|
227
|
+
.prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
|
|
228
|
+
.get(approvalId);
|
|
229
|
+
if (!row) {
|
|
230
|
+
throw new Error(`playbook state: failed to read back approval ${approvalId} after insert`);
|
|
231
|
+
}
|
|
232
|
+
return rowToPlaybookApproval(row);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Fetches an approval by its opaque token. Returns `null` if no row matches.
|
|
236
|
+
* The token column carries a UNIQUE constraint so at most one row is returned.
|
|
237
|
+
*/
|
|
238
|
+
export function getPlaybookApprovalByToken(db, token) {
|
|
239
|
+
const row = db.prepare('SELECT * FROM playbook_approvals WHERE token = ?').get(token);
|
|
240
|
+
return row ? rowToPlaybookApproval(row) : null;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Applies a partial patch to an approval record inside a transaction. Used by
|
|
244
|
+
* the approval-resume flow to transactionally set both `status` and
|
|
245
|
+
* `approved_at` when a human resolves a HITL checkpoint.
|
|
246
|
+
*/
|
|
247
|
+
export function updatePlaybookApproval(db, approvalId, patch) {
|
|
248
|
+
const sets = [];
|
|
249
|
+
const values = [];
|
|
250
|
+
if ('runId' in patch && patch.runId !== undefined) {
|
|
251
|
+
sets.push('run_id = ?');
|
|
252
|
+
values.push(patch.runId);
|
|
253
|
+
}
|
|
254
|
+
if ('nodeId' in patch && patch.nodeId !== undefined) {
|
|
255
|
+
sets.push('node_id = ?');
|
|
256
|
+
values.push(patch.nodeId);
|
|
257
|
+
}
|
|
258
|
+
if ('token' in patch && patch.token !== undefined) {
|
|
259
|
+
sets.push('token = ?');
|
|
260
|
+
values.push(patch.token);
|
|
261
|
+
}
|
|
262
|
+
if ('approvedAt' in patch) {
|
|
263
|
+
sets.push('approved_at = ?');
|
|
264
|
+
values.push(patch.approvedAt ?? null);
|
|
265
|
+
}
|
|
266
|
+
if ('approver' in patch) {
|
|
267
|
+
sets.push('approver = ?');
|
|
268
|
+
values.push(patch.approver ?? null);
|
|
269
|
+
}
|
|
270
|
+
if ('reason' in patch) {
|
|
271
|
+
sets.push('reason = ?');
|
|
272
|
+
values.push(patch.reason ?? null);
|
|
273
|
+
}
|
|
274
|
+
if ('status' in patch && patch.status !== undefined) {
|
|
275
|
+
sets.push('status = ?');
|
|
276
|
+
values.push(patch.status);
|
|
277
|
+
}
|
|
278
|
+
if ('autoPassed' in patch && patch.autoPassed !== undefined) {
|
|
279
|
+
sets.push('auto_passed = ?');
|
|
280
|
+
values.push(patch.autoPassed ? 1 : 0);
|
|
281
|
+
}
|
|
282
|
+
if (sets.length === 0) {
|
|
283
|
+
const row = db
|
|
284
|
+
.prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
|
|
285
|
+
.get(approvalId);
|
|
286
|
+
if (!row) {
|
|
287
|
+
throw new Error(`playbook state: approval ${approvalId} not found for update`);
|
|
288
|
+
}
|
|
289
|
+
return rowToPlaybookApproval(row);
|
|
290
|
+
}
|
|
291
|
+
db.exec('BEGIN');
|
|
292
|
+
try {
|
|
293
|
+
const stmt = db.prepare(`UPDATE playbook_approvals SET ${sets.join(', ')} WHERE approval_id = ?`);
|
|
294
|
+
values.push(approvalId);
|
|
295
|
+
const result = stmt.run(...values);
|
|
296
|
+
if (result.changes === 0) {
|
|
297
|
+
throw new Error(`playbook state: approval ${approvalId} not found for update`);
|
|
298
|
+
}
|
|
299
|
+
db.exec('COMMIT');
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
db.exec('ROLLBACK');
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
const row = db
|
|
306
|
+
.prepare('SELECT * FROM playbook_approvals WHERE approval_id = ?')
|
|
307
|
+
.get(approvalId);
|
|
308
|
+
if (!row) {
|
|
309
|
+
throw new Error(`playbook state: approval ${approvalId} disappeared after update`);
|
|
310
|
+
}
|
|
311
|
+
return rowToPlaybookApproval(row);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Lists all approvals for a given run, ordered by `requested_at ASC` so the
|
|
315
|
+
* first HITL checkpoint surfaces first.
|
|
316
|
+
*/
|
|
317
|
+
export function listPlaybookApprovals(db, runId) {
|
|
318
|
+
const rows = db
|
|
319
|
+
.prepare('SELECT * FROM playbook_approvals WHERE run_id = ? ORDER BY requested_at ASC, approval_id ASC')
|
|
320
|
+
.all(runId);
|
|
321
|
+
return rows.map(rowToPlaybookApproval);
|
|
322
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cleocode/playbooks",
|
|
3
|
+
"version": "2026.4.88",
|
|
4
|
+
"description": "Playbook DSL + runtime for CLEO — T889 Orchestration Coherence v3",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/",
|
|
16
|
+
"src/"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
|
20
|
+
"js-yaml": "^4.1.0",
|
|
21
|
+
"@cleocode/contracts": "2026.4.88",
|
|
22
|
+
"@cleocode/core": "2026.4.88"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/js-yaml": "^4.0.9",
|
|
26
|
+
"typescript": "^6.0.2",
|
|
27
|
+
"vitest": "^4.1.4"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"cleo",
|
|
31
|
+
"playbook",
|
|
32
|
+
"orchestration",
|
|
33
|
+
"dsl",
|
|
34
|
+
"cantbook"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/kryptobaseddev/cleo.git",
|
|
40
|
+
"directory": "packages/playbooks"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -b",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"test:watch": "vitest"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W4-16 approval / resume-token real-sqlite tests (no mocks).
|
|
3
|
+
*
|
|
4
|
+
* All tests run against an in-memory sqlite DB with the T889 migration
|
|
5
|
+
* applied — zero fakes, zero stubs, zero mocks of `createHmac`.
|
|
6
|
+
*
|
|
7
|
+
* @task T889 / T908 / W4-16
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { dirname, resolve } from 'node:path';
|
|
13
|
+
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
16
|
+
import {
|
|
17
|
+
approveGate,
|
|
18
|
+
createApprovalGate,
|
|
19
|
+
E_APPROVAL_ALREADY_DECIDED,
|
|
20
|
+
E_APPROVAL_NOT_FOUND,
|
|
21
|
+
generateResumeToken,
|
|
22
|
+
getPendingApprovals,
|
|
23
|
+
getPlaybookSecret,
|
|
24
|
+
rejectGate,
|
|
25
|
+
} from '../approval.js';
|
|
26
|
+
|
|
27
|
+
const _require = createRequire(import.meta.url);
|
|
28
|
+
type DatabaseSync = _DatabaseSyncType;
|
|
29
|
+
const { DatabaseSync } = _require('node:sqlite') as {
|
|
30
|
+
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
31
|
+
};
|
|
32
|
+
|
|
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
|
+
function seedRun(db: DatabaseSync, runId: string): void {
|
|
52
|
+
db.prepare(
|
|
53
|
+
`INSERT INTO playbook_runs (run_id, playbook_name, playbook_hash)
|
|
54
|
+
VALUES (?, 'rcasd', 'hash')`,
|
|
55
|
+
).run(runId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('W4-16: Resume-token generation', () => {
|
|
59
|
+
it('is deterministic for identical inputs', () => {
|
|
60
|
+
const t1 = generateResumeToken('r1', 'n1', { a: 1, b: 2 }, 'secret');
|
|
61
|
+
const t2 = generateResumeToken('r1', 'n1', { a: 1, b: 2 }, 'secret');
|
|
62
|
+
expect(t1).toBe(t2);
|
|
63
|
+
expect(t1).toHaveLength(32);
|
|
64
|
+
expect(t1).toMatch(/^[0-9a-f]{32}$/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('differs for different runIds', () => {
|
|
68
|
+
const a = generateResumeToken('r1', 'n1', {}, 'secret');
|
|
69
|
+
const b = generateResumeToken('r2', 'n1', {}, 'secret');
|
|
70
|
+
expect(a).not.toBe(b);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('differs for different bindings', () => {
|
|
74
|
+
const a = generateResumeToken('r1', 'n1', { x: 1 }, 'secret');
|
|
75
|
+
const b = generateResumeToken('r1', 'n1', { x: 2 }, 'secret');
|
|
76
|
+
expect(a).not.toBe(b);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('is binding-key-order invariant via canonical JSON', () => {
|
|
80
|
+
const a = generateResumeToken('r1', 'n1', { alpha: 1, beta: 2 }, 'secret');
|
|
81
|
+
const b = generateResumeToken('r1', 'n1', { beta: 2, alpha: 1 }, 'secret');
|
|
82
|
+
expect(a).toBe(b);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('differs when secret rotates', () => {
|
|
86
|
+
const a = generateResumeToken('r1', 'n1', {}, 'secretA');
|
|
87
|
+
const b = generateResumeToken('r1', 'n1', {}, 'secretB');
|
|
88
|
+
expect(a).not.toBe(b);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('getPlaybookSecret reads CLEO_PLAYBOOK_SECRET from env override', () => {
|
|
92
|
+
const s = getPlaybookSecret({ CLEO_PLAYBOOK_SECRET: 'prod-secret' } as NodeJS.ProcessEnv);
|
|
93
|
+
expect(s).toBe('prod-secret');
|
|
94
|
+
const fallback = getPlaybookSecret({} as NodeJS.ProcessEnv);
|
|
95
|
+
expect(fallback.length).toBeGreaterThan(0);
|
|
96
|
+
expect(fallback).not.toBe('prod-secret');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('W4-16: Approval DB operations', () => {
|
|
101
|
+
let db: DatabaseSync;
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
db = new DatabaseSync(':memory:');
|
|
105
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
106
|
+
applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
|
|
107
|
+
seedRun(db, 'run-1');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => db.close());
|
|
111
|
+
|
|
112
|
+
it('createApprovalGate writes row with generated token and status=pending', () => {
|
|
113
|
+
const approval = createApprovalGate(db, {
|
|
114
|
+
runId: 'run-1',
|
|
115
|
+
nodeId: 'node-a',
|
|
116
|
+
bindings: { x: 1 },
|
|
117
|
+
secret: 'test-secret',
|
|
118
|
+
});
|
|
119
|
+
const expectedToken = generateResumeToken('run-1', 'node-a', { x: 1 }, 'test-secret');
|
|
120
|
+
expect(approval.token).toBe(expectedToken);
|
|
121
|
+
expect(approval.status).toBe('pending');
|
|
122
|
+
expect(approval.autoPassed).toBe(false);
|
|
123
|
+
expect(approval.approvalId).toMatch(/^[0-9a-f-]{36}$/);
|
|
124
|
+
expect(approval.requestedAt).toBeTruthy();
|
|
125
|
+
expect(approval.approvedAt).toBeUndefined();
|
|
126
|
+
expect(approval.approver).toBeUndefined();
|
|
127
|
+
expect(approval.reason).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('createApprovalGate with autoPassed=true records status=approved + auto_passed=1', () => {
|
|
131
|
+
const approval = createApprovalGate(db, {
|
|
132
|
+
runId: 'run-1',
|
|
133
|
+
nodeId: 'node-auto',
|
|
134
|
+
bindings: {},
|
|
135
|
+
autoPassed: true,
|
|
136
|
+
approver: 'policy:conservative',
|
|
137
|
+
reason: 'auto-approve low-risk deterministic step',
|
|
138
|
+
secret: 'test-secret',
|
|
139
|
+
});
|
|
140
|
+
expect(approval.status).toBe('approved');
|
|
141
|
+
expect(approval.autoPassed).toBe(true);
|
|
142
|
+
expect(approval.approver).toBe('policy:conservative');
|
|
143
|
+
expect(approval.reason).toBe('auto-approve low-risk deterministic step');
|
|
144
|
+
|
|
145
|
+
const row = db
|
|
146
|
+
.prepare('SELECT auto_passed FROM playbook_approvals WHERE approval_id = ?')
|
|
147
|
+
.get(approval.approvalId) as { auto_passed: number };
|
|
148
|
+
expect(row.auto_passed).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('approveGate sets status=approved, approvedAt, approver, reason', () => {
|
|
152
|
+
const gate = createApprovalGate(db, {
|
|
153
|
+
runId: 'run-1',
|
|
154
|
+
nodeId: 'node-b',
|
|
155
|
+
bindings: { y: 'hello' },
|
|
156
|
+
secret: 'test-secret',
|
|
157
|
+
});
|
|
158
|
+
const updated = approveGate(db, gate.token, 'keaton@cleo', 'reviewed RCASD plan');
|
|
159
|
+
expect(updated.status).toBe('approved');
|
|
160
|
+
expect(updated.approver).toBe('keaton@cleo');
|
|
161
|
+
expect(updated.reason).toBe('reviewed RCASD plan');
|
|
162
|
+
expect(updated.approvedAt).toBeTruthy();
|
|
163
|
+
expect(updated.autoPassed).toBe(false);
|
|
164
|
+
expect(updated.approvalId).toBe(gate.approvalId);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('approveGate throws E_APPROVAL_NOT_FOUND for unknown token', () => {
|
|
168
|
+
expect(() => approveGate(db, 'deadbeef'.repeat(4), 'who', 'why')).toThrowError(
|
|
169
|
+
new RegExp(E_APPROVAL_NOT_FOUND),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('approveGate throws E_APPROVAL_ALREADY_DECIDED for already-approved gate', () => {
|
|
174
|
+
const gate = createApprovalGate(db, {
|
|
175
|
+
runId: 'run-1',
|
|
176
|
+
nodeId: 'node-c',
|
|
177
|
+
bindings: {},
|
|
178
|
+
secret: 'test-secret',
|
|
179
|
+
});
|
|
180
|
+
approveGate(db, gate.token, 'user1');
|
|
181
|
+
expect(() => approveGate(db, gate.token, 'user2')).toThrowError(
|
|
182
|
+
new RegExp(E_APPROVAL_ALREADY_DECIDED),
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('rejectGate sets status=rejected with approver + reason', () => {
|
|
187
|
+
const gate = createApprovalGate(db, {
|
|
188
|
+
runId: 'run-1',
|
|
189
|
+
nodeId: 'node-d',
|
|
190
|
+
bindings: {},
|
|
191
|
+
secret: 'test-secret',
|
|
192
|
+
});
|
|
193
|
+
const updated = rejectGate(db, gate.token, 'auditor', 'contract violation');
|
|
194
|
+
expect(updated.status).toBe('rejected');
|
|
195
|
+
expect(updated.approver).toBe('auditor');
|
|
196
|
+
expect(updated.reason).toBe('contract violation');
|
|
197
|
+
expect(updated.approvedAt).toBeTruthy();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('rejectGate throws E_APPROVAL_ALREADY_DECIDED for already-rejected gate', () => {
|
|
201
|
+
const gate = createApprovalGate(db, {
|
|
202
|
+
runId: 'run-1',
|
|
203
|
+
nodeId: 'node-e',
|
|
204
|
+
bindings: {},
|
|
205
|
+
secret: 'test-secret',
|
|
206
|
+
});
|
|
207
|
+
rejectGate(db, gate.token, 'user1');
|
|
208
|
+
expect(() => rejectGate(db, gate.token, 'user2')).toThrowError(
|
|
209
|
+
new RegExp(E_APPROVAL_ALREADY_DECIDED),
|
|
210
|
+
);
|
|
211
|
+
expect(() => approveGate(db, gate.token, 'user3')).toThrowError(
|
|
212
|
+
new RegExp(E_APPROVAL_ALREADY_DECIDED),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('getPendingApprovals returns only pending, ordered by requestedAt', () => {
|
|
217
|
+
const g1 = createApprovalGate(db, {
|
|
218
|
+
runId: 'run-1',
|
|
219
|
+
nodeId: 'node-p1',
|
|
220
|
+
bindings: { k: 1 },
|
|
221
|
+
secret: 'test-secret',
|
|
222
|
+
});
|
|
223
|
+
const g2 = createApprovalGate(db, {
|
|
224
|
+
runId: 'run-1',
|
|
225
|
+
nodeId: 'node-p2',
|
|
226
|
+
bindings: { k: 2 },
|
|
227
|
+
secret: 'test-secret',
|
|
228
|
+
});
|
|
229
|
+
const g3 = createApprovalGate(db, {
|
|
230
|
+
runId: 'run-1',
|
|
231
|
+
nodeId: 'node-p3',
|
|
232
|
+
bindings: { k: 3 },
|
|
233
|
+
autoPassed: true,
|
|
234
|
+
secret: 'test-secret',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const pending = getPendingApprovals(db);
|
|
238
|
+
expect(pending).toHaveLength(2);
|
|
239
|
+
const ids = pending.map((p) => p.approvalId);
|
|
240
|
+
expect(ids).toContain(g1.approvalId);
|
|
241
|
+
expect(ids).toContain(g2.approvalId);
|
|
242
|
+
expect(ids).not.toContain(g3.approvalId);
|
|
243
|
+
// Ordering: requested_at ASC, then approval_id ASC as tiebreaker
|
|
244
|
+
// since both inserts happened in the same second under datetime('now').
|
|
245
|
+
const sorted = [...pending].sort((a, b) => {
|
|
246
|
+
const t = a.requestedAt.localeCompare(b.requestedAt);
|
|
247
|
+
return t !== 0 ? t : a.approvalId.localeCompare(b.approvalId);
|
|
248
|
+
});
|
|
249
|
+
expect(pending.map((p) => p.approvalId)).toEqual(sorted.map((p) => p.approvalId));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('full flow: create -> approve -> pending list shrinks', () => {
|
|
253
|
+
const a = createApprovalGate(db, {
|
|
254
|
+
runId: 'run-1',
|
|
255
|
+
nodeId: 'node-x',
|
|
256
|
+
bindings: { a: 1 },
|
|
257
|
+
secret: 'test-secret',
|
|
258
|
+
});
|
|
259
|
+
const b = createApprovalGate(db, {
|
|
260
|
+
runId: 'run-1',
|
|
261
|
+
nodeId: 'node-y',
|
|
262
|
+
bindings: { b: 2 },
|
|
263
|
+
secret: 'test-secret',
|
|
264
|
+
});
|
|
265
|
+
expect(getPendingApprovals(db)).toHaveLength(2);
|
|
266
|
+
|
|
267
|
+
// Query by token round-trips the same approvalId
|
|
268
|
+
const lookup = db
|
|
269
|
+
.prepare('SELECT approval_id FROM playbook_approvals WHERE token = ?')
|
|
270
|
+
.get(a.token) as { approval_id: string };
|
|
271
|
+
expect(lookup.approval_id).toBe(a.approvalId);
|
|
272
|
+
|
|
273
|
+
approveGate(db, a.token, 'reviewer');
|
|
274
|
+
|
|
275
|
+
const remaining = getPendingApprovals(db);
|
|
276
|
+
expect(remaining).toHaveLength(1);
|
|
277
|
+
expect(remaining[0]?.approvalId).toBe(b.approvalId);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('rowToApproval handles null approver/reason correctly (fields omitted)', () => {
|
|
281
|
+
const gate = createApprovalGate(db, {
|
|
282
|
+
runId: 'run-1',
|
|
283
|
+
nodeId: 'node-null',
|
|
284
|
+
bindings: {},
|
|
285
|
+
secret: 'test-secret',
|
|
286
|
+
});
|
|
287
|
+
// Never approved — approver/reason/approvedAt should all be absent
|
|
288
|
+
expect(gate.approver).toBeUndefined();
|
|
289
|
+
expect(gate.reason).toBeUndefined();
|
|
290
|
+
expect(gate.approvedAt).toBeUndefined();
|
|
291
|
+
expect('approver' in gate).toBe(false);
|
|
292
|
+
expect('reason' in gate).toBe(false);
|
|
293
|
+
expect('approvedAt' in gate).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
});
|