switchman-dev 0.1.7 → 0.1.8
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/.github/workflows/ci.yml +26 -0
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +113 -0
- package/README.md +130 -16
- package/examples/README.md +9 -2
- package/package.json +6 -1
- package/src/cli/index.js +1413 -73
- package/src/core/ci.js +1 -1
- package/src/core/db.js +143 -21
- package/src/core/enforcement.js +122 -10
- package/src/core/ignore.js +1 -0
- package/src/core/licence.js +365 -0
- package/src/core/mcp.js +41 -2
- package/src/core/merge-gate.js +5 -3
- package/src/core/outcome.js +43 -44
- package/src/core/pipeline.js +66 -35
- package/src/core/planner.js +10 -6
- package/src/core/policy.js +1 -1
- package/src/core/queue.js +11 -2
- package/src/core/sync.js +216 -0
- package/src/mcp/server.js +18 -6
- package/tests.zip +0 -0
package/src/core/ci.js
CHANGED
package/src/core/db.js
CHANGED
|
@@ -14,7 +14,7 @@ const SWITCHMAN_DIR = '.switchman';
|
|
|
14
14
|
const DB_FILE = 'switchman.db';
|
|
15
15
|
const AUDIT_KEY_FILE = 'audit.key';
|
|
16
16
|
const MIGRATION_STATE_FILE = 'migration-state.json';
|
|
17
|
-
const CURRENT_SCHEMA_VERSION =
|
|
17
|
+
const CURRENT_SCHEMA_VERSION = 6;
|
|
18
18
|
|
|
19
19
|
// How long (ms) a writer will wait for a lock before giving up.
|
|
20
20
|
// 5 seconds is generous for a CLI tool with 3-10 concurrent agents.
|
|
@@ -22,6 +22,7 @@ const BUSY_TIMEOUT_MS = 10000;
|
|
|
22
22
|
const CLAIM_RETRY_DELAY_MS = 200;
|
|
23
23
|
const CLAIM_RETRY_ATTEMPTS = 20;
|
|
24
24
|
export const DEFAULT_STALE_LEASE_MINUTES = 15;
|
|
25
|
+
const DB_PRUNE_RETENTION_DAYS = 30;
|
|
25
26
|
|
|
26
27
|
function sleepSync(ms) {
|
|
27
28
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
@@ -524,6 +525,24 @@ function applySchemaVersion5(db) {
|
|
|
524
525
|
`);
|
|
525
526
|
}
|
|
526
527
|
|
|
528
|
+
function applySchemaVersion6(db) {
|
|
529
|
+
db.exec(`
|
|
530
|
+
UPDATE file_claims
|
|
531
|
+
SET released_at = COALESCE(released_at, datetime('now'))
|
|
532
|
+
WHERE released_at IS NULL
|
|
533
|
+
AND id NOT IN (
|
|
534
|
+
SELECT MIN(id)
|
|
535
|
+
FROM file_claims
|
|
536
|
+
WHERE released_at IS NULL
|
|
537
|
+
GROUP BY file_path
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_file_claims_active_path
|
|
541
|
+
ON file_claims(file_path)
|
|
542
|
+
WHERE released_at IS NULL;
|
|
543
|
+
`);
|
|
544
|
+
}
|
|
545
|
+
|
|
527
546
|
function ensureSchemaMigrated(db) {
|
|
528
547
|
const repoRoot = db.__switchmanRepoRoot;
|
|
529
548
|
if (!repoRoot) {
|
|
@@ -572,6 +591,9 @@ function ensureSchemaMigrated(db) {
|
|
|
572
591
|
if (currentVersion < 5) {
|
|
573
592
|
applySchemaVersion5(db);
|
|
574
593
|
}
|
|
594
|
+
if (currentVersion < 6) {
|
|
595
|
+
applySchemaVersion6(db);
|
|
596
|
+
}
|
|
575
597
|
setSchemaVersion(db, CURRENT_SCHEMA_VERSION);
|
|
576
598
|
});
|
|
577
599
|
clearMigrationState(repoRoot);
|
|
@@ -826,8 +848,8 @@ function reserveLeaseScopesTx(db, lease) {
|
|
|
826
848
|
const conflicts = findScopeReservationConflicts(reservations, activeReservations);
|
|
827
849
|
if (conflicts.length > 0) {
|
|
828
850
|
const summary = conflicts[0].type === 'subsystem'
|
|
829
|
-
?
|
|
830
|
-
: `${conflicts[0].
|
|
851
|
+
? `${conflicts[0].worktree} already owns subsystem:${conflicts[0].subsystem_tag}`
|
|
852
|
+
: `${conflicts[0].worktree} already owns ${conflicts[0].conflicting_scope_pattern} (requested ${conflicts[0].scope_pattern})`;
|
|
831
853
|
logAuditEventTx(db, {
|
|
832
854
|
eventType: 'scope_reservation_denied',
|
|
833
855
|
status: 'denied',
|
|
@@ -837,7 +859,7 @@ function reserveLeaseScopesTx(db, lease) {
|
|
|
837
859
|
leaseId: lease.id,
|
|
838
860
|
details: JSON.stringify({ conflicts, summary }),
|
|
839
861
|
});
|
|
840
|
-
throw new Error(`Scope
|
|
862
|
+
throw new Error(`Scope ownership conflict: ${summary}`);
|
|
841
863
|
}
|
|
842
864
|
|
|
843
865
|
const insert = db.prepare(`
|
|
@@ -1672,20 +1694,27 @@ export function createTask(db, { id, title, description, priority = 5 }) {
|
|
|
1672
1694
|
}
|
|
1673
1695
|
|
|
1674
1696
|
export function startTaskLease(db, taskId, worktree, agent) {
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1697
|
+
try {
|
|
1698
|
+
return withImmediateTransaction(db, () => {
|
|
1699
|
+
const task = getTaskTx(db, taskId);
|
|
1700
|
+
if (!task || task.status !== 'pending') {
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1680
1703
|
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1704
|
+
db.prepare(`
|
|
1705
|
+
UPDATE tasks
|
|
1706
|
+
SET status='in_progress', worktree=?, agent=?, updated_at=datetime('now')
|
|
1707
|
+
WHERE id=? AND status='pending'
|
|
1708
|
+
`).run(worktree, agent || null, taskId);
|
|
1686
1709
|
|
|
1687
|
-
|
|
1688
|
-
|
|
1710
|
+
return createLeaseTx(db, { taskId, worktree, agent });
|
|
1711
|
+
});
|
|
1712
|
+
} catch (err) {
|
|
1713
|
+
if (String(err?.message || '').startsWith('Scope ownership conflict:')) {
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
throw err;
|
|
1717
|
+
}
|
|
1689
1718
|
}
|
|
1690
1719
|
|
|
1691
1720
|
export function assignTask(db, taskId, worktree, agent) {
|
|
@@ -1693,14 +1722,52 @@ export function assignTask(db, taskId, worktree, agent) {
|
|
|
1693
1722
|
}
|
|
1694
1723
|
|
|
1695
1724
|
export function completeTask(db, taskId) {
|
|
1696
|
-
withImmediateTransaction(db, () => {
|
|
1725
|
+
return withImmediateTransaction(db, () => {
|
|
1726
|
+
const task = getTaskTx(db, taskId);
|
|
1727
|
+
if (!task) {
|
|
1728
|
+
throw new Error(`Task ${taskId} does not exist.`);
|
|
1729
|
+
}
|
|
1730
|
+
if (task.status === 'done') {
|
|
1731
|
+
return {
|
|
1732
|
+
ok: false,
|
|
1733
|
+
status: 'already_done',
|
|
1734
|
+
task: getTaskTx(db, taskId),
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
if (task.status === 'failed') {
|
|
1738
|
+
return {
|
|
1739
|
+
ok: false,
|
|
1740
|
+
status: 'failed',
|
|
1741
|
+
task: getTaskTx(db, taskId),
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
if (task.status !== 'in_progress') {
|
|
1745
|
+
return {
|
|
1746
|
+
ok: false,
|
|
1747
|
+
status: 'not_in_progress',
|
|
1748
|
+
task: getTaskTx(db, taskId),
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1697
1751
|
const activeLease = getActiveLeaseForTaskTx(db, taskId);
|
|
1752
|
+
if (!activeLease) {
|
|
1753
|
+
return {
|
|
1754
|
+
ok: false,
|
|
1755
|
+
status: 'no_active_lease',
|
|
1756
|
+
task: getTaskTx(db, taskId),
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1698
1759
|
finalizeTaskWithLeaseTx(db, taskId, activeLease, {
|
|
1699
1760
|
taskStatus: 'done',
|
|
1700
1761
|
leaseStatus: 'completed',
|
|
1701
1762
|
auditStatus: 'allowed',
|
|
1702
1763
|
auditEventType: 'task_completed',
|
|
1703
1764
|
});
|
|
1765
|
+
return {
|
|
1766
|
+
ok: true,
|
|
1767
|
+
status: 'completed',
|
|
1768
|
+
had_active_lease: true,
|
|
1769
|
+
task: getTaskTx(db, taskId),
|
|
1770
|
+
};
|
|
1704
1771
|
});
|
|
1705
1772
|
}
|
|
1706
1773
|
|
|
@@ -1755,7 +1822,13 @@ export function failLeaseTask(db, leaseId, reason) {
|
|
|
1755
1822
|
export function retryTask(db, taskId, reason = null) {
|
|
1756
1823
|
return withImmediateTransaction(db, () => {
|
|
1757
1824
|
const task = getTaskTx(db, taskId);
|
|
1758
|
-
if (!task
|
|
1825
|
+
if (!task) {
|
|
1826
|
+
return null;
|
|
1827
|
+
}
|
|
1828
|
+
const activeLease = getActiveLeaseForTaskTx(db, taskId);
|
|
1829
|
+
const retryable = ['failed', 'done'].includes(task.status)
|
|
1830
|
+
|| (task.status === 'in_progress' && !activeLease);
|
|
1831
|
+
if (!retryable) {
|
|
1759
1832
|
return null;
|
|
1760
1833
|
}
|
|
1761
1834
|
|
|
@@ -1766,7 +1839,7 @@ export function retryTask(db, taskId, reason = null) {
|
|
|
1766
1839
|
agent=NULL,
|
|
1767
1840
|
completed_at=NULL,
|
|
1768
1841
|
updated_at=datetime('now')
|
|
1769
|
-
WHERE id=? AND status IN ('failed', 'done')
|
|
1842
|
+
WHERE id=? AND status IN ('failed', 'done', 'in_progress')
|
|
1770
1843
|
`).run(taskId);
|
|
1771
1844
|
|
|
1772
1845
|
logAuditEventTx(db, {
|
|
@@ -2624,7 +2697,7 @@ export function getStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINUT
|
|
|
2624
2697
|
FROM leases l
|
|
2625
2698
|
JOIN tasks t ON l.task_id = t.id
|
|
2626
2699
|
WHERE l.status='active'
|
|
2627
|
-
AND l.heartbeat_at
|
|
2700
|
+
AND l.heartbeat_at <= datetime('now', ?)
|
|
2628
2701
|
ORDER BY l.heartbeat_at ASC
|
|
2629
2702
|
`).all(`-${staleAfterMinutes} minutes`);
|
|
2630
2703
|
}
|
|
@@ -2699,6 +2772,44 @@ export function reapStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINU
|
|
|
2699
2772
|
});
|
|
2700
2773
|
}
|
|
2701
2774
|
|
|
2775
|
+
export function pruneDatabaseMaintenance(db, { retentionDays = DB_PRUNE_RETENTION_DAYS } = {}) {
|
|
2776
|
+
const retentionWindow = `-${Math.max(1, Number.parseInt(retentionDays, 10) || DB_PRUNE_RETENTION_DAYS)} days`;
|
|
2777
|
+
return withImmediateTransaction(db, () => {
|
|
2778
|
+
const releasedClaims = db.prepare(`
|
|
2779
|
+
DELETE FROM file_claims
|
|
2780
|
+
WHERE released_at IS NOT NULL
|
|
2781
|
+
AND released_at <= datetime('now', ?)
|
|
2782
|
+
`).run(retentionWindow).changes;
|
|
2783
|
+
|
|
2784
|
+
const releasedReservations = db.prepare(`
|
|
2785
|
+
DELETE FROM scope_reservations
|
|
2786
|
+
WHERE released_at IS NOT NULL
|
|
2787
|
+
AND released_at <= datetime('now', ?)
|
|
2788
|
+
`).run(retentionWindow).changes;
|
|
2789
|
+
|
|
2790
|
+
const finishedLeases = db.prepare(`
|
|
2791
|
+
DELETE FROM leases
|
|
2792
|
+
WHERE status != 'active'
|
|
2793
|
+
AND finished_at IS NOT NULL
|
|
2794
|
+
AND finished_at <= datetime('now', ?)
|
|
2795
|
+
`).run(retentionWindow).changes;
|
|
2796
|
+
|
|
2797
|
+
const orphanedSnapshots = db.prepare(`
|
|
2798
|
+
DELETE FROM worktree_snapshots
|
|
2799
|
+
WHERE worktree NOT IN (
|
|
2800
|
+
SELECT name FROM worktrees
|
|
2801
|
+
)
|
|
2802
|
+
`).run().changes;
|
|
2803
|
+
|
|
2804
|
+
return {
|
|
2805
|
+
released_claims_pruned: releasedClaims,
|
|
2806
|
+
finished_leases_pruned: finishedLeases,
|
|
2807
|
+
released_scope_reservations_pruned: releasedReservations,
|
|
2808
|
+
orphaned_snapshots_pruned: orphanedSnapshots,
|
|
2809
|
+
};
|
|
2810
|
+
});
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2702
2813
|
// ─── File Claims ──────────────────────────────────────────────────────────────
|
|
2703
2814
|
|
|
2704
2815
|
export function claimFiles(db, taskId, worktree, filePaths, agent) {
|
|
@@ -2746,7 +2857,18 @@ export function claimFiles(db, taskId, worktree, filePaths, agent) {
|
|
|
2746
2857
|
throw new Error('One or more files are already actively claimed by another task.');
|
|
2747
2858
|
}
|
|
2748
2859
|
|
|
2749
|
-
|
|
2860
|
+
try {
|
|
2861
|
+
insert.run(taskId, lease.id, normalizedPath, worktree, agent || null);
|
|
2862
|
+
} catch (err) {
|
|
2863
|
+
const message = String(err?.message || '').toLowerCase();
|
|
2864
|
+
const isActiveClaimConstraint =
|
|
2865
|
+
message.includes('idx_file_claims_active_path')
|
|
2866
|
+
|| (message.includes('unique') && message.includes('file_claims'));
|
|
2867
|
+
if (isActiveClaimConstraint) {
|
|
2868
|
+
throw new Error('One or more files are already actively claimed by another task.');
|
|
2869
|
+
}
|
|
2870
|
+
throw err;
|
|
2871
|
+
}
|
|
2750
2872
|
activeClaimByPath.set(normalizedPath, {
|
|
2751
2873
|
task_id: taskId,
|
|
2752
2874
|
lease_id: lease.id,
|
package/src/core/enforcement.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
1
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
2
2
|
import { dirname, join, posix, relative, resolve } from 'path';
|
|
3
3
|
import { execFileSync, spawnSync } from 'child_process';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
getActiveFileClaims,
|
|
7
|
+
touchBoundaryValidationState,
|
|
7
8
|
getCompletedFileClaims,
|
|
8
9
|
getLease,
|
|
9
|
-
getTaskSpec,
|
|
10
10
|
getWorktree,
|
|
11
|
+
listWorktrees,
|
|
11
12
|
getWorktreeSnapshotState,
|
|
12
13
|
replaceWorktreeSnapshotState,
|
|
13
14
|
getStaleLeases,
|
|
15
|
+
listScopeReservations,
|
|
14
16
|
listAuditEvents,
|
|
15
17
|
listLeases,
|
|
18
|
+
listTasks,
|
|
19
|
+
getTask,
|
|
20
|
+
getTaskSpec,
|
|
16
21
|
logAuditEvent,
|
|
17
22
|
updateWorktreeCompliance,
|
|
18
23
|
} from './db.js';
|
|
@@ -30,6 +35,14 @@ const DEFAULT_ENFORCEMENT_POLICY = {
|
|
|
30
35
|
allowed_generated_paths: [],
|
|
31
36
|
};
|
|
32
37
|
|
|
38
|
+
function normalizeFsPath(filePath) {
|
|
39
|
+
try {
|
|
40
|
+
return realpathSync(filePath);
|
|
41
|
+
} catch {
|
|
42
|
+
return resolve(filePath);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
function getEnforcementPolicyPath(repoRoot) {
|
|
34
47
|
return join(repoRoot, '.switchman', 'enforcement.json');
|
|
35
48
|
}
|
|
@@ -125,7 +138,19 @@ function diffSnapshots(previousSnapshot, currentSnapshot) {
|
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
function getLeaseScopePatterns(db, lease) {
|
|
128
|
-
|
|
141
|
+
const reservations = listScopeReservations(db, { leaseId: lease.id });
|
|
142
|
+
const scopePatterns = reservations
|
|
143
|
+
.filter((reservation) => reservation.ownership_level === 'path_scope' && reservation.scope_pattern)
|
|
144
|
+
.map((reservation) => reservation.scope_pattern);
|
|
145
|
+
return [...new Set(scopePatterns)];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getLeaseSubsystemTags(db, lease) {
|
|
149
|
+
const reservations = listScopeReservations(db, { leaseId: lease.id });
|
|
150
|
+
const subsystemTags = reservations
|
|
151
|
+
.filter((reservation) => reservation.ownership_level === 'subsystem' && reservation.subsystem_tag)
|
|
152
|
+
.map((reservation) => reservation.subsystem_tag);
|
|
153
|
+
return [...new Set(subsystemTags)];
|
|
129
154
|
}
|
|
130
155
|
|
|
131
156
|
function findScopedLeaseOwner(db, leases, filePath, excludeLeaseId = null) {
|
|
@@ -139,6 +164,18 @@ function findScopedLeaseOwner(db, leases, filePath, excludeLeaseId = null) {
|
|
|
139
164
|
return null;
|
|
140
165
|
}
|
|
141
166
|
|
|
167
|
+
function findSubsystemLeaseOwner(db, leases, subsystemTags, excludeLeaseId = null) {
|
|
168
|
+
if (!subsystemTags.length) return null;
|
|
169
|
+
for (const lease of leases) {
|
|
170
|
+
if (excludeLeaseId && lease.id === excludeLeaseId) continue;
|
|
171
|
+
const leaseTags = getLeaseSubsystemTags(db, lease);
|
|
172
|
+
if (leaseTags.some((tag) => subsystemTags.includes(tag))) {
|
|
173
|
+
return lease;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
142
179
|
function normalizeDirectoryScopeRoot(pattern) {
|
|
143
180
|
return String(pattern || '').replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/\*$/, '').replace(/\/+$/, '');
|
|
144
181
|
}
|
|
@@ -163,8 +200,13 @@ function resolveLeasePathOwnership(db, lease, filePath, activeClaims, activeLeas
|
|
|
163
200
|
}
|
|
164
201
|
|
|
165
202
|
const ownScopePatterns = getLeaseScopePatterns(db, lease);
|
|
203
|
+
const ownSubsystemTags = getLeaseSubsystemTags(db, lease);
|
|
166
204
|
const ownScopeMatch = ownScopePatterns.length > 0 && matchesPathPatterns(filePath, ownScopePatterns);
|
|
167
205
|
if (ownScopeMatch) {
|
|
206
|
+
const foreignSubsystemOwner = findSubsystemLeaseOwner(db, activeLeases, ownSubsystemTags, lease.id);
|
|
207
|
+
if (foreignSubsystemOwner) {
|
|
208
|
+
return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
|
|
209
|
+
}
|
|
168
210
|
const foreignScopeOwner = findScopedLeaseOwner(db, activeLeases, filePath, lease.id);
|
|
169
211
|
if (foreignScopeOwner) {
|
|
170
212
|
return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
|
|
@@ -177,9 +219,25 @@ function resolveLeasePathOwnership(db, lease, filePath, activeClaims, activeLeas
|
|
|
177
219
|
return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
|
|
178
220
|
}
|
|
179
221
|
|
|
222
|
+
const foreignSubsystemOwner = findSubsystemLeaseOwner(db, activeLeases, ownSubsystemTags, lease.id);
|
|
223
|
+
if (foreignSubsystemOwner) {
|
|
224
|
+
return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
|
|
225
|
+
}
|
|
226
|
+
|
|
180
227
|
return { ok: false, reason_code: 'path_not_claimed', claim: null, ownership_type: null };
|
|
181
228
|
}
|
|
182
229
|
|
|
230
|
+
function completedScopedTaskOwnsPath(db, completedTasks, filePath) {
|
|
231
|
+
for (const task of completedTasks) {
|
|
232
|
+
const taskSpec = getTaskSpec(db, task.id);
|
|
233
|
+
const allowedPaths = Array.isArray(taskSpec?.allowed_paths) ? taskSpec.allowed_paths : [];
|
|
234
|
+
if (allowedPaths.length > 0 && matchesPathPatterns(filePath, allowedPaths)) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
183
241
|
function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
|
|
184
242
|
const activeLeases = options.activeLeases || listLeases(db, 'active');
|
|
185
243
|
const activeClaims = options.activeClaims || getActiveFileClaims(db);
|
|
@@ -188,12 +246,28 @@ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
|
|
|
188
246
|
|
|
189
247
|
const activeLease = activeLeases.find((lease) => lease.worktree === worktree.name) || null;
|
|
190
248
|
const claim = activeClaims.find((item) => item.file_path === filePath && item.worktree === worktree.name) || null;
|
|
249
|
+
const foreignClaim = activeClaims.find((item) => item.file_path === filePath && item.worktree !== worktree.name) || null;
|
|
250
|
+
const foreignScopeOwner = findScopedLeaseOwner(db, activeLeases, filePath, activeLease?.id || null);
|
|
191
251
|
|
|
192
252
|
if (!activeLease) {
|
|
193
|
-
return {
|
|
253
|
+
return {
|
|
254
|
+
status: 'denied',
|
|
255
|
+
reason_code: 'no_active_lease',
|
|
256
|
+
lease: null,
|
|
257
|
+
claim,
|
|
258
|
+
owner_claim: foreignClaim,
|
|
259
|
+
owner_lease: foreignScopeOwner,
|
|
260
|
+
};
|
|
194
261
|
}
|
|
195
262
|
if (staleLeaseIds.has(activeLease.id)) {
|
|
196
|
-
return {
|
|
263
|
+
return {
|
|
264
|
+
status: 'denied',
|
|
265
|
+
reason_code: 'lease_expired',
|
|
266
|
+
lease: activeLease,
|
|
267
|
+
claim,
|
|
268
|
+
owner_claim: foreignClaim,
|
|
269
|
+
owner_lease: foreignScopeOwner,
|
|
270
|
+
};
|
|
197
271
|
}
|
|
198
272
|
const ownership = resolveLeasePathOwnership(db, activeLease, filePath, activeClaims, activeLeases);
|
|
199
273
|
if (ownership.ok) {
|
|
@@ -203,12 +277,30 @@ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
|
|
|
203
277
|
lease: activeLease,
|
|
204
278
|
claim: ownership.claim ?? claim,
|
|
205
279
|
ownership_type: ownership.ownership_type,
|
|
280
|
+
owner_claim: null,
|
|
281
|
+
owner_lease: null,
|
|
206
282
|
};
|
|
207
283
|
}
|
|
208
284
|
if (matchesPathPatterns(filePath, policy.allowed_generated_paths || [])) {
|
|
209
|
-
return {
|
|
285
|
+
return {
|
|
286
|
+
status: 'allowed',
|
|
287
|
+
reason_code: 'policy_exception_allowed',
|
|
288
|
+
lease: activeLease,
|
|
289
|
+
claim: null,
|
|
290
|
+
ownership_type: 'policy',
|
|
291
|
+
owner_claim: null,
|
|
292
|
+
owner_lease: null,
|
|
293
|
+
};
|
|
210
294
|
}
|
|
211
|
-
return {
|
|
295
|
+
return {
|
|
296
|
+
status: 'denied',
|
|
297
|
+
reason_code: ownership.reason_code,
|
|
298
|
+
lease: activeLease,
|
|
299
|
+
claim: ownership.claim ?? claim,
|
|
300
|
+
ownership_type: null,
|
|
301
|
+
owner_claim: ownership.claim ?? foreignClaim,
|
|
302
|
+
owner_lease: foreignScopeOwner,
|
|
303
|
+
};
|
|
212
304
|
}
|
|
213
305
|
|
|
214
306
|
function normalizeRepoPath(repoRoot, targetPath) {
|
|
@@ -361,6 +453,9 @@ export function validateLeaseAccess(db, { leaseId, worktree = null }) {
|
|
|
361
453
|
}
|
|
362
454
|
|
|
363
455
|
function logWriteEvent(db, status, reasonCode, validation, eventType, details = null) {
|
|
456
|
+
if (status === 'allowed' && validation.lease?.id) {
|
|
457
|
+
touchBoundaryValidationState(db, validation.lease.id, `write:${details?.operation || 'unknown'}`);
|
|
458
|
+
}
|
|
364
459
|
logAuditEvent(db, {
|
|
365
460
|
eventType,
|
|
366
461
|
status,
|
|
@@ -716,6 +811,9 @@ export function evaluateWorktreeCompliance(db, repoRoot, worktree, options = {})
|
|
|
716
811
|
const activeLeases = options.activeLeases || listLeases(db, 'active');
|
|
717
812
|
const activeClaims = options.activeClaims || getActiveFileClaims(db);
|
|
718
813
|
const completedClaims = options.completedClaims || getCompletedFileClaims(db, worktree.name);
|
|
814
|
+
const completedTasks = options.completedTasks
|
|
815
|
+
|| listTasks(db)
|
|
816
|
+
.filter((task) => task.status === 'done' && task.worktree === worktree.name);
|
|
719
817
|
|
|
720
818
|
const changedFiles = getWorktreeChangedFiles(worktree.path, repoRoot);
|
|
721
819
|
const activeLease = activeLeases.find((lease) => lease.worktree === worktree.name) || null;
|
|
@@ -728,7 +826,7 @@ export function evaluateWorktreeCompliance(db, repoRoot, worktree, options = {})
|
|
|
728
826
|
for (const file of changedFiles) {
|
|
729
827
|
const completedClaim = completedClaimsByPath.get(file);
|
|
730
828
|
if (!activeLease) {
|
|
731
|
-
if (completedClaim) {
|
|
829
|
+
if (completedClaim || completedScopedTaskOwnsPath(db, completedTasks, file)) {
|
|
732
830
|
continue;
|
|
733
831
|
}
|
|
734
832
|
violations.push({ file, reason_code: 'no_active_lease' });
|
|
@@ -838,6 +936,9 @@ export function monitorWorktreesOnce(db, repoRoot, worktrees, options = {}) {
|
|
|
838
936
|
reason_code: classification.reason_code,
|
|
839
937
|
lease_id: classification.lease?.id ?? null,
|
|
840
938
|
task_id: classification.lease?.task_id ?? null,
|
|
939
|
+
owner_worktree: classification.owner_claim?.worktree || classification.owner_lease?.worktree || null,
|
|
940
|
+
owner_task_id: classification.owner_claim?.task_id || classification.owner_lease?.task_id || null,
|
|
941
|
+
owner_task_title: classification.owner_claim?.task_title || getTask(db, classification.owner_lease?.task_id || '')?.title || null,
|
|
841
942
|
enforcement_action: null,
|
|
842
943
|
quarantine_path: null,
|
|
843
944
|
};
|
|
@@ -853,6 +954,10 @@ export function monitorWorktreesOnce(db, repoRoot, worktrees, options = {}) {
|
|
|
853
954
|
details: JSON.stringify({ change_type: change.change_type }),
|
|
854
955
|
});
|
|
855
956
|
|
|
957
|
+
if (classification.status === 'allowed' && classification.lease?.id) {
|
|
958
|
+
touchBoundaryValidationState(db, classification.lease.id, `observed:${change.change_type}`);
|
|
959
|
+
}
|
|
960
|
+
|
|
856
961
|
if (classification.status === 'denied') {
|
|
857
962
|
updateWorktreeCompliance(db, worktree.name, COMPLIANCE_STATES.NON_COMPLIANT);
|
|
858
963
|
if (options.quarantine) {
|
|
@@ -894,9 +999,16 @@ export function runCommitGate(db, repoRoot, { cwd = process.cwd(), worktreeName
|
|
|
894
999
|
const currentWorktree = worktreeName
|
|
895
1000
|
? null
|
|
896
1001
|
: getCurrentWorktree(repoRoot, cwd);
|
|
1002
|
+
const registeredWorktree = worktreeName
|
|
1003
|
+
? getWorktree(db, worktreeName)
|
|
1004
|
+
: listWorktrees(db).find((entry) => normalizeFsPath(entry.path) === normalizeFsPath(cwd))
|
|
1005
|
+
|| (currentWorktree
|
|
1006
|
+
? listWorktrees(db).find((entry) => normalizeFsPath(entry.path) === normalizeFsPath(currentWorktree.path))
|
|
1007
|
+
: null)
|
|
1008
|
+
|| null;
|
|
897
1009
|
const resolvedWorktree = worktreeName
|
|
898
|
-
? { name: worktreeName, path: cwd }
|
|
899
|
-
: currentWorktree;
|
|
1010
|
+
? { name: worktreeName, path: registeredWorktree?.path || cwd }
|
|
1011
|
+
: registeredWorktree || currentWorktree;
|
|
900
1012
|
|
|
901
1013
|
if (!resolvedWorktree) {
|
|
902
1014
|
const result = {
|