sentinelayer-cli 0.8.0 → 0.8.2
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 +23 -2
- package/package.json +4 -4
- package/src/agents/ai-governance/index.js +12 -0
- package/src/agents/ai-governance/tools/base.js +171 -0
- package/src/agents/ai-governance/tools/eval-regression.js +47 -0
- package/src/agents/ai-governance/tools/hitl-audit.js +81 -0
- package/src/agents/ai-governance/tools/index.js +52 -0
- package/src/agents/ai-governance/tools/prompt-drift.js +42 -0
- package/src/agents/ai-governance/tools/provenance-check.js +69 -0
- package/src/agents/backend/index.js +12 -0
- package/src/agents/backend/tools/base.js +189 -0
- package/src/agents/backend/tools/circuit-breaker-check.js +123 -0
- package/src/agents/backend/tools/idempotency-audit.js +105 -0
- package/src/agents/backend/tools/index.js +87 -0
- package/src/agents/backend/tools/retry-audit.js +132 -0
- package/src/agents/backend/tools/timeout-audit.js +144 -0
- package/src/agents/code-quality/index.js +12 -0
- package/src/agents/code-quality/tools/base.js +159 -0
- package/src/agents/code-quality/tools/complexity-measure.js +197 -0
- package/src/agents/code-quality/tools/coupling-analysis.js +81 -0
- package/src/agents/code-quality/tools/cycle-detect.js +49 -0
- package/src/agents/code-quality/tools/dep-graph.js +196 -0
- package/src/agents/code-quality/tools/index.js +89 -0
- package/src/agents/data-layer/index.js +12 -0
- package/src/agents/data-layer/tools/base.js +181 -0
- package/src/agents/data-layer/tools/index-audit.js +165 -0
- package/src/agents/data-layer/tools/index.js +83 -0
- package/src/agents/data-layer/tools/migration-scan.js +135 -0
- package/src/agents/data-layer/tools/query-explain.js +120 -0
- package/src/agents/data-layer/tools/tenancy-scan.js +166 -0
- package/src/agents/documentation/index.js +12 -0
- package/src/agents/documentation/tools/api-diff.js +91 -0
- package/src/agents/documentation/tools/base.js +151 -0
- package/src/agents/documentation/tools/dead-link-check.js +58 -0
- package/src/agents/documentation/tools/docstring-coverage.js +78 -0
- package/src/agents/documentation/tools/index.js +52 -0
- package/src/agents/documentation/tools/readme-freshness.js +61 -0
- package/src/agents/envelope/fix-cycle.js +45 -0
- package/src/agents/envelope/index.js +31 -0
- package/src/agents/envelope/loop.js +150 -0
- package/src/agents/envelope/pulse.js +18 -0
- package/src/agents/envelope/stream.js +40 -0
- package/src/agents/infrastructure/index.js +12 -0
- package/src/agents/infrastructure/tools/base.js +171 -0
- package/src/agents/infrastructure/tools/checkov-run.js +32 -0
- package/src/agents/infrastructure/tools/drift-detect.js +59 -0
- package/src/agents/infrastructure/tools/iam-least-priv-check.js +78 -0
- package/src/agents/infrastructure/tools/index.js +52 -0
- package/src/agents/infrastructure/tools/tflint-run.js +31 -0
- package/src/agents/jules/loop.js +7 -4
- package/src/agents/jules/swarm/sub-agent.js +5 -1
- package/src/agents/jules/tools/auth-audit.js +10 -1
- package/src/agents/mode.js +113 -0
- package/src/agents/observability/index.js +12 -0
- package/src/agents/observability/tools/alert-audit.js +39 -0
- package/src/agents/observability/tools/base.js +181 -0
- package/src/agents/observability/tools/dashboard-gap.js +42 -0
- package/src/agents/observability/tools/index.js +54 -0
- package/src/agents/observability/tools/log-schema-check.js +74 -0
- package/src/agents/observability/tools/span-coverage.js +74 -0
- package/src/agents/persona-visuals.js +38 -0
- package/src/agents/release/index.js +12 -0
- package/src/agents/release/tools/base.js +181 -0
- package/src/agents/release/tools/changelog-diff.js +86 -0
- package/src/agents/release/tools/feature-flag-audit.js +126 -0
- package/src/agents/release/tools/index.js +61 -0
- package/src/agents/release/tools/rollback-verify.js +129 -0
- package/src/agents/release/tools/semver-check.js +109 -0
- package/src/agents/reliability/index.js +12 -0
- package/src/agents/reliability/tools/backpressure-check.js +129 -0
- package/src/agents/reliability/tools/base.js +181 -0
- package/src/agents/reliability/tools/chaos-probe.js +109 -0
- package/src/agents/reliability/tools/graceful-degradation-check.js +114 -0
- package/src/agents/reliability/tools/health-check-audit.js +111 -0
- package/src/agents/reliability/tools/index.js +87 -0
- package/src/agents/run-persona.js +109 -0
- package/src/agents/security/index.js +12 -0
- package/src/agents/security/tools/authz-audit.js +134 -0
- package/src/agents/security/tools/base.js +190 -0
- package/src/agents/security/tools/crypto-review.js +175 -0
- package/src/agents/security/tools/index.js +97 -0
- package/src/agents/security/tools/sast-scan.js +175 -0
- package/src/agents/security/tools/secrets-scan.js +216 -0
- package/src/agents/supply-chain/index.js +12 -0
- package/src/agents/supply-chain/tools/attestation-check.js +42 -0
- package/src/agents/supply-chain/tools/base.js +151 -0
- package/src/agents/supply-chain/tools/index.js +52 -0
- package/src/agents/supply-chain/tools/lockfile-integrity.js +73 -0
- package/src/agents/supply-chain/tools/package-verify.js +56 -0
- package/src/agents/supply-chain/tools/sbom-diff.js +34 -0
- package/src/agents/testing/index.js +12 -0
- package/src/agents/testing/tools/base.js +202 -0
- package/src/agents/testing/tools/coverage-gap.js +144 -0
- package/src/agents/testing/tools/flake-detect.js +125 -0
- package/src/agents/testing/tools/index.js +85 -0
- package/src/agents/testing/tools/mutation-test.js +143 -0
- package/src/agents/testing/tools/snapshot-diff.js +103 -0
- package/src/auth/gate.js +65 -37
- package/src/cli.js +1 -1
- package/src/commands/chat.js +3 -10
- package/src/commands/legacy-args.js +10 -0
- package/src/commands/omargate.js +36 -2
- package/src/commands/persona.js +46 -1
- package/src/commands/scan.js +3 -10
- package/src/commands/session.js +654 -6
- package/src/commands/spec.js +3 -10
- package/src/coord/events-log.js +141 -0
- package/src/coord/handshake.js +719 -0
- package/src/coord/index.js +35 -0
- package/src/coord/paths.js +84 -0
- package/src/coord/priority.js +62 -0
- package/src/coord/tarjan.js +157 -0
- package/src/cost/tokenizer.js +160 -0
- package/src/cost/tracker.js +61 -0
- package/src/daemon/artifact-lineage.js +362 -0
- package/src/daemon/assignment-ledger.js +117 -0
- package/src/daemon/ast-drift.js +496 -0
- package/src/daemon/ingest-refresh.js +69 -2
- package/src/ingest/engine.js +15 -0
- package/src/ingest/ownership.js +380 -0
- package/src/legacy-cli.js +68 -1
- package/src/orchestrator/kai-chen.js +126 -0
- package/src/review/ai-review.js +3 -10
- package/src/review/compliance-pack.js +389 -0
- package/src/review/investor-dd-config.js +54 -0
- package/src/review/investor-dd-file-loop.js +303 -0
- package/src/review/investor-dd-file-router.js +406 -0
- package/src/review/investor-dd-html-report.js +233 -0
- package/src/review/investor-dd-notification.js +120 -0
- package/src/review/investor-dd-orchestrator.js +405 -0
- package/src/review/investor-dd-persona-runner.js +275 -0
- package/src/review/live-validator.js +253 -0
- package/src/review/omargate-orchestrator.js +90 -2
- package/src/review/persona-prompts.js +244 -56
- package/src/review/reconciliation-rules.js +329 -0
- package/src/review/reproducibility-chain.js +136 -0
- package/src/review/scan-modes.js +102 -3
- package/src/session/agent-registry.js +7 -0
- package/src/session/analytics.js +479 -0
- package/src/session/daemon.js +609 -14
- package/src/session/file-locks.js +666 -0
- package/src/session/paths.js +4 -0
- package/src/session/recap.js +567 -0
- package/src/session/redact.js +82 -0
- package/src/session/runtime-bridge.js +24 -1
- package/src/session/scoring.js +406 -0
- package/src/session/setup-guides.js +304 -0
- package/src/session/store.js +318 -2
- package/src/session/stream.js +9 -1
- package/src/session/sync.js +753 -0
- package/src/session/tasks.js +1054 -0
- package/src/session/templates.js +188 -0
- package/src/swarm/runtime.js +1 -8
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
// LOCK / ACK / RELEASE handshake — #A9, spec §5.6.
|
|
2
|
+
//
|
|
3
|
+
// Cross-persona coordination primitive. One lock file per hashed repo path
|
|
4
|
+
// under .sentinel/locks/<hash>.lock.json. All state transitions stream into
|
|
5
|
+
// .sentinel/events.jsonl via events-log.js so the same log can drive Omar
|
|
6
|
+
// Gate's layer 2 lease verification, replay, and operator dashboards.
|
|
7
|
+
//
|
|
8
|
+
// Concurrency model:
|
|
9
|
+
// - A dir-based mutex at .sentinel/.lock-mutex.lock serializes every
|
|
10
|
+
// acquire / release so read-check-write races cannot split decisions.
|
|
11
|
+
// - The mutex is ~microseconds for well-behaved callers; the tradeoff is
|
|
12
|
+
// simplicity vs the wall clock of a fine-grained atomic design, and
|
|
13
|
+
// with ≤13 personas it's the right call.
|
|
14
|
+
// - Lock files are written via temp + rename for atomic publish once the
|
|
15
|
+
// acquire decision is committed.
|
|
16
|
+
//
|
|
17
|
+
// Fairness model:
|
|
18
|
+
// - Same-agent calls renew (the common re-entrant case during retries).
|
|
19
|
+
// - Higher-priority caller preempts and publishes a lock_preempted event
|
|
20
|
+
// so the incumbent can resume or bail.
|
|
21
|
+
// - Lower-or-equal priority caller is denied and a waiter entry is
|
|
22
|
+
// recorded for detectDeadlock().
|
|
23
|
+
|
|
24
|
+
import fsp from "node:fs/promises";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import process from "node:process";
|
|
28
|
+
import crypto from "node:crypto";
|
|
29
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
30
|
+
|
|
31
|
+
import { appendEvent } from "./events-log.js";
|
|
32
|
+
import {
|
|
33
|
+
resolveLocksDir,
|
|
34
|
+
resolveMutexLockPath,
|
|
35
|
+
resolveSentinelDir,
|
|
36
|
+
resolveWaitsLockPath,
|
|
37
|
+
resolveWaitsPath,
|
|
38
|
+
hashLockKey,
|
|
39
|
+
lockFileFor,
|
|
40
|
+
normalizeLockPath,
|
|
41
|
+
} from "./paths.js";
|
|
42
|
+
import {
|
|
43
|
+
PERSONA_PRIORITY,
|
|
44
|
+
lowestPriorityAgent,
|
|
45
|
+
outranks,
|
|
46
|
+
priorityIndex,
|
|
47
|
+
} from "./priority.js";
|
|
48
|
+
import { findCycles } from "./tarjan.js";
|
|
49
|
+
|
|
50
|
+
const LOCK_SCHEMA_VERSION = "1.0.0";
|
|
51
|
+
const MAX_TTL_S = 300;
|
|
52
|
+
const MIN_TTL_S = 1;
|
|
53
|
+
const DEFAULT_TTL_S = 120;
|
|
54
|
+
const MUTEX_TIMEOUT_MS = 10_000;
|
|
55
|
+
const MUTEX_STALE_MS = 30_000;
|
|
56
|
+
const MUTEX_POLL_MS = 25;
|
|
57
|
+
const WAIT_TTL_MS = 10 * 60 * 1000; // 10 minutes — waits older than this are stale.
|
|
58
|
+
|
|
59
|
+
function normalizeAgent(value) {
|
|
60
|
+
return String(value || "").trim().toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeReason(value) {
|
|
64
|
+
return String(value || "").trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeTtlSeconds(value) {
|
|
68
|
+
const candidate = Number(value);
|
|
69
|
+
if (!Number.isFinite(candidate) || candidate <= 0) {
|
|
70
|
+
return DEFAULT_TTL_S;
|
|
71
|
+
}
|
|
72
|
+
const floored = Math.floor(candidate);
|
|
73
|
+
if (floored < MIN_TTL_S) {
|
|
74
|
+
return MIN_TTL_S;
|
|
75
|
+
}
|
|
76
|
+
if (floored > MAX_TTL_S) {
|
|
77
|
+
return MAX_TTL_S;
|
|
78
|
+
}
|
|
79
|
+
return floored;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isoNow() {
|
|
83
|
+
return new Date().toISOString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isExpired(grant, nowIso = isoNow()) {
|
|
87
|
+
if (!grant || !grant.expiresAt) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const expiresEpoch = Date.parse(grant.expiresAt);
|
|
91
|
+
const nowEpoch = Date.parse(nowIso);
|
|
92
|
+
if (!Number.isFinite(expiresEpoch) || !Number.isFinite(nowEpoch)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return nowEpoch >= expiresEpoch;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readLockFile(lockPath) {
|
|
99
|
+
try {
|
|
100
|
+
const raw = await fsp.readFile(lockPath, "utf-8");
|
|
101
|
+
return JSON.parse(raw);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err && typeof err === "object" && err.code === "ENOENT") {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function writeLockFileAtomic(lockPath, grant) {
|
|
111
|
+
await fsp.mkdir(path.dirname(lockPath), { recursive: true });
|
|
112
|
+
const tmpPath = `${lockPath}.${process.pid}.${Date.now()}.tmp`;
|
|
113
|
+
await fsp.writeFile(tmpPath, `${JSON.stringify(grant, null, 2)}\n`, "utf-8");
|
|
114
|
+
await fsp.rename(tmpPath, lockPath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function deleteLockFile(lockPath) {
|
|
118
|
+
await fsp.rm(lockPath, { force: true }).catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function acquireMutex(
|
|
122
|
+
lockPath,
|
|
123
|
+
{
|
|
124
|
+
timeoutMs = MUTEX_TIMEOUT_MS,
|
|
125
|
+
staleMs = MUTEX_STALE_MS,
|
|
126
|
+
pollMs = MUTEX_POLL_MS,
|
|
127
|
+
} = {}
|
|
128
|
+
) {
|
|
129
|
+
const start = Date.now();
|
|
130
|
+
while (true) {
|
|
131
|
+
try {
|
|
132
|
+
await fsp.mkdir(lockPath);
|
|
133
|
+
return;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const code = err && typeof err === "object" ? err.code : "";
|
|
136
|
+
if (code !== "EEXIST" && code !== "EPERM" && code !== "EACCES") {
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const stat = await fsp.stat(lockPath);
|
|
141
|
+
const ageMs = Date.now() - Number(stat.mtimeMs || 0);
|
|
142
|
+
if (Number.isFinite(ageMs) && ageMs > staleMs) {
|
|
143
|
+
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Stat race — loop back.
|
|
148
|
+
}
|
|
149
|
+
if (Date.now() - start >= timeoutMs) {
|
|
150
|
+
throw new Error("Timed out waiting for .sentinel handshake mutex.");
|
|
151
|
+
}
|
|
152
|
+
await sleep(pollMs);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function releaseMutex(lockPath) {
|
|
158
|
+
await fsp.rm(lockPath, { recursive: true, force: true }).catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function withHandshakeMutex(targetPath, fn) {
|
|
162
|
+
const sentinelDir = resolveSentinelDir({ targetPath });
|
|
163
|
+
await fsp.mkdir(sentinelDir, { recursive: true });
|
|
164
|
+
const mutexPath = resolveMutexLockPath({ targetPath });
|
|
165
|
+
await acquireMutex(mutexPath);
|
|
166
|
+
try {
|
|
167
|
+
return await fn();
|
|
168
|
+
} finally {
|
|
169
|
+
await releaseMutex(mutexPath);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------- Waiters registry ----------------
|
|
174
|
+
|
|
175
|
+
async function acquireWaitsMutex(waitsLockPath) {
|
|
176
|
+
await acquireMutex(waitsLockPath);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function releaseWaitsMutex(waitsLockPath) {
|
|
180
|
+
await releaseMutex(waitsLockPath);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function readWaitsRegistry(waitsPath) {
|
|
184
|
+
try {
|
|
185
|
+
const raw = await fsp.readFile(waitsPath, "utf-8");
|
|
186
|
+
const parsed = JSON.parse(raw);
|
|
187
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
188
|
+
return { entries: {} };
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
entries:
|
|
192
|
+
parsed.entries && typeof parsed.entries === "object" && !Array.isArray(parsed.entries)
|
|
193
|
+
? parsed.entries
|
|
194
|
+
: {},
|
|
195
|
+
};
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (err && typeof err === "object" && err.code === "ENOENT") {
|
|
198
|
+
return { entries: {} };
|
|
199
|
+
}
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function writeWaitsRegistryAtomic(waitsPath, registry) {
|
|
205
|
+
await fsp.mkdir(path.dirname(waitsPath), { recursive: true });
|
|
206
|
+
const tmpPath = `${waitsPath}.${process.pid}.${Date.now()}.tmp`;
|
|
207
|
+
await fsp.writeFile(tmpPath, `${JSON.stringify(registry, null, 2)}\n`, "utf-8");
|
|
208
|
+
await fsp.rename(tmpPath, waitsPath);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pruneStaleWaits(entries, nowEpoch = Date.now()) {
|
|
212
|
+
const next = {};
|
|
213
|
+
const dropped = [];
|
|
214
|
+
for (const [agent, entry] of Object.entries(entries || {})) {
|
|
215
|
+
const recordedAt = Date.parse(entry?.recordedAt || "");
|
|
216
|
+
if (!Number.isFinite(recordedAt) || nowEpoch - recordedAt > WAIT_TTL_MS) {
|
|
217
|
+
dropped.push({ agent, entry });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
next[agent] = entry;
|
|
221
|
+
}
|
|
222
|
+
return { entries: next, dropped };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function mutateWaitsRegistry(targetPath, mutator) {
|
|
226
|
+
const waitsPath = resolveWaitsPath({ targetPath });
|
|
227
|
+
const waitsLockPath = resolveWaitsLockPath({ targetPath });
|
|
228
|
+
const sentinelDir = resolveSentinelDir({ targetPath });
|
|
229
|
+
await fsp.mkdir(sentinelDir, { recursive: true });
|
|
230
|
+
await acquireWaitsMutex(waitsLockPath);
|
|
231
|
+
try {
|
|
232
|
+
const registry = await readWaitsRegistry(waitsPath);
|
|
233
|
+
const pruned = pruneStaleWaits(registry.entries, Date.now());
|
|
234
|
+
registry.entries = pruned.entries;
|
|
235
|
+
const result = await mutator(registry, pruned.dropped);
|
|
236
|
+
await writeWaitsRegistryAtomic(waitsPath, registry);
|
|
237
|
+
return { result, dropped: pruned.dropped };
|
|
238
|
+
} finally {
|
|
239
|
+
await releaseWaitsMutex(waitsLockPath);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function recordWaiter({
|
|
244
|
+
targetPath,
|
|
245
|
+
agent,
|
|
246
|
+
blockedPath,
|
|
247
|
+
waitingForAgent,
|
|
248
|
+
reason,
|
|
249
|
+
}) {
|
|
250
|
+
const nowIso = isoNow();
|
|
251
|
+
await mutateWaitsRegistry(targetPath, async (registry) => {
|
|
252
|
+
registry.entries[agent] = {
|
|
253
|
+
agent,
|
|
254
|
+
blockedPath,
|
|
255
|
+
waitingForAgent,
|
|
256
|
+
reason,
|
|
257
|
+
recordedAt: nowIso,
|
|
258
|
+
};
|
|
259
|
+
return registry.entries[agent];
|
|
260
|
+
});
|
|
261
|
+
await appendEvent(
|
|
262
|
+
{
|
|
263
|
+
type: "wait_recorded",
|
|
264
|
+
agent,
|
|
265
|
+
path: blockedPath,
|
|
266
|
+
waitingForAgent,
|
|
267
|
+
reason,
|
|
268
|
+
ts: nowIso,
|
|
269
|
+
},
|
|
270
|
+
{ targetPath }
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function clearWaiter({ targetPath, agent, pathHint }) {
|
|
275
|
+
const nowIso = isoNow();
|
|
276
|
+
let cleared = null;
|
|
277
|
+
await mutateWaitsRegistry(targetPath, async (registry) => {
|
|
278
|
+
const existing = registry.entries[agent];
|
|
279
|
+
if (existing) {
|
|
280
|
+
cleared = existing;
|
|
281
|
+
delete registry.entries[agent];
|
|
282
|
+
}
|
|
283
|
+
return cleared;
|
|
284
|
+
});
|
|
285
|
+
if (cleared) {
|
|
286
|
+
await appendEvent(
|
|
287
|
+
{
|
|
288
|
+
type: "wait_cleared",
|
|
289
|
+
agent,
|
|
290
|
+
path: pathHint || cleared.blockedPath,
|
|
291
|
+
ts: nowIso,
|
|
292
|
+
},
|
|
293
|
+
{ targetPath }
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return cleared;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function listWaiters({ targetPath = process.cwd() } = {}) {
|
|
300
|
+
const { result } = await mutateWaitsRegistry(targetPath, async (registry) =>
|
|
301
|
+
Object.values(registry.entries)
|
|
302
|
+
);
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------- Active lock scanning ----------------
|
|
307
|
+
|
|
308
|
+
async function scanActiveLocks(targetPath) {
|
|
309
|
+
const locksDir = resolveLocksDir({ targetPath });
|
|
310
|
+
let entries;
|
|
311
|
+
try {
|
|
312
|
+
entries = await fsp.readdir(locksDir);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if (err && typeof err === "object" && err.code === "ENOENT") {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
const locks = [];
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
if (!entry.endsWith(".lock.json")) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const lockPath = path.join(locksDir, entry);
|
|
325
|
+
const grant = await readLockFile(lockPath);
|
|
326
|
+
if (!grant) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
locks.push({ ...grant, lockFile: lockPath });
|
|
330
|
+
}
|
|
331
|
+
return locks;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function sweepExpiredLocks(targetPath, nowIso = isoNow()) {
|
|
335
|
+
const active = await scanActiveLocks(targetPath);
|
|
336
|
+
const expired = [];
|
|
337
|
+
for (const grant of active) {
|
|
338
|
+
if (!isExpired(grant, nowIso)) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
await deleteLockFile(grant.lockFile);
|
|
342
|
+
expired.push(grant);
|
|
343
|
+
await appendEvent(
|
|
344
|
+
{
|
|
345
|
+
type: "lock_expired",
|
|
346
|
+
path: grant.path,
|
|
347
|
+
agent: grant.agent,
|
|
348
|
+
token: grant.token,
|
|
349
|
+
ts: nowIso,
|
|
350
|
+
},
|
|
351
|
+
{ targetPath }
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return expired;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------- Public API ----------------
|
|
358
|
+
|
|
359
|
+
export async function requestLock(req, { targetPath = process.cwd() } = {}) {
|
|
360
|
+
const agent = normalizeAgent(req?.agent);
|
|
361
|
+
if (!agent) {
|
|
362
|
+
throw new Error("agent is required.");
|
|
363
|
+
}
|
|
364
|
+
const normalizedPath = normalizeLockPath(req?.path, { targetPath });
|
|
365
|
+
const reason = normalizeReason(req?.reason);
|
|
366
|
+
const ttlSeconds = normalizeTtlSeconds(req?.ttl_s ?? req?.ttlSeconds);
|
|
367
|
+
|
|
368
|
+
return withHandshakeMutex(targetPath, async () => {
|
|
369
|
+
const nowIso = isoNow();
|
|
370
|
+
const lockPath = lockFileFor(normalizedPath, { targetPath });
|
|
371
|
+
let existing = await readLockFile(lockPath);
|
|
372
|
+
|
|
373
|
+
if (existing && isExpired(existing, nowIso)) {
|
|
374
|
+
await deleteLockFile(lockPath);
|
|
375
|
+
await appendEvent(
|
|
376
|
+
{
|
|
377
|
+
type: "lock_expired",
|
|
378
|
+
path: existing.path,
|
|
379
|
+
agent: existing.agent,
|
|
380
|
+
token: existing.token,
|
|
381
|
+
ts: nowIso,
|
|
382
|
+
},
|
|
383
|
+
{ targetPath }
|
|
384
|
+
);
|
|
385
|
+
existing = null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (existing) {
|
|
389
|
+
if (normalizeAgent(existing.agent) === agent) {
|
|
390
|
+
const renewedExpiresAt = new Date(
|
|
391
|
+
Date.parse(nowIso) + ttlSeconds * 1000
|
|
392
|
+
).toISOString();
|
|
393
|
+
const renewed = {
|
|
394
|
+
...existing,
|
|
395
|
+
grantedAt: existing.grantedAt || nowIso,
|
|
396
|
+
expiresAt: renewedExpiresAt,
|
|
397
|
+
ttlSeconds,
|
|
398
|
+
reason: reason || existing.reason || "",
|
|
399
|
+
};
|
|
400
|
+
await writeLockFileAtomic(lockPath, renewed);
|
|
401
|
+
await appendEvent(
|
|
402
|
+
{
|
|
403
|
+
type: "lock_renewed",
|
|
404
|
+
path: renewed.path,
|
|
405
|
+
agent: renewed.agent,
|
|
406
|
+
token: renewed.token,
|
|
407
|
+
ttlSeconds,
|
|
408
|
+
expiresAt: renewed.expiresAt,
|
|
409
|
+
ts: nowIso,
|
|
410
|
+
},
|
|
411
|
+
{ targetPath }
|
|
412
|
+
);
|
|
413
|
+
await clearWaiter({ targetPath, agent, pathHint: normalizedPath });
|
|
414
|
+
return {
|
|
415
|
+
granted: true,
|
|
416
|
+
renewed: true,
|
|
417
|
+
...publicGrant(renewed),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (outranks(agent, existing.agent)) {
|
|
422
|
+
await deleteLockFile(lockPath);
|
|
423
|
+
await appendEvent(
|
|
424
|
+
{
|
|
425
|
+
type: "lock_preempted",
|
|
426
|
+
path: normalizedPath,
|
|
427
|
+
preempted: existing.agent,
|
|
428
|
+
preemptedToken: existing.token,
|
|
429
|
+
newAgent: agent,
|
|
430
|
+
ts: nowIso,
|
|
431
|
+
},
|
|
432
|
+
{ targetPath }
|
|
433
|
+
);
|
|
434
|
+
existing = null;
|
|
435
|
+
} else {
|
|
436
|
+
const retryAfterSeconds = Math.max(
|
|
437
|
+
1,
|
|
438
|
+
Math.ceil((Date.parse(existing.expiresAt) - Date.parse(nowIso)) / 1000)
|
|
439
|
+
);
|
|
440
|
+
await appendEvent(
|
|
441
|
+
{
|
|
442
|
+
type: "lock_denied",
|
|
443
|
+
path: normalizedPath,
|
|
444
|
+
agent,
|
|
445
|
+
heldBy: existing.agent,
|
|
446
|
+
retryAfterSeconds,
|
|
447
|
+
ts: nowIso,
|
|
448
|
+
},
|
|
449
|
+
{ targetPath }
|
|
450
|
+
);
|
|
451
|
+
await recordWaiter({
|
|
452
|
+
targetPath,
|
|
453
|
+
agent,
|
|
454
|
+
blockedPath: normalizedPath,
|
|
455
|
+
waitingForAgent: normalizeAgent(existing.agent),
|
|
456
|
+
reason,
|
|
457
|
+
});
|
|
458
|
+
return {
|
|
459
|
+
granted: false,
|
|
460
|
+
retryAfterSeconds,
|
|
461
|
+
heldBy: normalizeAgent(existing.agent),
|
|
462
|
+
path: normalizedPath,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const token = crypto.randomUUID();
|
|
468
|
+
const grant = {
|
|
469
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
470
|
+
path: normalizedPath,
|
|
471
|
+
agent,
|
|
472
|
+
token,
|
|
473
|
+
grantedAt: nowIso,
|
|
474
|
+
expiresAt: new Date(Date.parse(nowIso) + ttlSeconds * 1000).toISOString(),
|
|
475
|
+
ttlSeconds,
|
|
476
|
+
reason,
|
|
477
|
+
holderPid: process.pid,
|
|
478
|
+
holderHostname: os.hostname(),
|
|
479
|
+
};
|
|
480
|
+
await writeLockFileAtomic(lockPath, grant);
|
|
481
|
+
|
|
482
|
+
const verify = await readLockFile(lockPath);
|
|
483
|
+
if (!verify || verify.token !== token) {
|
|
484
|
+
// Raced with someone else — under the mutex this should never happen,
|
|
485
|
+
// but defensive: report as a retry-after-1s denial rather than claiming
|
|
486
|
+
// a grant we don't actually own.
|
|
487
|
+
return {
|
|
488
|
+
granted: false,
|
|
489
|
+
retryAfterSeconds: 1,
|
|
490
|
+
heldBy: verify ? normalizeAgent(verify.agent) : null,
|
|
491
|
+
path: normalizedPath,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
await appendEvent(
|
|
496
|
+
{
|
|
497
|
+
type: "lock_granted",
|
|
498
|
+
path: normalizedPath,
|
|
499
|
+
agent,
|
|
500
|
+
token,
|
|
501
|
+
ttlSeconds,
|
|
502
|
+
expiresAt: grant.expiresAt,
|
|
503
|
+
reason,
|
|
504
|
+
ts: nowIso,
|
|
505
|
+
},
|
|
506
|
+
{ targetPath }
|
|
507
|
+
);
|
|
508
|
+
await clearWaiter({ targetPath, agent, pathHint: normalizedPath });
|
|
509
|
+
return {
|
|
510
|
+
granted: true,
|
|
511
|
+
renewed: false,
|
|
512
|
+
...publicGrant(grant),
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function releaseLock(
|
|
518
|
+
pathValue,
|
|
519
|
+
agentValue,
|
|
520
|
+
tokenValue,
|
|
521
|
+
diffHash,
|
|
522
|
+
{ targetPath = process.cwd() } = {}
|
|
523
|
+
) {
|
|
524
|
+
const agent = normalizeAgent(agentValue);
|
|
525
|
+
if (!agent) {
|
|
526
|
+
throw new Error("agent is required.");
|
|
527
|
+
}
|
|
528
|
+
const token = String(tokenValue || "").trim();
|
|
529
|
+
if (!token) {
|
|
530
|
+
throw new Error("token is required.");
|
|
531
|
+
}
|
|
532
|
+
const normalizedPath = normalizeLockPath(pathValue, { targetPath });
|
|
533
|
+
|
|
534
|
+
return withHandshakeMutex(targetPath, async () => {
|
|
535
|
+
const nowIso = isoNow();
|
|
536
|
+
const lockPath = lockFileFor(normalizedPath, { targetPath });
|
|
537
|
+
const existing = await readLockFile(lockPath);
|
|
538
|
+
if (!existing) {
|
|
539
|
+
return { released: false, reason: "not_locked", path: normalizedPath };
|
|
540
|
+
}
|
|
541
|
+
if (
|
|
542
|
+
normalizeAgent(existing.agent) !== agent ||
|
|
543
|
+
existing.token !== token
|
|
544
|
+
) {
|
|
545
|
+
return {
|
|
546
|
+
released: false,
|
|
547
|
+
reason: "mismatched_lease",
|
|
548
|
+
path: normalizedPath,
|
|
549
|
+
heldBy: normalizeAgent(existing.agent),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
await deleteLockFile(lockPath);
|
|
553
|
+
const normalizedDiffHash = String(diffHash || "").trim() || null;
|
|
554
|
+
await appendEvent(
|
|
555
|
+
{
|
|
556
|
+
type: "lock_released",
|
|
557
|
+
path: normalizedPath,
|
|
558
|
+
agent,
|
|
559
|
+
token,
|
|
560
|
+
diffHash: normalizedDiffHash,
|
|
561
|
+
ts: nowIso,
|
|
562
|
+
},
|
|
563
|
+
{ targetPath }
|
|
564
|
+
);
|
|
565
|
+
return {
|
|
566
|
+
released: true,
|
|
567
|
+
path: normalizedPath,
|
|
568
|
+
agent,
|
|
569
|
+
token,
|
|
570
|
+
diffHash: normalizedDiffHash,
|
|
571
|
+
};
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export async function listActiveLocks({ targetPath = process.cwd() } = {}) {
|
|
576
|
+
return withHandshakeMutex(targetPath, async () => {
|
|
577
|
+
const nowIso = isoNow();
|
|
578
|
+
await sweepExpiredLocks(targetPath, nowIso);
|
|
579
|
+
const active = await scanActiveLocks(targetPath);
|
|
580
|
+
return active.map((grant) => publicGrant(grant));
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export async function checkLock(pathValue, { targetPath = process.cwd() } = {}) {
|
|
585
|
+
const normalizedPath = normalizeLockPath(pathValue, { targetPath });
|
|
586
|
+
return withHandshakeMutex(targetPath, async () => {
|
|
587
|
+
const nowIso = isoNow();
|
|
588
|
+
const lockPath = lockFileFor(normalizedPath, { targetPath });
|
|
589
|
+
const existing = await readLockFile(lockPath);
|
|
590
|
+
if (!existing) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
if (isExpired(existing, nowIso)) {
|
|
594
|
+
await deleteLockFile(lockPath);
|
|
595
|
+
await appendEvent(
|
|
596
|
+
{
|
|
597
|
+
type: "lock_expired",
|
|
598
|
+
path: existing.path,
|
|
599
|
+
agent: existing.agent,
|
|
600
|
+
token: existing.token,
|
|
601
|
+
ts: nowIso,
|
|
602
|
+
},
|
|
603
|
+
{ targetPath }
|
|
604
|
+
);
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
return publicGrant(existing);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function detectDeadlock({ targetPath = process.cwd() } = {}) {
|
|
612
|
+
return withHandshakeMutex(targetPath, async () => {
|
|
613
|
+
const nowIso = isoNow();
|
|
614
|
+
await sweepExpiredLocks(targetPath, nowIso);
|
|
615
|
+
|
|
616
|
+
const active = await scanActiveLocks(targetPath);
|
|
617
|
+
const holdings = new Map(); // path -> agent
|
|
618
|
+
const heldByAgent = new Map(); // agent -> [paths]
|
|
619
|
+
for (const grant of active) {
|
|
620
|
+
holdings.set(grant.path, normalizeAgent(grant.agent));
|
|
621
|
+
const list = heldByAgent.get(normalizeAgent(grant.agent)) || [];
|
|
622
|
+
list.push(grant.path);
|
|
623
|
+
heldByAgent.set(normalizeAgent(grant.agent), list);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const { result: waiters } = await mutateWaitsRegistry(
|
|
627
|
+
targetPath,
|
|
628
|
+
async (registry) => Object.values(registry.entries)
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
const adjacency = {};
|
|
632
|
+
for (const waiter of waiters) {
|
|
633
|
+
const waitingForAgent =
|
|
634
|
+
waiter.waitingForAgent || holdings.get(waiter.blockedPath);
|
|
635
|
+
if (!waitingForAgent || waitingForAgent === waiter.agent) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const list = adjacency[waiter.agent] || [];
|
|
639
|
+
if (!list.includes(waitingForAgent)) {
|
|
640
|
+
list.push(waitingForAgent);
|
|
641
|
+
}
|
|
642
|
+
adjacency[waiter.agent] = list;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const cycles = findCycles(adjacency);
|
|
646
|
+
const broken = [];
|
|
647
|
+
for (const cycle of cycles) {
|
|
648
|
+
const victim = lowestPriorityAgent(cycle);
|
|
649
|
+
if (!victim) {
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const victimPaths = heldByAgent.get(victim) || [];
|
|
653
|
+
const releasedPaths = [];
|
|
654
|
+
for (const victimPath of victimPaths) {
|
|
655
|
+
const lockPath = lockFileFor(victimPath, { targetPath });
|
|
656
|
+
const grant = await readLockFile(lockPath);
|
|
657
|
+
if (!grant) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
await deleteLockFile(lockPath);
|
|
661
|
+
releasedPaths.push(victimPath);
|
|
662
|
+
await appendEvent(
|
|
663
|
+
{
|
|
664
|
+
type: "lock_preempted",
|
|
665
|
+
path: victimPath,
|
|
666
|
+
preempted: victim,
|
|
667
|
+
preemptedToken: grant.token,
|
|
668
|
+
newAgent: null,
|
|
669
|
+
reason: "deadlock_broken",
|
|
670
|
+
ts: nowIso,
|
|
671
|
+
},
|
|
672
|
+
{ targetPath }
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
await appendEvent(
|
|
676
|
+
{
|
|
677
|
+
type: "deadlock_broken",
|
|
678
|
+
cycle,
|
|
679
|
+
victim,
|
|
680
|
+
releasedPaths,
|
|
681
|
+
ts: nowIso,
|
|
682
|
+
},
|
|
683
|
+
{ targetPath }
|
|
684
|
+
);
|
|
685
|
+
broken.push({ cycle, victim, releasedPaths });
|
|
686
|
+
}
|
|
687
|
+
return { cycles, broken };
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function publicGrant(grant) {
|
|
692
|
+
if (!grant) {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
schemaVersion: grant.schemaVersion || LOCK_SCHEMA_VERSION,
|
|
697
|
+
path: grant.path,
|
|
698
|
+
agent: normalizeAgent(grant.agent),
|
|
699
|
+
token: grant.token,
|
|
700
|
+
grantedAt: grant.grantedAt,
|
|
701
|
+
expiresAt: grant.expiresAt,
|
|
702
|
+
ttlSeconds: grant.ttlSeconds,
|
|
703
|
+
reason: grant.reason || "",
|
|
704
|
+
holderPid: grant.holderPid,
|
|
705
|
+
holderHostname: grant.holderHostname,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export {
|
|
710
|
+
DEFAULT_TTL_S,
|
|
711
|
+
LOCK_SCHEMA_VERSION,
|
|
712
|
+
MAX_TTL_S,
|
|
713
|
+
MIN_TTL_S,
|
|
714
|
+
PERSONA_PRIORITY,
|
|
715
|
+
hashLockKey,
|
|
716
|
+
normalizeLockPath,
|
|
717
|
+
outranks,
|
|
718
|
+
priorityIndex,
|
|
719
|
+
};
|