@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W4-6 schema + contracts real-sqlite tests (no mocks).
|
|
3
|
+
* @task T889 / T904 / W4-6
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { dirname, resolve } from 'node:path';
|
|
9
|
+
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import type {
|
|
12
|
+
PlaybookApproval,
|
|
13
|
+
PlaybookApprovalStatus,
|
|
14
|
+
PlaybookDefinition,
|
|
15
|
+
PlaybookNode,
|
|
16
|
+
PlaybookRun,
|
|
17
|
+
PlaybookRunStatus,
|
|
18
|
+
} from '@cleocode/contracts';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
20
|
+
import { playbookApprovals, playbookRuns } from '../schema.js';
|
|
21
|
+
|
|
22
|
+
const _require = createRequire(import.meta.url);
|
|
23
|
+
type DatabaseSync = _DatabaseSyncType;
|
|
24
|
+
const { DatabaseSync } = _require('node:sqlite') as {
|
|
25
|
+
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const MIGRATION_SQL = resolve(
|
|
30
|
+
__dirname,
|
|
31
|
+
'../../../core/migrations/drizzle-tasks/20260417220000_t889-playbook-tables/migration.sql',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
function applyMigration(db: DatabaseSync, sql: string): void {
|
|
35
|
+
const statements = sql
|
|
36
|
+
.split(/--> statement-breakpoint/)
|
|
37
|
+
.map((s) => s.trim())
|
|
38
|
+
.filter((s) => s.length > 0);
|
|
39
|
+
for (const stmt of statements) {
|
|
40
|
+
// Strip leading comment-only lines but keep SQL bodies
|
|
41
|
+
const lines = stmt.split('\n');
|
|
42
|
+
const hasSql = lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('--'));
|
|
43
|
+
if (hasSql) db.exec(stmt);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('W4-6: Playbook contracts + Drizzle schema', () => {
|
|
48
|
+
describe('contract types are assignable', () => {
|
|
49
|
+
it('PlaybookRun round-trip type', () => {
|
|
50
|
+
const run: PlaybookRun = {
|
|
51
|
+
runId: 'r1',
|
|
52
|
+
playbookName: 'rcasd',
|
|
53
|
+
playbookHash: 'abc',
|
|
54
|
+
currentNode: null,
|
|
55
|
+
bindings: {},
|
|
56
|
+
errorContext: null,
|
|
57
|
+
status: 'running' satisfies PlaybookRunStatus,
|
|
58
|
+
iterationCounts: {},
|
|
59
|
+
startedAt: new Date().toISOString(),
|
|
60
|
+
};
|
|
61
|
+
expect(run.status).toBe('running');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('PlaybookApproval round-trip type', () => {
|
|
65
|
+
const a: PlaybookApproval = {
|
|
66
|
+
approvalId: 'a1',
|
|
67
|
+
runId: 'r1',
|
|
68
|
+
nodeId: 'n1',
|
|
69
|
+
token: 'abcdef0123456789abcdef0123456789',
|
|
70
|
+
requestedAt: new Date().toISOString(),
|
|
71
|
+
status: 'pending' satisfies PlaybookApprovalStatus,
|
|
72
|
+
autoPassed: false,
|
|
73
|
+
};
|
|
74
|
+
expect(a.token).toHaveLength(32);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('PlaybookDefinition accepts all three node kinds', () => {
|
|
78
|
+
const nodes: PlaybookNode[] = [
|
|
79
|
+
{ id: 'n1', type: 'agentic', skill: 'ct-research-agent', role: 'lead' },
|
|
80
|
+
{ id: 'n2', type: 'deterministic', command: 'pnpm', args: ['biome', 'ci', '.'] },
|
|
81
|
+
{ id: 'n3', type: 'approval', prompt: 'Approve?' },
|
|
82
|
+
];
|
|
83
|
+
const def: PlaybookDefinition = { version: '1.0', name: 'test', nodes, edges: [] };
|
|
84
|
+
expect(def.nodes).toHaveLength(3);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('Drizzle table objects', () => {
|
|
89
|
+
it('playbookRuns exposes 12 columns', () => {
|
|
90
|
+
const cols = Object.keys(playbookRuns);
|
|
91
|
+
for (const n of [
|
|
92
|
+
'runId',
|
|
93
|
+
'playbookName',
|
|
94
|
+
'playbookHash',
|
|
95
|
+
'currentNode',
|
|
96
|
+
'bindings',
|
|
97
|
+
'errorContext',
|
|
98
|
+
'status',
|
|
99
|
+
'iterationCounts',
|
|
100
|
+
'epicId',
|
|
101
|
+
'sessionId',
|
|
102
|
+
'startedAt',
|
|
103
|
+
'completedAt',
|
|
104
|
+
]) {
|
|
105
|
+
expect(cols).toContain(n);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('playbookApprovals exposes 10 columns', () => {
|
|
110
|
+
const cols = Object.keys(playbookApprovals);
|
|
111
|
+
for (const n of [
|
|
112
|
+
'approvalId',
|
|
113
|
+
'runId',
|
|
114
|
+
'nodeId',
|
|
115
|
+
'token',
|
|
116
|
+
'requestedAt',
|
|
117
|
+
'approvedAt',
|
|
118
|
+
'approver',
|
|
119
|
+
'reason',
|
|
120
|
+
'status',
|
|
121
|
+
'autoPassed',
|
|
122
|
+
]) {
|
|
123
|
+
expect(cols).toContain(n);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('migration against in-memory sqlite', () => {
|
|
129
|
+
let db: DatabaseSync;
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
db = new DatabaseSync(':memory:');
|
|
133
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
134
|
+
applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
|
|
135
|
+
});
|
|
136
|
+
afterEach(() => db.close());
|
|
137
|
+
|
|
138
|
+
it('playbook_runs has 12 columns with constraints', () => {
|
|
139
|
+
const cols = db.prepare('PRAGMA table_info(playbook_runs)').all() as Array<{
|
|
140
|
+
name: string;
|
|
141
|
+
notnull: number;
|
|
142
|
+
dflt_value: string | null;
|
|
143
|
+
pk: number;
|
|
144
|
+
}>;
|
|
145
|
+
const m = new Map(cols.map((c) => [c.name, c]));
|
|
146
|
+
expect(cols).toHaveLength(12);
|
|
147
|
+
expect(m.get('run_id')?.pk).toBe(1);
|
|
148
|
+
expect(m.get('status')?.dflt_value).toBe("'running'");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('playbook_approvals has 10 columns', () => {
|
|
152
|
+
const cols = db.prepare('PRAGMA table_info(playbook_approvals)').all() as Array<{
|
|
153
|
+
name: string;
|
|
154
|
+
dflt_value: string | null;
|
|
155
|
+
pk: number;
|
|
156
|
+
}>;
|
|
157
|
+
expect(cols).toHaveLength(10);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('CHECK constraint rejects invalid status', () => {
|
|
161
|
+
db.exec(
|
|
162
|
+
"INSERT INTO playbook_runs (run_id, playbook_name, playbook_hash) VALUES ('r1', 'p', 'h')",
|
|
163
|
+
);
|
|
164
|
+
expect(() => {
|
|
165
|
+
db.exec("UPDATE playbook_runs SET status='invalid' WHERE run_id='r1'");
|
|
166
|
+
}).toThrow();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('CASCADE DELETE removes approvals with parent run', () => {
|
|
170
|
+
db.exec(
|
|
171
|
+
"INSERT INTO playbook_runs (run_id, playbook_name, playbook_hash) VALUES ('r2', 'p', 'h')",
|
|
172
|
+
);
|
|
173
|
+
db.exec(
|
|
174
|
+
"INSERT INTO playbook_approvals (approval_id, run_id, node_id, token) VALUES ('a2', 'r2', 'n1', 'tok2000000000000000000000000000000')",
|
|
175
|
+
);
|
|
176
|
+
db.exec("DELETE FROM playbook_runs WHERE run_id='r2'");
|
|
177
|
+
const g = db.prepare("SELECT * FROM playbook_approvals WHERE approval_id='a2'").get();
|
|
178
|
+
expect(g).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('UNIQUE token constraint rejects duplicate', () => {
|
|
182
|
+
db.exec(
|
|
183
|
+
"INSERT INTO playbook_runs (run_id, playbook_name, playbook_hash) VALUES ('r3', 'p', 'h')",
|
|
184
|
+
);
|
|
185
|
+
db.exec(
|
|
186
|
+
"INSERT INTO playbook_approvals (approval_id, run_id, node_id, token) VALUES ('a3a', 'r3', 'n1', 'DUPTOK00000000000000000000000000')",
|
|
187
|
+
);
|
|
188
|
+
expect(() => {
|
|
189
|
+
db.exec(
|
|
190
|
+
"INSERT INTO playbook_approvals (approval_id, run_id, node_id, token) VALUES ('a3b', 'r3', 'n1', 'DUPTOK00000000000000000000000000')",
|
|
191
|
+
);
|
|
192
|
+
}).toThrow();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('round-trips a playbook_run', () => {
|
|
196
|
+
db.exec(
|
|
197
|
+
"INSERT INTO playbook_runs (run_id, playbook_name, playbook_hash, status) VALUES ('rt', 'rcasd', 'sha', 'running')",
|
|
198
|
+
);
|
|
199
|
+
const row = db.prepare('SELECT * FROM playbook_runs WHERE run_id=?').get('rt') as {
|
|
200
|
+
run_id: string;
|
|
201
|
+
status: string;
|
|
202
|
+
bindings: string;
|
|
203
|
+
};
|
|
204
|
+
expect(row.run_id).toBe('rt');
|
|
205
|
+
expect(row.status).toBe('running');
|
|
206
|
+
expect(row.bindings).toBe('{}');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { PLAYBOOKS_PACKAGE_VERSION } from '../index.js';
|
|
3
|
+
|
|
4
|
+
describe('@cleocode/playbooks — package scaffold', () => {
|
|
5
|
+
it('exports the package version constant', () => {
|
|
6
|
+
expect(typeof PLAYBOOKS_PACKAGE_VERSION).toBe('string');
|
|
7
|
+
expect(PLAYBOOKS_PACKAGE_VERSION).toMatch(/^\d{4}\.\d+\.\d+$/);
|
|
8
|
+
});
|
|
9
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W4-8 state layer real-sqlite tests (no mocks).
|
|
3
|
+
* Uses an in-memory DatabaseSync with the T889 migration applied.
|
|
4
|
+
*
|
|
5
|
+
* @task T889 / T904 / W4-8
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { dirname, resolve } from 'node:path';
|
|
11
|
+
import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
import {
|
|
15
|
+
createPlaybookApproval,
|
|
16
|
+
createPlaybookRun,
|
|
17
|
+
deletePlaybookRun,
|
|
18
|
+
getPlaybookApprovalByToken,
|
|
19
|
+
getPlaybookRun,
|
|
20
|
+
listPlaybookApprovals,
|
|
21
|
+
listPlaybookRuns,
|
|
22
|
+
updatePlaybookApproval,
|
|
23
|
+
updatePlaybookRun,
|
|
24
|
+
} from '../state.js';
|
|
25
|
+
|
|
26
|
+
const _require = createRequire(import.meta.url);
|
|
27
|
+
type DatabaseSync = _DatabaseSyncType;
|
|
28
|
+
const { DatabaseSync } = _require('node:sqlite') as {
|
|
29
|
+
DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
|
|
30
|
+
};
|
|
31
|
+
|
|
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
|
+
describe('W4-8: playbook state CRUD', () => {
|
|
51
|
+
let db: DatabaseSync;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
db = new DatabaseSync(':memory:');
|
|
55
|
+
db.exec('PRAGMA foreign_keys=ON');
|
|
56
|
+
applyMigration(db, readFileSync(MIGRATION_SQL, 'utf8'));
|
|
57
|
+
});
|
|
58
|
+
afterEach(() => db.close());
|
|
59
|
+
|
|
60
|
+
describe('playbook runs', () => {
|
|
61
|
+
it('createPlaybookRun returns row with UUID + running status + empty bindings', () => {
|
|
62
|
+
const run = createPlaybookRun(db, {
|
|
63
|
+
playbookName: 'rcasd',
|
|
64
|
+
playbookHash: 'sha-1',
|
|
65
|
+
});
|
|
66
|
+
expect(run.runId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
67
|
+
expect(run.playbookName).toBe('rcasd');
|
|
68
|
+
expect(run.playbookHash).toBe('sha-1');
|
|
69
|
+
expect(run.status).toBe('running');
|
|
70
|
+
expect(run.bindings).toEqual({});
|
|
71
|
+
expect(run.iterationCounts).toEqual({});
|
|
72
|
+
expect(run.currentNode).toBeNull();
|
|
73
|
+
expect(run.startedAt).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('createPlaybookRun serializes initialBindings into JSON column', () => {
|
|
77
|
+
const run = createPlaybookRun(db, {
|
|
78
|
+
playbookName: 'rcasd',
|
|
79
|
+
playbookHash: 'sha-2',
|
|
80
|
+
initialBindings: { taskId: 'T123', attempt: 1 },
|
|
81
|
+
});
|
|
82
|
+
expect(run.bindings).toEqual({ taskId: 'T123', attempt: 1 });
|
|
83
|
+
|
|
84
|
+
const fetched = getPlaybookRun(db, run.runId);
|
|
85
|
+
expect(fetched?.bindings).toEqual({ taskId: 'T123', attempt: 1 });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('getPlaybookRun returns null for unknown runId', () => {
|
|
89
|
+
expect(getPlaybookRun(db, 'does-not-exist')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('updatePlaybookRun patches currentNode, bindings, and status atomically', () => {
|
|
93
|
+
const run = createPlaybookRun(db, {
|
|
94
|
+
playbookName: 'rcasd',
|
|
95
|
+
playbookHash: 'sha-3',
|
|
96
|
+
});
|
|
97
|
+
const updated = updatePlaybookRun(db, run.runId, {
|
|
98
|
+
currentNode: 'approval-publish',
|
|
99
|
+
status: 'paused',
|
|
100
|
+
bindings: { resumeToken: 'tok-abc' },
|
|
101
|
+
iterationCounts: { 'agentic-assess': 2 },
|
|
102
|
+
});
|
|
103
|
+
expect(updated.currentNode).toBe('approval-publish');
|
|
104
|
+
expect(updated.status).toBe('paused');
|
|
105
|
+
expect(updated.bindings).toEqual({ resumeToken: 'tok-abc' });
|
|
106
|
+
expect(updated.iterationCounts).toEqual({ 'agentic-assess': 2 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('updatePlaybookRun rejects unknown run id', () => {
|
|
110
|
+
expect(() => updatePlaybookRun(db, 'missing', { status: 'completed' })).toThrow(
|
|
111
|
+
/not found for update/,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('listPlaybookRuns filters by status', () => {
|
|
116
|
+
const a = createPlaybookRun(db, { playbookName: 'p', playbookHash: 'h' });
|
|
117
|
+
const b = createPlaybookRun(db, { playbookName: 'p', playbookHash: 'h' });
|
|
118
|
+
updatePlaybookRun(db, a.runId, { status: 'completed' });
|
|
119
|
+
|
|
120
|
+
const completed = listPlaybookRuns(db, { status: 'completed' });
|
|
121
|
+
expect(completed.map((r) => r.runId)).toEqual([a.runId]);
|
|
122
|
+
const running = listPlaybookRuns(db, { status: 'running' });
|
|
123
|
+
expect(running.map((r) => r.runId)).toEqual([b.runId]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('listPlaybookRuns filters by epicId', () => {
|
|
127
|
+
const a = createPlaybookRun(db, {
|
|
128
|
+
playbookName: 'p',
|
|
129
|
+
playbookHash: 'h',
|
|
130
|
+
epicId: 'T889',
|
|
131
|
+
});
|
|
132
|
+
createPlaybookRun(db, {
|
|
133
|
+
playbookName: 'p',
|
|
134
|
+
playbookHash: 'h',
|
|
135
|
+
epicId: 'T900',
|
|
136
|
+
});
|
|
137
|
+
const t889 = listPlaybookRuns(db, { epicId: 'T889' });
|
|
138
|
+
expect(t889.map((r) => r.runId)).toEqual([a.runId]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('deletePlaybookRun CASCADE removes associated approvals', () => {
|
|
142
|
+
const run = createPlaybookRun(db, {
|
|
143
|
+
playbookName: 'p',
|
|
144
|
+
playbookHash: 'h',
|
|
145
|
+
});
|
|
146
|
+
createPlaybookApproval(db, {
|
|
147
|
+
runId: run.runId,
|
|
148
|
+
nodeId: 'approval-1',
|
|
149
|
+
token: 'tok-cascade-test-000000000000000',
|
|
150
|
+
});
|
|
151
|
+
expect(listPlaybookApprovals(db, run.runId)).toHaveLength(1);
|
|
152
|
+
|
|
153
|
+
const removed = deletePlaybookRun(db, run.runId);
|
|
154
|
+
expect(removed).toBe(true);
|
|
155
|
+
expect(getPlaybookRun(db, run.runId)).toBeNull();
|
|
156
|
+
expect(listPlaybookApprovals(db, run.runId)).toHaveLength(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('deletePlaybookRun returns false when nothing was removed', () => {
|
|
160
|
+
expect(deletePlaybookRun(db, 'no-such-run')).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('playbook approvals', () => {
|
|
165
|
+
it('createPlaybookApproval generates approvalId UUID + pending status', () => {
|
|
166
|
+
const run = createPlaybookRun(db, {
|
|
167
|
+
playbookName: 'p',
|
|
168
|
+
playbookHash: 'h',
|
|
169
|
+
});
|
|
170
|
+
const approval = createPlaybookApproval(db, {
|
|
171
|
+
runId: run.runId,
|
|
172
|
+
nodeId: 'approval-publish',
|
|
173
|
+
token: 'tok-approval-create-00000000000',
|
|
174
|
+
});
|
|
175
|
+
expect(approval.approvalId).toMatch(
|
|
176
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
177
|
+
);
|
|
178
|
+
expect(approval.status).toBe('pending');
|
|
179
|
+
expect(approval.autoPassed).toBe(false);
|
|
180
|
+
expect(approval.nodeId).toBe('approval-publish');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('getPlaybookApprovalByToken finds approval by exact token', () => {
|
|
184
|
+
const run = createPlaybookRun(db, {
|
|
185
|
+
playbookName: 'p',
|
|
186
|
+
playbookHash: 'h',
|
|
187
|
+
});
|
|
188
|
+
const approval = createPlaybookApproval(db, {
|
|
189
|
+
runId: run.runId,
|
|
190
|
+
nodeId: 'approval-publish',
|
|
191
|
+
token: 'tok-lookup-exact-0000000000000000',
|
|
192
|
+
});
|
|
193
|
+
const fetched = getPlaybookApprovalByToken(db, 'tok-lookup-exact-0000000000000000');
|
|
194
|
+
expect(fetched?.approvalId).toBe(approval.approvalId);
|
|
195
|
+
expect(getPlaybookApprovalByToken(db, 'tok-lookup-exact-DIFFERENT-00000')).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('updatePlaybookApproval sets approvedAt, approver, and status', () => {
|
|
199
|
+
const run = createPlaybookRun(db, {
|
|
200
|
+
playbookName: 'p',
|
|
201
|
+
playbookHash: 'h',
|
|
202
|
+
});
|
|
203
|
+
const approval = createPlaybookApproval(db, {
|
|
204
|
+
runId: run.runId,
|
|
205
|
+
nodeId: 'approval-publish',
|
|
206
|
+
token: 'tok-update-target-0000000000000',
|
|
207
|
+
});
|
|
208
|
+
const approvedAt = '2026-04-17T22:00:00Z';
|
|
209
|
+
const updated = updatePlaybookApproval(db, approval.approvalId, {
|
|
210
|
+
approvedAt,
|
|
211
|
+
approver: 'kryptokeaton',
|
|
212
|
+
status: 'approved',
|
|
213
|
+
reason: 'explicit human approval',
|
|
214
|
+
});
|
|
215
|
+
expect(updated.approvedAt).toBe(approvedAt);
|
|
216
|
+
expect(updated.approver).toBe('kryptokeaton');
|
|
217
|
+
expect(updated.status).toBe('approved');
|
|
218
|
+
expect(updated.reason).toBe('explicit human approval');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('listPlaybookApprovals returns rows in requestedAt order with stable secondary key', () => {
|
|
222
|
+
const run = createPlaybookRun(db, {
|
|
223
|
+
playbookName: 'p',
|
|
224
|
+
playbookHash: 'h',
|
|
225
|
+
});
|
|
226
|
+
const a = createPlaybookApproval(db, {
|
|
227
|
+
runId: run.runId,
|
|
228
|
+
nodeId: 'approval-1',
|
|
229
|
+
token: 'tok-order-first-000000000000000',
|
|
230
|
+
});
|
|
231
|
+
const b = createPlaybookApproval(db, {
|
|
232
|
+
runId: run.runId,
|
|
233
|
+
nodeId: 'approval-2',
|
|
234
|
+
token: 'tok-order-second-000000000000000',
|
|
235
|
+
});
|
|
236
|
+
const rows = listPlaybookApprovals(db, run.runId);
|
|
237
|
+
// Second-resolution datetime('now') means requested_at ties are common;
|
|
238
|
+
// secondary sort is approval_id ASC, so compare against that canonical order.
|
|
239
|
+
const expected = [a.approvalId, b.approvalId].sort();
|
|
240
|
+
expect(rows.map((r) => r.approvalId)).toEqual(expected);
|
|
241
|
+
expect(rows).toHaveLength(2);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('JSON column parse failure', () => {
|
|
246
|
+
it('throws descriptive error on malformed bindings column', () => {
|
|
247
|
+
const run = createPlaybookRun(db, {
|
|
248
|
+
playbookName: 'p',
|
|
249
|
+
playbookHash: 'h',
|
|
250
|
+
});
|
|
251
|
+
db.prepare('UPDATE playbook_runs SET bindings = ? WHERE run_id = ?').run(
|
|
252
|
+
'{not-json',
|
|
253
|
+
run.runId,
|
|
254
|
+
);
|
|
255
|
+
expect(() => getPlaybookRun(db, run.runId)).toThrow(/failed to parse JSON column "bindings"/);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|