switchman-dev 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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 = 5000;
16
- const CLAIM_RETRY_DELAY_MS = 100;
17
- const CLAIM_RETRY_ATTEMPTS = 5;
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,98 @@ 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
+ );
105
276
  `);
106
277
 
107
278
  const fileClaimColumns = getTableColumns(db, 'file_claims');
@@ -109,6 +280,28 @@ function ensureSchema(db) {
109
280
  db.exec(`ALTER TABLE file_claims ADD COLUMN lease_id TEXT REFERENCES leases(id)`);
110
281
  }
111
282
 
283
+ const worktreeColumns = getTableColumns(db, 'worktrees');
284
+ if (worktreeColumns.length > 0 && !worktreeColumns.includes('enforcement_mode')) {
285
+ db.exec(`ALTER TABLE worktrees ADD COLUMN enforcement_mode TEXT NOT NULL DEFAULT 'observed'`);
286
+ }
287
+ if (worktreeColumns.length > 0 && !worktreeColumns.includes('compliance_state')) {
288
+ db.exec(`ALTER TABLE worktrees ADD COLUMN compliance_state TEXT NOT NULL DEFAULT 'observed'`);
289
+ }
290
+
291
+ const auditColumns = getTableColumns(db, 'audit_log');
292
+ if (auditColumns.length > 0 && !auditColumns.includes('sequence')) {
293
+ db.exec(`ALTER TABLE audit_log ADD COLUMN sequence INTEGER`);
294
+ }
295
+ if (auditColumns.length > 0 && !auditColumns.includes('prev_hash')) {
296
+ db.exec(`ALTER TABLE audit_log ADD COLUMN prev_hash TEXT`);
297
+ }
298
+ if (auditColumns.length > 0 && !auditColumns.includes('entry_hash')) {
299
+ db.exec(`ALTER TABLE audit_log ADD COLUMN entry_hash TEXT`);
300
+ }
301
+ if (auditColumns.length > 0 && !auditColumns.includes('signature')) {
302
+ db.exec(`ALTER TABLE audit_log ADD COLUMN signature TEXT`);
303
+ }
304
+
112
305
  db.exec(`
113
306
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
114
307
  CREATE INDEX IF NOT EXISTS idx_leases_task ON leases(task_id);
@@ -123,11 +316,240 @@ function ensureSchema(db) {
123
316
  CREATE UNIQUE INDEX IF NOT EXISTS idx_file_claims_unique_active
124
317
  ON file_claims(file_path)
125
318
  WHERE released_at IS NULL;
319
+ CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON audit_log(event_type);
320
+ CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
321
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_sequence ON audit_log(sequence) WHERE sequence IS NOT NULL;
322
+ CREATE INDEX IF NOT EXISTS idx_worktree_snapshots_worktree ON worktree_snapshots(worktree);
323
+ CREATE INDEX IF NOT EXISTS idx_task_specs_updated_at ON task_specs(updated_at);
324
+ CREATE INDEX IF NOT EXISTS idx_scope_reservations_lease_id ON scope_reservations(lease_id);
325
+ CREATE INDEX IF NOT EXISTS idx_scope_reservations_task_id ON scope_reservations(task_id);
326
+ CREATE INDEX IF NOT EXISTS idx_scope_reservations_active ON scope_reservations(released_at) WHERE released_at IS NULL;
327
+ CREATE INDEX IF NOT EXISTS idx_scope_reservations_scope_pattern ON scope_reservations(scope_pattern);
328
+ CREATE INDEX IF NOT EXISTS idx_scope_reservations_subsystem_tag ON scope_reservations(subsystem_tag);
329
+ CREATE INDEX IF NOT EXISTS idx_boundary_validation_pipeline_id ON boundary_validation_state(pipeline_id);
330
+ CREATE INDEX IF NOT EXISTS idx_boundary_validation_status ON boundary_validation_state(status);
331
+ CREATE INDEX IF NOT EXISTS idx_dependency_invalidations_source_lease ON dependency_invalidations(source_lease_id);
332
+ CREATE INDEX IF NOT EXISTS idx_dependency_invalidations_affected_task ON dependency_invalidations(affected_task_id);
333
+ CREATE INDEX IF NOT EXISTS idx_dependency_invalidations_affected_pipeline ON dependency_invalidations(affected_pipeline_id);
334
+ CREATE INDEX IF NOT EXISTS idx_dependency_invalidations_status ON dependency_invalidations(status);
335
+ CREATE INDEX IF NOT EXISTS idx_code_objects_file_path ON code_objects(file_path);
336
+ CREATE INDEX IF NOT EXISTS idx_code_objects_name ON code_objects(name);
126
337
  `);
127
338
 
339
+ migrateLegacyAuditLog(db);
128
340
  migrateLegacyActiveTasks(db);
129
341
  }
130
342
 
343
+ function normalizeScopeRoot(pattern) {
344
+ return String(pattern || '')
345
+ .replace(/\\/g, '/')
346
+ .replace(/\/\*\*$/, '')
347
+ .replace(/\/\*$/, '')
348
+ .replace(/\/+$/, '');
349
+ }
350
+
351
+ function scopeRootsOverlap(leftPattern, rightPattern) {
352
+ const left = normalizeScopeRoot(leftPattern);
353
+ const right = normalizeScopeRoot(rightPattern);
354
+ if (!left || !right) return false;
355
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
356
+ }
357
+
358
+ function intersectValues(left = [], right = []) {
359
+ const rightSet = new Set(right);
360
+ return [...new Set(left)].filter((value) => rightSet.has(value));
361
+ }
362
+
363
+ function buildSpecOverlap(sourceSpec = null, affectedSpec = null) {
364
+ const sourceSubsystems = Array.isArray(sourceSpec?.subsystem_tags) ? sourceSpec.subsystem_tags : [];
365
+ const affectedSubsystems = Array.isArray(affectedSpec?.subsystem_tags) ? affectedSpec.subsystem_tags : [];
366
+ const sharedSubsystems = intersectValues(sourceSubsystems, affectedSubsystems);
367
+
368
+ const sourceScopes = Array.isArray(sourceSpec?.allowed_paths) ? sourceSpec.allowed_paths : [];
369
+ const affectedScopes = Array.isArray(affectedSpec?.allowed_paths) ? affectedSpec.allowed_paths : [];
370
+ const sharedScopes = [];
371
+ for (const sourceScope of sourceScopes) {
372
+ for (const affectedScope of affectedScopes) {
373
+ if (scopeRootsOverlap(sourceScope, affectedScope)) {
374
+ sharedScopes.push({
375
+ source_scope_pattern: sourceScope,
376
+ affected_scope_pattern: affectedScope,
377
+ });
378
+ }
379
+ }
380
+ }
381
+
382
+ return {
383
+ shared_subsystems: sharedSubsystems,
384
+ shared_scopes: sharedScopes,
385
+ };
386
+ }
387
+
388
+ function buildLeaseScopeReservations(lease, taskSpec) {
389
+ if (!taskSpec) return [];
390
+
391
+ const reservations = [];
392
+ const pathPatterns = Array.isArray(taskSpec.allowed_paths) ? [...new Set(taskSpec.allowed_paths)] : [];
393
+ const subsystemTags = Array.isArray(taskSpec.subsystem_tags) ? [...new Set(taskSpec.subsystem_tags)] : [];
394
+
395
+ for (const scopePattern of pathPatterns) {
396
+ reservations.push({
397
+ leaseId: lease.id,
398
+ taskId: lease.task_id,
399
+ worktree: lease.worktree,
400
+ ownershipLevel: 'path_scope',
401
+ scopePattern,
402
+ subsystemTag: null,
403
+ });
404
+ }
405
+
406
+ for (const subsystemTag of subsystemTags) {
407
+ reservations.push({
408
+ leaseId: lease.id,
409
+ taskId: lease.task_id,
410
+ worktree: lease.worktree,
411
+ ownershipLevel: 'subsystem',
412
+ scopePattern: null,
413
+ subsystemTag,
414
+ });
415
+ }
416
+
417
+ return reservations;
418
+ }
419
+
420
+ function getActiveScopeReservationsTx(db, { leaseId = null, worktree = null } = {}) {
421
+ if (leaseId) {
422
+ return db.prepare(`
423
+ SELECT *
424
+ FROM scope_reservations
425
+ WHERE lease_id=? AND released_at IS NULL
426
+ ORDER BY id ASC
427
+ `).all(leaseId);
428
+ }
429
+
430
+ if (worktree) {
431
+ return db.prepare(`
432
+ SELECT *
433
+ FROM scope_reservations
434
+ WHERE worktree=? AND released_at IS NULL
435
+ ORDER BY id ASC
436
+ `).all(worktree);
437
+ }
438
+
439
+ return db.prepare(`
440
+ SELECT *
441
+ FROM scope_reservations
442
+ WHERE released_at IS NULL
443
+ ORDER BY id ASC
444
+ `).all();
445
+ }
446
+
447
+ function findScopeReservationConflicts(reservations, activeReservations) {
448
+ const conflicts = [];
449
+
450
+ for (const reservation of reservations) {
451
+ for (const activeReservation of activeReservations) {
452
+ if (activeReservation.lease_id === reservation.leaseId) continue;
453
+
454
+ if (
455
+ reservation.ownershipLevel === 'subsystem' &&
456
+ activeReservation.ownership_level === 'subsystem' &&
457
+ reservation.subsystemTag &&
458
+ reservation.subsystemTag === activeReservation.subsystem_tag
459
+ ) {
460
+ conflicts.push({
461
+ type: 'subsystem',
462
+ subsystem_tag: reservation.subsystemTag,
463
+ lease_id: activeReservation.lease_id,
464
+ worktree: activeReservation.worktree,
465
+ });
466
+ continue;
467
+ }
468
+
469
+ if (
470
+ reservation.ownershipLevel === 'path_scope' &&
471
+ activeReservation.ownership_level === 'path_scope' &&
472
+ scopeRootsOverlap(reservation.scopePattern, activeReservation.scope_pattern)
473
+ ) {
474
+ conflicts.push({
475
+ type: 'path_scope',
476
+ scope_pattern: reservation.scopePattern,
477
+ conflicting_scope_pattern: activeReservation.scope_pattern,
478
+ lease_id: activeReservation.lease_id,
479
+ worktree: activeReservation.worktree,
480
+ });
481
+ }
482
+ }
483
+ }
484
+
485
+ return conflicts;
486
+ }
487
+
488
+ function reserveLeaseScopesTx(db, lease) {
489
+ const existing = getActiveScopeReservationsTx(db, { leaseId: lease.id });
490
+ if (existing.length > 0) {
491
+ return existing;
492
+ }
493
+
494
+ const taskSpec = getTaskSpec(db, lease.task_id);
495
+ const reservations = buildLeaseScopeReservations(lease, taskSpec);
496
+ if (!reservations.length) {
497
+ return [];
498
+ }
499
+
500
+ const activeReservations = getActiveScopeReservationsTx(db).filter((reservation) => reservation.lease_id !== lease.id);
501
+ const conflicts = findScopeReservationConflicts(reservations, activeReservations);
502
+ if (conflicts.length > 0) {
503
+ const summary = conflicts[0].type === 'subsystem'
504
+ ? `subsystem:${conflicts[0].subsystem_tag}`
505
+ : `${conflicts[0].scope_pattern} overlaps ${conflicts[0].conflicting_scope_pattern}`;
506
+ logAuditEventTx(db, {
507
+ eventType: 'scope_reservation_denied',
508
+ status: 'denied',
509
+ reasonCode: 'scope_reserved_by_other_lease',
510
+ worktree: lease.worktree,
511
+ taskId: lease.task_id,
512
+ leaseId: lease.id,
513
+ details: JSON.stringify({ conflicts, summary }),
514
+ });
515
+ throw new Error(`Scope reservation conflict: ${summary}`);
516
+ }
517
+
518
+ const insert = db.prepare(`
519
+ INSERT INTO scope_reservations (lease_id, task_id, worktree, ownership_level, scope_pattern, subsystem_tag)
520
+ VALUES (?, ?, ?, ?, ?, ?)
521
+ `);
522
+
523
+ for (const reservation of reservations) {
524
+ insert.run(
525
+ reservation.leaseId,
526
+ reservation.taskId,
527
+ reservation.worktree,
528
+ reservation.ownershipLevel,
529
+ reservation.scopePattern,
530
+ reservation.subsystemTag,
531
+ );
532
+ }
533
+
534
+ logAuditEventTx(db, {
535
+ eventType: 'scope_reserved',
536
+ status: 'allowed',
537
+ worktree: lease.worktree,
538
+ taskId: lease.task_id,
539
+ leaseId: lease.id,
540
+ details: JSON.stringify({
541
+ ownership_levels: [...new Set(reservations.map((reservation) => reservation.ownershipLevel))],
542
+ reservations: reservations.map((reservation) => ({
543
+ ownership_level: reservation.ownershipLevel,
544
+ scope_pattern: reservation.scopePattern,
545
+ subsystem_tag: reservation.subsystemTag,
546
+ })),
547
+ }),
548
+ });
549
+
550
+ return getActiveScopeReservationsTx(db, { leaseId: lease.id });
551
+ }
552
+
131
553
  function touchWorktreeLeaseState(db, worktree, agent, status) {
132
554
  if (!worktree) return;
133
555
  db.prepare(`
@@ -160,8 +582,119 @@ function createLeaseTx(db, { id, taskId, worktree, agent, status = 'active', fai
160
582
  INSERT INTO leases (id, task_id, worktree, agent, status, failure_reason)
161
583
  VALUES (?, ?, ?, ?, ?, ?)
162
584
  `).run(leaseId, taskId, worktree, agent || null, status, failureReason);
585
+ const lease = getLeaseTx(db, leaseId);
586
+ if (status === 'active') {
587
+ reserveLeaseScopesTx(db, lease);
588
+ }
163
589
  touchWorktreeLeaseState(db, worktree, agent, status === 'active' ? 'busy' : 'idle');
164
- return getLeaseTx(db, leaseId);
590
+ logAuditEventTx(db, {
591
+ eventType: 'lease_started',
592
+ status: 'allowed',
593
+ worktree,
594
+ taskId,
595
+ leaseId,
596
+ details: JSON.stringify({ agent: agent || null }),
597
+ });
598
+ return lease;
599
+ }
600
+
601
+ function logAuditEventTx(db, { eventType, status = 'info', reasonCode = null, worktree = null, taskId = null, leaseId = null, filePath = null, details = null }) {
602
+ const { secret } = getAuditContext(db);
603
+ const previousEvent = db.prepare(`
604
+ SELECT sequence, entry_hash
605
+ FROM audit_log
606
+ WHERE sequence IS NOT NULL
607
+ ORDER BY sequence DESC, id DESC
608
+ LIMIT 1
609
+ `).get();
610
+ const createdAt = new Date().toISOString();
611
+ const sequence = (previousEvent?.sequence || 0) + 1;
612
+ const prevHash = previousEvent?.entry_hash || null;
613
+ const normalizedDetails = details == null ? null : String(details);
614
+ const entryHash = computeAuditEntryHash({
615
+ sequence,
616
+ prevHash,
617
+ eventType,
618
+ status,
619
+ reasonCode,
620
+ worktree,
621
+ taskId,
622
+ leaseId,
623
+ filePath,
624
+ details: normalizedDetails,
625
+ createdAt,
626
+ });
627
+ const signature = signAuditEntry(secret, entryHash);
628
+ db.prepare(`
629
+ INSERT INTO audit_log (
630
+ event_type, status, reason_code, worktree, task_id, lease_id, file_path, details, created_at, sequence, prev_hash, entry_hash, signature
631
+ )
632
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
633
+ `).run(
634
+ eventType,
635
+ status,
636
+ reasonCode,
637
+ worktree,
638
+ taskId,
639
+ leaseId,
640
+ filePath,
641
+ normalizedDetails,
642
+ createdAt,
643
+ sequence,
644
+ prevHash,
645
+ entryHash,
646
+ signature,
647
+ );
648
+ }
649
+
650
+ function migrateLegacyAuditLog(db) {
651
+ const rows = db.prepare(`
652
+ SELECT *
653
+ FROM audit_log
654
+ WHERE sequence IS NULL OR entry_hash IS NULL OR signature IS NULL
655
+ ORDER BY datetime(created_at) ASC, id ASC
656
+ `).all();
657
+
658
+ if (!rows.length) return;
659
+
660
+ const { secret } = getAuditContext(db);
661
+ let previous = db.prepare(`
662
+ SELECT sequence, entry_hash
663
+ FROM audit_log
664
+ WHERE sequence IS NOT NULL AND entry_hash IS NOT NULL
665
+ ORDER BY sequence DESC, id DESC
666
+ LIMIT 1
667
+ `).get();
668
+
669
+ const update = db.prepare(`
670
+ UPDATE audit_log
671
+ SET created_at=?, sequence=?, prev_hash=?, entry_hash=?, signature=?
672
+ WHERE id=?
673
+ `);
674
+
675
+ let nextSequence = previous?.sequence || 0;
676
+ let prevHash = previous?.entry_hash || null;
677
+
678
+ for (const row of rows) {
679
+ nextSequence += 1;
680
+ const createdAt = row.created_at || new Date().toISOString();
681
+ const entryHash = computeAuditEntryHash({
682
+ sequence: nextSequence,
683
+ prevHash,
684
+ eventType: row.event_type,
685
+ status: row.status,
686
+ reasonCode: row.reason_code,
687
+ worktree: row.worktree,
688
+ taskId: row.task_id,
689
+ leaseId: row.lease_id,
690
+ filePath: row.file_path,
691
+ details: row.details,
692
+ createdAt,
693
+ });
694
+ const signature = signAuditEntry(secret, entryHash);
695
+ update.run(createdAt, nextSequence, prevHash, entryHash, signature, row.id);
696
+ prevHash = entryHash;
697
+ }
165
698
  }
166
699
 
167
700
  function migrateLegacyActiveTasks(db) {
@@ -219,6 +752,7 @@ function resolveActiveLeaseTx(db, taskId, worktree, agent) {
219
752
  agent=COALESCE(?, agent)
220
753
  WHERE id=?
221
754
  `).run(agent || null, lease.id);
755
+ reserveLeaseScopesTx(db, lease);
222
756
  touchWorktreeLeaseState(db, worktree, agent || lease.agent, 'busy');
223
757
  return getLeaseTx(db, lease.id);
224
758
  }
@@ -246,6 +780,331 @@ function releaseClaimsForLeaseTx(db, leaseId) {
246
780
  `).run(leaseId);
247
781
  }
248
782
 
783
+ function releaseScopeReservationsForLeaseTx(db, leaseId) {
784
+ db.prepare(`
785
+ UPDATE scope_reservations
786
+ SET released_at=datetime('now')
787
+ WHERE lease_id=? AND released_at IS NULL
788
+ `).run(leaseId);
789
+ }
790
+
791
+ function releaseClaimsForTaskTx(db, taskId) {
792
+ db.prepare(`
793
+ UPDATE file_claims
794
+ SET released_at=datetime('now')
795
+ WHERE task_id=? AND released_at IS NULL
796
+ `).run(taskId);
797
+ }
798
+
799
+ function releaseScopeReservationsForTaskTx(db, taskId) {
800
+ db.prepare(`
801
+ UPDATE scope_reservations
802
+ SET released_at=datetime('now')
803
+ WHERE task_id=? AND released_at IS NULL
804
+ `).run(taskId);
805
+ }
806
+
807
+ function getBoundaryValidationStateTx(db, leaseId) {
808
+ return db.prepare(`
809
+ SELECT *
810
+ FROM boundary_validation_state
811
+ WHERE lease_id=?
812
+ `).get(leaseId);
813
+ }
814
+
815
+ function listActiveDependencyInvalidationsTx(db, { sourceLeaseId = null, affectedTaskId = null, pipelineId = null } = {}) {
816
+ const clauses = ['resolved_at IS NULL'];
817
+ const params = [];
818
+ if (sourceLeaseId) {
819
+ clauses.push('source_lease_id=?');
820
+ params.push(sourceLeaseId);
821
+ }
822
+ if (affectedTaskId) {
823
+ clauses.push('affected_task_id=?');
824
+ params.push(affectedTaskId);
825
+ }
826
+ if (pipelineId) {
827
+ clauses.push('(source_pipeline_id=? OR affected_pipeline_id=?)');
828
+ params.push(pipelineId, pipelineId);
829
+ }
830
+
831
+ return db.prepare(`
832
+ SELECT *
833
+ FROM dependency_invalidations
834
+ WHERE ${clauses.join(' AND ')}
835
+ ORDER BY created_at DESC, id DESC
836
+ `).all(...params);
837
+ }
838
+
839
+ function resolveDependencyInvalidationsForAffectedTaskTx(db, affectedTaskId, resolvedBy = null) {
840
+ db.prepare(`
841
+ UPDATE dependency_invalidations
842
+ SET status='revalidated',
843
+ resolved_at=datetime('now'),
844
+ details=CASE
845
+ WHEN details IS NULL OR details='' THEN json_object('resolved_by', ?)
846
+ ELSE json_set(details, '$.resolved_by', ?)
847
+ END
848
+ WHERE affected_task_id=? AND resolved_at IS NULL
849
+ `).run(resolvedBy || null, resolvedBy || null, affectedTaskId);
850
+ }
851
+
852
+ function syncDependencyInvalidationsForLeaseTx(db, leaseId, source = 'write') {
853
+ const execution = getLeaseExecutionContext(db, leaseId);
854
+ if (!execution?.task || !execution.task_spec) {
855
+ return [];
856
+ }
857
+
858
+ const sourceTask = execution.task;
859
+ const sourceSpec = execution.task_spec;
860
+ const sourcePipelineId = sourceSpec.pipeline_id || null;
861
+ const sourceWorktree = execution.lease.worktree || sourceTask.worktree || null;
862
+ const tasks = listTasks(db);
863
+ const desired = [];
864
+
865
+ for (const affectedTask of tasks) {
866
+ if (affectedTask.id === sourceTask.id) continue;
867
+ if (!['in_progress', 'done'].includes(affectedTask.status)) continue;
868
+
869
+ const affectedSpec = getTaskSpec(db, affectedTask.id);
870
+ if (!affectedSpec) continue;
871
+ if ((affectedSpec.pipeline_id || null) === sourcePipelineId) continue;
872
+
873
+ const overlap = buildSpecOverlap(sourceSpec, affectedSpec);
874
+ if (overlap.shared_subsystems.length === 0 && overlap.shared_scopes.length === 0) continue;
875
+
876
+ const affectedWorktree = affectedTask.worktree || null;
877
+ for (const subsystemTag of overlap.shared_subsystems) {
878
+ desired.push({
879
+ source_lease_id: leaseId,
880
+ source_task_id: sourceTask.id,
881
+ source_pipeline_id: sourcePipelineId,
882
+ source_worktree: sourceWorktree,
883
+ affected_task_id: affectedTask.id,
884
+ affected_pipeline_id: affectedSpec.pipeline_id || null,
885
+ affected_worktree: affectedWorktree,
886
+ status: 'stale',
887
+ reason_type: 'subsystem_overlap',
888
+ subsystem_tag: subsystemTag,
889
+ source_scope_pattern: null,
890
+ affected_scope_pattern: null,
891
+ details: {
892
+ source,
893
+ source_task_title: sourceTask.title,
894
+ affected_task_title: affectedTask.title,
895
+ },
896
+ });
897
+ }
898
+
899
+ for (const sharedScope of overlap.shared_scopes) {
900
+ desired.push({
901
+ source_lease_id: leaseId,
902
+ source_task_id: sourceTask.id,
903
+ source_pipeline_id: sourcePipelineId,
904
+ source_worktree: sourceWorktree,
905
+ affected_task_id: affectedTask.id,
906
+ affected_pipeline_id: affectedSpec.pipeline_id || null,
907
+ affected_worktree: affectedWorktree,
908
+ status: 'stale',
909
+ reason_type: 'scope_overlap',
910
+ subsystem_tag: null,
911
+ source_scope_pattern: sharedScope.source_scope_pattern,
912
+ affected_scope_pattern: sharedScope.affected_scope_pattern,
913
+ details: {
914
+ source,
915
+ source_task_title: sourceTask.title,
916
+ affected_task_title: affectedTask.title,
917
+ },
918
+ });
919
+ }
920
+ }
921
+
922
+ const desiredKeys = new Set(desired.map((item) => JSON.stringify([
923
+ item.affected_task_id,
924
+ item.reason_type,
925
+ item.subsystem_tag || '',
926
+ item.source_scope_pattern || '',
927
+ item.affected_scope_pattern || '',
928
+ ])));
929
+ const existing = listActiveDependencyInvalidationsTx(db, { sourceLeaseId: leaseId });
930
+
931
+ for (const existingRow of existing) {
932
+ const existingKey = JSON.stringify([
933
+ existingRow.affected_task_id,
934
+ existingRow.reason_type,
935
+ existingRow.subsystem_tag || '',
936
+ existingRow.source_scope_pattern || '',
937
+ existingRow.affected_scope_pattern || '',
938
+ ]);
939
+ if (!desiredKeys.has(existingKey)) {
940
+ db.prepare(`
941
+ UPDATE dependency_invalidations
942
+ SET status='revalidated',
943
+ resolved_at=datetime('now'),
944
+ details=CASE
945
+ WHEN details IS NULL OR details='' THEN json_object('resolved_by', ?)
946
+ ELSE json_set(details, '$.resolved_by', ?)
947
+ END
948
+ WHERE id=?
949
+ `).run(source, source, existingRow.id);
950
+ }
951
+ }
952
+
953
+ for (const item of desired) {
954
+ const existingRow = existing.find((row) =>
955
+ row.affected_task_id === item.affected_task_id
956
+ && row.reason_type === item.reason_type
957
+ && (row.subsystem_tag || null) === (item.subsystem_tag || null)
958
+ && (row.source_scope_pattern || null) === (item.source_scope_pattern || null)
959
+ && (row.affected_scope_pattern || null) === (item.affected_scope_pattern || null)
960
+ && row.resolved_at === null
961
+ );
962
+
963
+ if (existingRow) {
964
+ db.prepare(`
965
+ UPDATE dependency_invalidations
966
+ SET source_task_id=?,
967
+ source_pipeline_id=?,
968
+ source_worktree=?,
969
+ affected_pipeline_id=?,
970
+ affected_worktree=?,
971
+ status='stale',
972
+ details=?,
973
+ resolved_at=NULL
974
+ WHERE id=?
975
+ `).run(
976
+ item.source_task_id,
977
+ item.source_pipeline_id,
978
+ item.source_worktree,
979
+ item.affected_pipeline_id,
980
+ item.affected_worktree,
981
+ JSON.stringify(item.details || {}),
982
+ existingRow.id,
983
+ );
984
+ continue;
985
+ }
986
+
987
+ db.prepare(`
988
+ INSERT INTO dependency_invalidations (
989
+ source_lease_id, source_task_id, source_pipeline_id, source_worktree,
990
+ affected_task_id, affected_pipeline_id, affected_worktree,
991
+ status, reason_type, subsystem_tag, source_scope_pattern, affected_scope_pattern, details
992
+ )
993
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
994
+ `).run(
995
+ item.source_lease_id,
996
+ item.source_task_id,
997
+ item.source_pipeline_id,
998
+ item.source_worktree,
999
+ item.affected_task_id,
1000
+ item.affected_pipeline_id,
1001
+ item.affected_worktree,
1002
+ item.status,
1003
+ item.reason_type,
1004
+ item.subsystem_tag,
1005
+ item.source_scope_pattern,
1006
+ item.affected_scope_pattern,
1007
+ JSON.stringify(item.details || {}),
1008
+ );
1009
+ }
1010
+
1011
+ return listActiveDependencyInvalidationsTx(db, { sourceLeaseId: leaseId }).map((row) => ({
1012
+ ...row,
1013
+ details: row.details ? JSON.parse(row.details) : {},
1014
+ }));
1015
+ }
1016
+
1017
+ function upsertBoundaryValidationStateTx(db, state) {
1018
+ db.prepare(`
1019
+ INSERT INTO boundary_validation_state (
1020
+ lease_id, task_id, pipeline_id, status, missing_task_types, touched_at, last_evaluated_at, details
1021
+ )
1022
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)
1023
+ ON CONFLICT(lease_id) DO UPDATE SET
1024
+ status=excluded.status,
1025
+ missing_task_types=excluded.missing_task_types,
1026
+ touched_at=COALESCE(boundary_validation_state.touched_at, excluded.touched_at),
1027
+ last_evaluated_at=datetime('now'),
1028
+ details=excluded.details
1029
+ `).run(
1030
+ state.lease_id,
1031
+ state.task_id,
1032
+ state.pipeline_id || null,
1033
+ state.status,
1034
+ JSON.stringify(state.missing_task_types || []),
1035
+ state.touched_at || null,
1036
+ JSON.stringify(state.details || {}),
1037
+ );
1038
+ }
1039
+
1040
+ function computeBoundaryValidationStateTx(db, leaseId, { touched = false, source = null } = {}) {
1041
+ const execution = getLeaseExecutionContext(db, leaseId);
1042
+ if (!execution?.task || !execution.task_spec?.validation_rules) {
1043
+ return null;
1044
+ }
1045
+
1046
+ const validationRules = execution.task_spec.validation_rules;
1047
+ const requiredTaskTypes = validationRules.required_completed_task_types || [];
1048
+ if (requiredTaskTypes.length === 0) {
1049
+ return null;
1050
+ }
1051
+
1052
+ const pipelineId = execution.task_spec.pipeline_id || null;
1053
+ const existing = getBoundaryValidationStateTx(db, leaseId);
1054
+ const touchedAt = existing?.touched_at || (touched ? new Date().toISOString() : null);
1055
+ if (!touchedAt) {
1056
+ return null;
1057
+ }
1058
+
1059
+ const pipelineTasks = pipelineId
1060
+ ? listTasks(db).filter((task) => getTaskSpec(db, task.id)?.pipeline_id === pipelineId)
1061
+ : [];
1062
+ const completedTaskTypes = new Set(
1063
+ pipelineTasks
1064
+ .filter((task) => task.status === 'done')
1065
+ .map((task) => getTaskSpec(db, task.id)?.task_type)
1066
+ .filter(Boolean),
1067
+ );
1068
+ const missingTaskTypes = requiredTaskTypes.filter((taskType) => !completedTaskTypes.has(taskType));
1069
+ const status = missingTaskTypes.length === 0
1070
+ ? 'satisfied'
1071
+ : (validationRules.enforcement === 'blocked' ? 'blocked' : 'pending_validation');
1072
+
1073
+ return {
1074
+ lease_id: leaseId,
1075
+ task_id: execution.task.id,
1076
+ pipeline_id: pipelineId,
1077
+ status,
1078
+ missing_task_types: missingTaskTypes,
1079
+ touched_at: touchedAt,
1080
+ details: {
1081
+ source,
1082
+ enforcement: validationRules.enforcement,
1083
+ required_completed_task_types: requiredTaskTypes,
1084
+ rationale: validationRules.rationale || [],
1085
+ subsystem_tags: execution.task_spec.subsystem_tags || [],
1086
+ },
1087
+ };
1088
+ }
1089
+
1090
+ function syncPipelineBoundaryValidationStatesTx(db, pipelineId, { source = null } = {}) {
1091
+ if (!pipelineId) return [];
1092
+ const states = db.prepare(`
1093
+ SELECT *
1094
+ FROM boundary_validation_state
1095
+ WHERE pipeline_id=?
1096
+ `).all(pipelineId);
1097
+
1098
+ const updated = [];
1099
+ for (const state of states) {
1100
+ const recomputed = computeBoundaryValidationStateTx(db, state.lease_id, { touched: false, source });
1101
+ if (!recomputed) continue;
1102
+ upsertBoundaryValidationStateTx(db, recomputed);
1103
+ updated.push(recomputed);
1104
+ }
1105
+ return updated;
1106
+ }
1107
+
249
1108
  function closeActiveLeasesForTaskTx(db, taskId, status, failureReason = null) {
250
1109
  const activeLeases = db.prepare(`
251
1110
  SELECT * FROM leases
@@ -262,11 +1121,66 @@ function closeActiveLeasesForTaskTx(db, taskId, status, failureReason = null) {
262
1121
 
263
1122
  for (const lease of activeLeases) {
264
1123
  touchWorktreeLeaseState(db, lease.worktree, lease.agent, 'idle');
1124
+ releaseScopeReservationsForLeaseTx(db, lease.id);
265
1125
  }
266
1126
 
267
1127
  return activeLeases;
268
1128
  }
269
1129
 
1130
+ function finalizeTaskWithLeaseTx(db, taskId, activeLease, { taskStatus, leaseStatus, failureReason = null, auditStatus, auditEventType, auditReasonCode = null }) {
1131
+ const taskSpec = getTaskSpec(db, taskId);
1132
+ if (taskStatus === 'done') {
1133
+ db.prepare(`
1134
+ UPDATE tasks
1135
+ SET status='done', completed_at=datetime('now'), updated_at=datetime('now')
1136
+ WHERE id=?
1137
+ `).run(taskId);
1138
+ } else if (taskStatus === 'failed') {
1139
+ db.prepare(`
1140
+ UPDATE tasks
1141
+ SET status='failed', description=COALESCE(description,'') || '\nFAILED: ' || ?, updated_at=datetime('now')
1142
+ WHERE id=?
1143
+ `).run(failureReason || 'unknown', taskId);
1144
+ }
1145
+
1146
+ if (activeLease) {
1147
+ db.prepare(`
1148
+ UPDATE leases
1149
+ SET status=?,
1150
+ finished_at=datetime('now'),
1151
+ failure_reason=?
1152
+ WHERE id=? AND status='active'
1153
+ `).run(leaseStatus, failureReason, activeLease.id);
1154
+ touchWorktreeLeaseState(db, activeLease.worktree, activeLease.agent, 'idle');
1155
+ releaseClaimsForLeaseTx(db, activeLease.id);
1156
+ releaseScopeReservationsForLeaseTx(db, activeLease.id);
1157
+ } else {
1158
+ releaseClaimsForTaskTx(db, taskId);
1159
+ releaseScopeReservationsForTaskTx(db, taskId);
1160
+ }
1161
+
1162
+ closeActiveLeasesForTaskTx(db, taskId, leaseStatus, failureReason);
1163
+
1164
+ logAuditEventTx(db, {
1165
+ eventType: auditEventType,
1166
+ status: auditStatus,
1167
+ reasonCode: auditReasonCode,
1168
+ worktree: activeLease?.worktree ?? null,
1169
+ taskId,
1170
+ leaseId: activeLease?.id ?? null,
1171
+ details: failureReason || null,
1172
+ });
1173
+
1174
+ if (activeLease?.id && taskStatus === 'done') {
1175
+ const touchedState = computeBoundaryValidationStateTx(db, activeLease.id, { touched: true, source: auditEventType });
1176
+ if (touchedState) {
1177
+ upsertBoundaryValidationStateTx(db, touchedState);
1178
+ }
1179
+ }
1180
+
1181
+ syncPipelineBoundaryValidationStatesTx(db, taskSpec?.pipeline_id || null, { source: auditEventType });
1182
+ }
1183
+
270
1184
  function withImmediateTransaction(db, fn) {
271
1185
  for (let attempt = 1; attempt <= CLAIM_RETRY_ATTEMPTS; attempt++) {
272
1186
  let beganTransaction = false;
@@ -304,8 +1218,10 @@ export function initDb(repoRoot) {
304
1218
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
305
1219
 
306
1220
  const db = new DatabaseSync(getDbPath(repoRoot));
307
- configureDb(db);
308
- ensureSchema(db);
1221
+ db.__switchmanRepoRoot = repoRoot;
1222
+ db.__switchmanAuditSecret = getAuditSecret(repoRoot);
1223
+ configureDb(db, { initialize: true });
1224
+ withBusyRetry(() => ensureSchema(db));
309
1225
  return db;
310
1226
  }
311
1227
 
@@ -315,8 +1231,10 @@ export function openDb(repoRoot) {
315
1231
  throw new Error(`No switchman database found. Run 'switchman init' first.`);
316
1232
  }
317
1233
  const db = new DatabaseSync(dbPath);
1234
+ db.__switchmanRepoRoot = repoRoot;
1235
+ db.__switchmanAuditSecret = getAuditSecret(repoRoot);
318
1236
  configureDb(db);
319
- ensureSchema(db);
1237
+ withBusyRetry(() => ensureSchema(db));
320
1238
  return db;
321
1239
  }
322
1240
 
@@ -354,23 +1272,92 @@ export function assignTask(db, taskId, worktree, agent) {
354
1272
 
355
1273
  export function completeTask(db, taskId) {
356
1274
  withImmediateTransaction(db, () => {
357
- db.prepare(`
358
- UPDATE tasks
359
- SET status='done', completed_at=datetime('now'), updated_at=datetime('now')
360
- WHERE id=?
361
- `).run(taskId);
362
- closeActiveLeasesForTaskTx(db, taskId, 'completed');
1275
+ const activeLease = getActiveLeaseForTaskTx(db, taskId);
1276
+ finalizeTaskWithLeaseTx(db, taskId, activeLease, {
1277
+ taskStatus: 'done',
1278
+ leaseStatus: 'completed',
1279
+ auditStatus: 'allowed',
1280
+ auditEventType: 'task_completed',
1281
+ });
363
1282
  });
364
1283
  }
365
1284
 
366
1285
  export function failTask(db, taskId, reason) {
367
1286
  withImmediateTransaction(db, () => {
1287
+ const activeLease = getActiveLeaseForTaskTx(db, taskId);
1288
+ finalizeTaskWithLeaseTx(db, taskId, activeLease, {
1289
+ taskStatus: 'failed',
1290
+ leaseStatus: 'failed',
1291
+ failureReason: reason || 'unknown',
1292
+ auditStatus: 'denied',
1293
+ auditEventType: 'task_failed',
1294
+ auditReasonCode: 'task_failed',
1295
+ });
1296
+ });
1297
+ }
1298
+
1299
+ export function completeLeaseTask(db, leaseId) {
1300
+ return withImmediateTransaction(db, () => {
1301
+ const activeLease = getLeaseTx(db, leaseId);
1302
+ if (!activeLease || activeLease.status !== 'active') {
1303
+ return null;
1304
+ }
1305
+ finalizeTaskWithLeaseTx(db, activeLease.task_id, activeLease, {
1306
+ taskStatus: 'done',
1307
+ leaseStatus: 'completed',
1308
+ auditStatus: 'allowed',
1309
+ auditEventType: 'task_completed',
1310
+ });
1311
+ return getTaskTx(db, activeLease.task_id);
1312
+ });
1313
+ }
1314
+
1315
+ export function failLeaseTask(db, leaseId, reason) {
1316
+ return withImmediateTransaction(db, () => {
1317
+ const activeLease = getLeaseTx(db, leaseId);
1318
+ if (!activeLease || activeLease.status !== 'active') {
1319
+ return null;
1320
+ }
1321
+ finalizeTaskWithLeaseTx(db, activeLease.task_id, activeLease, {
1322
+ taskStatus: 'failed',
1323
+ leaseStatus: 'failed',
1324
+ failureReason: reason || 'unknown',
1325
+ auditStatus: 'denied',
1326
+ auditEventType: 'task_failed',
1327
+ auditReasonCode: 'task_failed',
1328
+ });
1329
+ return getTaskTx(db, activeLease.task_id);
1330
+ });
1331
+ }
1332
+
1333
+ export function retryTask(db, taskId, reason = null) {
1334
+ return withImmediateTransaction(db, () => {
1335
+ const task = getTaskTx(db, taskId);
1336
+ if (!task || !['failed', 'done'].includes(task.status)) {
1337
+ return null;
1338
+ }
1339
+
368
1340
  db.prepare(`
369
1341
  UPDATE tasks
370
- SET status='failed', description=COALESCE(description,'') || '\nFAILED: ' || ?, updated_at=datetime('now')
371
- WHERE id=?
372
- `).run(reason || 'unknown', taskId);
373
- closeActiveLeasesForTaskTx(db, taskId, 'failed', reason || 'unknown');
1342
+ SET status='pending',
1343
+ worktree=NULL,
1344
+ agent=NULL,
1345
+ completed_at=NULL,
1346
+ updated_at=datetime('now')
1347
+ WHERE id=? AND status IN ('failed', 'done')
1348
+ `).run(taskId);
1349
+
1350
+ logAuditEventTx(db, {
1351
+ eventType: 'task_retried',
1352
+ status: 'allowed',
1353
+ taskId,
1354
+ details: reason || null,
1355
+ });
1356
+
1357
+ resolveDependencyInvalidationsForAffectedTaskTx(db, taskId, 'task_retried');
1358
+ syncPipelineBoundaryValidationStatesTx(db, getTaskSpec(db, taskId)?.pipeline_id || null, { source: 'task_retried' });
1359
+
1360
+ return getTaskTx(db, taskId);
374
1361
  });
375
1362
  }
376
1363
 
@@ -385,6 +1372,39 @@ export function getTask(db, taskId) {
385
1372
  return db.prepare(`SELECT * FROM tasks WHERE id=?`).get(taskId);
386
1373
  }
387
1374
 
1375
+ export function upsertTaskSpec(db, taskId, spec) {
1376
+ db.prepare(`
1377
+ INSERT INTO task_specs (task_id, spec_json, updated_at)
1378
+ VALUES (?, ?, datetime('now'))
1379
+ ON CONFLICT(task_id) DO UPDATE SET
1380
+ spec_json=excluded.spec_json,
1381
+ updated_at=datetime('now')
1382
+ `).run(taskId, JSON.stringify(spec || {}));
1383
+
1384
+ const activeLease = getActiveLeaseForTaskTx(db, taskId);
1385
+ if (activeLease) {
1386
+ withImmediateTransaction(db, () => {
1387
+ releaseScopeReservationsForLeaseTx(db, activeLease.id);
1388
+ reserveLeaseScopesTx(db, activeLease);
1389
+ const updatedState = computeBoundaryValidationStateTx(db, activeLease.id, { touched: false, source: 'task_spec_updated' });
1390
+ if (updatedState) {
1391
+ upsertBoundaryValidationStateTx(db, updatedState);
1392
+ }
1393
+ syncPipelineBoundaryValidationStatesTx(db, spec?.pipeline_id || null, { source: 'task_spec_updated' });
1394
+ });
1395
+ }
1396
+ }
1397
+
1398
+ export function getTaskSpec(db, taskId) {
1399
+ const row = db.prepare(`SELECT spec_json FROM task_specs WHERE task_id=?`).get(taskId);
1400
+ if (!row) return null;
1401
+ try {
1402
+ return JSON.parse(row.spec_json);
1403
+ } catch {
1404
+ return null;
1405
+ }
1406
+ }
1407
+
388
1408
  export function getNextPendingTask(db) {
389
1409
  return db.prepare(`
390
1410
  SELECT * FROM tasks WHERE status='pending'
@@ -411,6 +1431,136 @@ export function listLeases(db, statusFilter) {
411
1431
  `).all();
412
1432
  }
413
1433
 
1434
+ export function listScopeReservations(db, { activeOnly = true, leaseId = null, taskId = null, worktree = null } = {}) {
1435
+ const clauses = [];
1436
+ const params = [];
1437
+
1438
+ if (activeOnly) {
1439
+ clauses.push('released_at IS NULL');
1440
+ }
1441
+ if (leaseId) {
1442
+ clauses.push('lease_id=?');
1443
+ params.push(leaseId);
1444
+ }
1445
+ if (taskId) {
1446
+ clauses.push('task_id=?');
1447
+ params.push(taskId);
1448
+ }
1449
+ if (worktree) {
1450
+ clauses.push('worktree=?');
1451
+ params.push(worktree);
1452
+ }
1453
+
1454
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
1455
+ return db.prepare(`
1456
+ SELECT *
1457
+ FROM scope_reservations
1458
+ ${where}
1459
+ ORDER BY id ASC
1460
+ `).all(...params);
1461
+ }
1462
+
1463
+ export function getBoundaryValidationState(db, leaseId) {
1464
+ const row = getBoundaryValidationStateTx(db, leaseId);
1465
+ if (!row) return null;
1466
+ return {
1467
+ ...row,
1468
+ missing_task_types: JSON.parse(row.missing_task_types || '[]'),
1469
+ details: row.details ? JSON.parse(row.details) : {},
1470
+ };
1471
+ }
1472
+
1473
+ export function listBoundaryValidationStates(db, { status = null, pipelineId = null } = {}) {
1474
+ const clauses = [];
1475
+ const params = [];
1476
+ if (status) {
1477
+ clauses.push('status=?');
1478
+ params.push(status);
1479
+ }
1480
+ if (pipelineId) {
1481
+ clauses.push('pipeline_id=?');
1482
+ params.push(pipelineId);
1483
+ }
1484
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
1485
+ return db.prepare(`
1486
+ SELECT *
1487
+ FROM boundary_validation_state
1488
+ ${where}
1489
+ ORDER BY last_evaluated_at DESC, lease_id ASC
1490
+ `).all(...params).map((row) => ({
1491
+ ...row,
1492
+ missing_task_types: JSON.parse(row.missing_task_types || '[]'),
1493
+ details: row.details ? JSON.parse(row.details) : {},
1494
+ }));
1495
+ }
1496
+
1497
+ export function listDependencyInvalidations(db, { status = 'stale', pipelineId = null, affectedTaskId = null } = {}) {
1498
+ const clauses = [];
1499
+ const params = [];
1500
+ if (status === 'stale') {
1501
+ clauses.push('resolved_at IS NULL');
1502
+ } else if (status === 'revalidated') {
1503
+ clauses.push('resolved_at IS NOT NULL');
1504
+ }
1505
+ if (pipelineId) {
1506
+ clauses.push('(source_pipeline_id=? OR affected_pipeline_id=?)');
1507
+ params.push(pipelineId, pipelineId);
1508
+ }
1509
+ if (affectedTaskId) {
1510
+ clauses.push('affected_task_id=?');
1511
+ params.push(affectedTaskId);
1512
+ }
1513
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
1514
+ return db.prepare(`
1515
+ SELECT *
1516
+ FROM dependency_invalidations
1517
+ ${where}
1518
+ ORDER BY created_at DESC, id DESC
1519
+ `).all(...params).map((row) => ({
1520
+ ...row,
1521
+ details: row.details ? JSON.parse(row.details) : {},
1522
+ }));
1523
+ }
1524
+
1525
+ export function touchBoundaryValidationState(db, leaseId, source = 'write') {
1526
+ return withImmediateTransaction(db, () => {
1527
+ const state = computeBoundaryValidationStateTx(db, leaseId, { touched: true, source });
1528
+ if (state) {
1529
+ upsertBoundaryValidationStateTx(db, state);
1530
+ logAuditEventTx(db, {
1531
+ eventType: 'boundary_validation_state',
1532
+ status: state.status === 'satisfied' ? 'allowed' : (state.status === 'blocked' ? 'denied' : 'warn'),
1533
+ reasonCode: state.status === 'satisfied' ? null : 'boundary_validation_pending',
1534
+ taskId: state.task_id,
1535
+ leaseId: state.lease_id,
1536
+ details: JSON.stringify({
1537
+ status: state.status,
1538
+ missing_task_types: state.missing_task_types,
1539
+ source,
1540
+ }),
1541
+ });
1542
+ }
1543
+
1544
+ const invalidations = syncDependencyInvalidationsForLeaseTx(db, leaseId, source);
1545
+ if (invalidations.length > 0) {
1546
+ logAuditEventTx(db, {
1547
+ eventType: 'dependency_invalidations_updated',
1548
+ status: 'warn',
1549
+ reasonCode: 'dependent_work_stale',
1550
+ taskId: state?.task_id || getLeaseTx(db, leaseId)?.task_id || null,
1551
+ leaseId,
1552
+ details: JSON.stringify({
1553
+ source,
1554
+ stale_count: invalidations.length,
1555
+ affected_task_ids: [...new Set(invalidations.map((item) => item.affected_task_id))],
1556
+ }),
1557
+ });
1558
+ }
1559
+
1560
+ return state ? getBoundaryValidationState(db, leaseId) : null;
1561
+ });
1562
+ }
1563
+
414
1564
  export function getLease(db, leaseId) {
415
1565
  return db.prepare(`
416
1566
  SELECT l.*, t.title AS task_title
@@ -420,6 +1570,21 @@ export function getLease(db, leaseId) {
420
1570
  `).get(leaseId);
421
1571
  }
422
1572
 
1573
+ export function getLeaseExecutionContext(db, leaseId) {
1574
+ const lease = getLease(db, leaseId);
1575
+ if (!lease) return null;
1576
+ const task = getTask(db, lease.task_id);
1577
+ const worktree = getWorktree(db, lease.worktree);
1578
+ return {
1579
+ lease,
1580
+ task,
1581
+ task_spec: task ? getTaskSpec(db, task.id) : null,
1582
+ worktree,
1583
+ claims: getActiveFileClaims(db).filter((claim) => claim.lease_id === lease.id),
1584
+ scope_reservations: listScopeReservations(db, { leaseId: lease.id }),
1585
+ };
1586
+ }
1587
+
423
1588
  export function getActiveLeaseForTask(db, taskId) {
424
1589
  const lease = getActiveLeaseForTaskTx(db, taskId);
425
1590
  return lease ? getLease(db, lease.id) : null;
@@ -439,6 +1604,14 @@ export function heartbeatLease(db, leaseId, agent) {
439
1604
 
440
1605
  const lease = getLease(db, leaseId);
441
1606
  touchWorktreeLeaseState(db, lease.worktree, agent || lease.agent, 'busy');
1607
+ logAuditEventTx(db, {
1608
+ eventType: 'lease_heartbeated',
1609
+ status: 'allowed',
1610
+ worktree: lease.worktree,
1611
+ taskId: lease.task_id,
1612
+ leaseId: lease.id,
1613
+ details: JSON.stringify({ agent: agent || lease.agent || null }),
1614
+ });
442
1615
  return lease;
443
1616
  }
444
1617
 
@@ -485,8 +1658,17 @@ export function reapStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINU
485
1658
  for (const lease of staleLeases) {
486
1659
  expireLease.run(lease.id);
487
1660
  releaseClaimsForLeaseTx(db, lease.id);
1661
+ releaseScopeReservationsForLeaseTx(db, lease.id);
488
1662
  resetTask.run(lease.task_id, lease.task_id);
489
1663
  touchWorktreeLeaseState(db, lease.worktree, lease.agent, 'idle');
1664
+ logAuditEventTx(db, {
1665
+ eventType: 'lease_expired',
1666
+ status: 'denied',
1667
+ reasonCode: 'lease_expired',
1668
+ worktree: lease.worktree,
1669
+ taskId: lease.task_id,
1670
+ leaseId: lease.id,
1671
+ });
490
1672
  }
491
1673
 
492
1674
  return staleLeases.map((lease) => ({
@@ -534,6 +1716,14 @@ export function claimFiles(db, taskId, worktree, filePaths, agent) {
534
1716
  }
535
1717
 
536
1718
  insert.run(taskId, lease.id, fp, worktree, agent || null);
1719
+ logAuditEventTx(db, {
1720
+ eventType: 'file_claimed',
1721
+ status: 'allowed',
1722
+ worktree,
1723
+ taskId,
1724
+ leaseId: lease.id,
1725
+ filePath: fp,
1726
+ });
537
1727
  }
538
1728
 
539
1729
  db.prepare(`
@@ -549,10 +1739,7 @@ export function claimFiles(db, taskId, worktree, filePaths, agent) {
549
1739
  }
550
1740
 
551
1741
  export function releaseFileClaims(db, taskId) {
552
- db.prepare(`
553
- UPDATE file_claims SET released_at=datetime('now')
554
- WHERE task_id=? AND released_at IS NULL
555
- `).run(taskId);
1742
+ releaseClaimsForTaskTx(db, taskId);
556
1743
  }
557
1744
 
558
1745
  export function releaseLeaseFileClaims(db, leaseId) {
@@ -571,6 +1758,27 @@ export function getActiveFileClaims(db) {
571
1758
  `).all();
572
1759
  }
573
1760
 
1761
+ export function getCompletedFileClaims(db, worktree = null) {
1762
+ if (worktree) {
1763
+ return db.prepare(`
1764
+ SELECT fc.*, t.title as task_title, t.status as task_status, t.completed_at
1765
+ FROM file_claims fc
1766
+ JOIN tasks t ON fc.task_id = t.id
1767
+ WHERE fc.worktree=?
1768
+ AND t.status='done'
1769
+ ORDER BY COALESCE(fc.released_at, t.completed_at) DESC, fc.file_path
1770
+ `).all(worktree);
1771
+ }
1772
+
1773
+ return db.prepare(`
1774
+ SELECT fc.*, t.title as task_title, t.status as task_status, t.completed_at
1775
+ FROM file_claims fc
1776
+ JOIN tasks t ON fc.task_id = t.id
1777
+ WHERE t.status='done'
1778
+ ORDER BY COALESCE(fc.released_at, t.completed_at) DESC, fc.file_path
1779
+ `).all();
1780
+ }
1781
+
574
1782
  export function checkFileConflicts(db, filePaths, excludeWorktree) {
575
1783
  const conflicts = [];
576
1784
  const stmt = db.prepare(`
@@ -593,23 +1801,40 @@ export function checkFileConflicts(db, filePaths, excludeWorktree) {
593
1801
  // ─── Worktrees ────────────────────────────────────────────────────────────────
594
1802
 
595
1803
  export function registerWorktree(db, { name, path, branch, agent }) {
1804
+ const normalizedPath = normalizeWorktreePath(path);
1805
+ const existingByPath = db.prepare(`SELECT name FROM worktrees WHERE path=?`).get(normalizedPath);
1806
+ const canonicalName = existingByPath?.name || name;
596
1807
  db.prepare(`
597
1808
  INSERT INTO worktrees (name, path, branch, agent)
598
1809
  VALUES (?, ?, ?, ?)
599
1810
  ON CONFLICT(name) DO UPDATE SET
600
1811
  path=excluded.path, branch=excluded.branch,
601
1812
  agent=excluded.agent, last_seen=datetime('now'), status='idle'
602
- `).run(name, path, branch, agent || null);
1813
+ `).run(canonicalName, normalizedPath, branch, agent || null);
603
1814
  }
604
1815
 
605
1816
  export function listWorktrees(db) {
606
1817
  return db.prepare(`SELECT * FROM worktrees ORDER BY registered_at`).all();
607
1818
  }
608
1819
 
1820
+ export function getWorktree(db, name) {
1821
+ return db.prepare(`SELECT * FROM worktrees WHERE name=?`).get(name);
1822
+ }
1823
+
609
1824
  export function updateWorktreeStatus(db, name, status) {
610
1825
  db.prepare(`UPDATE worktrees SET status=?, last_seen=datetime('now') WHERE name=?`).run(status, name);
611
1826
  }
612
1827
 
1828
+ export function updateWorktreeCompliance(db, name, complianceState, enforcementMode = null) {
1829
+ db.prepare(`
1830
+ UPDATE worktrees
1831
+ SET compliance_state=?,
1832
+ enforcement_mode=COALESCE(?, enforcement_mode),
1833
+ last_seen=datetime('now')
1834
+ WHERE name=?
1835
+ `).run(complianceState, enforcementMode, name);
1836
+ }
1837
+
613
1838
  // ─── Conflict Log ─────────────────────────────────────────────────────────────
614
1839
 
615
1840
  export function logConflict(db, worktreeA, worktreeB, conflictingFiles) {
@@ -619,6 +1844,172 @@ export function logConflict(db, worktreeA, worktreeB, conflictingFiles) {
619
1844
  `).run(worktreeA, worktreeB, JSON.stringify(conflictingFiles));
620
1845
  }
621
1846
 
1847
+ export function logAuditEvent(db, payload) {
1848
+ logAuditEventTx(db, payload);
1849
+ }
1850
+
1851
+ export function listAuditEvents(db, { eventType = null, status = null, taskId = null, limit = 50 } = {}) {
1852
+ if (eventType && status && taskId) {
1853
+ return db.prepare(`
1854
+ SELECT * FROM audit_log
1855
+ WHERE event_type=? AND status=? AND task_id=?
1856
+ ORDER BY created_at DESC, id DESC
1857
+ LIMIT ?
1858
+ `).all(eventType, status, taskId, limit);
1859
+ }
1860
+ if (eventType && taskId) {
1861
+ return db.prepare(`
1862
+ SELECT * FROM audit_log
1863
+ WHERE event_type=? AND task_id=?
1864
+ ORDER BY created_at DESC, id DESC
1865
+ LIMIT ?
1866
+ `).all(eventType, taskId, limit);
1867
+ }
1868
+ if (status && taskId) {
1869
+ return db.prepare(`
1870
+ SELECT * FROM audit_log
1871
+ WHERE status=? AND task_id=?
1872
+ ORDER BY created_at DESC, id DESC
1873
+ LIMIT ?
1874
+ `).all(status, taskId, limit);
1875
+ }
1876
+ if (eventType && status) {
1877
+ return db.prepare(`
1878
+ SELECT * FROM audit_log
1879
+ WHERE event_type=? AND status=?
1880
+ ORDER BY created_at DESC, id DESC
1881
+ LIMIT ?
1882
+ `).all(eventType, status, limit);
1883
+ }
1884
+ if (eventType) {
1885
+ return db.prepare(`
1886
+ SELECT * FROM audit_log
1887
+ WHERE event_type=?
1888
+ ORDER BY created_at DESC, id DESC
1889
+ LIMIT ?
1890
+ `).all(eventType, limit);
1891
+ }
1892
+ if (status) {
1893
+ return db.prepare(`
1894
+ SELECT * FROM audit_log
1895
+ WHERE status=?
1896
+ ORDER BY created_at DESC, id DESC
1897
+ LIMIT ?
1898
+ `).all(status, limit);
1899
+ }
1900
+ return db.prepare(`
1901
+ SELECT * FROM audit_log
1902
+ ORDER BY created_at DESC, id DESC
1903
+ LIMIT ?
1904
+ `).all(limit);
1905
+ }
1906
+
1907
+ export function verifyAuditTrail(db) {
1908
+ const { secret } = getAuditContext(db);
1909
+ const events = db.prepare(`
1910
+ SELECT *
1911
+ FROM audit_log
1912
+ ORDER BY sequence ASC, id ASC
1913
+ `).all();
1914
+
1915
+ const failures = [];
1916
+ let expectedSequence = 1;
1917
+ let expectedPrevHash = null;
1918
+
1919
+ for (const event of events) {
1920
+ if (event.sequence == null) {
1921
+ failures.push({ id: event.id, reason_code: 'missing_sequence', message: 'Audit event is missing sequence metadata.' });
1922
+ continue;
1923
+ }
1924
+ if (event.sequence !== expectedSequence) {
1925
+ failures.push({
1926
+ id: event.id,
1927
+ sequence: event.sequence,
1928
+ reason_code: 'sequence_gap',
1929
+ message: `Expected sequence ${expectedSequence} but found ${event.sequence}.`,
1930
+ });
1931
+ expectedSequence = event.sequence;
1932
+ }
1933
+ if ((event.prev_hash || null) !== expectedPrevHash) {
1934
+ failures.push({
1935
+ id: event.id,
1936
+ sequence: event.sequence,
1937
+ reason_code: 'prev_hash_mismatch',
1938
+ message: 'Previous hash does not match the prior audit event.',
1939
+ });
1940
+ }
1941
+
1942
+ const recomputedHash = computeAuditEntryHash({
1943
+ sequence: event.sequence,
1944
+ prevHash: event.prev_hash || null,
1945
+ eventType: event.event_type,
1946
+ status: event.status,
1947
+ reasonCode: event.reason_code,
1948
+ worktree: event.worktree,
1949
+ taskId: event.task_id,
1950
+ leaseId: event.lease_id,
1951
+ filePath: event.file_path,
1952
+ details: event.details,
1953
+ createdAt: event.created_at,
1954
+ });
1955
+ if (event.entry_hash !== recomputedHash) {
1956
+ failures.push({
1957
+ id: event.id,
1958
+ sequence: event.sequence,
1959
+ reason_code: 'entry_hash_mismatch',
1960
+ message: 'Audit event payload hash does not match the stored entry hash.',
1961
+ });
1962
+ }
1963
+
1964
+ const expectedSignature = signAuditEntry(secret, recomputedHash);
1965
+ if (event.signature !== expectedSignature) {
1966
+ failures.push({
1967
+ id: event.id,
1968
+ sequence: event.sequence,
1969
+ reason_code: 'signature_mismatch',
1970
+ message: 'Audit event signature does not match the project audit key.',
1971
+ });
1972
+ }
1973
+
1974
+ expectedPrevHash = event.entry_hash || null;
1975
+ expectedSequence += 1;
1976
+ }
1977
+
1978
+ return {
1979
+ ok: failures.length === 0,
1980
+ count: events.length,
1981
+ failures,
1982
+ };
1983
+ }
1984
+
1985
+ export function getWorktreeSnapshotState(db, worktree) {
1986
+ const rows = db.prepare(`
1987
+ SELECT * FROM worktree_snapshots
1988
+ WHERE worktree=?
1989
+ ORDER BY file_path
1990
+ `).all(worktree);
1991
+
1992
+ return new Map(rows.map((row) => [row.file_path, row.fingerprint]));
1993
+ }
1994
+
1995
+ export function replaceWorktreeSnapshotState(db, worktree, snapshot) {
1996
+ withImmediateTransaction(db, () => {
1997
+ db.prepare(`
1998
+ DELETE FROM worktree_snapshots
1999
+ WHERE worktree=?
2000
+ `).run(worktree);
2001
+
2002
+ const insert = db.prepare(`
2003
+ INSERT INTO worktree_snapshots (worktree, file_path, fingerprint)
2004
+ VALUES (?, ?, ?)
2005
+ `);
2006
+
2007
+ for (const [filePath, fingerprint] of snapshot.entries()) {
2008
+ insert.run(worktree, filePath, fingerprint);
2009
+ }
2010
+ });
2011
+ }
2012
+
622
2013
  export function getConflictLog(db) {
623
2014
  return db.prepare(`SELECT * FROM conflict_log ORDER BY detected_at DESC LIMIT 50`).all();
624
2015
  }