switchman-dev 0.1.1 → 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/README.md +327 -16
- package/examples/README.md +18 -2
- package/examples/walkthrough.sh +12 -16
- package/package.json +1 -1
- package/src/cli/index.js +2077 -216
- package/src/core/ci.js +114 -0
- package/src/core/db.js +1848 -73
- package/src/core/detector.js +109 -7
- package/src/core/enforcement.js +966 -0
- package/src/core/git.js +42 -5
- package/src/core/ignore.js +47 -0
- package/src/core/mcp.js +47 -0
- package/src/core/merge-gate.js +305 -0
- package/src/core/monitor.js +39 -0
- package/src/core/outcome.js +153 -0
- package/src/core/pipeline.js +1113 -0
- package/src/core/planner.js +508 -0
- package/src/core/semantic.js +311 -0
- package/src/mcp/server.js +491 -23
package/src/core/db.js
CHANGED
|
@@ -3,84 +3,1225 @@
|
|
|
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, readFileSync, writeFileSync } from 'fs';
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs';
|
|
8
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 =
|
|
17
|
+
const BUSY_TIMEOUT_MS = 10000;
|
|
18
|
+
const CLAIM_RETRY_DELAY_MS = 200;
|
|
19
|
+
const CLAIM_RETRY_ATTEMPTS = 20;
|
|
20
|
+
export const DEFAULT_STALE_LEASE_MINUTES = 15;
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
function sleepSync(ms) {
|
|
23
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
function isBusyError(err) {
|
|
27
|
+
const message = String(err?.message || '').toLowerCase();
|
|
28
|
+
return message.includes('database is locked') || message.includes('sqlite_busy');
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|
|
28
44
|
|
|
29
|
-
|
|
45
|
+
function normalizeWorktreePath(path) {
|
|
46
|
+
try {
|
|
47
|
+
return realpathSync(path);
|
|
48
|
+
} catch {
|
|
49
|
+
return resolve(path);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
30
52
|
|
|
53
|
+
function makeId(prefix) {
|
|
54
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function configureDb(db, { initialize = false } = {}) {
|
|
31
58
|
db.exec(`
|
|
32
|
-
PRAGMA
|
|
59
|
+
PRAGMA foreign_keys=ON;
|
|
33
60
|
PRAGMA synchronous=NORMAL;
|
|
34
61
|
PRAGMA busy_timeout=${BUSY_TIMEOUT_MS};
|
|
62
|
+
`);
|
|
63
|
+
|
|
64
|
+
if (initialize) {
|
|
65
|
+
withBusyRetry(() => {
|
|
66
|
+
db.exec(`PRAGMA journal_mode=WAL;`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getTableColumns(db, tableName) {
|
|
72
|
+
return db.prepare(`PRAGMA table_info(${tableName})`).all().map((column) => column.name);
|
|
73
|
+
}
|
|
35
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
|
+
|
|
123
|
+
function ensureSchema(db) {
|
|
124
|
+
db.exec(`
|
|
36
125
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
37
|
-
id
|
|
38
|
-
title
|
|
39
|
-
description
|
|
40
|
-
status
|
|
41
|
-
worktree
|
|
42
|
-
agent
|
|
43
|
-
priority
|
|
44
|
-
created_at
|
|
45
|
-
updated_at
|
|
126
|
+
id TEXT PRIMARY KEY,
|
|
127
|
+
title TEXT NOT NULL,
|
|
128
|
+
description TEXT,
|
|
129
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
130
|
+
worktree TEXT,
|
|
131
|
+
agent TEXT,
|
|
132
|
+
priority INTEGER DEFAULT 5,
|
|
133
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
134
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
46
135
|
completed_at TEXT
|
|
47
136
|
);
|
|
48
137
|
|
|
138
|
+
CREATE TABLE IF NOT EXISTS leases (
|
|
139
|
+
id TEXT PRIMARY KEY,
|
|
140
|
+
task_id TEXT NOT NULL,
|
|
141
|
+
worktree TEXT NOT NULL,
|
|
142
|
+
agent TEXT,
|
|
143
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
144
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
145
|
+
heartbeat_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
146
|
+
finished_at TEXT,
|
|
147
|
+
failure_reason TEXT,
|
|
148
|
+
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
|
149
|
+
);
|
|
150
|
+
|
|
49
151
|
CREATE TABLE IF NOT EXISTS file_claims (
|
|
50
152
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
153
|
task_id TEXT NOT NULL,
|
|
154
|
+
lease_id TEXT,
|
|
52
155
|
file_path TEXT NOT NULL,
|
|
53
156
|
worktree TEXT NOT NULL,
|
|
54
157
|
agent TEXT,
|
|
55
158
|
claimed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
56
159
|
released_at TEXT,
|
|
57
|
-
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
|
160
|
+
FOREIGN KEY(task_id) REFERENCES tasks(id),
|
|
161
|
+
FOREIGN KEY(lease_id) REFERENCES leases(id)
|
|
58
162
|
);
|
|
59
163
|
|
|
60
164
|
CREATE TABLE IF NOT EXISTS worktrees (
|
|
61
|
-
name
|
|
62
|
-
path
|
|
63
|
-
branch
|
|
64
|
-
agent
|
|
65
|
-
status
|
|
165
|
+
name TEXT PRIMARY KEY,
|
|
166
|
+
path TEXT NOT NULL,
|
|
167
|
+
branch TEXT NOT NULL,
|
|
168
|
+
agent TEXT,
|
|
169
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
170
|
+
enforcement_mode TEXT NOT NULL DEFAULT 'observed',
|
|
171
|
+
compliance_state TEXT NOT NULL DEFAULT 'observed',
|
|
66
172
|
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
67
|
-
last_seen
|
|
173
|
+
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
|
|
68
174
|
);
|
|
69
175
|
|
|
70
176
|
CREATE TABLE IF NOT EXISTS conflict_log (
|
|
71
|
-
id
|
|
72
|
-
detected_at
|
|
73
|
-
worktree_a
|
|
74
|
-
worktree_b
|
|
177
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
178
|
+
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
179
|
+
worktree_a TEXT NOT NULL,
|
|
180
|
+
worktree_b TEXT NOT NULL,
|
|
75
181
|
conflicting_files TEXT NOT NULL,
|
|
76
|
-
resolved
|
|
182
|
+
resolved INTEGER DEFAULT 0
|
|
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'))
|
|
77
275
|
);
|
|
276
|
+
`);
|
|
277
|
+
|
|
278
|
+
const fileClaimColumns = getTableColumns(db, 'file_claims');
|
|
279
|
+
if (fileClaimColumns.length > 0 && !fileClaimColumns.includes('lease_id')) {
|
|
280
|
+
db.exec(`ALTER TABLE file_claims ADD COLUMN lease_id TEXT REFERENCES leases(id)`);
|
|
281
|
+
}
|
|
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
|
+
}
|
|
78
304
|
|
|
305
|
+
db.exec(`
|
|
79
306
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
307
|
+
CREATE INDEX IF NOT EXISTS idx_leases_task ON leases(task_id);
|
|
308
|
+
CREATE INDEX IF NOT EXISTS idx_leases_status ON leases(status);
|
|
309
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_leases_unique_active_task
|
|
310
|
+
ON leases(task_id)
|
|
311
|
+
WHERE status='active';
|
|
312
|
+
CREATE INDEX IF NOT EXISTS idx_file_claims_task_id ON file_claims(task_id);
|
|
313
|
+
CREATE INDEX IF NOT EXISTS idx_file_claims_lease_id ON file_claims(lease_id);
|
|
80
314
|
CREATE INDEX IF NOT EXISTS idx_file_claims_path ON file_claims(file_path);
|
|
81
315
|
CREATE INDEX IF NOT EXISTS idx_file_claims_active ON file_claims(released_at) WHERE released_at IS NULL;
|
|
316
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_file_claims_unique_active
|
|
317
|
+
ON file_claims(file_path)
|
|
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);
|
|
82
337
|
`);
|
|
83
338
|
|
|
339
|
+
migrateLegacyAuditLog(db);
|
|
340
|
+
migrateLegacyActiveTasks(db);
|
|
341
|
+
}
|
|
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
|
+
|
|
553
|
+
function touchWorktreeLeaseState(db, worktree, agent, status) {
|
|
554
|
+
if (!worktree) return;
|
|
555
|
+
db.prepare(`
|
|
556
|
+
UPDATE worktrees
|
|
557
|
+
SET status=?, agent=COALESCE(?, agent), last_seen=datetime('now')
|
|
558
|
+
WHERE name=?
|
|
559
|
+
`).run(status, agent || null, worktree);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function getTaskTx(db, taskId) {
|
|
563
|
+
return db.prepare(`SELECT * FROM tasks WHERE id=?`).get(taskId);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function getLeaseTx(db, leaseId) {
|
|
567
|
+
return db.prepare(`SELECT * FROM leases WHERE id=?`).get(leaseId);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function getActiveLeaseForTaskTx(db, taskId) {
|
|
571
|
+
return db.prepare(`
|
|
572
|
+
SELECT * FROM leases
|
|
573
|
+
WHERE task_id=? AND status='active'
|
|
574
|
+
ORDER BY started_at DESC
|
|
575
|
+
LIMIT 1
|
|
576
|
+
`).get(taskId);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function createLeaseTx(db, { id, taskId, worktree, agent, status = 'active', failureReason = null }) {
|
|
580
|
+
const leaseId = id || makeId('lease');
|
|
581
|
+
db.prepare(`
|
|
582
|
+
INSERT INTO leases (id, task_id, worktree, agent, status, failure_reason)
|
|
583
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
584
|
+
`).run(leaseId, taskId, worktree, agent || null, status, failureReason);
|
|
585
|
+
const lease = getLeaseTx(db, leaseId);
|
|
586
|
+
if (status === 'active') {
|
|
587
|
+
reserveLeaseScopesTx(db, lease);
|
|
588
|
+
}
|
|
589
|
+
touchWorktreeLeaseState(db, worktree, agent, status === 'active' ? 'busy' : 'idle');
|
|
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
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function migrateLegacyActiveTasks(db) {
|
|
701
|
+
const legacyTasks = db.prepare(`
|
|
702
|
+
SELECT *
|
|
703
|
+
FROM tasks
|
|
704
|
+
WHERE status='in_progress'
|
|
705
|
+
AND worktree IS NOT NULL
|
|
706
|
+
AND NOT EXISTS (
|
|
707
|
+
SELECT 1 FROM leases
|
|
708
|
+
WHERE leases.task_id = tasks.id
|
|
709
|
+
AND leases.status='active'
|
|
710
|
+
)
|
|
711
|
+
`).all();
|
|
712
|
+
|
|
713
|
+
if (!legacyTasks.length) return;
|
|
714
|
+
|
|
715
|
+
const backfillClaims = db.prepare(`
|
|
716
|
+
UPDATE file_claims
|
|
717
|
+
SET lease_id=?
|
|
718
|
+
WHERE task_id=? AND released_at IS NULL AND lease_id IS NULL
|
|
719
|
+
`);
|
|
720
|
+
|
|
721
|
+
for (const task of legacyTasks) {
|
|
722
|
+
const lease = createLeaseTx(db, {
|
|
723
|
+
taskId: task.id,
|
|
724
|
+
worktree: task.worktree,
|
|
725
|
+
agent: task.agent,
|
|
726
|
+
});
|
|
727
|
+
backfillClaims.run(lease.id, task.id);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function resolveActiveLeaseTx(db, taskId, worktree, agent) {
|
|
732
|
+
const task = getTaskTx(db, taskId);
|
|
733
|
+
if (!task) {
|
|
734
|
+
throw new Error(`Task ${taskId} does not exist.`);
|
|
735
|
+
}
|
|
736
|
+
if (task.status !== 'in_progress') {
|
|
737
|
+
throw new Error(`Task ${taskId} is not in progress.`);
|
|
738
|
+
}
|
|
739
|
+
if (task.worktree && task.worktree !== worktree) {
|
|
740
|
+
throw new Error(`Task ${taskId} is assigned to worktree ${task.worktree}, not ${worktree}.`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
let lease = getActiveLeaseForTaskTx(db, taskId);
|
|
744
|
+
if (lease) {
|
|
745
|
+
if (lease.worktree !== worktree) {
|
|
746
|
+
throw new Error(`Task ${taskId} already has an active lease for worktree ${lease.worktree}, not ${worktree}.`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
db.prepare(`
|
|
750
|
+
UPDATE leases
|
|
751
|
+
SET heartbeat_at=datetime('now'),
|
|
752
|
+
agent=COALESCE(?, agent)
|
|
753
|
+
WHERE id=?
|
|
754
|
+
`).run(agent || null, lease.id);
|
|
755
|
+
reserveLeaseScopesTx(db, lease);
|
|
756
|
+
touchWorktreeLeaseState(db, worktree, agent || lease.agent, 'busy');
|
|
757
|
+
return getLeaseTx(db, lease.id);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
lease = createLeaseTx(db, {
|
|
761
|
+
taskId,
|
|
762
|
+
worktree,
|
|
763
|
+
agent: agent || task.agent,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
db.prepare(`
|
|
767
|
+
UPDATE file_claims
|
|
768
|
+
SET lease_id=?
|
|
769
|
+
WHERE task_id=? AND worktree=? AND released_at IS NULL AND lease_id IS NULL
|
|
770
|
+
`).run(lease.id, taskId, worktree);
|
|
771
|
+
|
|
772
|
+
return lease;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function releaseClaimsForLeaseTx(db, leaseId) {
|
|
776
|
+
db.prepare(`
|
|
777
|
+
UPDATE file_claims
|
|
778
|
+
SET released_at=datetime('now')
|
|
779
|
+
WHERE lease_id=? AND released_at IS NULL
|
|
780
|
+
`).run(leaseId);
|
|
781
|
+
}
|
|
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
|
+
|
|
1108
|
+
function closeActiveLeasesForTaskTx(db, taskId, status, failureReason = null) {
|
|
1109
|
+
const activeLeases = db.prepare(`
|
|
1110
|
+
SELECT * FROM leases
|
|
1111
|
+
WHERE task_id=? AND status='active'
|
|
1112
|
+
`).all(taskId);
|
|
1113
|
+
|
|
1114
|
+
db.prepare(`
|
|
1115
|
+
UPDATE leases
|
|
1116
|
+
SET status=?,
|
|
1117
|
+
finished_at=datetime('now'),
|
|
1118
|
+
failure_reason=?
|
|
1119
|
+
WHERE task_id=? AND status='active'
|
|
1120
|
+
`).run(status, failureReason, taskId);
|
|
1121
|
+
|
|
1122
|
+
for (const lease of activeLeases) {
|
|
1123
|
+
touchWorktreeLeaseState(db, lease.worktree, lease.agent, 'idle');
|
|
1124
|
+
releaseScopeReservationsForLeaseTx(db, lease.id);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return activeLeases;
|
|
1128
|
+
}
|
|
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
|
+
|
|
1184
|
+
function withImmediateTransaction(db, fn) {
|
|
1185
|
+
for (let attempt = 1; attempt <= CLAIM_RETRY_ATTEMPTS; attempt++) {
|
|
1186
|
+
let beganTransaction = false;
|
|
1187
|
+
try {
|
|
1188
|
+
db.exec('BEGIN IMMEDIATE');
|
|
1189
|
+
beganTransaction = true;
|
|
1190
|
+
const result = fn();
|
|
1191
|
+
db.exec('COMMIT');
|
|
1192
|
+
return result;
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
if (beganTransaction) {
|
|
1195
|
+
try { db.exec('ROLLBACK'); } catch { /* no-op */ }
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (isBusyError(err) && attempt < CLAIM_RETRY_ATTEMPTS) {
|
|
1199
|
+
sleepSync(CLAIM_RETRY_DELAY_MS * attempt);
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
throw err;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
export function getSwitchmanDir(repoRoot) {
|
|
1209
|
+
return join(repoRoot, SWITCHMAN_DIR);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
export function getDbPath(repoRoot) {
|
|
1213
|
+
return join(repoRoot, SWITCHMAN_DIR, DB_FILE);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
export function initDb(repoRoot) {
|
|
1217
|
+
const dir = getSwitchmanDir(repoRoot);
|
|
1218
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1219
|
+
|
|
1220
|
+
const db = new DatabaseSync(getDbPath(repoRoot));
|
|
1221
|
+
db.__switchmanRepoRoot = repoRoot;
|
|
1222
|
+
db.__switchmanAuditSecret = getAuditSecret(repoRoot);
|
|
1223
|
+
configureDb(db, { initialize: true });
|
|
1224
|
+
withBusyRetry(() => ensureSchema(db));
|
|
84
1225
|
return db;
|
|
85
1226
|
}
|
|
86
1227
|
|
|
@@ -90,14 +1231,17 @@ export function openDb(repoRoot) {
|
|
|
90
1231
|
throw new Error(`No switchman database found. Run 'switchman init' first.`);
|
|
91
1232
|
}
|
|
92
1233
|
const db = new DatabaseSync(dbPath);
|
|
93
|
-
db.
|
|
1234
|
+
db.__switchmanRepoRoot = repoRoot;
|
|
1235
|
+
db.__switchmanAuditSecret = getAuditSecret(repoRoot);
|
|
1236
|
+
configureDb(db);
|
|
1237
|
+
withBusyRetry(() => ensureSchema(db));
|
|
94
1238
|
return db;
|
|
95
1239
|
}
|
|
96
1240
|
|
|
97
1241
|
// ─── Tasks ────────────────────────────────────────────────────────────────────
|
|
98
1242
|
|
|
99
1243
|
export function createTask(db, { id, title, description, priority = 5 }) {
|
|
100
|
-
const taskId = id ||
|
|
1244
|
+
const taskId = id || makeId('task');
|
|
101
1245
|
db.prepare(`
|
|
102
1246
|
INSERT INTO tasks (id, title, description, priority)
|
|
103
1247
|
VALUES (?, ?, ?, ?)
|
|
@@ -105,29 +1249,116 @@ export function createTask(db, { id, title, description, priority = 5 }) {
|
|
|
105
1249
|
return taskId;
|
|
106
1250
|
}
|
|
107
1251
|
|
|
1252
|
+
export function startTaskLease(db, taskId, worktree, agent) {
|
|
1253
|
+
return withImmediateTransaction(db, () => {
|
|
1254
|
+
const task = getTaskTx(db, taskId);
|
|
1255
|
+
if (!task || task.status !== 'pending') {
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
db.prepare(`
|
|
1260
|
+
UPDATE tasks
|
|
1261
|
+
SET status='in_progress', worktree=?, agent=?, updated_at=datetime('now')
|
|
1262
|
+
WHERE id=? AND status='pending'
|
|
1263
|
+
`).run(worktree, agent || null, taskId);
|
|
1264
|
+
|
|
1265
|
+
return createLeaseTx(db, { taskId, worktree, agent });
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
|
|
108
1269
|
export function assignTask(db, taskId, worktree, agent) {
|
|
109
|
-
|
|
110
|
-
UPDATE tasks
|
|
111
|
-
SET status='in_progress', worktree=?, agent=?, updated_at=datetime('now')
|
|
112
|
-
WHERE id=? AND status='pending'
|
|
113
|
-
`).run(worktree, agent || null, taskId);
|
|
114
|
-
return result.changes > 0;
|
|
1270
|
+
return Boolean(startTaskLease(db, taskId, worktree, agent));
|
|
115
1271
|
}
|
|
116
1272
|
|
|
117
1273
|
export function completeTask(db, taskId) {
|
|
118
|
-
db
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
1274
|
+
withImmediateTransaction(db, () => {
|
|
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
|
+
});
|
|
1282
|
+
});
|
|
123
1283
|
}
|
|
124
1284
|
|
|
125
1285
|
export function failTask(db, taskId, reason) {
|
|
126
|
-
db
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
|
|
1340
|
+
db.prepare(`
|
|
1341
|
+
UPDATE tasks
|
|
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);
|
|
1361
|
+
});
|
|
131
1362
|
}
|
|
132
1363
|
|
|
133
1364
|
export function listTasks(db, statusFilter) {
|
|
@@ -141,6 +1372,39 @@ export function getTask(db, taskId) {
|
|
|
141
1372
|
return db.prepare(`SELECT * FROM tasks WHERE id=?`).get(taskId);
|
|
142
1373
|
}
|
|
143
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
|
+
|
|
144
1408
|
export function getNextPendingTask(db) {
|
|
145
1409
|
return db.prepare(`
|
|
146
1410
|
SELECT * FROM tasks WHERE status='pending'
|
|
@@ -148,52 +1412,380 @@ export function getNextPendingTask(db) {
|
|
|
148
1412
|
`).get();
|
|
149
1413
|
}
|
|
150
1414
|
|
|
1415
|
+
export function listLeases(db, statusFilter) {
|
|
1416
|
+
if (statusFilter) {
|
|
1417
|
+
return db.prepare(`
|
|
1418
|
+
SELECT l.*, t.title AS task_title
|
|
1419
|
+
FROM leases l
|
|
1420
|
+
JOIN tasks t ON l.task_id = t.id
|
|
1421
|
+
WHERE l.status=?
|
|
1422
|
+
ORDER BY l.started_at DESC
|
|
1423
|
+
`).all(statusFilter);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
return db.prepare(`
|
|
1427
|
+
SELECT l.*, t.title AS task_title
|
|
1428
|
+
FROM leases l
|
|
1429
|
+
JOIN tasks t ON l.task_id = t.id
|
|
1430
|
+
ORDER BY l.started_at DESC
|
|
1431
|
+
`).all();
|
|
1432
|
+
}
|
|
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
|
+
|
|
1564
|
+
export function getLease(db, leaseId) {
|
|
1565
|
+
return db.prepare(`
|
|
1566
|
+
SELECT l.*, t.title AS task_title
|
|
1567
|
+
FROM leases l
|
|
1568
|
+
JOIN tasks t ON l.task_id = t.id
|
|
1569
|
+
WHERE l.id=?
|
|
1570
|
+
`).get(leaseId);
|
|
1571
|
+
}
|
|
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
|
+
|
|
1588
|
+
export function getActiveLeaseForTask(db, taskId) {
|
|
1589
|
+
const lease = getActiveLeaseForTaskTx(db, taskId);
|
|
1590
|
+
return lease ? getLease(db, lease.id) : null;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
export function heartbeatLease(db, leaseId, agent) {
|
|
1594
|
+
const result = db.prepare(`
|
|
1595
|
+
UPDATE leases
|
|
1596
|
+
SET heartbeat_at=datetime('now'),
|
|
1597
|
+
agent=COALESCE(?, agent)
|
|
1598
|
+
WHERE id=? AND status='active'
|
|
1599
|
+
`).run(agent || null, leaseId);
|
|
1600
|
+
|
|
1601
|
+
if (result.changes === 0) {
|
|
1602
|
+
return null;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
const lease = getLease(db, leaseId);
|
|
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
|
+
});
|
|
1615
|
+
return lease;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
export function getStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINUTES) {
|
|
1619
|
+
return db.prepare(`
|
|
1620
|
+
SELECT l.*, t.title AS task_title
|
|
1621
|
+
FROM leases l
|
|
1622
|
+
JOIN tasks t ON l.task_id = t.id
|
|
1623
|
+
WHERE l.status='active'
|
|
1624
|
+
AND l.heartbeat_at < datetime('now', ?)
|
|
1625
|
+
ORDER BY l.heartbeat_at ASC
|
|
1626
|
+
`).all(`-${staleAfterMinutes} minutes`);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
export function reapStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINUTES) {
|
|
1630
|
+
return withImmediateTransaction(db, () => {
|
|
1631
|
+
const staleLeases = getStaleLeases(db, staleAfterMinutes);
|
|
1632
|
+
if (!staleLeases.length) {
|
|
1633
|
+
return [];
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
const expireLease = db.prepare(`
|
|
1637
|
+
UPDATE leases
|
|
1638
|
+
SET status='expired',
|
|
1639
|
+
finished_at=datetime('now'),
|
|
1640
|
+
failure_reason=COALESCE(failure_reason, 'stale lease reaped')
|
|
1641
|
+
WHERE id=? AND status='active'
|
|
1642
|
+
`);
|
|
1643
|
+
|
|
1644
|
+
const resetTask = db.prepare(`
|
|
1645
|
+
UPDATE tasks
|
|
1646
|
+
SET status='pending',
|
|
1647
|
+
worktree=NULL,
|
|
1648
|
+
agent=NULL,
|
|
1649
|
+
updated_at=datetime('now')
|
|
1650
|
+
WHERE id=? AND status='in_progress'
|
|
1651
|
+
AND NOT EXISTS (
|
|
1652
|
+
SELECT 1 FROM leases
|
|
1653
|
+
WHERE task_id=?
|
|
1654
|
+
AND status='active'
|
|
1655
|
+
)
|
|
1656
|
+
`);
|
|
1657
|
+
|
|
1658
|
+
for (const lease of staleLeases) {
|
|
1659
|
+
expireLease.run(lease.id);
|
|
1660
|
+
releaseClaimsForLeaseTx(db, lease.id);
|
|
1661
|
+
releaseScopeReservationsForLeaseTx(db, lease.id);
|
|
1662
|
+
resetTask.run(lease.task_id, lease.task_id);
|
|
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
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
return staleLeases.map((lease) => ({
|
|
1675
|
+
...lease,
|
|
1676
|
+
status: 'expired',
|
|
1677
|
+
failure_reason: lease.failure_reason || 'stale lease reaped',
|
|
1678
|
+
}));
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
|
|
151
1682
|
// ─── File Claims ──────────────────────────────────────────────────────────────
|
|
152
1683
|
|
|
153
1684
|
export function claimFiles(db, taskId, worktree, filePaths, agent) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
1685
|
+
return withImmediateTransaction(db, () => {
|
|
1686
|
+
const lease = resolveActiveLeaseTx(db, taskId, worktree, agent);
|
|
1687
|
+
const findActiveClaim = db.prepare(`
|
|
1688
|
+
SELECT *
|
|
1689
|
+
FROM file_claims
|
|
1690
|
+
WHERE file_path=? AND released_at IS NULL
|
|
1691
|
+
LIMIT 1
|
|
1692
|
+
`);
|
|
1693
|
+
const insert = db.prepare(`
|
|
1694
|
+
INSERT INTO file_claims (task_id, lease_id, file_path, worktree, agent)
|
|
1695
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1696
|
+
`);
|
|
1697
|
+
|
|
164
1698
|
for (const fp of filePaths) {
|
|
165
|
-
|
|
1699
|
+
const existing = findActiveClaim.get(fp);
|
|
1700
|
+
if (existing) {
|
|
1701
|
+
const sameLease = existing.lease_id === lease.id;
|
|
1702
|
+
const sameLegacyOwner = existing.lease_id == null && existing.task_id === taskId && existing.worktree === worktree;
|
|
1703
|
+
|
|
1704
|
+
if (sameLease || sameLegacyOwner) {
|
|
1705
|
+
if (sameLegacyOwner) {
|
|
1706
|
+
db.prepare(`
|
|
1707
|
+
UPDATE file_claims
|
|
1708
|
+
SET lease_id=?, agent=COALESCE(?, agent)
|
|
1709
|
+
WHERE id=?
|
|
1710
|
+
`).run(lease.id, agent || null, existing.id);
|
|
1711
|
+
}
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
throw new Error('One or more files are already actively claimed by another task.');
|
|
1716
|
+
}
|
|
1717
|
+
|
|
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
|
+
});
|
|
166
1727
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
1728
|
+
|
|
1729
|
+
db.prepare(`
|
|
1730
|
+
UPDATE leases
|
|
1731
|
+
SET heartbeat_at=datetime('now'),
|
|
1732
|
+
agent=COALESCE(?, agent)
|
|
1733
|
+
WHERE id=?
|
|
1734
|
+
`).run(agent || null, lease.id);
|
|
1735
|
+
|
|
1736
|
+
touchWorktreeLeaseState(db, worktree, agent || lease.agent, 'busy');
|
|
1737
|
+
return getLeaseTx(db, lease.id);
|
|
1738
|
+
});
|
|
172
1739
|
}
|
|
173
1740
|
|
|
174
1741
|
export function releaseFileClaims(db, taskId) {
|
|
175
|
-
db
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
1742
|
+
releaseClaimsForTaskTx(db, taskId);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
export function releaseLeaseFileClaims(db, leaseId) {
|
|
1746
|
+
releaseClaimsForLeaseTx(db, leaseId);
|
|
179
1747
|
}
|
|
180
1748
|
|
|
181
1749
|
export function getActiveFileClaims(db) {
|
|
182
1750
|
return db.prepare(`
|
|
183
|
-
SELECT fc.*, t.title as task_title, t.status as task_status
|
|
1751
|
+
SELECT fc.*, t.title as task_title, t.status as task_status,
|
|
1752
|
+
l.id as lease_id, l.status as lease_status, l.heartbeat_at as lease_heartbeat_at
|
|
184
1753
|
FROM file_claims fc
|
|
185
1754
|
JOIN tasks t ON fc.task_id = t.id
|
|
1755
|
+
LEFT JOIN leases l ON fc.lease_id = l.id
|
|
186
1756
|
WHERE fc.released_at IS NULL
|
|
187
1757
|
ORDER BY fc.file_path
|
|
188
1758
|
`).all();
|
|
189
1759
|
}
|
|
190
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
|
+
|
|
191
1782
|
export function checkFileConflicts(db, filePaths, excludeWorktree) {
|
|
192
1783
|
const conflicts = [];
|
|
193
1784
|
const stmt = db.prepare(`
|
|
194
|
-
SELECT fc.*, t.title as task_title
|
|
1785
|
+
SELECT fc.*, t.title as task_title, l.id as lease_id, l.status as lease_status
|
|
195
1786
|
FROM file_claims fc
|
|
196
1787
|
JOIN tasks t ON fc.task_id = t.id
|
|
1788
|
+
LEFT JOIN leases l ON fc.lease_id = l.id
|
|
197
1789
|
WHERE fc.file_path=?
|
|
198
1790
|
AND fc.released_at IS NULL
|
|
199
1791
|
AND fc.worktree != ?
|
|
@@ -209,23 +1801,40 @@ export function checkFileConflicts(db, filePaths, excludeWorktree) {
|
|
|
209
1801
|
// ─── Worktrees ────────────────────────────────────────────────────────────────
|
|
210
1802
|
|
|
211
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;
|
|
212
1807
|
db.prepare(`
|
|
213
1808
|
INSERT INTO worktrees (name, path, branch, agent)
|
|
214
1809
|
VALUES (?, ?, ?, ?)
|
|
215
1810
|
ON CONFLICT(name) DO UPDATE SET
|
|
216
1811
|
path=excluded.path, branch=excluded.branch,
|
|
217
1812
|
agent=excluded.agent, last_seen=datetime('now'), status='idle'
|
|
218
|
-
`).run(
|
|
1813
|
+
`).run(canonicalName, normalizedPath, branch, agent || null);
|
|
219
1814
|
}
|
|
220
1815
|
|
|
221
1816
|
export function listWorktrees(db) {
|
|
222
1817
|
return db.prepare(`SELECT * FROM worktrees ORDER BY registered_at`).all();
|
|
223
1818
|
}
|
|
224
1819
|
|
|
1820
|
+
export function getWorktree(db, name) {
|
|
1821
|
+
return db.prepare(`SELECT * FROM worktrees WHERE name=?`).get(name);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
225
1824
|
export function updateWorktreeStatus(db, name, status) {
|
|
226
1825
|
db.prepare(`UPDATE worktrees SET status=?, last_seen=datetime('now') WHERE name=?`).run(status, name);
|
|
227
1826
|
}
|
|
228
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
|
+
|
|
229
1838
|
// ─── Conflict Log ─────────────────────────────────────────────────────────────
|
|
230
1839
|
|
|
231
1840
|
export function logConflict(db, worktreeA, worktreeB, conflictingFiles) {
|
|
@@ -235,6 +1844,172 @@ export function logConflict(db, worktreeA, worktreeB, conflictingFiles) {
|
|
|
235
1844
|
`).run(worktreeA, worktreeB, JSON.stringify(conflictingFiles));
|
|
236
1845
|
}
|
|
237
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
|
+
|
|
238
2013
|
export function getConflictLog(db) {
|
|
239
2014
|
return db.prepare(`SELECT * FROM conflict_log ORDER BY detected_at DESC LIMIT 50`).all();
|
|
240
2015
|
}
|