switchman-dev 0.1.2 → 0.1.4
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/README.md +95 -205
- package/examples/README.md +18 -2
- package/examples/walkthrough.sh +12 -16
- package/package.json +3 -3
- package/src/cli/index.js +2517 -331
- package/src/core/ci.js +114 -0
- package/src/core/db.js +1669 -28
- package/src/core/detector.js +109 -7
- package/src/core/enforcement.js +966 -0
- package/src/core/git.js +108 -5
- package/src/core/ignore.js +49 -0
- package/src/core/mcp.js +76 -0
- package/src/core/merge-gate.js +305 -0
- package/src/core/monitor.js +39 -0
- package/src/core/outcome.js +190 -0
- package/src/core/pipeline.js +1113 -0
- package/src/core/planner.js +508 -0
- package/src/core/policy.js +49 -0
- package/src/core/queue.js +225 -0
- package/src/core/semantic.js +311 -0
- package/src/mcp/server.js +321 -1
package/src/core/db.js
CHANGED
|
@@ -3,18 +3,20 @@
|
|
|
3
3
|
* SQLite-backed task queue and file ownership registry
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
|
6
7
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
-
import { existsSync, mkdirSync } from 'fs';
|
|
8
|
-
import { join } from 'path';
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs';
|
|
9
|
+
import { join, resolve } from 'path';
|
|
9
10
|
|
|
10
11
|
const SWITCHMAN_DIR = '.switchman';
|
|
11
12
|
const DB_FILE = 'switchman.db';
|
|
13
|
+
const AUDIT_KEY_FILE = 'audit.key';
|
|
12
14
|
|
|
13
15
|
// How long (ms) a writer will wait for a lock before giving up.
|
|
14
16
|
// 5 seconds is generous for a CLI tool with 3-10 concurrent agents.
|
|
15
|
-
const BUSY_TIMEOUT_MS =
|
|
16
|
-
const CLAIM_RETRY_DELAY_MS =
|
|
17
|
-
const CLAIM_RETRY_ATTEMPTS =
|
|
17
|
+
const BUSY_TIMEOUT_MS = 10000;
|
|
18
|
+
const CLAIM_RETRY_DELAY_MS = 200;
|
|
19
|
+
const CLAIM_RETRY_ATTEMPTS = 20;
|
|
18
20
|
export const DEFAULT_STALE_LEASE_MINUTES = 15;
|
|
19
21
|
|
|
20
22
|
function sleepSync(ms) {
|
|
@@ -26,23 +28,98 @@ function isBusyError(err) {
|
|
|
26
28
|
return message.includes('database is locked') || message.includes('sqlite_busy');
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
function withBusyRetry(fn, { attempts = CLAIM_RETRY_ATTEMPTS, delayMs = CLAIM_RETRY_DELAY_MS } = {}) {
|
|
32
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
return fn();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (isBusyError(err) && attempt < attempts) {
|
|
37
|
+
sleepSync(delayMs * attempt);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeWorktreePath(path) {
|
|
46
|
+
try {
|
|
47
|
+
return realpathSync(path);
|
|
48
|
+
} catch {
|
|
49
|
+
return resolve(path);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
29
53
|
function makeId(prefix) {
|
|
30
54
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
31
55
|
}
|
|
32
56
|
|
|
33
|
-
function configureDb(db) {
|
|
57
|
+
function configureDb(db, { initialize = false } = {}) {
|
|
34
58
|
db.exec(`
|
|
35
59
|
PRAGMA foreign_keys=ON;
|
|
36
|
-
PRAGMA journal_mode=WAL;
|
|
37
60
|
PRAGMA synchronous=NORMAL;
|
|
38
61
|
PRAGMA busy_timeout=${BUSY_TIMEOUT_MS};
|
|
39
62
|
`);
|
|
63
|
+
|
|
64
|
+
if (initialize) {
|
|
65
|
+
withBusyRetry(() => {
|
|
66
|
+
db.exec(`PRAGMA journal_mode=WAL;`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
40
69
|
}
|
|
41
70
|
|
|
42
71
|
function getTableColumns(db, tableName) {
|
|
43
72
|
return db.prepare(`PRAGMA table_info(${tableName})`).all().map((column) => column.name);
|
|
44
73
|
}
|
|
45
74
|
|
|
75
|
+
function getAuditSecret(repoRoot) {
|
|
76
|
+
const keyPath = join(getSwitchmanDir(repoRoot), AUDIT_KEY_FILE);
|
|
77
|
+
if (!existsSync(keyPath)) {
|
|
78
|
+
const secret = randomBytes(32).toString('hex');
|
|
79
|
+
writeFileSync(keyPath, `${secret}\n`, { mode: 0o600 });
|
|
80
|
+
try {
|
|
81
|
+
chmodSync(keyPath, 0o600);
|
|
82
|
+
} catch {
|
|
83
|
+
// Best-effort on platforms that do not fully support chmod semantics.
|
|
84
|
+
}
|
|
85
|
+
return secret;
|
|
86
|
+
}
|
|
87
|
+
return readFileSync(keyPath, 'utf8').trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getAuditContext(db) {
|
|
91
|
+
const repoRoot = db.__switchmanRepoRoot;
|
|
92
|
+
const secret = db.__switchmanAuditSecret;
|
|
93
|
+
if (!repoRoot || !secret) {
|
|
94
|
+
throw new Error('Audit context is not configured for this database.');
|
|
95
|
+
}
|
|
96
|
+
return { repoRoot, secret };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function canonicalizeAuditEvent(event) {
|
|
100
|
+
return JSON.stringify({
|
|
101
|
+
sequence: event.sequence,
|
|
102
|
+
prev_hash: event.prevHash,
|
|
103
|
+
event_type: event.eventType,
|
|
104
|
+
status: event.status,
|
|
105
|
+
reason_code: event.reasonCode,
|
|
106
|
+
worktree: event.worktree,
|
|
107
|
+
task_id: event.taskId,
|
|
108
|
+
lease_id: event.leaseId,
|
|
109
|
+
file_path: event.filePath,
|
|
110
|
+
details: event.details,
|
|
111
|
+
created_at: event.createdAt,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function computeAuditEntryHash(event) {
|
|
116
|
+
return createHash('sha256').update(canonicalizeAuditEvent(event)).digest('hex');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function signAuditEntry(secret, entryHash) {
|
|
120
|
+
return createHmac('sha256', secret).update(entryHash).digest('hex');
|
|
121
|
+
}
|
|
122
|
+
|
|
46
123
|
function ensureSchema(db) {
|
|
47
124
|
db.exec(`
|
|
48
125
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
@@ -90,6 +167,8 @@ function ensureSchema(db) {
|
|
|
90
167
|
branch TEXT NOT NULL,
|
|
91
168
|
agent TEXT,
|
|
92
169
|
status TEXT NOT NULL DEFAULT 'idle',
|
|
170
|
+
enforcement_mode TEXT NOT NULL DEFAULT 'observed',
|
|
171
|
+
compliance_state TEXT NOT NULL DEFAULT 'observed',
|
|
93
172
|
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
94
173
|
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
|
|
95
174
|
);
|
|
@@ -102,6 +181,130 @@ function ensureSchema(db) {
|
|
|
102
181
|
conflicting_files TEXT NOT NULL,
|
|
103
182
|
resolved INTEGER DEFAULT 0
|
|
104
183
|
);
|
|
184
|
+
|
|
185
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
186
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
187
|
+
event_type TEXT NOT NULL,
|
|
188
|
+
status TEXT NOT NULL DEFAULT 'info',
|
|
189
|
+
reason_code TEXT,
|
|
190
|
+
worktree TEXT,
|
|
191
|
+
task_id TEXT,
|
|
192
|
+
lease_id TEXT,
|
|
193
|
+
file_path TEXT,
|
|
194
|
+
details TEXT,
|
|
195
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
196
|
+
sequence INTEGER,
|
|
197
|
+
prev_hash TEXT,
|
|
198
|
+
entry_hash TEXT,
|
|
199
|
+
signature TEXT
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
CREATE TABLE IF NOT EXISTS worktree_snapshots (
|
|
203
|
+
worktree TEXT NOT NULL,
|
|
204
|
+
file_path TEXT NOT NULL,
|
|
205
|
+
fingerprint TEXT NOT NULL,
|
|
206
|
+
observed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
207
|
+
PRIMARY KEY (worktree, file_path)
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
CREATE TABLE IF NOT EXISTS task_specs (
|
|
211
|
+
task_id TEXT PRIMARY KEY,
|
|
212
|
+
spec_json TEXT NOT NULL,
|
|
213
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
214
|
+
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
CREATE TABLE IF NOT EXISTS scope_reservations (
|
|
218
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
219
|
+
lease_id TEXT NOT NULL,
|
|
220
|
+
task_id TEXT NOT NULL,
|
|
221
|
+
worktree TEXT NOT NULL,
|
|
222
|
+
ownership_level TEXT NOT NULL,
|
|
223
|
+
scope_pattern TEXT,
|
|
224
|
+
subsystem_tag TEXT,
|
|
225
|
+
reserved_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
226
|
+
released_at TEXT,
|
|
227
|
+
FOREIGN KEY(lease_id) REFERENCES leases(id),
|
|
228
|
+
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
CREATE TABLE IF NOT EXISTS boundary_validation_state (
|
|
232
|
+
lease_id TEXT PRIMARY KEY,
|
|
233
|
+
task_id TEXT NOT NULL,
|
|
234
|
+
pipeline_id TEXT,
|
|
235
|
+
status TEXT NOT NULL,
|
|
236
|
+
missing_task_types TEXT NOT NULL DEFAULT '[]',
|
|
237
|
+
touched_at TEXT,
|
|
238
|
+
last_evaluated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
239
|
+
details TEXT,
|
|
240
|
+
FOREIGN KEY(lease_id) REFERENCES leases(id),
|
|
241
|
+
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
CREATE TABLE IF NOT EXISTS dependency_invalidations (
|
|
245
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
246
|
+
source_lease_id TEXT NOT NULL,
|
|
247
|
+
source_task_id TEXT NOT NULL,
|
|
248
|
+
source_pipeline_id TEXT,
|
|
249
|
+
source_worktree TEXT,
|
|
250
|
+
affected_task_id TEXT NOT NULL,
|
|
251
|
+
affected_pipeline_id TEXT,
|
|
252
|
+
affected_worktree TEXT,
|
|
253
|
+
status TEXT NOT NULL DEFAULT 'stale',
|
|
254
|
+
reason_type TEXT NOT NULL,
|
|
255
|
+
subsystem_tag TEXT,
|
|
256
|
+
source_scope_pattern TEXT,
|
|
257
|
+
affected_scope_pattern TEXT,
|
|
258
|
+
details TEXT,
|
|
259
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
260
|
+
resolved_at TEXT,
|
|
261
|
+
FOREIGN KEY(source_lease_id) REFERENCES leases(id),
|
|
262
|
+
FOREIGN KEY(source_task_id) REFERENCES tasks(id),
|
|
263
|
+
FOREIGN KEY(affected_task_id) REFERENCES tasks(id)
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
CREATE TABLE IF NOT EXISTS code_objects (
|
|
267
|
+
object_id TEXT PRIMARY KEY,
|
|
268
|
+
file_path TEXT NOT NULL,
|
|
269
|
+
kind TEXT NOT NULL,
|
|
270
|
+
name TEXT NOT NULL,
|
|
271
|
+
source_text TEXT NOT NULL,
|
|
272
|
+
subsystem_tags TEXT NOT NULL DEFAULT '[]',
|
|
273
|
+
area TEXT,
|
|
274
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
CREATE TABLE IF NOT EXISTS merge_queue (
|
|
278
|
+
id TEXT PRIMARY KEY,
|
|
279
|
+
source_type TEXT NOT NULL,
|
|
280
|
+
source_ref TEXT NOT NULL,
|
|
281
|
+
source_worktree TEXT,
|
|
282
|
+
source_pipeline_id TEXT,
|
|
283
|
+
target_branch TEXT NOT NULL DEFAULT 'main',
|
|
284
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
285
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
286
|
+
max_retries INTEGER NOT NULL DEFAULT 1,
|
|
287
|
+
last_error_code TEXT,
|
|
288
|
+
last_error_summary TEXT,
|
|
289
|
+
next_action TEXT,
|
|
290
|
+
merged_commit TEXT,
|
|
291
|
+
submitted_by TEXT,
|
|
292
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
293
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
294
|
+
last_attempt_at TEXT,
|
|
295
|
+
started_at TEXT,
|
|
296
|
+
finished_at TEXT
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
CREATE TABLE IF NOT EXISTS merge_queue_events (
|
|
300
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
301
|
+
queue_item_id TEXT NOT NULL,
|
|
302
|
+
event_type TEXT NOT NULL,
|
|
303
|
+
status TEXT,
|
|
304
|
+
details TEXT,
|
|
305
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
306
|
+
FOREIGN KEY(queue_item_id) REFERENCES merge_queue(id) ON DELETE CASCADE
|
|
307
|
+
);
|
|
105
308
|
`);
|
|
106
309
|
|
|
107
310
|
const fileClaimColumns = getTableColumns(db, 'file_claims');
|
|
@@ -109,6 +312,33 @@ function ensureSchema(db) {
|
|
|
109
312
|
db.exec(`ALTER TABLE file_claims ADD COLUMN lease_id TEXT REFERENCES leases(id)`);
|
|
110
313
|
}
|
|
111
314
|
|
|
315
|
+
const worktreeColumns = getTableColumns(db, 'worktrees');
|
|
316
|
+
if (worktreeColumns.length > 0 && !worktreeColumns.includes('enforcement_mode')) {
|
|
317
|
+
db.exec(`ALTER TABLE worktrees ADD COLUMN enforcement_mode TEXT NOT NULL DEFAULT 'observed'`);
|
|
318
|
+
}
|
|
319
|
+
if (worktreeColumns.length > 0 && !worktreeColumns.includes('compliance_state')) {
|
|
320
|
+
db.exec(`ALTER TABLE worktrees ADD COLUMN compliance_state TEXT NOT NULL DEFAULT 'observed'`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const auditColumns = getTableColumns(db, 'audit_log');
|
|
324
|
+
if (auditColumns.length > 0 && !auditColumns.includes('sequence')) {
|
|
325
|
+
db.exec(`ALTER TABLE audit_log ADD COLUMN sequence INTEGER`);
|
|
326
|
+
}
|
|
327
|
+
if (auditColumns.length > 0 && !auditColumns.includes('prev_hash')) {
|
|
328
|
+
db.exec(`ALTER TABLE audit_log ADD COLUMN prev_hash TEXT`);
|
|
329
|
+
}
|
|
330
|
+
if (auditColumns.length > 0 && !auditColumns.includes('entry_hash')) {
|
|
331
|
+
db.exec(`ALTER TABLE audit_log ADD COLUMN entry_hash TEXT`);
|
|
332
|
+
}
|
|
333
|
+
if (auditColumns.length > 0 && !auditColumns.includes('signature')) {
|
|
334
|
+
db.exec(`ALTER TABLE audit_log ADD COLUMN signature TEXT`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const mergeQueueColumns = getTableColumns(db, 'merge_queue');
|
|
338
|
+
if (mergeQueueColumns.length > 0 && !mergeQueueColumns.includes('last_attempt_at')) {
|
|
339
|
+
db.exec(`ALTER TABLE merge_queue ADD COLUMN last_attempt_at TEXT`);
|
|
340
|
+
}
|
|
341
|
+
|
|
112
342
|
db.exec(`
|
|
113
343
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
114
344
|
CREATE INDEX IF NOT EXISTS idx_leases_task ON leases(task_id);
|
|
@@ -123,11 +353,244 @@ function ensureSchema(db) {
|
|
|
123
353
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_file_claims_unique_active
|
|
124
354
|
ON file_claims(file_path)
|
|
125
355
|
WHERE released_at IS NULL;
|
|
356
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON audit_log(event_type);
|
|
357
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
|
358
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_sequence ON audit_log(sequence) WHERE sequence IS NOT NULL;
|
|
359
|
+
CREATE INDEX IF NOT EXISTS idx_worktree_snapshots_worktree ON worktree_snapshots(worktree);
|
|
360
|
+
CREATE INDEX IF NOT EXISTS idx_task_specs_updated_at ON task_specs(updated_at);
|
|
361
|
+
CREATE INDEX IF NOT EXISTS idx_scope_reservations_lease_id ON scope_reservations(lease_id);
|
|
362
|
+
CREATE INDEX IF NOT EXISTS idx_scope_reservations_task_id ON scope_reservations(task_id);
|
|
363
|
+
CREATE INDEX IF NOT EXISTS idx_scope_reservations_active ON scope_reservations(released_at) WHERE released_at IS NULL;
|
|
364
|
+
CREATE INDEX IF NOT EXISTS idx_scope_reservations_scope_pattern ON scope_reservations(scope_pattern);
|
|
365
|
+
CREATE INDEX IF NOT EXISTS idx_scope_reservations_subsystem_tag ON scope_reservations(subsystem_tag);
|
|
366
|
+
CREATE INDEX IF NOT EXISTS idx_boundary_validation_pipeline_id ON boundary_validation_state(pipeline_id);
|
|
367
|
+
CREATE INDEX IF NOT EXISTS idx_boundary_validation_status ON boundary_validation_state(status);
|
|
368
|
+
CREATE INDEX IF NOT EXISTS idx_dependency_invalidations_source_lease ON dependency_invalidations(source_lease_id);
|
|
369
|
+
CREATE INDEX IF NOT EXISTS idx_dependency_invalidations_affected_task ON dependency_invalidations(affected_task_id);
|
|
370
|
+
CREATE INDEX IF NOT EXISTS idx_dependency_invalidations_affected_pipeline ON dependency_invalidations(affected_pipeline_id);
|
|
371
|
+
CREATE INDEX IF NOT EXISTS idx_dependency_invalidations_status ON dependency_invalidations(status);
|
|
372
|
+
CREATE INDEX IF NOT EXISTS idx_code_objects_file_path ON code_objects(file_path);
|
|
373
|
+
CREATE INDEX IF NOT EXISTS idx_code_objects_name ON code_objects(name);
|
|
374
|
+
CREATE INDEX IF NOT EXISTS idx_merge_queue_status ON merge_queue(status);
|
|
375
|
+
CREATE INDEX IF NOT EXISTS idx_merge_queue_created_at ON merge_queue(created_at);
|
|
376
|
+
CREATE INDEX IF NOT EXISTS idx_merge_queue_pipeline_id ON merge_queue(source_pipeline_id);
|
|
377
|
+
CREATE INDEX IF NOT EXISTS idx_merge_queue_events_item ON merge_queue_events(queue_item_id);
|
|
126
378
|
`);
|
|
127
379
|
|
|
380
|
+
migrateLegacyAuditLog(db);
|
|
128
381
|
migrateLegacyActiveTasks(db);
|
|
129
382
|
}
|
|
130
383
|
|
|
384
|
+
function normalizeScopeRoot(pattern) {
|
|
385
|
+
return String(pattern || '')
|
|
386
|
+
.replace(/\\/g, '/')
|
|
387
|
+
.replace(/\/\*\*$/, '')
|
|
388
|
+
.replace(/\/\*$/, '')
|
|
389
|
+
.replace(/\/+$/, '');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function scopeRootsOverlap(leftPattern, rightPattern) {
|
|
393
|
+
const left = normalizeScopeRoot(leftPattern);
|
|
394
|
+
const right = normalizeScopeRoot(rightPattern);
|
|
395
|
+
if (!left || !right) return false;
|
|
396
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function intersectValues(left = [], right = []) {
|
|
400
|
+
const rightSet = new Set(right);
|
|
401
|
+
return [...new Set(left)].filter((value) => rightSet.has(value));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function buildSpecOverlap(sourceSpec = null, affectedSpec = null) {
|
|
405
|
+
const sourceSubsystems = Array.isArray(sourceSpec?.subsystem_tags) ? sourceSpec.subsystem_tags : [];
|
|
406
|
+
const affectedSubsystems = Array.isArray(affectedSpec?.subsystem_tags) ? affectedSpec.subsystem_tags : [];
|
|
407
|
+
const sharedSubsystems = intersectValues(sourceSubsystems, affectedSubsystems);
|
|
408
|
+
|
|
409
|
+
const sourceScopes = Array.isArray(sourceSpec?.allowed_paths) ? sourceSpec.allowed_paths : [];
|
|
410
|
+
const affectedScopes = Array.isArray(affectedSpec?.allowed_paths) ? affectedSpec.allowed_paths : [];
|
|
411
|
+
const sharedScopes = [];
|
|
412
|
+
for (const sourceScope of sourceScopes) {
|
|
413
|
+
for (const affectedScope of affectedScopes) {
|
|
414
|
+
if (scopeRootsOverlap(sourceScope, affectedScope)) {
|
|
415
|
+
sharedScopes.push({
|
|
416
|
+
source_scope_pattern: sourceScope,
|
|
417
|
+
affected_scope_pattern: affectedScope,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
shared_subsystems: sharedSubsystems,
|
|
425
|
+
shared_scopes: sharedScopes,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function buildLeaseScopeReservations(lease, taskSpec) {
|
|
430
|
+
if (!taskSpec) return [];
|
|
431
|
+
|
|
432
|
+
const reservations = [];
|
|
433
|
+
const pathPatterns = Array.isArray(taskSpec.allowed_paths) ? [...new Set(taskSpec.allowed_paths)] : [];
|
|
434
|
+
const subsystemTags = Array.isArray(taskSpec.subsystem_tags) ? [...new Set(taskSpec.subsystem_tags)] : [];
|
|
435
|
+
|
|
436
|
+
for (const scopePattern of pathPatterns) {
|
|
437
|
+
reservations.push({
|
|
438
|
+
leaseId: lease.id,
|
|
439
|
+
taskId: lease.task_id,
|
|
440
|
+
worktree: lease.worktree,
|
|
441
|
+
ownershipLevel: 'path_scope',
|
|
442
|
+
scopePattern,
|
|
443
|
+
subsystemTag: null,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
for (const subsystemTag of subsystemTags) {
|
|
448
|
+
reservations.push({
|
|
449
|
+
leaseId: lease.id,
|
|
450
|
+
taskId: lease.task_id,
|
|
451
|
+
worktree: lease.worktree,
|
|
452
|
+
ownershipLevel: 'subsystem',
|
|
453
|
+
scopePattern: null,
|
|
454
|
+
subsystemTag,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return reservations;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function getActiveScopeReservationsTx(db, { leaseId = null, worktree = null } = {}) {
|
|
462
|
+
if (leaseId) {
|
|
463
|
+
return db.prepare(`
|
|
464
|
+
SELECT *
|
|
465
|
+
FROM scope_reservations
|
|
466
|
+
WHERE lease_id=? AND released_at IS NULL
|
|
467
|
+
ORDER BY id ASC
|
|
468
|
+
`).all(leaseId);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (worktree) {
|
|
472
|
+
return db.prepare(`
|
|
473
|
+
SELECT *
|
|
474
|
+
FROM scope_reservations
|
|
475
|
+
WHERE worktree=? AND released_at IS NULL
|
|
476
|
+
ORDER BY id ASC
|
|
477
|
+
`).all(worktree);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return db.prepare(`
|
|
481
|
+
SELECT *
|
|
482
|
+
FROM scope_reservations
|
|
483
|
+
WHERE released_at IS NULL
|
|
484
|
+
ORDER BY id ASC
|
|
485
|
+
`).all();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function findScopeReservationConflicts(reservations, activeReservations) {
|
|
489
|
+
const conflicts = [];
|
|
490
|
+
|
|
491
|
+
for (const reservation of reservations) {
|
|
492
|
+
for (const activeReservation of activeReservations) {
|
|
493
|
+
if (activeReservation.lease_id === reservation.leaseId) continue;
|
|
494
|
+
|
|
495
|
+
if (
|
|
496
|
+
reservation.ownershipLevel === 'subsystem' &&
|
|
497
|
+
activeReservation.ownership_level === 'subsystem' &&
|
|
498
|
+
reservation.subsystemTag &&
|
|
499
|
+
reservation.subsystemTag === activeReservation.subsystem_tag
|
|
500
|
+
) {
|
|
501
|
+
conflicts.push({
|
|
502
|
+
type: 'subsystem',
|
|
503
|
+
subsystem_tag: reservation.subsystemTag,
|
|
504
|
+
lease_id: activeReservation.lease_id,
|
|
505
|
+
worktree: activeReservation.worktree,
|
|
506
|
+
});
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (
|
|
511
|
+
reservation.ownershipLevel === 'path_scope' &&
|
|
512
|
+
activeReservation.ownership_level === 'path_scope' &&
|
|
513
|
+
scopeRootsOverlap(reservation.scopePattern, activeReservation.scope_pattern)
|
|
514
|
+
) {
|
|
515
|
+
conflicts.push({
|
|
516
|
+
type: 'path_scope',
|
|
517
|
+
scope_pattern: reservation.scopePattern,
|
|
518
|
+
conflicting_scope_pattern: activeReservation.scope_pattern,
|
|
519
|
+
lease_id: activeReservation.lease_id,
|
|
520
|
+
worktree: activeReservation.worktree,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return conflicts;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function reserveLeaseScopesTx(db, lease) {
|
|
530
|
+
const existing = getActiveScopeReservationsTx(db, { leaseId: lease.id });
|
|
531
|
+
if (existing.length > 0) {
|
|
532
|
+
return existing;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const taskSpec = getTaskSpec(db, lease.task_id);
|
|
536
|
+
const reservations = buildLeaseScopeReservations(lease, taskSpec);
|
|
537
|
+
if (!reservations.length) {
|
|
538
|
+
return [];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const activeReservations = getActiveScopeReservationsTx(db).filter((reservation) => reservation.lease_id !== lease.id);
|
|
542
|
+
const conflicts = findScopeReservationConflicts(reservations, activeReservations);
|
|
543
|
+
if (conflicts.length > 0) {
|
|
544
|
+
const summary = conflicts[0].type === 'subsystem'
|
|
545
|
+
? `subsystem:${conflicts[0].subsystem_tag}`
|
|
546
|
+
: `${conflicts[0].scope_pattern} overlaps ${conflicts[0].conflicting_scope_pattern}`;
|
|
547
|
+
logAuditEventTx(db, {
|
|
548
|
+
eventType: 'scope_reservation_denied',
|
|
549
|
+
status: 'denied',
|
|
550
|
+
reasonCode: 'scope_reserved_by_other_lease',
|
|
551
|
+
worktree: lease.worktree,
|
|
552
|
+
taskId: lease.task_id,
|
|
553
|
+
leaseId: lease.id,
|
|
554
|
+
details: JSON.stringify({ conflicts, summary }),
|
|
555
|
+
});
|
|
556
|
+
throw new Error(`Scope reservation conflict: ${summary}`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const insert = db.prepare(`
|
|
560
|
+
INSERT INTO scope_reservations (lease_id, task_id, worktree, ownership_level, scope_pattern, subsystem_tag)
|
|
561
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
562
|
+
`);
|
|
563
|
+
|
|
564
|
+
for (const reservation of reservations) {
|
|
565
|
+
insert.run(
|
|
566
|
+
reservation.leaseId,
|
|
567
|
+
reservation.taskId,
|
|
568
|
+
reservation.worktree,
|
|
569
|
+
reservation.ownershipLevel,
|
|
570
|
+
reservation.scopePattern,
|
|
571
|
+
reservation.subsystemTag,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
logAuditEventTx(db, {
|
|
576
|
+
eventType: 'scope_reserved',
|
|
577
|
+
status: 'allowed',
|
|
578
|
+
worktree: lease.worktree,
|
|
579
|
+
taskId: lease.task_id,
|
|
580
|
+
leaseId: lease.id,
|
|
581
|
+
details: JSON.stringify({
|
|
582
|
+
ownership_levels: [...new Set(reservations.map((reservation) => reservation.ownershipLevel))],
|
|
583
|
+
reservations: reservations.map((reservation) => ({
|
|
584
|
+
ownership_level: reservation.ownershipLevel,
|
|
585
|
+
scope_pattern: reservation.scopePattern,
|
|
586
|
+
subsystem_tag: reservation.subsystemTag,
|
|
587
|
+
})),
|
|
588
|
+
}),
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
return getActiveScopeReservationsTx(db, { leaseId: lease.id });
|
|
592
|
+
}
|
|
593
|
+
|
|
131
594
|
function touchWorktreeLeaseState(db, worktree, agent, status) {
|
|
132
595
|
if (!worktree) return;
|
|
133
596
|
db.prepare(`
|
|
@@ -160,8 +623,119 @@ function createLeaseTx(db, { id, taskId, worktree, agent, status = 'active', fai
|
|
|
160
623
|
INSERT INTO leases (id, task_id, worktree, agent, status, failure_reason)
|
|
161
624
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
162
625
|
`).run(leaseId, taskId, worktree, agent || null, status, failureReason);
|
|
626
|
+
const lease = getLeaseTx(db, leaseId);
|
|
627
|
+
if (status === 'active') {
|
|
628
|
+
reserveLeaseScopesTx(db, lease);
|
|
629
|
+
}
|
|
163
630
|
touchWorktreeLeaseState(db, worktree, agent, status === 'active' ? 'busy' : 'idle');
|
|
164
|
-
|
|
631
|
+
logAuditEventTx(db, {
|
|
632
|
+
eventType: 'lease_started',
|
|
633
|
+
status: 'allowed',
|
|
634
|
+
worktree,
|
|
635
|
+
taskId,
|
|
636
|
+
leaseId,
|
|
637
|
+
details: JSON.stringify({ agent: agent || null }),
|
|
638
|
+
});
|
|
639
|
+
return lease;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function logAuditEventTx(db, { eventType, status = 'info', reasonCode = null, worktree = null, taskId = null, leaseId = null, filePath = null, details = null }) {
|
|
643
|
+
const { secret } = getAuditContext(db);
|
|
644
|
+
const previousEvent = db.prepare(`
|
|
645
|
+
SELECT sequence, entry_hash
|
|
646
|
+
FROM audit_log
|
|
647
|
+
WHERE sequence IS NOT NULL
|
|
648
|
+
ORDER BY sequence DESC, id DESC
|
|
649
|
+
LIMIT 1
|
|
650
|
+
`).get();
|
|
651
|
+
const createdAt = new Date().toISOString();
|
|
652
|
+
const sequence = (previousEvent?.sequence || 0) + 1;
|
|
653
|
+
const prevHash = previousEvent?.entry_hash || null;
|
|
654
|
+
const normalizedDetails = details == null ? null : String(details);
|
|
655
|
+
const entryHash = computeAuditEntryHash({
|
|
656
|
+
sequence,
|
|
657
|
+
prevHash,
|
|
658
|
+
eventType,
|
|
659
|
+
status,
|
|
660
|
+
reasonCode,
|
|
661
|
+
worktree,
|
|
662
|
+
taskId,
|
|
663
|
+
leaseId,
|
|
664
|
+
filePath,
|
|
665
|
+
details: normalizedDetails,
|
|
666
|
+
createdAt,
|
|
667
|
+
});
|
|
668
|
+
const signature = signAuditEntry(secret, entryHash);
|
|
669
|
+
db.prepare(`
|
|
670
|
+
INSERT INTO audit_log (
|
|
671
|
+
event_type, status, reason_code, worktree, task_id, lease_id, file_path, details, created_at, sequence, prev_hash, entry_hash, signature
|
|
672
|
+
)
|
|
673
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
674
|
+
`).run(
|
|
675
|
+
eventType,
|
|
676
|
+
status,
|
|
677
|
+
reasonCode,
|
|
678
|
+
worktree,
|
|
679
|
+
taskId,
|
|
680
|
+
leaseId,
|
|
681
|
+
filePath,
|
|
682
|
+
normalizedDetails,
|
|
683
|
+
createdAt,
|
|
684
|
+
sequence,
|
|
685
|
+
prevHash,
|
|
686
|
+
entryHash,
|
|
687
|
+
signature,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function migrateLegacyAuditLog(db) {
|
|
692
|
+
const rows = db.prepare(`
|
|
693
|
+
SELECT *
|
|
694
|
+
FROM audit_log
|
|
695
|
+
WHERE sequence IS NULL OR entry_hash IS NULL OR signature IS NULL
|
|
696
|
+
ORDER BY datetime(created_at) ASC, id ASC
|
|
697
|
+
`).all();
|
|
698
|
+
|
|
699
|
+
if (!rows.length) return;
|
|
700
|
+
|
|
701
|
+
const { secret } = getAuditContext(db);
|
|
702
|
+
let previous = db.prepare(`
|
|
703
|
+
SELECT sequence, entry_hash
|
|
704
|
+
FROM audit_log
|
|
705
|
+
WHERE sequence IS NOT NULL AND entry_hash IS NOT NULL
|
|
706
|
+
ORDER BY sequence DESC, id DESC
|
|
707
|
+
LIMIT 1
|
|
708
|
+
`).get();
|
|
709
|
+
|
|
710
|
+
const update = db.prepare(`
|
|
711
|
+
UPDATE audit_log
|
|
712
|
+
SET created_at=?, sequence=?, prev_hash=?, entry_hash=?, signature=?
|
|
713
|
+
WHERE id=?
|
|
714
|
+
`);
|
|
715
|
+
|
|
716
|
+
let nextSequence = previous?.sequence || 0;
|
|
717
|
+
let prevHash = previous?.entry_hash || null;
|
|
718
|
+
|
|
719
|
+
for (const row of rows) {
|
|
720
|
+
nextSequence += 1;
|
|
721
|
+
const createdAt = row.created_at || new Date().toISOString();
|
|
722
|
+
const entryHash = computeAuditEntryHash({
|
|
723
|
+
sequence: nextSequence,
|
|
724
|
+
prevHash,
|
|
725
|
+
eventType: row.event_type,
|
|
726
|
+
status: row.status,
|
|
727
|
+
reasonCode: row.reason_code,
|
|
728
|
+
worktree: row.worktree,
|
|
729
|
+
taskId: row.task_id,
|
|
730
|
+
leaseId: row.lease_id,
|
|
731
|
+
filePath: row.file_path,
|
|
732
|
+
details: row.details,
|
|
733
|
+
createdAt,
|
|
734
|
+
});
|
|
735
|
+
const signature = signAuditEntry(secret, entryHash);
|
|
736
|
+
update.run(createdAt, nextSequence, prevHash, entryHash, signature, row.id);
|
|
737
|
+
prevHash = entryHash;
|
|
738
|
+
}
|
|
165
739
|
}
|
|
166
740
|
|
|
167
741
|
function migrateLegacyActiveTasks(db) {
|
|
@@ -219,6 +793,7 @@ function resolveActiveLeaseTx(db, taskId, worktree, agent) {
|
|
|
219
793
|
agent=COALESCE(?, agent)
|
|
220
794
|
WHERE id=?
|
|
221
795
|
`).run(agent || null, lease.id);
|
|
796
|
+
reserveLeaseScopesTx(db, lease);
|
|
222
797
|
touchWorktreeLeaseState(db, worktree, agent || lease.agent, 'busy');
|
|
223
798
|
return getLeaseTx(db, lease.id);
|
|
224
799
|
}
|
|
@@ -246,6 +821,331 @@ function releaseClaimsForLeaseTx(db, leaseId) {
|
|
|
246
821
|
`).run(leaseId);
|
|
247
822
|
}
|
|
248
823
|
|
|
824
|
+
function releaseScopeReservationsForLeaseTx(db, leaseId) {
|
|
825
|
+
db.prepare(`
|
|
826
|
+
UPDATE scope_reservations
|
|
827
|
+
SET released_at=datetime('now')
|
|
828
|
+
WHERE lease_id=? AND released_at IS NULL
|
|
829
|
+
`).run(leaseId);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function releaseClaimsForTaskTx(db, taskId) {
|
|
833
|
+
db.prepare(`
|
|
834
|
+
UPDATE file_claims
|
|
835
|
+
SET released_at=datetime('now')
|
|
836
|
+
WHERE task_id=? AND released_at IS NULL
|
|
837
|
+
`).run(taskId);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function releaseScopeReservationsForTaskTx(db, taskId) {
|
|
841
|
+
db.prepare(`
|
|
842
|
+
UPDATE scope_reservations
|
|
843
|
+
SET released_at=datetime('now')
|
|
844
|
+
WHERE task_id=? AND released_at IS NULL
|
|
845
|
+
`).run(taskId);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function getBoundaryValidationStateTx(db, leaseId) {
|
|
849
|
+
return db.prepare(`
|
|
850
|
+
SELECT *
|
|
851
|
+
FROM boundary_validation_state
|
|
852
|
+
WHERE lease_id=?
|
|
853
|
+
`).get(leaseId);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function listActiveDependencyInvalidationsTx(db, { sourceLeaseId = null, affectedTaskId = null, pipelineId = null } = {}) {
|
|
857
|
+
const clauses = ['resolved_at IS NULL'];
|
|
858
|
+
const params = [];
|
|
859
|
+
if (sourceLeaseId) {
|
|
860
|
+
clauses.push('source_lease_id=?');
|
|
861
|
+
params.push(sourceLeaseId);
|
|
862
|
+
}
|
|
863
|
+
if (affectedTaskId) {
|
|
864
|
+
clauses.push('affected_task_id=?');
|
|
865
|
+
params.push(affectedTaskId);
|
|
866
|
+
}
|
|
867
|
+
if (pipelineId) {
|
|
868
|
+
clauses.push('(source_pipeline_id=? OR affected_pipeline_id=?)');
|
|
869
|
+
params.push(pipelineId, pipelineId);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return db.prepare(`
|
|
873
|
+
SELECT *
|
|
874
|
+
FROM dependency_invalidations
|
|
875
|
+
WHERE ${clauses.join(' AND ')}
|
|
876
|
+
ORDER BY created_at DESC, id DESC
|
|
877
|
+
`).all(...params);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function resolveDependencyInvalidationsForAffectedTaskTx(db, affectedTaskId, resolvedBy = null) {
|
|
881
|
+
db.prepare(`
|
|
882
|
+
UPDATE dependency_invalidations
|
|
883
|
+
SET status='revalidated',
|
|
884
|
+
resolved_at=datetime('now'),
|
|
885
|
+
details=CASE
|
|
886
|
+
WHEN details IS NULL OR details='' THEN json_object('resolved_by', ?)
|
|
887
|
+
ELSE json_set(details, '$.resolved_by', ?)
|
|
888
|
+
END
|
|
889
|
+
WHERE affected_task_id=? AND resolved_at IS NULL
|
|
890
|
+
`).run(resolvedBy || null, resolvedBy || null, affectedTaskId);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function syncDependencyInvalidationsForLeaseTx(db, leaseId, source = 'write') {
|
|
894
|
+
const execution = getLeaseExecutionContext(db, leaseId);
|
|
895
|
+
if (!execution?.task || !execution.task_spec) {
|
|
896
|
+
return [];
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const sourceTask = execution.task;
|
|
900
|
+
const sourceSpec = execution.task_spec;
|
|
901
|
+
const sourcePipelineId = sourceSpec.pipeline_id || null;
|
|
902
|
+
const sourceWorktree = execution.lease.worktree || sourceTask.worktree || null;
|
|
903
|
+
const tasks = listTasks(db);
|
|
904
|
+
const desired = [];
|
|
905
|
+
|
|
906
|
+
for (const affectedTask of tasks) {
|
|
907
|
+
if (affectedTask.id === sourceTask.id) continue;
|
|
908
|
+
if (!['in_progress', 'done'].includes(affectedTask.status)) continue;
|
|
909
|
+
|
|
910
|
+
const affectedSpec = getTaskSpec(db, affectedTask.id);
|
|
911
|
+
if (!affectedSpec) continue;
|
|
912
|
+
if ((affectedSpec.pipeline_id || null) === sourcePipelineId) continue;
|
|
913
|
+
|
|
914
|
+
const overlap = buildSpecOverlap(sourceSpec, affectedSpec);
|
|
915
|
+
if (overlap.shared_subsystems.length === 0 && overlap.shared_scopes.length === 0) continue;
|
|
916
|
+
|
|
917
|
+
const affectedWorktree = affectedTask.worktree || null;
|
|
918
|
+
for (const subsystemTag of overlap.shared_subsystems) {
|
|
919
|
+
desired.push({
|
|
920
|
+
source_lease_id: leaseId,
|
|
921
|
+
source_task_id: sourceTask.id,
|
|
922
|
+
source_pipeline_id: sourcePipelineId,
|
|
923
|
+
source_worktree: sourceWorktree,
|
|
924
|
+
affected_task_id: affectedTask.id,
|
|
925
|
+
affected_pipeline_id: affectedSpec.pipeline_id || null,
|
|
926
|
+
affected_worktree: affectedWorktree,
|
|
927
|
+
status: 'stale',
|
|
928
|
+
reason_type: 'subsystem_overlap',
|
|
929
|
+
subsystem_tag: subsystemTag,
|
|
930
|
+
source_scope_pattern: null,
|
|
931
|
+
affected_scope_pattern: null,
|
|
932
|
+
details: {
|
|
933
|
+
source,
|
|
934
|
+
source_task_title: sourceTask.title,
|
|
935
|
+
affected_task_title: affectedTask.title,
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
for (const sharedScope of overlap.shared_scopes) {
|
|
941
|
+
desired.push({
|
|
942
|
+
source_lease_id: leaseId,
|
|
943
|
+
source_task_id: sourceTask.id,
|
|
944
|
+
source_pipeline_id: sourcePipelineId,
|
|
945
|
+
source_worktree: sourceWorktree,
|
|
946
|
+
affected_task_id: affectedTask.id,
|
|
947
|
+
affected_pipeline_id: affectedSpec.pipeline_id || null,
|
|
948
|
+
affected_worktree: affectedWorktree,
|
|
949
|
+
status: 'stale',
|
|
950
|
+
reason_type: 'scope_overlap',
|
|
951
|
+
subsystem_tag: null,
|
|
952
|
+
source_scope_pattern: sharedScope.source_scope_pattern,
|
|
953
|
+
affected_scope_pattern: sharedScope.affected_scope_pattern,
|
|
954
|
+
details: {
|
|
955
|
+
source,
|
|
956
|
+
source_task_title: sourceTask.title,
|
|
957
|
+
affected_task_title: affectedTask.title,
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const desiredKeys = new Set(desired.map((item) => JSON.stringify([
|
|
964
|
+
item.affected_task_id,
|
|
965
|
+
item.reason_type,
|
|
966
|
+
item.subsystem_tag || '',
|
|
967
|
+
item.source_scope_pattern || '',
|
|
968
|
+
item.affected_scope_pattern || '',
|
|
969
|
+
])));
|
|
970
|
+
const existing = listActiveDependencyInvalidationsTx(db, { sourceLeaseId: leaseId });
|
|
971
|
+
|
|
972
|
+
for (const existingRow of existing) {
|
|
973
|
+
const existingKey = JSON.stringify([
|
|
974
|
+
existingRow.affected_task_id,
|
|
975
|
+
existingRow.reason_type,
|
|
976
|
+
existingRow.subsystem_tag || '',
|
|
977
|
+
existingRow.source_scope_pattern || '',
|
|
978
|
+
existingRow.affected_scope_pattern || '',
|
|
979
|
+
]);
|
|
980
|
+
if (!desiredKeys.has(existingKey)) {
|
|
981
|
+
db.prepare(`
|
|
982
|
+
UPDATE dependency_invalidations
|
|
983
|
+
SET status='revalidated',
|
|
984
|
+
resolved_at=datetime('now'),
|
|
985
|
+
details=CASE
|
|
986
|
+
WHEN details IS NULL OR details='' THEN json_object('resolved_by', ?)
|
|
987
|
+
ELSE json_set(details, '$.resolved_by', ?)
|
|
988
|
+
END
|
|
989
|
+
WHERE id=?
|
|
990
|
+
`).run(source, source, existingRow.id);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
for (const item of desired) {
|
|
995
|
+
const existingRow = existing.find((row) =>
|
|
996
|
+
row.affected_task_id === item.affected_task_id
|
|
997
|
+
&& row.reason_type === item.reason_type
|
|
998
|
+
&& (row.subsystem_tag || null) === (item.subsystem_tag || null)
|
|
999
|
+
&& (row.source_scope_pattern || null) === (item.source_scope_pattern || null)
|
|
1000
|
+
&& (row.affected_scope_pattern || null) === (item.affected_scope_pattern || null)
|
|
1001
|
+
&& row.resolved_at === null
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
if (existingRow) {
|
|
1005
|
+
db.prepare(`
|
|
1006
|
+
UPDATE dependency_invalidations
|
|
1007
|
+
SET source_task_id=?,
|
|
1008
|
+
source_pipeline_id=?,
|
|
1009
|
+
source_worktree=?,
|
|
1010
|
+
affected_pipeline_id=?,
|
|
1011
|
+
affected_worktree=?,
|
|
1012
|
+
status='stale',
|
|
1013
|
+
details=?,
|
|
1014
|
+
resolved_at=NULL
|
|
1015
|
+
WHERE id=?
|
|
1016
|
+
`).run(
|
|
1017
|
+
item.source_task_id,
|
|
1018
|
+
item.source_pipeline_id,
|
|
1019
|
+
item.source_worktree,
|
|
1020
|
+
item.affected_pipeline_id,
|
|
1021
|
+
item.affected_worktree,
|
|
1022
|
+
JSON.stringify(item.details || {}),
|
|
1023
|
+
existingRow.id,
|
|
1024
|
+
);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
db.prepare(`
|
|
1029
|
+
INSERT INTO dependency_invalidations (
|
|
1030
|
+
source_lease_id, source_task_id, source_pipeline_id, source_worktree,
|
|
1031
|
+
affected_task_id, affected_pipeline_id, affected_worktree,
|
|
1032
|
+
status, reason_type, subsystem_tag, source_scope_pattern, affected_scope_pattern, details
|
|
1033
|
+
)
|
|
1034
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1035
|
+
`).run(
|
|
1036
|
+
item.source_lease_id,
|
|
1037
|
+
item.source_task_id,
|
|
1038
|
+
item.source_pipeline_id,
|
|
1039
|
+
item.source_worktree,
|
|
1040
|
+
item.affected_task_id,
|
|
1041
|
+
item.affected_pipeline_id,
|
|
1042
|
+
item.affected_worktree,
|
|
1043
|
+
item.status,
|
|
1044
|
+
item.reason_type,
|
|
1045
|
+
item.subsystem_tag,
|
|
1046
|
+
item.source_scope_pattern,
|
|
1047
|
+
item.affected_scope_pattern,
|
|
1048
|
+
JSON.stringify(item.details || {}),
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return listActiveDependencyInvalidationsTx(db, { sourceLeaseId: leaseId }).map((row) => ({
|
|
1053
|
+
...row,
|
|
1054
|
+
details: row.details ? JSON.parse(row.details) : {},
|
|
1055
|
+
}));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function upsertBoundaryValidationStateTx(db, state) {
|
|
1059
|
+
db.prepare(`
|
|
1060
|
+
INSERT INTO boundary_validation_state (
|
|
1061
|
+
lease_id, task_id, pipeline_id, status, missing_task_types, touched_at, last_evaluated_at, details
|
|
1062
|
+
)
|
|
1063
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)
|
|
1064
|
+
ON CONFLICT(lease_id) DO UPDATE SET
|
|
1065
|
+
status=excluded.status,
|
|
1066
|
+
missing_task_types=excluded.missing_task_types,
|
|
1067
|
+
touched_at=COALESCE(boundary_validation_state.touched_at, excluded.touched_at),
|
|
1068
|
+
last_evaluated_at=datetime('now'),
|
|
1069
|
+
details=excluded.details
|
|
1070
|
+
`).run(
|
|
1071
|
+
state.lease_id,
|
|
1072
|
+
state.task_id,
|
|
1073
|
+
state.pipeline_id || null,
|
|
1074
|
+
state.status,
|
|
1075
|
+
JSON.stringify(state.missing_task_types || []),
|
|
1076
|
+
state.touched_at || null,
|
|
1077
|
+
JSON.stringify(state.details || {}),
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function computeBoundaryValidationStateTx(db, leaseId, { touched = false, source = null } = {}) {
|
|
1082
|
+
const execution = getLeaseExecutionContext(db, leaseId);
|
|
1083
|
+
if (!execution?.task || !execution.task_spec?.validation_rules) {
|
|
1084
|
+
return null;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const validationRules = execution.task_spec.validation_rules;
|
|
1088
|
+
const requiredTaskTypes = validationRules.required_completed_task_types || [];
|
|
1089
|
+
if (requiredTaskTypes.length === 0) {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const pipelineId = execution.task_spec.pipeline_id || null;
|
|
1094
|
+
const existing = getBoundaryValidationStateTx(db, leaseId);
|
|
1095
|
+
const touchedAt = existing?.touched_at || (touched ? new Date().toISOString() : null);
|
|
1096
|
+
if (!touchedAt) {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const pipelineTasks = pipelineId
|
|
1101
|
+
? listTasks(db).filter((task) => getTaskSpec(db, task.id)?.pipeline_id === pipelineId)
|
|
1102
|
+
: [];
|
|
1103
|
+
const completedTaskTypes = new Set(
|
|
1104
|
+
pipelineTasks
|
|
1105
|
+
.filter((task) => task.status === 'done')
|
|
1106
|
+
.map((task) => getTaskSpec(db, task.id)?.task_type)
|
|
1107
|
+
.filter(Boolean),
|
|
1108
|
+
);
|
|
1109
|
+
const missingTaskTypes = requiredTaskTypes.filter((taskType) => !completedTaskTypes.has(taskType));
|
|
1110
|
+
const status = missingTaskTypes.length === 0
|
|
1111
|
+
? 'satisfied'
|
|
1112
|
+
: (validationRules.enforcement === 'blocked' ? 'blocked' : 'pending_validation');
|
|
1113
|
+
|
|
1114
|
+
return {
|
|
1115
|
+
lease_id: leaseId,
|
|
1116
|
+
task_id: execution.task.id,
|
|
1117
|
+
pipeline_id: pipelineId,
|
|
1118
|
+
status,
|
|
1119
|
+
missing_task_types: missingTaskTypes,
|
|
1120
|
+
touched_at: touchedAt,
|
|
1121
|
+
details: {
|
|
1122
|
+
source,
|
|
1123
|
+
enforcement: validationRules.enforcement,
|
|
1124
|
+
required_completed_task_types: requiredTaskTypes,
|
|
1125
|
+
rationale: validationRules.rationale || [],
|
|
1126
|
+
subsystem_tags: execution.task_spec.subsystem_tags || [],
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function syncPipelineBoundaryValidationStatesTx(db, pipelineId, { source = null } = {}) {
|
|
1132
|
+
if (!pipelineId) return [];
|
|
1133
|
+
const states = db.prepare(`
|
|
1134
|
+
SELECT *
|
|
1135
|
+
FROM boundary_validation_state
|
|
1136
|
+
WHERE pipeline_id=?
|
|
1137
|
+
`).all(pipelineId);
|
|
1138
|
+
|
|
1139
|
+
const updated = [];
|
|
1140
|
+
for (const state of states) {
|
|
1141
|
+
const recomputed = computeBoundaryValidationStateTx(db, state.lease_id, { touched: false, source });
|
|
1142
|
+
if (!recomputed) continue;
|
|
1143
|
+
upsertBoundaryValidationStateTx(db, recomputed);
|
|
1144
|
+
updated.push(recomputed);
|
|
1145
|
+
}
|
|
1146
|
+
return updated;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
249
1149
|
function closeActiveLeasesForTaskTx(db, taskId, status, failureReason = null) {
|
|
250
1150
|
const activeLeases = db.prepare(`
|
|
251
1151
|
SELECT * FROM leases
|
|
@@ -262,11 +1162,66 @@ function closeActiveLeasesForTaskTx(db, taskId, status, failureReason = null) {
|
|
|
262
1162
|
|
|
263
1163
|
for (const lease of activeLeases) {
|
|
264
1164
|
touchWorktreeLeaseState(db, lease.worktree, lease.agent, 'idle');
|
|
1165
|
+
releaseScopeReservationsForLeaseTx(db, lease.id);
|
|
265
1166
|
}
|
|
266
1167
|
|
|
267
1168
|
return activeLeases;
|
|
268
1169
|
}
|
|
269
1170
|
|
|
1171
|
+
function finalizeTaskWithLeaseTx(db, taskId, activeLease, { taskStatus, leaseStatus, failureReason = null, auditStatus, auditEventType, auditReasonCode = null }) {
|
|
1172
|
+
const taskSpec = getTaskSpec(db, taskId);
|
|
1173
|
+
if (taskStatus === 'done') {
|
|
1174
|
+
db.prepare(`
|
|
1175
|
+
UPDATE tasks
|
|
1176
|
+
SET status='done', completed_at=datetime('now'), updated_at=datetime('now')
|
|
1177
|
+
WHERE id=?
|
|
1178
|
+
`).run(taskId);
|
|
1179
|
+
} else if (taskStatus === 'failed') {
|
|
1180
|
+
db.prepare(`
|
|
1181
|
+
UPDATE tasks
|
|
1182
|
+
SET status='failed', description=COALESCE(description,'') || '\nFAILED: ' || ?, updated_at=datetime('now')
|
|
1183
|
+
WHERE id=?
|
|
1184
|
+
`).run(failureReason || 'unknown', taskId);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
if (activeLease) {
|
|
1188
|
+
db.prepare(`
|
|
1189
|
+
UPDATE leases
|
|
1190
|
+
SET status=?,
|
|
1191
|
+
finished_at=datetime('now'),
|
|
1192
|
+
failure_reason=?
|
|
1193
|
+
WHERE id=? AND status='active'
|
|
1194
|
+
`).run(leaseStatus, failureReason, activeLease.id);
|
|
1195
|
+
touchWorktreeLeaseState(db, activeLease.worktree, activeLease.agent, 'idle');
|
|
1196
|
+
releaseClaimsForLeaseTx(db, activeLease.id);
|
|
1197
|
+
releaseScopeReservationsForLeaseTx(db, activeLease.id);
|
|
1198
|
+
} else {
|
|
1199
|
+
releaseClaimsForTaskTx(db, taskId);
|
|
1200
|
+
releaseScopeReservationsForTaskTx(db, taskId);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
closeActiveLeasesForTaskTx(db, taskId, leaseStatus, failureReason);
|
|
1204
|
+
|
|
1205
|
+
logAuditEventTx(db, {
|
|
1206
|
+
eventType: auditEventType,
|
|
1207
|
+
status: auditStatus,
|
|
1208
|
+
reasonCode: auditReasonCode,
|
|
1209
|
+
worktree: activeLease?.worktree ?? null,
|
|
1210
|
+
taskId,
|
|
1211
|
+
leaseId: activeLease?.id ?? null,
|
|
1212
|
+
details: failureReason || null,
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
if (activeLease?.id && taskStatus === 'done') {
|
|
1216
|
+
const touchedState = computeBoundaryValidationStateTx(db, activeLease.id, { touched: true, source: auditEventType });
|
|
1217
|
+
if (touchedState) {
|
|
1218
|
+
upsertBoundaryValidationStateTx(db, touchedState);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
syncPipelineBoundaryValidationStatesTx(db, taskSpec?.pipeline_id || null, { source: auditEventType });
|
|
1223
|
+
}
|
|
1224
|
+
|
|
270
1225
|
function withImmediateTransaction(db, fn) {
|
|
271
1226
|
for (let attempt = 1; attempt <= CLAIM_RETRY_ATTEMPTS; attempt++) {
|
|
272
1227
|
let beganTransaction = false;
|
|
@@ -304,8 +1259,10 @@ export function initDb(repoRoot) {
|
|
|
304
1259
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
305
1260
|
|
|
306
1261
|
const db = new DatabaseSync(getDbPath(repoRoot));
|
|
307
|
-
|
|
308
|
-
|
|
1262
|
+
db.__switchmanRepoRoot = repoRoot;
|
|
1263
|
+
db.__switchmanAuditSecret = getAuditSecret(repoRoot);
|
|
1264
|
+
configureDb(db, { initialize: true });
|
|
1265
|
+
withBusyRetry(() => ensureSchema(db));
|
|
309
1266
|
return db;
|
|
310
1267
|
}
|
|
311
1268
|
|
|
@@ -315,8 +1272,10 @@ export function openDb(repoRoot) {
|
|
|
315
1272
|
throw new Error(`No switchman database found. Run 'switchman init' first.`);
|
|
316
1273
|
}
|
|
317
1274
|
const db = new DatabaseSync(dbPath);
|
|
1275
|
+
db.__switchmanRepoRoot = repoRoot;
|
|
1276
|
+
db.__switchmanAuditSecret = getAuditSecret(repoRoot);
|
|
318
1277
|
configureDb(db);
|
|
319
|
-
ensureSchema(db);
|
|
1278
|
+
withBusyRetry(() => ensureSchema(db));
|
|
320
1279
|
return db;
|
|
321
1280
|
}
|
|
322
1281
|
|
|
@@ -354,23 +1313,92 @@ export function assignTask(db, taskId, worktree, agent) {
|
|
|
354
1313
|
|
|
355
1314
|
export function completeTask(db, taskId) {
|
|
356
1315
|
withImmediateTransaction(db, () => {
|
|
357
|
-
db
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
1316
|
+
const activeLease = getActiveLeaseForTaskTx(db, taskId);
|
|
1317
|
+
finalizeTaskWithLeaseTx(db, taskId, activeLease, {
|
|
1318
|
+
taskStatus: 'done',
|
|
1319
|
+
leaseStatus: 'completed',
|
|
1320
|
+
auditStatus: 'allowed',
|
|
1321
|
+
auditEventType: 'task_completed',
|
|
1322
|
+
});
|
|
363
1323
|
});
|
|
364
1324
|
}
|
|
365
1325
|
|
|
366
1326
|
export function failTask(db, taskId, reason) {
|
|
367
1327
|
withImmediateTransaction(db, () => {
|
|
1328
|
+
const activeLease = getActiveLeaseForTaskTx(db, taskId);
|
|
1329
|
+
finalizeTaskWithLeaseTx(db, taskId, activeLease, {
|
|
1330
|
+
taskStatus: 'failed',
|
|
1331
|
+
leaseStatus: 'failed',
|
|
1332
|
+
failureReason: reason || 'unknown',
|
|
1333
|
+
auditStatus: 'denied',
|
|
1334
|
+
auditEventType: 'task_failed',
|
|
1335
|
+
auditReasonCode: 'task_failed',
|
|
1336
|
+
});
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
export function completeLeaseTask(db, leaseId) {
|
|
1341
|
+
return withImmediateTransaction(db, () => {
|
|
1342
|
+
const activeLease = getLeaseTx(db, leaseId);
|
|
1343
|
+
if (!activeLease || activeLease.status !== 'active') {
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
finalizeTaskWithLeaseTx(db, activeLease.task_id, activeLease, {
|
|
1347
|
+
taskStatus: 'done',
|
|
1348
|
+
leaseStatus: 'completed',
|
|
1349
|
+
auditStatus: 'allowed',
|
|
1350
|
+
auditEventType: 'task_completed',
|
|
1351
|
+
});
|
|
1352
|
+
return getTaskTx(db, activeLease.task_id);
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
export function failLeaseTask(db, leaseId, reason) {
|
|
1357
|
+
return withImmediateTransaction(db, () => {
|
|
1358
|
+
const activeLease = getLeaseTx(db, leaseId);
|
|
1359
|
+
if (!activeLease || activeLease.status !== 'active') {
|
|
1360
|
+
return null;
|
|
1361
|
+
}
|
|
1362
|
+
finalizeTaskWithLeaseTx(db, activeLease.task_id, activeLease, {
|
|
1363
|
+
taskStatus: 'failed',
|
|
1364
|
+
leaseStatus: 'failed',
|
|
1365
|
+
failureReason: reason || 'unknown',
|
|
1366
|
+
auditStatus: 'denied',
|
|
1367
|
+
auditEventType: 'task_failed',
|
|
1368
|
+
auditReasonCode: 'task_failed',
|
|
1369
|
+
});
|
|
1370
|
+
return getTaskTx(db, activeLease.task_id);
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
export function retryTask(db, taskId, reason = null) {
|
|
1375
|
+
return withImmediateTransaction(db, () => {
|
|
1376
|
+
const task = getTaskTx(db, taskId);
|
|
1377
|
+
if (!task || !['failed', 'done'].includes(task.status)) {
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
368
1381
|
db.prepare(`
|
|
369
1382
|
UPDATE tasks
|
|
370
|
-
SET status='
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
1383
|
+
SET status='pending',
|
|
1384
|
+
worktree=NULL,
|
|
1385
|
+
agent=NULL,
|
|
1386
|
+
completed_at=NULL,
|
|
1387
|
+
updated_at=datetime('now')
|
|
1388
|
+
WHERE id=? AND status IN ('failed', 'done')
|
|
1389
|
+
`).run(taskId);
|
|
1390
|
+
|
|
1391
|
+
logAuditEventTx(db, {
|
|
1392
|
+
eventType: 'task_retried',
|
|
1393
|
+
status: 'allowed',
|
|
1394
|
+
taskId,
|
|
1395
|
+
details: reason || null,
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
resolveDependencyInvalidationsForAffectedTaskTx(db, taskId, 'task_retried');
|
|
1399
|
+
syncPipelineBoundaryValidationStatesTx(db, getTaskSpec(db, taskId)?.pipeline_id || null, { source: 'task_retried' });
|
|
1400
|
+
|
|
1401
|
+
return getTaskTx(db, taskId);
|
|
374
1402
|
});
|
|
375
1403
|
}
|
|
376
1404
|
|
|
@@ -385,6 +1413,231 @@ export function getTask(db, taskId) {
|
|
|
385
1413
|
return db.prepare(`SELECT * FROM tasks WHERE id=?`).get(taskId);
|
|
386
1414
|
}
|
|
387
1415
|
|
|
1416
|
+
export function enqueueMergeItem(db, {
|
|
1417
|
+
id = null,
|
|
1418
|
+
sourceType,
|
|
1419
|
+
sourceRef,
|
|
1420
|
+
sourceWorktree = null,
|
|
1421
|
+
sourcePipelineId = null,
|
|
1422
|
+
targetBranch = 'main',
|
|
1423
|
+
maxRetries = 1,
|
|
1424
|
+
submittedBy = null,
|
|
1425
|
+
} = {}) {
|
|
1426
|
+
const itemId = id || makeId('mq');
|
|
1427
|
+
db.prepare(`
|
|
1428
|
+
INSERT INTO merge_queue (
|
|
1429
|
+
id, source_type, source_ref, source_worktree, source_pipeline_id,
|
|
1430
|
+
target_branch, status, retry_count, max_retries, submitted_by
|
|
1431
|
+
)
|
|
1432
|
+
VALUES (?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?)
|
|
1433
|
+
`).run(
|
|
1434
|
+
itemId,
|
|
1435
|
+
sourceType,
|
|
1436
|
+
sourceRef,
|
|
1437
|
+
sourceWorktree || null,
|
|
1438
|
+
sourcePipelineId || null,
|
|
1439
|
+
targetBranch || 'main',
|
|
1440
|
+
Math.max(0, Number.parseInt(maxRetries, 10) || 0),
|
|
1441
|
+
submittedBy || null,
|
|
1442
|
+
);
|
|
1443
|
+
|
|
1444
|
+
logMergeQueueEvent(db, itemId, {
|
|
1445
|
+
eventType: 'merge_queue_enqueued',
|
|
1446
|
+
status: 'queued',
|
|
1447
|
+
details: JSON.stringify({
|
|
1448
|
+
source_type: sourceType,
|
|
1449
|
+
source_ref: sourceRef,
|
|
1450
|
+
source_worktree: sourceWorktree || null,
|
|
1451
|
+
source_pipeline_id: sourcePipelineId || null,
|
|
1452
|
+
target_branch: targetBranch || 'main',
|
|
1453
|
+
}),
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
return getMergeQueueItem(db, itemId);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
export function listMergeQueue(db, { status = null } = {}) {
|
|
1460
|
+
if (status) {
|
|
1461
|
+
return db.prepare(`
|
|
1462
|
+
SELECT *
|
|
1463
|
+
FROM merge_queue
|
|
1464
|
+
WHERE status=?
|
|
1465
|
+
ORDER BY datetime(created_at) ASC, id ASC
|
|
1466
|
+
`).all(status);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
return db.prepare(`
|
|
1470
|
+
SELECT *
|
|
1471
|
+
FROM merge_queue
|
|
1472
|
+
ORDER BY datetime(created_at) ASC, id ASC
|
|
1473
|
+
`).all();
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
export function getMergeQueueItem(db, itemId) {
|
|
1477
|
+
return db.prepare(`
|
|
1478
|
+
SELECT *
|
|
1479
|
+
FROM merge_queue
|
|
1480
|
+
WHERE id=?
|
|
1481
|
+
`).get(itemId);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
export function listMergeQueueEvents(db, itemId, { limit = 10 } = {}) {
|
|
1485
|
+
return db.prepare(`
|
|
1486
|
+
SELECT *
|
|
1487
|
+
FROM merge_queue_events
|
|
1488
|
+
WHERE queue_item_id=?
|
|
1489
|
+
ORDER BY id DESC
|
|
1490
|
+
LIMIT ?
|
|
1491
|
+
`).all(itemId, limit);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
export function logMergeQueueEvent(db, itemId, {
|
|
1495
|
+
eventType,
|
|
1496
|
+
status = null,
|
|
1497
|
+
details = null,
|
|
1498
|
+
} = {}) {
|
|
1499
|
+
db.prepare(`
|
|
1500
|
+
INSERT INTO merge_queue_events (queue_item_id, event_type, status, details)
|
|
1501
|
+
VALUES (?, ?, ?, ?)
|
|
1502
|
+
`).run(itemId, eventType, status || null, details == null ? null : String(details));
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
export function startMergeQueueItem(db, itemId) {
|
|
1506
|
+
return withImmediateTransaction(db, () => {
|
|
1507
|
+
const item = getMergeQueueItem(db, itemId);
|
|
1508
|
+
if (!item || !['queued', 'retrying'].includes(item.status)) {
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
db.prepare(`
|
|
1513
|
+
UPDATE merge_queue
|
|
1514
|
+
SET status='validating',
|
|
1515
|
+
started_at=COALESCE(started_at, datetime('now')),
|
|
1516
|
+
last_attempt_at=datetime('now'),
|
|
1517
|
+
updated_at=datetime('now')
|
|
1518
|
+
WHERE id=? AND status IN ('queued', 'retrying')
|
|
1519
|
+
`).run(itemId);
|
|
1520
|
+
|
|
1521
|
+
logMergeQueueEvent(db, itemId, {
|
|
1522
|
+
eventType: 'merge_queue_started',
|
|
1523
|
+
status: 'validating',
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
return getMergeQueueItem(db, itemId);
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
export function markMergeQueueState(db, itemId, {
|
|
1531
|
+
status,
|
|
1532
|
+
lastErrorCode = null,
|
|
1533
|
+
lastErrorSummary = null,
|
|
1534
|
+
nextAction = null,
|
|
1535
|
+
mergedCommit = null,
|
|
1536
|
+
incrementRetry = false,
|
|
1537
|
+
} = {}) {
|
|
1538
|
+
const terminal = ['merged', 'blocked', 'failed', 'canceled'].includes(status);
|
|
1539
|
+
db.prepare(`
|
|
1540
|
+
UPDATE merge_queue
|
|
1541
|
+
SET status=?,
|
|
1542
|
+
last_error_code=?,
|
|
1543
|
+
last_error_summary=?,
|
|
1544
|
+
next_action=?,
|
|
1545
|
+
merged_commit=COALESCE(?, merged_commit),
|
|
1546
|
+
retry_count=retry_count + ?,
|
|
1547
|
+
updated_at=datetime('now'),
|
|
1548
|
+
finished_at=CASE WHEN ? THEN datetime('now') ELSE finished_at END
|
|
1549
|
+
WHERE id=?
|
|
1550
|
+
`).run(
|
|
1551
|
+
status,
|
|
1552
|
+
lastErrorCode || null,
|
|
1553
|
+
lastErrorSummary || null,
|
|
1554
|
+
nextAction || null,
|
|
1555
|
+
mergedCommit || null,
|
|
1556
|
+
incrementRetry ? 1 : 0,
|
|
1557
|
+
terminal ? 1 : 0,
|
|
1558
|
+
itemId,
|
|
1559
|
+
);
|
|
1560
|
+
|
|
1561
|
+
logMergeQueueEvent(db, itemId, {
|
|
1562
|
+
eventType: 'merge_queue_state_changed',
|
|
1563
|
+
status,
|
|
1564
|
+
details: JSON.stringify({
|
|
1565
|
+
last_error_code: lastErrorCode || null,
|
|
1566
|
+
last_error_summary: lastErrorSummary || null,
|
|
1567
|
+
next_action: nextAction || null,
|
|
1568
|
+
merged_commit: mergedCommit || null,
|
|
1569
|
+
increment_retry: incrementRetry,
|
|
1570
|
+
}),
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
return getMergeQueueItem(db, itemId);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
export function retryMergeQueueItem(db, itemId) {
|
|
1577
|
+
const item = getMergeQueueItem(db, itemId);
|
|
1578
|
+
if (!item || !['blocked', 'failed'].includes(item.status)) {
|
|
1579
|
+
return null;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
db.prepare(`
|
|
1583
|
+
UPDATE merge_queue
|
|
1584
|
+
SET status='retrying',
|
|
1585
|
+
last_error_code=NULL,
|
|
1586
|
+
last_error_summary=NULL,
|
|
1587
|
+
next_action=NULL,
|
|
1588
|
+
finished_at=NULL,
|
|
1589
|
+
updated_at=datetime('now')
|
|
1590
|
+
WHERE id=?
|
|
1591
|
+
`).run(itemId);
|
|
1592
|
+
|
|
1593
|
+
logMergeQueueEvent(db, itemId, {
|
|
1594
|
+
eventType: 'merge_queue_retried',
|
|
1595
|
+
status: 'retrying',
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
return getMergeQueueItem(db, itemId);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
export function removeMergeQueueItem(db, itemId) {
|
|
1602
|
+
const item = getMergeQueueItem(db, itemId);
|
|
1603
|
+
if (!item) return null;
|
|
1604
|
+
db.prepare(`DELETE FROM merge_queue WHERE id=?`).run(itemId);
|
|
1605
|
+
return item;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
export function upsertTaskSpec(db, taskId, spec) {
|
|
1609
|
+
db.prepare(`
|
|
1610
|
+
INSERT INTO task_specs (task_id, spec_json, updated_at)
|
|
1611
|
+
VALUES (?, ?, datetime('now'))
|
|
1612
|
+
ON CONFLICT(task_id) DO UPDATE SET
|
|
1613
|
+
spec_json=excluded.spec_json,
|
|
1614
|
+
updated_at=datetime('now')
|
|
1615
|
+
`).run(taskId, JSON.stringify(spec || {}));
|
|
1616
|
+
|
|
1617
|
+
const activeLease = getActiveLeaseForTaskTx(db, taskId);
|
|
1618
|
+
if (activeLease) {
|
|
1619
|
+
withImmediateTransaction(db, () => {
|
|
1620
|
+
releaseScopeReservationsForLeaseTx(db, activeLease.id);
|
|
1621
|
+
reserveLeaseScopesTx(db, activeLease);
|
|
1622
|
+
const updatedState = computeBoundaryValidationStateTx(db, activeLease.id, { touched: false, source: 'task_spec_updated' });
|
|
1623
|
+
if (updatedState) {
|
|
1624
|
+
upsertBoundaryValidationStateTx(db, updatedState);
|
|
1625
|
+
}
|
|
1626
|
+
syncPipelineBoundaryValidationStatesTx(db, spec?.pipeline_id || null, { source: 'task_spec_updated' });
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
export function getTaskSpec(db, taskId) {
|
|
1632
|
+
const row = db.prepare(`SELECT spec_json FROM task_specs WHERE task_id=?`).get(taskId);
|
|
1633
|
+
if (!row) return null;
|
|
1634
|
+
try {
|
|
1635
|
+
return JSON.parse(row.spec_json);
|
|
1636
|
+
} catch {
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
388
1641
|
export function getNextPendingTask(db) {
|
|
389
1642
|
return db.prepare(`
|
|
390
1643
|
SELECT * FROM tasks WHERE status='pending'
|
|
@@ -411,6 +1664,136 @@ export function listLeases(db, statusFilter) {
|
|
|
411
1664
|
`).all();
|
|
412
1665
|
}
|
|
413
1666
|
|
|
1667
|
+
export function listScopeReservations(db, { activeOnly = true, leaseId = null, taskId = null, worktree = null } = {}) {
|
|
1668
|
+
const clauses = [];
|
|
1669
|
+
const params = [];
|
|
1670
|
+
|
|
1671
|
+
if (activeOnly) {
|
|
1672
|
+
clauses.push('released_at IS NULL');
|
|
1673
|
+
}
|
|
1674
|
+
if (leaseId) {
|
|
1675
|
+
clauses.push('lease_id=?');
|
|
1676
|
+
params.push(leaseId);
|
|
1677
|
+
}
|
|
1678
|
+
if (taskId) {
|
|
1679
|
+
clauses.push('task_id=?');
|
|
1680
|
+
params.push(taskId);
|
|
1681
|
+
}
|
|
1682
|
+
if (worktree) {
|
|
1683
|
+
clauses.push('worktree=?');
|
|
1684
|
+
params.push(worktree);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
1688
|
+
return db.prepare(`
|
|
1689
|
+
SELECT *
|
|
1690
|
+
FROM scope_reservations
|
|
1691
|
+
${where}
|
|
1692
|
+
ORDER BY id ASC
|
|
1693
|
+
`).all(...params);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
export function getBoundaryValidationState(db, leaseId) {
|
|
1697
|
+
const row = getBoundaryValidationStateTx(db, leaseId);
|
|
1698
|
+
if (!row) return null;
|
|
1699
|
+
return {
|
|
1700
|
+
...row,
|
|
1701
|
+
missing_task_types: JSON.parse(row.missing_task_types || '[]'),
|
|
1702
|
+
details: row.details ? JSON.parse(row.details) : {},
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
export function listBoundaryValidationStates(db, { status = null, pipelineId = null } = {}) {
|
|
1707
|
+
const clauses = [];
|
|
1708
|
+
const params = [];
|
|
1709
|
+
if (status) {
|
|
1710
|
+
clauses.push('status=?');
|
|
1711
|
+
params.push(status);
|
|
1712
|
+
}
|
|
1713
|
+
if (pipelineId) {
|
|
1714
|
+
clauses.push('pipeline_id=?');
|
|
1715
|
+
params.push(pipelineId);
|
|
1716
|
+
}
|
|
1717
|
+
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
1718
|
+
return db.prepare(`
|
|
1719
|
+
SELECT *
|
|
1720
|
+
FROM boundary_validation_state
|
|
1721
|
+
${where}
|
|
1722
|
+
ORDER BY last_evaluated_at DESC, lease_id ASC
|
|
1723
|
+
`).all(...params).map((row) => ({
|
|
1724
|
+
...row,
|
|
1725
|
+
missing_task_types: JSON.parse(row.missing_task_types || '[]'),
|
|
1726
|
+
details: row.details ? JSON.parse(row.details) : {},
|
|
1727
|
+
}));
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
export function listDependencyInvalidations(db, { status = 'stale', pipelineId = null, affectedTaskId = null } = {}) {
|
|
1731
|
+
const clauses = [];
|
|
1732
|
+
const params = [];
|
|
1733
|
+
if (status === 'stale') {
|
|
1734
|
+
clauses.push('resolved_at IS NULL');
|
|
1735
|
+
} else if (status === 'revalidated') {
|
|
1736
|
+
clauses.push('resolved_at IS NOT NULL');
|
|
1737
|
+
}
|
|
1738
|
+
if (pipelineId) {
|
|
1739
|
+
clauses.push('(source_pipeline_id=? OR affected_pipeline_id=?)');
|
|
1740
|
+
params.push(pipelineId, pipelineId);
|
|
1741
|
+
}
|
|
1742
|
+
if (affectedTaskId) {
|
|
1743
|
+
clauses.push('affected_task_id=?');
|
|
1744
|
+
params.push(affectedTaskId);
|
|
1745
|
+
}
|
|
1746
|
+
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
|
1747
|
+
return db.prepare(`
|
|
1748
|
+
SELECT *
|
|
1749
|
+
FROM dependency_invalidations
|
|
1750
|
+
${where}
|
|
1751
|
+
ORDER BY created_at DESC, id DESC
|
|
1752
|
+
`).all(...params).map((row) => ({
|
|
1753
|
+
...row,
|
|
1754
|
+
details: row.details ? JSON.parse(row.details) : {},
|
|
1755
|
+
}));
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
export function touchBoundaryValidationState(db, leaseId, source = 'write') {
|
|
1759
|
+
return withImmediateTransaction(db, () => {
|
|
1760
|
+
const state = computeBoundaryValidationStateTx(db, leaseId, { touched: true, source });
|
|
1761
|
+
if (state) {
|
|
1762
|
+
upsertBoundaryValidationStateTx(db, state);
|
|
1763
|
+
logAuditEventTx(db, {
|
|
1764
|
+
eventType: 'boundary_validation_state',
|
|
1765
|
+
status: state.status === 'satisfied' ? 'allowed' : (state.status === 'blocked' ? 'denied' : 'warn'),
|
|
1766
|
+
reasonCode: state.status === 'satisfied' ? null : 'boundary_validation_pending',
|
|
1767
|
+
taskId: state.task_id,
|
|
1768
|
+
leaseId: state.lease_id,
|
|
1769
|
+
details: JSON.stringify({
|
|
1770
|
+
status: state.status,
|
|
1771
|
+
missing_task_types: state.missing_task_types,
|
|
1772
|
+
source,
|
|
1773
|
+
}),
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const invalidations = syncDependencyInvalidationsForLeaseTx(db, leaseId, source);
|
|
1778
|
+
if (invalidations.length > 0) {
|
|
1779
|
+
logAuditEventTx(db, {
|
|
1780
|
+
eventType: 'dependency_invalidations_updated',
|
|
1781
|
+
status: 'warn',
|
|
1782
|
+
reasonCode: 'dependent_work_stale',
|
|
1783
|
+
taskId: state?.task_id || getLeaseTx(db, leaseId)?.task_id || null,
|
|
1784
|
+
leaseId,
|
|
1785
|
+
details: JSON.stringify({
|
|
1786
|
+
source,
|
|
1787
|
+
stale_count: invalidations.length,
|
|
1788
|
+
affected_task_ids: [...new Set(invalidations.map((item) => item.affected_task_id))],
|
|
1789
|
+
}),
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
return state ? getBoundaryValidationState(db, leaseId) : null;
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
|
|
414
1797
|
export function getLease(db, leaseId) {
|
|
415
1798
|
return db.prepare(`
|
|
416
1799
|
SELECT l.*, t.title AS task_title
|
|
@@ -420,6 +1803,21 @@ export function getLease(db, leaseId) {
|
|
|
420
1803
|
`).get(leaseId);
|
|
421
1804
|
}
|
|
422
1805
|
|
|
1806
|
+
export function getLeaseExecutionContext(db, leaseId) {
|
|
1807
|
+
const lease = getLease(db, leaseId);
|
|
1808
|
+
if (!lease) return null;
|
|
1809
|
+
const task = getTask(db, lease.task_id);
|
|
1810
|
+
const worktree = getWorktree(db, lease.worktree);
|
|
1811
|
+
return {
|
|
1812
|
+
lease,
|
|
1813
|
+
task,
|
|
1814
|
+
task_spec: task ? getTaskSpec(db, task.id) : null,
|
|
1815
|
+
worktree,
|
|
1816
|
+
claims: getActiveFileClaims(db).filter((claim) => claim.lease_id === lease.id),
|
|
1817
|
+
scope_reservations: listScopeReservations(db, { leaseId: lease.id }),
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
|
|
423
1821
|
export function getActiveLeaseForTask(db, taskId) {
|
|
424
1822
|
const lease = getActiveLeaseForTaskTx(db, taskId);
|
|
425
1823
|
return lease ? getLease(db, lease.id) : null;
|
|
@@ -439,6 +1837,14 @@ export function heartbeatLease(db, leaseId, agent) {
|
|
|
439
1837
|
|
|
440
1838
|
const lease = getLease(db, leaseId);
|
|
441
1839
|
touchWorktreeLeaseState(db, lease.worktree, agent || lease.agent, 'busy');
|
|
1840
|
+
logAuditEventTx(db, {
|
|
1841
|
+
eventType: 'lease_heartbeated',
|
|
1842
|
+
status: 'allowed',
|
|
1843
|
+
worktree: lease.worktree,
|
|
1844
|
+
taskId: lease.task_id,
|
|
1845
|
+
leaseId: lease.id,
|
|
1846
|
+
details: JSON.stringify({ agent: agent || lease.agent || null }),
|
|
1847
|
+
});
|
|
442
1848
|
return lease;
|
|
443
1849
|
}
|
|
444
1850
|
|
|
@@ -453,7 +1859,7 @@ export function getStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINUT
|
|
|
453
1859
|
`).all(`-${staleAfterMinutes} minutes`);
|
|
454
1860
|
}
|
|
455
1861
|
|
|
456
|
-
export function reapStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINUTES) {
|
|
1862
|
+
export function reapStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINUTES, { requeueTask = true } = {}) {
|
|
457
1863
|
return withImmediateTransaction(db, () => {
|
|
458
1864
|
const staleLeases = getStaleLeases(db, staleAfterMinutes);
|
|
459
1865
|
if (!staleLeases.length) {
|
|
@@ -482,11 +1888,37 @@ export function reapStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINU
|
|
|
482
1888
|
)
|
|
483
1889
|
`);
|
|
484
1890
|
|
|
1891
|
+
const failTaskForStaleLease = db.prepare(`
|
|
1892
|
+
UPDATE tasks
|
|
1893
|
+
SET status='failed',
|
|
1894
|
+
description=COALESCE(description,'') || '\nFAILED: lease_expired: stale lease reaped',
|
|
1895
|
+
updated_at=datetime('now')
|
|
1896
|
+
WHERE id=? AND status='in_progress'
|
|
1897
|
+
AND NOT EXISTS (
|
|
1898
|
+
SELECT 1 FROM leases
|
|
1899
|
+
WHERE task_id=?
|
|
1900
|
+
AND status='active'
|
|
1901
|
+
)
|
|
1902
|
+
`);
|
|
1903
|
+
|
|
485
1904
|
for (const lease of staleLeases) {
|
|
486
1905
|
expireLease.run(lease.id);
|
|
487
1906
|
releaseClaimsForLeaseTx(db, lease.id);
|
|
488
|
-
|
|
1907
|
+
releaseScopeReservationsForLeaseTx(db, lease.id);
|
|
1908
|
+
if (requeueTask) {
|
|
1909
|
+
resetTask.run(lease.task_id, lease.task_id);
|
|
1910
|
+
} else {
|
|
1911
|
+
failTaskForStaleLease.run(lease.task_id, lease.task_id);
|
|
1912
|
+
}
|
|
489
1913
|
touchWorktreeLeaseState(db, lease.worktree, lease.agent, 'idle');
|
|
1914
|
+
logAuditEventTx(db, {
|
|
1915
|
+
eventType: 'lease_expired',
|
|
1916
|
+
status: 'denied',
|
|
1917
|
+
reasonCode: 'lease_expired',
|
|
1918
|
+
worktree: lease.worktree,
|
|
1919
|
+
taskId: lease.task_id,
|
|
1920
|
+
leaseId: lease.id,
|
|
1921
|
+
});
|
|
490
1922
|
}
|
|
491
1923
|
|
|
492
1924
|
return staleLeases.map((lease) => ({
|
|
@@ -534,6 +1966,14 @@ export function claimFiles(db, taskId, worktree, filePaths, agent) {
|
|
|
534
1966
|
}
|
|
535
1967
|
|
|
536
1968
|
insert.run(taskId, lease.id, fp, worktree, agent || null);
|
|
1969
|
+
logAuditEventTx(db, {
|
|
1970
|
+
eventType: 'file_claimed',
|
|
1971
|
+
status: 'allowed',
|
|
1972
|
+
worktree,
|
|
1973
|
+
taskId,
|
|
1974
|
+
leaseId: lease.id,
|
|
1975
|
+
filePath: fp,
|
|
1976
|
+
});
|
|
537
1977
|
}
|
|
538
1978
|
|
|
539
1979
|
db.prepare(`
|
|
@@ -549,10 +1989,7 @@ export function claimFiles(db, taskId, worktree, filePaths, agent) {
|
|
|
549
1989
|
}
|
|
550
1990
|
|
|
551
1991
|
export function releaseFileClaims(db, taskId) {
|
|
552
|
-
db
|
|
553
|
-
UPDATE file_claims SET released_at=datetime('now')
|
|
554
|
-
WHERE task_id=? AND released_at IS NULL
|
|
555
|
-
`).run(taskId);
|
|
1992
|
+
releaseClaimsForTaskTx(db, taskId);
|
|
556
1993
|
}
|
|
557
1994
|
|
|
558
1995
|
export function releaseLeaseFileClaims(db, leaseId) {
|
|
@@ -571,6 +2008,27 @@ export function getActiveFileClaims(db) {
|
|
|
571
2008
|
`).all();
|
|
572
2009
|
}
|
|
573
2010
|
|
|
2011
|
+
export function getCompletedFileClaims(db, worktree = null) {
|
|
2012
|
+
if (worktree) {
|
|
2013
|
+
return db.prepare(`
|
|
2014
|
+
SELECT fc.*, t.title as task_title, t.status as task_status, t.completed_at
|
|
2015
|
+
FROM file_claims fc
|
|
2016
|
+
JOIN tasks t ON fc.task_id = t.id
|
|
2017
|
+
WHERE fc.worktree=?
|
|
2018
|
+
AND t.status='done'
|
|
2019
|
+
ORDER BY COALESCE(fc.released_at, t.completed_at) DESC, fc.file_path
|
|
2020
|
+
`).all(worktree);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
return db.prepare(`
|
|
2024
|
+
SELECT fc.*, t.title as task_title, t.status as task_status, t.completed_at
|
|
2025
|
+
FROM file_claims fc
|
|
2026
|
+
JOIN tasks t ON fc.task_id = t.id
|
|
2027
|
+
WHERE t.status='done'
|
|
2028
|
+
ORDER BY COALESCE(fc.released_at, t.completed_at) DESC, fc.file_path
|
|
2029
|
+
`).all();
|
|
2030
|
+
}
|
|
2031
|
+
|
|
574
2032
|
export function checkFileConflicts(db, filePaths, excludeWorktree) {
|
|
575
2033
|
const conflicts = [];
|
|
576
2034
|
const stmt = db.prepare(`
|
|
@@ -593,23 +2051,40 @@ export function checkFileConflicts(db, filePaths, excludeWorktree) {
|
|
|
593
2051
|
// ─── Worktrees ────────────────────────────────────────────────────────────────
|
|
594
2052
|
|
|
595
2053
|
export function registerWorktree(db, { name, path, branch, agent }) {
|
|
2054
|
+
const normalizedPath = normalizeWorktreePath(path);
|
|
2055
|
+
const existingByPath = db.prepare(`SELECT name FROM worktrees WHERE path=?`).get(normalizedPath);
|
|
2056
|
+
const canonicalName = existingByPath?.name || name;
|
|
596
2057
|
db.prepare(`
|
|
597
2058
|
INSERT INTO worktrees (name, path, branch, agent)
|
|
598
2059
|
VALUES (?, ?, ?, ?)
|
|
599
2060
|
ON CONFLICT(name) DO UPDATE SET
|
|
600
2061
|
path=excluded.path, branch=excluded.branch,
|
|
601
2062
|
agent=excluded.agent, last_seen=datetime('now'), status='idle'
|
|
602
|
-
`).run(
|
|
2063
|
+
`).run(canonicalName, normalizedPath, branch, agent || null);
|
|
603
2064
|
}
|
|
604
2065
|
|
|
605
2066
|
export function listWorktrees(db) {
|
|
606
2067
|
return db.prepare(`SELECT * FROM worktrees ORDER BY registered_at`).all();
|
|
607
2068
|
}
|
|
608
2069
|
|
|
2070
|
+
export function getWorktree(db, name) {
|
|
2071
|
+
return db.prepare(`SELECT * FROM worktrees WHERE name=?`).get(name);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
609
2074
|
export function updateWorktreeStatus(db, name, status) {
|
|
610
2075
|
db.prepare(`UPDATE worktrees SET status=?, last_seen=datetime('now') WHERE name=?`).run(status, name);
|
|
611
2076
|
}
|
|
612
2077
|
|
|
2078
|
+
export function updateWorktreeCompliance(db, name, complianceState, enforcementMode = null) {
|
|
2079
|
+
db.prepare(`
|
|
2080
|
+
UPDATE worktrees
|
|
2081
|
+
SET compliance_state=?,
|
|
2082
|
+
enforcement_mode=COALESCE(?, enforcement_mode),
|
|
2083
|
+
last_seen=datetime('now')
|
|
2084
|
+
WHERE name=?
|
|
2085
|
+
`).run(complianceState, enforcementMode, name);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
613
2088
|
// ─── Conflict Log ─────────────────────────────────────────────────────────────
|
|
614
2089
|
|
|
615
2090
|
export function logConflict(db, worktreeA, worktreeB, conflictingFiles) {
|
|
@@ -619,6 +2094,172 @@ export function logConflict(db, worktreeA, worktreeB, conflictingFiles) {
|
|
|
619
2094
|
`).run(worktreeA, worktreeB, JSON.stringify(conflictingFiles));
|
|
620
2095
|
}
|
|
621
2096
|
|
|
2097
|
+
export function logAuditEvent(db, payload) {
|
|
2098
|
+
logAuditEventTx(db, payload);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
export function listAuditEvents(db, { eventType = null, status = null, taskId = null, limit = 50 } = {}) {
|
|
2102
|
+
if (eventType && status && taskId) {
|
|
2103
|
+
return db.prepare(`
|
|
2104
|
+
SELECT * FROM audit_log
|
|
2105
|
+
WHERE event_type=? AND status=? AND task_id=?
|
|
2106
|
+
ORDER BY created_at DESC, id DESC
|
|
2107
|
+
LIMIT ?
|
|
2108
|
+
`).all(eventType, status, taskId, limit);
|
|
2109
|
+
}
|
|
2110
|
+
if (eventType && taskId) {
|
|
2111
|
+
return db.prepare(`
|
|
2112
|
+
SELECT * FROM audit_log
|
|
2113
|
+
WHERE event_type=? AND task_id=?
|
|
2114
|
+
ORDER BY created_at DESC, id DESC
|
|
2115
|
+
LIMIT ?
|
|
2116
|
+
`).all(eventType, taskId, limit);
|
|
2117
|
+
}
|
|
2118
|
+
if (status && taskId) {
|
|
2119
|
+
return db.prepare(`
|
|
2120
|
+
SELECT * FROM audit_log
|
|
2121
|
+
WHERE status=? AND task_id=?
|
|
2122
|
+
ORDER BY created_at DESC, id DESC
|
|
2123
|
+
LIMIT ?
|
|
2124
|
+
`).all(status, taskId, limit);
|
|
2125
|
+
}
|
|
2126
|
+
if (eventType && status) {
|
|
2127
|
+
return db.prepare(`
|
|
2128
|
+
SELECT * FROM audit_log
|
|
2129
|
+
WHERE event_type=? AND status=?
|
|
2130
|
+
ORDER BY created_at DESC, id DESC
|
|
2131
|
+
LIMIT ?
|
|
2132
|
+
`).all(eventType, status, limit);
|
|
2133
|
+
}
|
|
2134
|
+
if (eventType) {
|
|
2135
|
+
return db.prepare(`
|
|
2136
|
+
SELECT * FROM audit_log
|
|
2137
|
+
WHERE event_type=?
|
|
2138
|
+
ORDER BY created_at DESC, id DESC
|
|
2139
|
+
LIMIT ?
|
|
2140
|
+
`).all(eventType, limit);
|
|
2141
|
+
}
|
|
2142
|
+
if (status) {
|
|
2143
|
+
return db.prepare(`
|
|
2144
|
+
SELECT * FROM audit_log
|
|
2145
|
+
WHERE status=?
|
|
2146
|
+
ORDER BY created_at DESC, id DESC
|
|
2147
|
+
LIMIT ?
|
|
2148
|
+
`).all(status, limit);
|
|
2149
|
+
}
|
|
2150
|
+
return db.prepare(`
|
|
2151
|
+
SELECT * FROM audit_log
|
|
2152
|
+
ORDER BY created_at DESC, id DESC
|
|
2153
|
+
LIMIT ?
|
|
2154
|
+
`).all(limit);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
export function verifyAuditTrail(db) {
|
|
2158
|
+
const { secret } = getAuditContext(db);
|
|
2159
|
+
const events = db.prepare(`
|
|
2160
|
+
SELECT *
|
|
2161
|
+
FROM audit_log
|
|
2162
|
+
ORDER BY sequence ASC, id ASC
|
|
2163
|
+
`).all();
|
|
2164
|
+
|
|
2165
|
+
const failures = [];
|
|
2166
|
+
let expectedSequence = 1;
|
|
2167
|
+
let expectedPrevHash = null;
|
|
2168
|
+
|
|
2169
|
+
for (const event of events) {
|
|
2170
|
+
if (event.sequence == null) {
|
|
2171
|
+
failures.push({ id: event.id, reason_code: 'missing_sequence', message: 'Audit event is missing sequence metadata.' });
|
|
2172
|
+
continue;
|
|
2173
|
+
}
|
|
2174
|
+
if (event.sequence !== expectedSequence) {
|
|
2175
|
+
failures.push({
|
|
2176
|
+
id: event.id,
|
|
2177
|
+
sequence: event.sequence,
|
|
2178
|
+
reason_code: 'sequence_gap',
|
|
2179
|
+
message: `Expected sequence ${expectedSequence} but found ${event.sequence}.`,
|
|
2180
|
+
});
|
|
2181
|
+
expectedSequence = event.sequence;
|
|
2182
|
+
}
|
|
2183
|
+
if ((event.prev_hash || null) !== expectedPrevHash) {
|
|
2184
|
+
failures.push({
|
|
2185
|
+
id: event.id,
|
|
2186
|
+
sequence: event.sequence,
|
|
2187
|
+
reason_code: 'prev_hash_mismatch',
|
|
2188
|
+
message: 'Previous hash does not match the prior audit event.',
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
const recomputedHash = computeAuditEntryHash({
|
|
2193
|
+
sequence: event.sequence,
|
|
2194
|
+
prevHash: event.prev_hash || null,
|
|
2195
|
+
eventType: event.event_type,
|
|
2196
|
+
status: event.status,
|
|
2197
|
+
reasonCode: event.reason_code,
|
|
2198
|
+
worktree: event.worktree,
|
|
2199
|
+
taskId: event.task_id,
|
|
2200
|
+
leaseId: event.lease_id,
|
|
2201
|
+
filePath: event.file_path,
|
|
2202
|
+
details: event.details,
|
|
2203
|
+
createdAt: event.created_at,
|
|
2204
|
+
});
|
|
2205
|
+
if (event.entry_hash !== recomputedHash) {
|
|
2206
|
+
failures.push({
|
|
2207
|
+
id: event.id,
|
|
2208
|
+
sequence: event.sequence,
|
|
2209
|
+
reason_code: 'entry_hash_mismatch',
|
|
2210
|
+
message: 'Audit event payload hash does not match the stored entry hash.',
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
const expectedSignature = signAuditEntry(secret, recomputedHash);
|
|
2215
|
+
if (event.signature !== expectedSignature) {
|
|
2216
|
+
failures.push({
|
|
2217
|
+
id: event.id,
|
|
2218
|
+
sequence: event.sequence,
|
|
2219
|
+
reason_code: 'signature_mismatch',
|
|
2220
|
+
message: 'Audit event signature does not match the project audit key.',
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
expectedPrevHash = event.entry_hash || null;
|
|
2225
|
+
expectedSequence += 1;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
return {
|
|
2229
|
+
ok: failures.length === 0,
|
|
2230
|
+
count: events.length,
|
|
2231
|
+
failures,
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
export function getWorktreeSnapshotState(db, worktree) {
|
|
2236
|
+
const rows = db.prepare(`
|
|
2237
|
+
SELECT * FROM worktree_snapshots
|
|
2238
|
+
WHERE worktree=?
|
|
2239
|
+
ORDER BY file_path
|
|
2240
|
+
`).all(worktree);
|
|
2241
|
+
|
|
2242
|
+
return new Map(rows.map((row) => [row.file_path, row.fingerprint]));
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
export function replaceWorktreeSnapshotState(db, worktree, snapshot) {
|
|
2246
|
+
withImmediateTransaction(db, () => {
|
|
2247
|
+
db.prepare(`
|
|
2248
|
+
DELETE FROM worktree_snapshots
|
|
2249
|
+
WHERE worktree=?
|
|
2250
|
+
`).run(worktree);
|
|
2251
|
+
|
|
2252
|
+
const insert = db.prepare(`
|
|
2253
|
+
INSERT INTO worktree_snapshots (worktree, file_path, fingerprint)
|
|
2254
|
+
VALUES (?, ?, ?)
|
|
2255
|
+
`);
|
|
2256
|
+
|
|
2257
|
+
for (const [filePath, fingerprint] of snapshot.entries()) {
|
|
2258
|
+
insert.run(worktree, filePath, fingerprint);
|
|
2259
|
+
}
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
|
|
622
2263
|
export function getConflictLog(db) {
|
|
623
2264
|
return db.prepare(`SELECT * FROM conflict_log ORDER BY detected_at DESC LIMIT 50`).all();
|
|
624
2265
|
}
|