sentinelayer-cli 0.39.2 → 0.41.0
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 +4 -4
- package/package.json +2 -3
- package/src/agents/ai-governance/tools/base.js +19 -166
- package/src/agents/backend/tools/base.js +33 -186
- package/src/agents/code-quality/tools/base.js +27 -154
- package/src/agents/data-layer/tools/base.js +35 -180
- package/src/agents/documentation/tools/base.js +18 -146
- package/src/agents/infrastructure/tools/base.js +17 -164
- package/src/agents/observability/tools/base.js +35 -180
- package/src/agents/performance/tools/base.js +35 -205
- package/src/agents/release/tools/base.js +35 -180
- package/src/agents/reliability/tools/base.js +35 -180
- package/src/agents/security/tools/base.js +33 -187
- package/src/agents/shared-tools/domain-base.js +246 -0
- package/src/agents/supply-chain/tools/base.js +15 -144
- package/src/agents/testing/tools/base.js +33 -184
- package/src/cli.js +5 -0
- package/src/commands/mcp.js +1 -1
- package/src/commands/ring-owner.js +58 -0
- package/src/commands/session.js +703 -263
- package/src/engram/governance.js +67 -0
- package/src/engram/index.js +64 -0
- package/src/engram/namespace.js +89 -0
- package/src/engram/observations.js +34 -0
- package/src/engram/sla.js +104 -0
- package/src/engram/store.js +115 -0
- package/src/engram/tools.js +146 -0
- package/src/engram/trust.js +105 -0
- package/src/legacy-cli.js +6 -3
- package/src/mcp/registry.js +27 -10
- package/src/mcp/session-stdio-server.js +209 -18
- package/src/mcp/smoke.js +9 -8
- package/src/pocket/ring-owner.js +97 -0
- package/src/review/local-review.js +9 -5
- package/src/review/omargate-orchestrator.js +111 -14
- package/src/review/spec-binding.js +16 -1
- package/src/scan/generator.js +1 -1
- package/src/session/agent-registry.js +22 -63
- package/src/session/checkpoints.js +194 -0
- package/src/session/coordination-guidance.js +6 -6
- package/src/session/daemon.js +19 -124
- package/src/session/file-lease-integrations.js +1300 -0
- package/src/session/file-locks.js +967 -464
- package/src/session/listener.js +243 -20
- package/src/session/listeners.js +71 -80
- package/src/session/paths.js +2 -0
- package/src/session/recall/embedder.js +181 -0
- package/src/session/recall/entities.js +0 -0
- package/src/session/recall/index-build.js +38 -0
- package/src/session/recall/index-core.js +108 -0
- package/src/session/recall/index.js +131 -0
- package/src/session/recall/needle-eval.js +279 -0
- package/src/session/recall/observation-core.js +183 -0
- package/src/session/recall/observations.js +21 -0
- package/src/session/recall/provenance.js +153 -0
- package/src/session/recall/retrieve.js +374 -0
- package/src/session/recall/text.js +0 -0
- package/src/session/recall/token-stats.js +81 -0
- package/src/session/recap.js +29 -23
- package/src/session/remote-hydrate.js +5 -1
- package/src/session/sync.js +661 -11
- package/src/session/wake/pump.js +8 -3
|
@@ -1,371 +1,662 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import fsp from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
SentinelayerApiError,
|
|
9
|
+
requestJson,
|
|
10
|
+
requestJsonMutation,
|
|
11
|
+
} from "../auth/http.js";
|
|
12
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
8
13
|
import { resolveSessionPaths } from "./paths.js";
|
|
9
|
-
import { appendToStream } from "./stream.js";
|
|
10
14
|
|
|
11
|
-
const
|
|
15
|
+
const FILE_LEASE_CAPABILITY_SCHEMA_VERSION = "1.0.0";
|
|
12
16
|
const DEFAULT_FILE_LOCK_TTL_SECONDS = 300;
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
+
const MIN_FILE_LOCK_TTL_SECONDS = 15;
|
|
18
|
+
const MAX_FILE_LOCK_TTL_SECONDS = 3_600;
|
|
19
|
+
const DEFAULT_CAPABILITY_LOCK_TIMEOUT_MS = 10_000;
|
|
20
|
+
const DEFAULT_CAPABILITY_LOCK_STALE_MS = 30_000;
|
|
21
|
+
const DEFAULT_CAPABILITY_LOCK_POLL_MS = 25;
|
|
22
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
23
|
+
const AGENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,31}$/;
|
|
24
|
+
const LEASE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43,128}$/;
|
|
25
|
+
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[/\\]/;
|
|
17
26
|
|
|
18
27
|
function normalizeString(value) {
|
|
19
28
|
return String(value || "").trim();
|
|
20
29
|
}
|
|
21
30
|
|
|
22
|
-
function
|
|
31
|
+
function normalizeSessionId(value) {
|
|
23
32
|
const normalized = normalizeString(value);
|
|
24
|
-
if (!normalized) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (!Number.isFinite(epoch)) {
|
|
29
|
-
return fallbackIso;
|
|
33
|
+
if (!SESSION_ID_PATTERN.test(normalized)) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
"sessionId must be 1-64 repository-safe letters, numbers, dots, underscores, or hyphens.",
|
|
36
|
+
);
|
|
30
37
|
}
|
|
31
|
-
return
|
|
38
|
+
return normalized;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const normalized = Number(value);
|
|
39
|
-
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
40
|
-
throw new Error("Value must be a positive integer.");
|
|
41
|
+
function normalizeAgentId(value) {
|
|
42
|
+
const normalized = normalizeString(value).toLowerCase();
|
|
43
|
+
if (!AGENT_ID_PATTERN.test(normalized)) {
|
|
44
|
+
throw new Error("agentId must match ^[A-Za-z0-9][A-Za-z0-9._:-]{0,31}$.");
|
|
41
45
|
}
|
|
42
|
-
return
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function toIsoAfterSeconds(nowIso, seconds) {
|
|
46
|
-
const nowEpoch = Date.parse(normalizeIsoTimestamp(nowIso, nowIso));
|
|
47
|
-
return new Date(nowEpoch + Math.max(1, Math.floor(Number(seconds) || 0)) * 1000).toISOString();
|
|
46
|
+
return normalized;
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
function
|
|
51
|
-
|
|
49
|
+
function normalizeTtlSeconds(value) {
|
|
50
|
+
const normalized =
|
|
51
|
+
value === undefined || value === null || normalizeString(value) === ""
|
|
52
|
+
? DEFAULT_FILE_LOCK_TTL_SECONDS
|
|
53
|
+
: Number(value);
|
|
54
|
+
if (
|
|
55
|
+
!Number.isInteger(normalized) ||
|
|
56
|
+
normalized < MIN_FILE_LOCK_TTL_SECONDS ||
|
|
57
|
+
normalized > MAX_FILE_LOCK_TTL_SECONDS
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`ttlSeconds must be an integer between ${MIN_FILE_LOCK_TTL_SECONDS} and ${MAX_FILE_LOCK_TTL_SECONDS}.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return normalized;
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
function
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const deltaSeconds = Math.max(0, Math.floor(deltaMs / 1000));
|
|
59
|
-
if (deltaSeconds < 60) {
|
|
60
|
-
return `${deltaSeconds}s ago`;
|
|
66
|
+
function normalizeBoundedText(value, { field, maxLength }) {
|
|
67
|
+
const normalized = normalizeString(value);
|
|
68
|
+
if (normalized.length > maxLength) {
|
|
69
|
+
throw new Error(`${field} must be ${maxLength} characters or fewer.`);
|
|
61
70
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return `${deltaMinutes}m ago`;
|
|
71
|
+
if (/[\u0000-\u001f\u007f]/u.test(normalized)) {
|
|
72
|
+
throw new Error(`${field} must not contain control characters.`);
|
|
65
73
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
return normalized;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeApiUrl(value) {
|
|
78
|
+
const normalized = normalizeString(value).replace(/\/+$/u, "");
|
|
79
|
+
if (!normalized) {
|
|
80
|
+
throw new Error("Sentinelayer API URL is unavailable.");
|
|
69
81
|
}
|
|
70
|
-
|
|
71
|
-
return `${deltaDays}d ago`;
|
|
82
|
+
return normalized;
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
function normalizeFilePath(filePath, { targetPath = process.cwd() } = {}) {
|
|
75
|
-
const raw = normalizeString(filePath);
|
|
86
|
+
const raw = normalizeString(filePath).normalize("NFC");
|
|
76
87
|
if (!raw) {
|
|
77
88
|
throw new Error("filePath is required.");
|
|
78
89
|
}
|
|
90
|
+
if (raw.length > 1_024) {
|
|
91
|
+
throw new Error("filePath must be 1024 characters or fewer.");
|
|
92
|
+
}
|
|
93
|
+
if (/[\u0000-\u001f\u007f]/u.test(raw)) {
|
|
94
|
+
throw new Error("filePath must not contain control characters.");
|
|
95
|
+
}
|
|
79
96
|
|
|
80
|
-
|
|
97
|
+
const workspaceRoot = path.resolve(String(targetPath || "."));
|
|
98
|
+
let candidate = raw;
|
|
81
99
|
if (path.isAbsolute(raw)) {
|
|
82
|
-
|
|
100
|
+
const relative = path.relative(workspaceRoot, path.resolve(raw));
|
|
101
|
+
if (
|
|
102
|
+
!relative ||
|
|
103
|
+
path.isAbsolute(relative) ||
|
|
104
|
+
relative === ".." ||
|
|
105
|
+
relative.startsWith(`..${path.sep}`)
|
|
106
|
+
) {
|
|
107
|
+
throw new Error("filePath must resolve to a file inside the workspace.");
|
|
108
|
+
}
|
|
109
|
+
candidate = relative;
|
|
110
|
+
} else if (raw.startsWith(("/", "\\")) || WINDOWS_ABSOLUTE_PATH_PATTERN.test(raw)) {
|
|
111
|
+
throw new Error("filePath must be repository-relative.");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const segments = [];
|
|
115
|
+
for (const segment of candidate.replace(/\\/gu, "/").split("/")) {
|
|
116
|
+
const normalizedSegment = segment.normalize("NFC");
|
|
117
|
+
if (!normalizedSegment || normalizedSegment === ".") {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (normalizedSegment === "..") {
|
|
121
|
+
throw new Error("filePath must not traverse outside the workspace.");
|
|
122
|
+
}
|
|
123
|
+
segments.push(normalizedSegment);
|
|
83
124
|
}
|
|
84
|
-
normalized =
|
|
85
|
-
|
|
86
|
-
if (!normalized || normalized === ".") {
|
|
125
|
+
const normalized = segments.join("/");
|
|
126
|
+
if (!normalized) {
|
|
87
127
|
throw new Error("filePath is required.");
|
|
88
128
|
}
|
|
129
|
+
if (normalized.length > 1_024) {
|
|
130
|
+
throw new Error("normalized filePath must be 1024 characters or fewer.");
|
|
131
|
+
}
|
|
89
132
|
return normalized;
|
|
90
133
|
}
|
|
91
134
|
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
135
|
+
async function resolveRealPathThroughNearestAncestor(absolutePath) {
|
|
136
|
+
let cursor = path.resolve(absolutePath);
|
|
137
|
+
const missingSegments = [];
|
|
138
|
+
while (true) {
|
|
139
|
+
try {
|
|
140
|
+
const realAncestor = await fsp.realpath(cursor);
|
|
141
|
+
return path.join(realAncestor, ...missingSegments);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (
|
|
144
|
+
!error ||
|
|
145
|
+
typeof error !== "object" ||
|
|
146
|
+
!["ENOENT", "ENOTDIR"].includes(error.code)
|
|
147
|
+
) {
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
const parent = path.dirname(cursor);
|
|
151
|
+
if (parent === cursor) {
|
|
152
|
+
throw new Error("Unable to resolve filePath through an existing workspace ancestor.", {
|
|
153
|
+
cause: error,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
missingSegments.unshift(path.basename(cursor));
|
|
157
|
+
cursor = parent;
|
|
158
|
+
}
|
|
96
159
|
}
|
|
97
|
-
return normalized;
|
|
98
160
|
}
|
|
99
161
|
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
162
|
+
async function canonicalizeFilePath(
|
|
163
|
+
filePath,
|
|
164
|
+
{ targetPath = process.cwd() } = {},
|
|
165
|
+
) {
|
|
166
|
+
const workspaceRoot = path.resolve(String(targetPath || "."));
|
|
167
|
+
const realWorkspaceRoot = await fsp.realpath(workspaceRoot);
|
|
168
|
+
const lexicalPath = normalizeFilePath(filePath, { targetPath: workspaceRoot });
|
|
169
|
+
const realCandidate = await resolveRealPathThroughNearestAncestor(
|
|
170
|
+
path.join(workspaceRoot, ...lexicalPath.split("/")),
|
|
171
|
+
);
|
|
172
|
+
const relative = path.relative(realWorkspaceRoot, realCandidate);
|
|
173
|
+
if (
|
|
174
|
+
!relative ||
|
|
175
|
+
path.isAbsolute(relative) ||
|
|
176
|
+
relative === ".." ||
|
|
177
|
+
relative.startsWith(`..${path.sep}`)
|
|
178
|
+
) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
"filePath resolves through a symlink or junction outside the workspace.",
|
|
181
|
+
);
|
|
105
182
|
}
|
|
106
|
-
return
|
|
183
|
+
return normalizeFilePath(relative, { targetPath: realWorkspaceRoot });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function pathKey(value) {
|
|
187
|
+
return normalizeString(value).normalize("NFC").toLowerCase().replace(/\/+$/u, "");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function fileLeasePathCovers(leasePath, targetPath) {
|
|
191
|
+
const lease = pathKey(leasePath);
|
|
192
|
+
const target = pathKey(targetPath);
|
|
193
|
+
return Boolean(lease && target && (target === lease || target.startsWith(`${lease}/`)));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function fileLeasePathsOverlap(leftPath, rightPath) {
|
|
197
|
+
return fileLeasePathCovers(leftPath, rightPath) || fileLeasePathCovers(rightPath, leftPath);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseEpoch(value) {
|
|
201
|
+
const parsed = Date.parse(normalizeString(value));
|
|
202
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
107
203
|
}
|
|
108
204
|
|
|
109
|
-
function
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
205
|
+
function formatSince(fromIso, nowMs = Date.now()) {
|
|
206
|
+
const fromMs = parseEpoch(fromIso);
|
|
207
|
+
const deltaSeconds = Math.max(0, Math.floor((nowMs - fromMs) / 1_000));
|
|
208
|
+
if (deltaSeconds < 60) {
|
|
209
|
+
return `${deltaSeconds}s ago`;
|
|
210
|
+
}
|
|
211
|
+
const deltaMinutes = Math.floor(deltaSeconds / 60);
|
|
212
|
+
if (deltaMinutes < 60) {
|
|
213
|
+
return `${deltaMinutes}m ago`;
|
|
113
214
|
}
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
return
|
|
215
|
+
const deltaHours = Math.floor(deltaMinutes / 60);
|
|
216
|
+
if (deltaHours < 24) {
|
|
217
|
+
return `${deltaHours}h ago`;
|
|
117
218
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
219
|
+
return `${Math.floor(deltaHours / 24)}d ago`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function presentLease(rawLease = {}) {
|
|
223
|
+
const source = rawLease && typeof rawLease === "object" ? rawLease : {};
|
|
224
|
+
const file = normalizeString(source.path || source.file || source.filePath);
|
|
225
|
+
const agentId = normalizeString(source.holderId || source.agentId).toLowerCase();
|
|
226
|
+
const lockedAt = normalizeString(source.acquiredAt || source.lockedAt);
|
|
227
|
+
const ttlSeconds = Number(source.ttlSeconds);
|
|
228
|
+
const revision = Number(source.revision);
|
|
121
229
|
return {
|
|
122
|
-
|
|
230
|
+
leaseId: normalizeString(source.leaseId || source.id) || null,
|
|
231
|
+
sessionId: normalizeString(source.sessionId) || null,
|
|
232
|
+
file,
|
|
233
|
+
path: file,
|
|
123
234
|
agentId,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
235
|
+
heldBy: agentId,
|
|
236
|
+
intent: normalizeString(source.intent),
|
|
237
|
+
status: normalizeString(source.status) || "active",
|
|
238
|
+
lockedAt: lockedAt || null,
|
|
239
|
+
acquiredAt: lockedAt || null,
|
|
240
|
+
renewedAt: normalizeString(source.renewedAt) || null,
|
|
241
|
+
expiresAt: normalizeString(source.expiresAt) || null,
|
|
242
|
+
releasedAt: normalizeString(source.releasedAt) || null,
|
|
243
|
+
releaseReason: normalizeString(source.releaseReason) || null,
|
|
244
|
+
ttlSeconds: Number.isInteger(ttlSeconds) ? ttlSeconds : DEFAULT_FILE_LOCK_TTL_SECONDS,
|
|
245
|
+
revision: Number.isInteger(revision) ? revision : 1,
|
|
246
|
+
since: lockedAt ? formatSince(lockedAt) : null,
|
|
128
247
|
};
|
|
129
248
|
}
|
|
130
249
|
|
|
131
|
-
function
|
|
250
|
+
function newCapabilityToken() {
|
|
251
|
+
return randomBytes(32).toString("base64url");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function normalizeCapabilityClaim(raw, { sessionId } = {}) {
|
|
132
255
|
const source = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
locks[normalizedFilePath] = record;
|
|
256
|
+
const leaseId = normalizeString(source.leaseId);
|
|
257
|
+
const claimSessionId = normalizeSessionId(source.sessionId || sessionId);
|
|
258
|
+
const holderId = normalizeAgentId(source.holderId);
|
|
259
|
+
const claimPath = normalizeFilePath(source.path, { targetPath: "." });
|
|
260
|
+
const leaseToken = normalizeString(source.leaseToken);
|
|
261
|
+
if (!leaseId || !/^[0-9a-f-]{36}$/iu.test(leaseId)) {
|
|
262
|
+
throw new Error("File-lease capability cache contains an invalid lease id.");
|
|
263
|
+
}
|
|
264
|
+
if (!LEASE_TOKEN_PATTERN.test(leaseToken)) {
|
|
265
|
+
throw new Error("File-lease capability cache contains an invalid capability token.");
|
|
145
266
|
}
|
|
267
|
+
return {
|
|
268
|
+
leaseId,
|
|
269
|
+
sessionId: claimSessionId,
|
|
270
|
+
path: claimPath,
|
|
271
|
+
holderId,
|
|
272
|
+
leaseToken,
|
|
273
|
+
expiresAt: normalizeString(source.expiresAt) || null,
|
|
274
|
+
ttlSeconds: normalizeTtlSeconds(source.ttlSeconds),
|
|
275
|
+
revision: Math.max(1, Math.floor(Number(source.revision) || 1)),
|
|
276
|
+
updatedAt: normalizeString(source.updatedAt) || new Date().toISOString(),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
146
279
|
|
|
280
|
+
function emptyCapabilityStore(sessionId) {
|
|
147
281
|
return {
|
|
148
|
-
schemaVersion:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
282
|
+
schemaVersion: FILE_LEASE_CAPABILITY_SCHEMA_VERSION,
|
|
283
|
+
authoritative: false,
|
|
284
|
+
sessionId,
|
|
285
|
+
updatedAt: new Date().toISOString(),
|
|
286
|
+
claims: [],
|
|
152
287
|
};
|
|
153
288
|
}
|
|
154
289
|
|
|
155
|
-
async function
|
|
290
|
+
async function readCapabilityStore(sessionId, { targetPath = process.cwd() } = {}) {
|
|
291
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
292
|
+
const paths = resolveSessionPaths(normalizedSessionId, { targetPath });
|
|
293
|
+
let parsed;
|
|
156
294
|
try {
|
|
157
|
-
|
|
158
|
-
return JSON.parse(raw);
|
|
295
|
+
parsed = JSON.parse(await fsp.readFile(paths.fileLeaseCapabilitiesPath, "utf-8"));
|
|
159
296
|
} catch (error) {
|
|
160
|
-
if (
|
|
161
|
-
return
|
|
297
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
298
|
+
return emptyCapabilityStore(normalizedSessionId);
|
|
299
|
+
}
|
|
300
|
+
if (error instanceof SyntaxError) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
"File-lease capability cache is corrupt; refusing to bypass the authoritative guard.",
|
|
303
|
+
{ cause: error },
|
|
304
|
+
);
|
|
162
305
|
}
|
|
163
306
|
throw error;
|
|
164
307
|
}
|
|
308
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
309
|
+
throw new Error("File-lease capability cache has an invalid root object.");
|
|
310
|
+
}
|
|
311
|
+
if (parsed.authoritative !== false) {
|
|
312
|
+
throw new Error("File-lease capability cache must be explicitly non-authoritative.");
|
|
313
|
+
}
|
|
314
|
+
if (normalizeSessionId(parsed.sessionId) !== normalizedSessionId) {
|
|
315
|
+
throw new Error("File-lease capability cache belongs to a different session.");
|
|
316
|
+
}
|
|
317
|
+
if (!Array.isArray(parsed.claims)) {
|
|
318
|
+
throw new Error("File-lease capability cache claims must be an array.");
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
schemaVersion: FILE_LEASE_CAPABILITY_SCHEMA_VERSION,
|
|
322
|
+
authoritative: false,
|
|
323
|
+
sessionId: normalizedSessionId,
|
|
324
|
+
updatedAt: normalizeString(parsed.updatedAt) || new Date().toISOString(),
|
|
325
|
+
claims: parsed.claims.map((claim) =>
|
|
326
|
+
normalizeCapabilityClaim(claim, { sessionId: normalizedSessionId }),
|
|
327
|
+
),
|
|
328
|
+
};
|
|
165
329
|
}
|
|
166
330
|
|
|
167
|
-
async function
|
|
168
|
-
await fsp.mkdir(
|
|
169
|
-
const tmpPath = `${
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
331
|
+
async function writeCapabilityStore(paths, store) {
|
|
332
|
+
await fsp.mkdir(paths.sessionDir, { recursive: true, mode: 0o700 });
|
|
333
|
+
const tmpPath = `${paths.fileLeaseCapabilitiesPath}.${process.pid}.${Date.now()}.tmp`;
|
|
334
|
+
const payload = {
|
|
335
|
+
schemaVersion: FILE_LEASE_CAPABILITY_SCHEMA_VERSION,
|
|
336
|
+
authoritative: false,
|
|
337
|
+
sessionId: paths.sessionId,
|
|
338
|
+
updatedAt: new Date().toISOString(),
|
|
339
|
+
claims: store.claims,
|
|
340
|
+
};
|
|
175
341
|
try {
|
|
176
|
-
await fsp.
|
|
342
|
+
await fsp.writeFile(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
343
|
+
encoding: "utf-8",
|
|
344
|
+
mode: 0o600,
|
|
345
|
+
});
|
|
346
|
+
await fsp.chmod(tmpPath, 0o600).catch(() => {});
|
|
347
|
+
await fsp.rename(tmpPath, paths.fileLeaseCapabilitiesPath);
|
|
348
|
+
await fsp.chmod(paths.fileLeaseCapabilitiesPath, 0o600).catch(() => {});
|
|
177
349
|
} catch (error) {
|
|
178
|
-
|
|
179
|
-
throw new Error(`Session '${paths.sessionId}' was not found.`);
|
|
180
|
-
}
|
|
350
|
+
await fsp.rm(tmpPath, { force: true }).catch(() => {});
|
|
181
351
|
throw error;
|
|
182
352
|
}
|
|
183
353
|
}
|
|
184
354
|
|
|
185
|
-
async function
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
355
|
+
async function acquireCapabilityMutex(
|
|
356
|
+
lockPath,
|
|
357
|
+
{
|
|
358
|
+
timeoutMs = DEFAULT_CAPABILITY_LOCK_TIMEOUT_MS,
|
|
359
|
+
staleMs = DEFAULT_CAPABILITY_LOCK_STALE_MS,
|
|
360
|
+
pollMs = DEFAULT_CAPABILITY_LOCK_POLL_MS,
|
|
361
|
+
} = {},
|
|
362
|
+
) {
|
|
363
|
+
const startedAt = Date.now();
|
|
191
364
|
while (true) {
|
|
192
365
|
try {
|
|
193
|
-
await fsp.mkdir(lockPath);
|
|
366
|
+
await fsp.mkdir(lockPath, { recursive: false, mode: 0o700 });
|
|
194
367
|
return;
|
|
195
368
|
} catch (error) {
|
|
196
|
-
|
|
197
|
-
if (!(code === "EEXIST" || code === "EPERM" || code === "EACCES")) {
|
|
369
|
+
if (!error || typeof error !== "object" || error.code !== "EEXIST") {
|
|
198
370
|
throw error;
|
|
199
371
|
}
|
|
200
|
-
|
|
201
372
|
try {
|
|
202
373
|
const stat = await fsp.stat(lockPath);
|
|
203
|
-
|
|
204
|
-
if (Number.isFinite(ageMs) && ageMs > staleMs) {
|
|
374
|
+
if (Date.now() - Number(stat.mtimeMs || 0) > staleMs) {
|
|
205
375
|
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
206
376
|
continue;
|
|
207
377
|
}
|
|
208
378
|
} catch {
|
|
209
|
-
|
|
379
|
+
continue;
|
|
210
380
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
throw new Error("Timed out waiting for session file lock registry.");
|
|
381
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
382
|
+
throw new Error("Timed out waiting for the local file-lease capability cache.");
|
|
214
383
|
}
|
|
215
384
|
await sleep(pollMs);
|
|
216
385
|
}
|
|
217
386
|
}
|
|
218
387
|
}
|
|
219
388
|
|
|
220
|
-
async function
|
|
221
|
-
|
|
389
|
+
async function mutateCapabilityStore(
|
|
390
|
+
sessionId,
|
|
391
|
+
{ targetPath = process.cwd() } = {},
|
|
392
|
+
mutator = async () => {},
|
|
393
|
+
) {
|
|
394
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
395
|
+
const paths = resolveSessionPaths(normalizedSessionId, { targetPath });
|
|
396
|
+
await fsp.mkdir(paths.sessionDir, { recursive: true, mode: 0o700 });
|
|
397
|
+
await acquireCapabilityMutex(paths.fileLeaseCapabilitiesLockPath);
|
|
398
|
+
try {
|
|
399
|
+
const store = await readCapabilityStore(normalizedSessionId, { targetPath });
|
|
400
|
+
const result = await mutator(store);
|
|
401
|
+
await writeCapabilityStore(paths, store);
|
|
402
|
+
return result;
|
|
403
|
+
} finally {
|
|
404
|
+
await fsp.rm(paths.fileLeaseCapabilitiesLockPath, {
|
|
405
|
+
recursive: true,
|
|
406
|
+
force: true,
|
|
407
|
+
}).catch(() => {});
|
|
408
|
+
}
|
|
222
409
|
}
|
|
223
410
|
|
|
224
|
-
function
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
expiredAt: normalizeIsoTimestamp(nowIso, new Date().toISOString()),
|
|
234
|
-
});
|
|
235
|
-
delete registry.locks[filePath];
|
|
236
|
-
}
|
|
237
|
-
return expired;
|
|
411
|
+
function findExactClaim(claims, holderId, filePath) {
|
|
412
|
+
const holder = normalizeAgentId(holderId);
|
|
413
|
+
const targetKey = pathKey(filePath);
|
|
414
|
+
return (
|
|
415
|
+
claims.find(
|
|
416
|
+
(claim) =>
|
|
417
|
+
normalizeAgentId(claim.holderId) === holder && pathKey(claim.path) === targetKey,
|
|
418
|
+
) || null
|
|
419
|
+
);
|
|
238
420
|
}
|
|
239
421
|
|
|
240
|
-
function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
since: formatSince(lockRecord.lockedAt, nowIso),
|
|
252
|
-
};
|
|
422
|
+
function findCoveringClaim(claims, holderId, filePath) {
|
|
423
|
+
const holder = normalizeAgentId(holderId);
|
|
424
|
+
return (
|
|
425
|
+
claims
|
|
426
|
+
.filter(
|
|
427
|
+
(claim) =>
|
|
428
|
+
normalizeAgentId(claim.holderId) === holder &&
|
|
429
|
+
fileLeasePathCovers(claim.path, filePath),
|
|
430
|
+
)
|
|
431
|
+
.sort((left, right) => pathKey(right.path).length - pathKey(left.path).length)[0] || null
|
|
432
|
+
);
|
|
253
433
|
}
|
|
254
434
|
|
|
255
|
-
async function
|
|
435
|
+
async function saveCapabilityClaim(sessionId, claim, { targetPath = process.cwd() } = {}) {
|
|
436
|
+
const normalized = normalizeCapabilityClaim(claim, { sessionId });
|
|
437
|
+
await mutateCapabilityStore(
|
|
438
|
+
sessionId,
|
|
439
|
+
{ targetPath },
|
|
440
|
+
async (store) => {
|
|
441
|
+
store.claims = store.claims.filter(
|
|
442
|
+
(existing) =>
|
|
443
|
+
existing.leaseId !== normalized.leaseId &&
|
|
444
|
+
!(
|
|
445
|
+
normalizeAgentId(existing.holderId) === normalized.holderId &&
|
|
446
|
+
pathKey(existing.path) === pathKey(normalized.path)
|
|
447
|
+
),
|
|
448
|
+
);
|
|
449
|
+
store.claims.push(normalized);
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
return normalized;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function removeCapabilityClaims(
|
|
256
456
|
sessionId,
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
payload,
|
|
260
|
-
{
|
|
261
|
-
targetPath = process.cwd(),
|
|
262
|
-
nowIso = new Date().toISOString(),
|
|
263
|
-
syncRemote = true,
|
|
264
|
-
awaitRemoteSync = false,
|
|
265
|
-
} = {}
|
|
457
|
+
predicate,
|
|
458
|
+
{ targetPath = process.cwd() } = {},
|
|
266
459
|
) {
|
|
267
|
-
return
|
|
460
|
+
return mutateCapabilityStore(
|
|
268
461
|
sessionId,
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}),
|
|
276
|
-
{
|
|
277
|
-
targetPath,
|
|
278
|
-
syncRemote,
|
|
279
|
-
awaitRemoteSync,
|
|
280
|
-
}
|
|
462
|
+
{ targetPath },
|
|
463
|
+
async (store) => {
|
|
464
|
+
const removed = store.claims.filter(predicate);
|
|
465
|
+
store.claims = store.claims.filter((claim) => !predicate(claim));
|
|
466
|
+
return removed;
|
|
467
|
+
},
|
|
281
468
|
);
|
|
282
469
|
}
|
|
283
470
|
|
|
284
|
-
async function
|
|
471
|
+
async function resolveLeaseApi({
|
|
472
|
+
targetPath = process.cwd(),
|
|
473
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
474
|
+
} = {}) {
|
|
475
|
+
const auth = await resolveAuthSession({
|
|
476
|
+
cwd: targetPath,
|
|
477
|
+
env: process.env,
|
|
478
|
+
autoRotate: false,
|
|
479
|
+
});
|
|
480
|
+
if (!auth?.token || !auth?.apiUrl) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
"Authoritative session file leases require Sentinelayer auth. Run `sl auth login` first.",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
apiUrl: normalizeApiUrl(auth.apiUrl),
|
|
487
|
+
headers: { Authorization: `Bearer ${auth.token}` },
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function leaseCollectionUrl(apiUrl, sessionId) {
|
|
492
|
+
return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/file-leases`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function leaseMemberUrl(apiUrl, sessionId, leaseId, action) {
|
|
496
|
+
return `${leaseCollectionUrl(apiUrl, sessionId)}/${encodeURIComponent(leaseId)}/${action}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function listRemoteLeases(
|
|
285
500
|
sessionId,
|
|
286
|
-
expiredLocks = [],
|
|
287
501
|
{
|
|
288
502
|
targetPath = process.cwd(),
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
} = {}
|
|
503
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
504
|
+
request = requestJson,
|
|
505
|
+
} = {},
|
|
292
506
|
) {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
expiredAt: normalizeIsoTimestamp(nowIso, new Date().toISOString()),
|
|
302
|
-
};
|
|
303
|
-
const event = await appendLockEvent(
|
|
304
|
-
sessionId,
|
|
305
|
-
"file_lock_expired",
|
|
306
|
-
actorAgentId,
|
|
307
|
-
payload,
|
|
308
|
-
{
|
|
309
|
-
targetPath,
|
|
310
|
-
nowIso,
|
|
311
|
-
}
|
|
312
|
-
);
|
|
313
|
-
events.push(event);
|
|
507
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
508
|
+
const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
|
|
509
|
+
const response = await request(leaseCollectionUrl(apiUrl, normalizedSessionId), {
|
|
510
|
+
method: "GET",
|
|
511
|
+
headers,
|
|
512
|
+
});
|
|
513
|
+
if (response?.authoritative !== true || !Array.isArray(response?.leases)) {
|
|
514
|
+
throw new Error("File-lease authority returned an invalid list response; refusing local fallback.");
|
|
314
515
|
}
|
|
315
|
-
return
|
|
516
|
+
return response.leases.map((lease) => presentLease(lease));
|
|
316
517
|
}
|
|
317
518
|
|
|
318
|
-
async function
|
|
519
|
+
async function guardRemoteClaims(
|
|
319
520
|
sessionId,
|
|
521
|
+
holderId,
|
|
522
|
+
paths,
|
|
523
|
+
claims,
|
|
320
524
|
{
|
|
321
525
|
targetPath = process.cwd(),
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
expiredEventAgentId = SENTI_AGENT_ID,
|
|
526
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
527
|
+
requestMutation = requestJsonMutation,
|
|
325
528
|
} = {},
|
|
326
|
-
mutator = async () => ({})
|
|
327
529
|
) {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
530
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
531
|
+
const normalizedHolderId = normalizeAgentId(holderId);
|
|
532
|
+
const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
|
|
533
|
+
const response = await requestMutation(
|
|
534
|
+
`${leaseCollectionUrl(apiUrl, normalizedSessionId)}/guard`,
|
|
535
|
+
{
|
|
536
|
+
method: "POST",
|
|
537
|
+
operationName: "session-file-lease-guard",
|
|
538
|
+
headers,
|
|
539
|
+
body: {
|
|
540
|
+
holderId: normalizedHolderId,
|
|
541
|
+
claims: paths.map((file, index) => ({
|
|
542
|
+
path: file,
|
|
543
|
+
leaseId: claims[index]?.leaseId || null,
|
|
544
|
+
leaseToken: claims[index]?.leaseToken || null,
|
|
545
|
+
})),
|
|
546
|
+
},
|
|
547
|
+
maxRetries: 1,
|
|
548
|
+
},
|
|
549
|
+
);
|
|
550
|
+
if (
|
|
551
|
+
response?.authoritative !== true ||
|
|
552
|
+
typeof response?.allowed !== "boolean" ||
|
|
553
|
+
normalizeString(response?.sessionId) !== normalizedSessionId ||
|
|
554
|
+
normalizeString(response?.holderId).toLowerCase() !== normalizedHolderId ||
|
|
555
|
+
!Array.isArray(response?.guarded) ||
|
|
556
|
+
!Array.isArray(response?.denials)
|
|
557
|
+
) {
|
|
558
|
+
throw new Error("File-lease authority returned an invalid guard response; edit blocked.");
|
|
352
559
|
}
|
|
560
|
+
if (response.allowed) {
|
|
561
|
+
const requestedByPath = new Map(
|
|
562
|
+
paths.map((file, index) => [
|
|
563
|
+
normalizeString(file).normalize("NFC"),
|
|
564
|
+
{ claim: claims[index] },
|
|
565
|
+
]),
|
|
566
|
+
);
|
|
567
|
+
const guardedPaths = new Set();
|
|
568
|
+
let bindingIsValid =
|
|
569
|
+
response.denials.length === 0 &&
|
|
570
|
+
response.guarded.length === paths.length &&
|
|
571
|
+
requestedByPath.size === paths.length;
|
|
353
572
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
573
|
+
for (const guarded of response.guarded) {
|
|
574
|
+
const guardedPath = normalizeString(guarded?.path).normalize("NFC");
|
|
575
|
+
const requested = requestedByPath.get(guardedPath);
|
|
576
|
+
if (!requested || guardedPaths.has(guardedPath)) {
|
|
577
|
+
bindingIsValid = false;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
guardedPaths.add(guardedPath);
|
|
581
|
+
|
|
582
|
+
const submittedLeaseId = normalizeString(requested.claim?.leaseId);
|
|
583
|
+
const submittedLeaseToken = normalizeString(requested.claim?.leaseToken);
|
|
584
|
+
if (!submittedLeaseId || !submittedLeaseToken) {
|
|
585
|
+
bindingIsValid = false;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
361
588
|
|
|
589
|
+
const responseLease =
|
|
590
|
+
guarded?.lease && typeof guarded.lease === "object" && !Array.isArray(guarded.lease)
|
|
591
|
+
? guarded.lease
|
|
592
|
+
: {};
|
|
593
|
+
const returnedLeaseIds = [
|
|
594
|
+
guarded?.leaseId,
|
|
595
|
+
responseLease.leaseId,
|
|
596
|
+
responseLease.id,
|
|
597
|
+
]
|
|
598
|
+
.filter((value) => value !== undefined && value !== null)
|
|
599
|
+
.map((value) => normalizeString(value).toLowerCase());
|
|
600
|
+
const returnedLeaseTokens = [
|
|
601
|
+
guarded?.leaseToken,
|
|
602
|
+
responseLease.leaseToken,
|
|
603
|
+
]
|
|
604
|
+
.filter((value) => value !== undefined && value !== null)
|
|
605
|
+
.map((value) => normalizeString(value));
|
|
606
|
+
if (
|
|
607
|
+
returnedLeaseIds.some(
|
|
608
|
+
(leaseId) => !leaseId || leaseId !== submittedLeaseId.toLowerCase(),
|
|
609
|
+
) ||
|
|
610
|
+
returnedLeaseTokens.some(
|
|
611
|
+
(leaseToken) => !leaseToken || leaseToken !== submittedLeaseToken,
|
|
612
|
+
)
|
|
613
|
+
) {
|
|
614
|
+
bindingIsValid = false;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (!bindingIsValid || guardedPaths.size !== paths.length) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
"File-lease authority returned an inconsistent allow decision; edit blocked.",
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
362
623
|
return {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
expiredEvents,
|
|
624
|
+
...response,
|
|
625
|
+
files: paths,
|
|
366
626
|
};
|
|
367
627
|
}
|
|
368
628
|
|
|
629
|
+
export async function guardFileLeases(
|
|
630
|
+
sessionId,
|
|
631
|
+
agentId,
|
|
632
|
+
filePaths,
|
|
633
|
+
{
|
|
634
|
+
targetPath = process.cwd(),
|
|
635
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
636
|
+
requestMutation = requestJsonMutation,
|
|
637
|
+
} = {},
|
|
638
|
+
) {
|
|
639
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
640
|
+
const normalizedAgentId = normalizeAgentId(agentId);
|
|
641
|
+
const files = await Promise.all(
|
|
642
|
+
(Array.isArray(filePaths) ? filePaths : [filePaths]).map((file) =>
|
|
643
|
+
canonicalizeFilePath(file, { targetPath }),
|
|
644
|
+
),
|
|
645
|
+
);
|
|
646
|
+
if (files.length === 0) {
|
|
647
|
+
throw new Error("At least one file path is required for the file-lease guard.");
|
|
648
|
+
}
|
|
649
|
+
const store = await readCapabilityStore(normalizedSessionId, { targetPath });
|
|
650
|
+
const claims = files.map((file) =>
|
|
651
|
+
findCoveringClaim(store.claims, normalizedAgentId, file),
|
|
652
|
+
);
|
|
653
|
+
return guardRemoteClaims(normalizedSessionId, normalizedAgentId, files, claims, {
|
|
654
|
+
targetPath,
|
|
655
|
+
resolveAuthSession,
|
|
656
|
+
requestMutation,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
369
660
|
export async function lockFile(
|
|
370
661
|
sessionId,
|
|
371
662
|
agentId,
|
|
@@ -374,239 +665,426 @@ export async function lockFile(
|
|
|
374
665
|
intent = "",
|
|
375
666
|
ttlSeconds = DEFAULT_FILE_LOCK_TTL_SECONDS,
|
|
376
667
|
targetPath = process.cwd(),
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
} = {}
|
|
668
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
669
|
+
request = requestJson,
|
|
670
|
+
requestMutation = requestJsonMutation,
|
|
671
|
+
} = {},
|
|
381
672
|
) {
|
|
673
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
382
674
|
const normalizedAgentId = normalizeAgentId(agentId);
|
|
383
|
-
const normalizedFilePath =
|
|
384
|
-
const normalizedIntent =
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
locked: false,
|
|
401
|
-
file: normalizedFilePath,
|
|
402
|
-
heldBy: normalizeString(existing.agentId),
|
|
403
|
-
since: formatSince(existing.lockedAt, normalizedNow),
|
|
404
|
-
lock: presentLock({ ...existing, file: normalizedFilePath }, { nowIso: normalizedNow }),
|
|
405
|
-
};
|
|
406
|
-
}
|
|
675
|
+
const normalizedFilePath = await canonicalizeFilePath(filePath, { targetPath });
|
|
676
|
+
const normalizedIntent = normalizeBoundedText(intent, {
|
|
677
|
+
field: "intent",
|
|
678
|
+
maxLength: 512,
|
|
679
|
+
});
|
|
680
|
+
const normalizedTtlSeconds = normalizeTtlSeconds(ttlSeconds);
|
|
681
|
+
const store = await readCapabilityStore(normalizedSessionId, { targetPath });
|
|
682
|
+
const coveringClaim = findCoveringClaim(
|
|
683
|
+
store.claims,
|
|
684
|
+
normalizedAgentId,
|
|
685
|
+
normalizedFilePath,
|
|
686
|
+
);
|
|
687
|
+
const exactClaim = findExactClaim(
|
|
688
|
+
store.claims,
|
|
689
|
+
normalizedAgentId,
|
|
690
|
+
normalizedFilePath,
|
|
691
|
+
);
|
|
407
692
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
// reclaim handles liveness), but lets operators see which process
|
|
419
|
-
// on which host took a lock when debugging stale-lock incidents.
|
|
420
|
-
holderPid: process.pid,
|
|
421
|
-
holderHostname: os.hostname(),
|
|
422
|
-
};
|
|
423
|
-
registry.locks[normalizedFilePath] = lockRecord;
|
|
693
|
+
if (coveringClaim && !exactClaim) {
|
|
694
|
+
const guard = await guardRemoteClaims(
|
|
695
|
+
normalizedSessionId,
|
|
696
|
+
normalizedAgentId,
|
|
697
|
+
[normalizedFilePath],
|
|
698
|
+
[coveringClaim],
|
|
699
|
+
{ targetPath, resolveAuthSession, requestMutation },
|
|
700
|
+
);
|
|
701
|
+
if (guard.allowed) {
|
|
702
|
+
const lease = presentLease(guard.guarded[0]?.lease || {});
|
|
424
703
|
return {
|
|
425
704
|
locked: true,
|
|
705
|
+
duplicate: true,
|
|
706
|
+
inheritedScope: true,
|
|
426
707
|
file: normalizedFilePath,
|
|
427
|
-
lock:
|
|
708
|
+
lock: lease,
|
|
709
|
+
lease,
|
|
428
710
|
};
|
|
429
711
|
}
|
|
430
|
-
|
|
712
|
+
}
|
|
431
713
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
714
|
+
const leaseToken = exactClaim?.leaseToken || newCapabilityToken();
|
|
715
|
+
const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
|
|
716
|
+
let response;
|
|
717
|
+
try {
|
|
718
|
+
response = await requestMutation(
|
|
719
|
+
leaseCollectionUrl(apiUrl, normalizedSessionId),
|
|
437
720
|
{
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
721
|
+
method: "POST",
|
|
722
|
+
operationName: "session-file-lease-acquire",
|
|
723
|
+
headers,
|
|
724
|
+
body: {
|
|
725
|
+
path: normalizedFilePath,
|
|
726
|
+
holderId: normalizedAgentId,
|
|
727
|
+
leaseToken,
|
|
728
|
+
ttlSeconds: normalizedTtlSeconds,
|
|
729
|
+
intent: normalizedIntent || null,
|
|
730
|
+
},
|
|
442
731
|
},
|
|
443
|
-
|
|
732
|
+
);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
if (
|
|
735
|
+
error instanceof SentinelayerApiError &&
|
|
736
|
+
error.code === "FILE_LEASE_CONFLICT"
|
|
737
|
+
) {
|
|
738
|
+
const leases = await listRemoteLeases(normalizedSessionId, {
|
|
444
739
|
targetPath,
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
740
|
+
resolveAuthSession,
|
|
741
|
+
request,
|
|
742
|
+
});
|
|
743
|
+
const conflict =
|
|
744
|
+
leases.find((lease) => fileLeasePathsOverlap(lease.file, normalizedFilePath)) ||
|
|
745
|
+
null;
|
|
746
|
+
return {
|
|
747
|
+
locked: false,
|
|
748
|
+
file: normalizedFilePath,
|
|
749
|
+
reason:
|
|
750
|
+
conflict?.agentId === normalizedAgentId
|
|
751
|
+
? "holder_capability_unavailable"
|
|
752
|
+
: "held_by_other_agent",
|
|
753
|
+
heldBy: conflict?.agentId || null,
|
|
754
|
+
since: conflict?.since || null,
|
|
755
|
+
lock: conflict,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
throw error;
|
|
759
|
+
}
|
|
760
|
+
if (
|
|
761
|
+
response?.ok !== true
|
|
762
|
+
|| response?.authoritative !== true
|
|
763
|
+
|| !response?.lease
|
|
764
|
+
) {
|
|
765
|
+
throw new Error("File-lease authority returned an invalid acquire response.");
|
|
766
|
+
}
|
|
767
|
+
const lease = presentLease(response.lease);
|
|
768
|
+
if (
|
|
769
|
+
!lease.leaseId ||
|
|
770
|
+
pathKey(lease.file) !== pathKey(normalizedFilePath) ||
|
|
771
|
+
lease.agentId !== normalizedAgentId
|
|
772
|
+
) {
|
|
773
|
+
throw new Error("File-lease authority returned a mismatched acquire response.");
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
try {
|
|
777
|
+
await saveCapabilityClaim(
|
|
778
|
+
normalizedSessionId,
|
|
779
|
+
{
|
|
780
|
+
leaseId: lease.leaseId,
|
|
781
|
+
sessionId: normalizedSessionId,
|
|
782
|
+
path: lease.file,
|
|
783
|
+
holderId: normalizedAgentId,
|
|
784
|
+
leaseToken,
|
|
785
|
+
expiresAt: lease.expiresAt,
|
|
786
|
+
ttlSeconds: lease.ttlSeconds,
|
|
787
|
+
revision: lease.revision,
|
|
788
|
+
updatedAt: new Date().toISOString(),
|
|
789
|
+
},
|
|
790
|
+
{ targetPath },
|
|
791
|
+
);
|
|
792
|
+
} catch (cacheError) {
|
|
793
|
+
let compensated = false;
|
|
794
|
+
try {
|
|
795
|
+
const release = await requestMutation(
|
|
796
|
+
leaseMemberUrl(apiUrl, normalizedSessionId, lease.leaseId, "release"),
|
|
797
|
+
{
|
|
798
|
+
method: "POST",
|
|
799
|
+
operationName: "session-file-lease-acquire-compensation",
|
|
800
|
+
headers,
|
|
801
|
+
body: {
|
|
802
|
+
holderId: normalizedAgentId,
|
|
803
|
+
leaseToken,
|
|
804
|
+
reason: "capability_cache_write_failed",
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
);
|
|
808
|
+
compensated = Boolean(
|
|
809
|
+
release?.authoritative === true
|
|
810
|
+
&& (release?.released || release?.alreadyReleased || release?.expired),
|
|
811
|
+
);
|
|
812
|
+
} catch {
|
|
813
|
+
compensated = false;
|
|
814
|
+
}
|
|
815
|
+
throw new Error(
|
|
816
|
+
compensated
|
|
817
|
+
? "File lease was released because its local capability could not be stored."
|
|
818
|
+
: "File-lease capability storage failed; edit blocked and the lease will expire by TTL.",
|
|
819
|
+
{ cause: cacheError },
|
|
449
820
|
);
|
|
450
|
-
return {
|
|
451
|
-
...mutation.result,
|
|
452
|
-
event,
|
|
453
|
-
expiredEvents: mutation.expiredEvents,
|
|
454
|
-
};
|
|
455
821
|
}
|
|
456
822
|
|
|
457
823
|
return {
|
|
458
|
-
|
|
459
|
-
|
|
824
|
+
locked: true,
|
|
825
|
+
duplicate: Boolean(response.duplicate),
|
|
826
|
+
file: normalizedFilePath,
|
|
827
|
+
lock: lease,
|
|
828
|
+
lease,
|
|
460
829
|
};
|
|
461
830
|
}
|
|
462
831
|
|
|
832
|
+
async function findClaimForRelease(sessionId, agentId, filePath, { targetPath }) {
|
|
833
|
+
const store = await readCapabilityStore(sessionId, { targetPath });
|
|
834
|
+
return findExactClaim(store.claims, agentId, filePath);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async function releaseCapabilityClaim(
|
|
838
|
+
sessionId,
|
|
839
|
+
agentId,
|
|
840
|
+
claim,
|
|
841
|
+
{
|
|
842
|
+
reason,
|
|
843
|
+
targetPath,
|
|
844
|
+
resolveAuthSession,
|
|
845
|
+
requestMutation,
|
|
846
|
+
},
|
|
847
|
+
) {
|
|
848
|
+
const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
|
|
849
|
+
let response;
|
|
850
|
+
try {
|
|
851
|
+
response = await requestMutation(
|
|
852
|
+
leaseMemberUrl(apiUrl, sessionId, claim.leaseId, "release"),
|
|
853
|
+
{
|
|
854
|
+
method: "POST",
|
|
855
|
+
operationName: "session-file-lease-release",
|
|
856
|
+
headers,
|
|
857
|
+
body: {
|
|
858
|
+
holderId: agentId,
|
|
859
|
+
leaseToken: claim.leaseToken,
|
|
860
|
+
reason,
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
);
|
|
864
|
+
} catch (error) {
|
|
865
|
+
if (
|
|
866
|
+
error instanceof SentinelayerApiError &&
|
|
867
|
+
error.code === "FILE_LEASE_NOT_FOUND"
|
|
868
|
+
) {
|
|
869
|
+
response = {
|
|
870
|
+
ok: true,
|
|
871
|
+
authoritative: true,
|
|
872
|
+
released: false,
|
|
873
|
+
alreadyReleased: true,
|
|
874
|
+
};
|
|
875
|
+
} else {
|
|
876
|
+
throw error;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (response?.ok !== true || response?.authoritative !== true) {
|
|
880
|
+
throw new Error("File-lease authority returned an invalid release response.");
|
|
881
|
+
}
|
|
882
|
+
await removeCapabilityClaims(
|
|
883
|
+
sessionId,
|
|
884
|
+
(existing) => existing.leaseId === claim.leaseId,
|
|
885
|
+
{ targetPath },
|
|
886
|
+
);
|
|
887
|
+
return response;
|
|
888
|
+
}
|
|
889
|
+
|
|
463
890
|
export async function unlockFile(
|
|
464
891
|
sessionId,
|
|
465
892
|
agentId,
|
|
466
893
|
filePath,
|
|
467
894
|
{
|
|
468
895
|
reason = "manual_release",
|
|
469
|
-
force = false,
|
|
470
896
|
targetPath = process.cwd(),
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
} = {}
|
|
897
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
898
|
+
request = requestJson,
|
|
899
|
+
requestMutation = requestJsonMutation,
|
|
900
|
+
} = {},
|
|
476
901
|
) {
|
|
902
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
477
903
|
const normalizedAgentId = normalizeAgentId(agentId);
|
|
478
|
-
const normalizedFilePath =
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
904
|
+
const normalizedFilePath = await canonicalizeFilePath(filePath, { targetPath });
|
|
905
|
+
const normalizedReason =
|
|
906
|
+
normalizeBoundedText(reason || "manual_release", {
|
|
907
|
+
field: "reason",
|
|
908
|
+
maxLength: 128,
|
|
909
|
+
}) || "manual_release";
|
|
910
|
+
const claim = await findClaimForRelease(
|
|
911
|
+
normalizedSessionId,
|
|
912
|
+
normalizedAgentId,
|
|
913
|
+
normalizedFilePath,
|
|
914
|
+
{ targetPath },
|
|
915
|
+
);
|
|
916
|
+
if (!claim) {
|
|
917
|
+
const leases = await listRemoteLeases(normalizedSessionId, {
|
|
485
918
|
targetPath,
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
reason: "not_locked",
|
|
497
|
-
};
|
|
498
|
-
}
|
|
499
|
-
if (!force && normalizeString(existing.agentId) !== normalizedAgentId) {
|
|
500
|
-
return {
|
|
501
|
-
unlocked: false,
|
|
502
|
-
file: normalizedFilePath,
|
|
503
|
-
reason: "held_by_other_agent",
|
|
504
|
-
heldBy: normalizeString(existing.agentId),
|
|
505
|
-
since: formatSince(existing.lockedAt, normalizedNow),
|
|
506
|
-
lock: presentLock({ ...existing, file: normalizedFilePath }, { nowIso: normalizedNow }),
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
delete registry.locks[normalizedFilePath];
|
|
919
|
+
resolveAuthSession,
|
|
920
|
+
request,
|
|
921
|
+
});
|
|
922
|
+
const exact = leases.find(
|
|
923
|
+
(lease) => pathKey(lease.file) === pathKey(normalizedFilePath),
|
|
924
|
+
);
|
|
925
|
+
const covering = leases.find((lease) =>
|
|
926
|
+
fileLeasePathCovers(lease.file, normalizedFilePath),
|
|
927
|
+
);
|
|
928
|
+
if (covering && pathKey(covering.file) !== pathKey(normalizedFilePath)) {
|
|
511
929
|
return {
|
|
512
|
-
unlocked:
|
|
930
|
+
unlocked: false,
|
|
513
931
|
file: normalizedFilePath,
|
|
514
|
-
|
|
932
|
+
reason: "covered_by_parent_scope",
|
|
933
|
+
heldBy: covering.agentId,
|
|
934
|
+
lock: covering,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
if (!exact) {
|
|
938
|
+
return {
|
|
939
|
+
unlocked: false,
|
|
940
|
+
file: normalizedFilePath,
|
|
941
|
+
reason: "not_locked",
|
|
515
942
|
};
|
|
516
943
|
}
|
|
517
|
-
);
|
|
518
|
-
|
|
519
|
-
if (!mutation.result?.unlocked) {
|
|
520
944
|
return {
|
|
521
|
-
|
|
522
|
-
|
|
945
|
+
unlocked: false,
|
|
946
|
+
file: normalizedFilePath,
|
|
947
|
+
reason:
|
|
948
|
+
exact.agentId === normalizedAgentId
|
|
949
|
+
? "holder_capability_unavailable"
|
|
950
|
+
: "held_by_other_agent",
|
|
951
|
+
heldBy: exact.agentId,
|
|
952
|
+
since: exact.since,
|
|
953
|
+
lock: exact,
|
|
523
954
|
};
|
|
524
955
|
}
|
|
525
956
|
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
emittedBy,
|
|
957
|
+
const response = await releaseCapabilityClaim(
|
|
958
|
+
normalizedSessionId,
|
|
959
|
+
normalizedAgentId,
|
|
960
|
+
claim,
|
|
531
961
|
{
|
|
532
|
-
file: normalizedFilePath,
|
|
533
|
-
heldBy: normalizeString(mutation.result.lock?.agentId) || normalizedAgentId,
|
|
534
|
-
intent: normalizeString(mutation.result.lock?.intent),
|
|
535
962
|
reason: normalizedReason,
|
|
536
|
-
},
|
|
537
|
-
{
|
|
538
963
|
targetPath,
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
964
|
+
resolveAuthSession,
|
|
965
|
+
requestMutation,
|
|
966
|
+
},
|
|
543
967
|
);
|
|
544
|
-
|
|
968
|
+
const lease = response.lease ? presentLease(response.lease) : null;
|
|
545
969
|
return {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
970
|
+
unlocked: Boolean(response.released),
|
|
971
|
+
file: normalizedFilePath,
|
|
972
|
+
reason: response.released
|
|
973
|
+
? "unlocked"
|
|
974
|
+
: response.expired
|
|
975
|
+
? "expired"
|
|
976
|
+
: "already_released",
|
|
977
|
+
lock: lease,
|
|
549
978
|
};
|
|
550
979
|
}
|
|
551
980
|
|
|
552
|
-
export async function
|
|
981
|
+
export async function renewFileLease(
|
|
553
982
|
sessionId,
|
|
983
|
+
agentId,
|
|
554
984
|
filePath,
|
|
555
985
|
{
|
|
986
|
+
ttlSeconds = DEFAULT_FILE_LOCK_TTL_SECONDS,
|
|
556
987
|
targetPath = process.cwd(),
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
} = {}
|
|
988
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
989
|
+
requestMutation = requestJsonMutation,
|
|
990
|
+
} = {},
|
|
560
991
|
) {
|
|
561
|
-
const
|
|
562
|
-
const
|
|
992
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
993
|
+
const normalizedAgentId = normalizeAgentId(agentId);
|
|
994
|
+
const normalizedFilePath = await canonicalizeFilePath(filePath, { targetPath });
|
|
995
|
+
const normalizedTtlSeconds = normalizeTtlSeconds(ttlSeconds);
|
|
996
|
+
const store = await readCapabilityStore(normalizedSessionId, { targetPath });
|
|
997
|
+
const claim = findCoveringClaim(
|
|
998
|
+
store.claims,
|
|
999
|
+
normalizedAgentId,
|
|
1000
|
+
normalizedFilePath,
|
|
1001
|
+
);
|
|
1002
|
+
if (!claim) {
|
|
1003
|
+
return {
|
|
1004
|
+
renewed: false,
|
|
1005
|
+
file: normalizedFilePath,
|
|
1006
|
+
reason: "holder_capability_unavailable",
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
563
1009
|
|
|
564
|
-
const
|
|
565
|
-
|
|
1010
|
+
const { apiUrl, headers } = await resolveLeaseApi({ targetPath, resolveAuthSession });
|
|
1011
|
+
const response = await requestMutation(
|
|
1012
|
+
leaseMemberUrl(apiUrl, normalizedSessionId, claim.leaseId, "renew"),
|
|
566
1013
|
{
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
1014
|
+
method: "POST",
|
|
1015
|
+
operationName: "session-file-lease-renew",
|
|
1016
|
+
headers,
|
|
1017
|
+
body: {
|
|
1018
|
+
holderId: normalizedAgentId,
|
|
1019
|
+
leaseToken: claim.leaseToken,
|
|
1020
|
+
ttlSeconds: normalizedTtlSeconds,
|
|
1021
|
+
},
|
|
571
1022
|
},
|
|
572
|
-
async (registry) => {
|
|
573
|
-
const existing = registry.locks[normalizedFilePath] || null;
|
|
574
|
-
if (!existing) {
|
|
575
|
-
return null;
|
|
576
|
-
}
|
|
577
|
-
return presentLock({ ...existing, file: normalizedFilePath }, { nowIso: normalizedNow });
|
|
578
|
-
}
|
|
579
1023
|
);
|
|
580
|
-
|
|
581
|
-
|
|
1024
|
+
if (
|
|
1025
|
+
response?.ok !== true
|
|
1026
|
+
|| response?.authoritative !== true
|
|
1027
|
+
|| response?.renewed !== true
|
|
1028
|
+
|| !response?.lease
|
|
1029
|
+
) {
|
|
1030
|
+
throw new Error("File-lease authority returned an invalid renew response.");
|
|
1031
|
+
}
|
|
1032
|
+
const lease = presentLease(response.lease);
|
|
1033
|
+
await saveCapabilityClaim(
|
|
1034
|
+
normalizedSessionId,
|
|
1035
|
+
{
|
|
1036
|
+
...claim,
|
|
1037
|
+
path: lease.file,
|
|
1038
|
+
expiresAt: lease.expiresAt,
|
|
1039
|
+
ttlSeconds: lease.ttlSeconds,
|
|
1040
|
+
revision: lease.revision,
|
|
1041
|
+
updatedAt: new Date().toISOString(),
|
|
1042
|
+
},
|
|
1043
|
+
{ targetPath },
|
|
1044
|
+
);
|
|
1045
|
+
return {
|
|
1046
|
+
renewed: true,
|
|
1047
|
+
file: normalizedFilePath,
|
|
1048
|
+
lease,
|
|
1049
|
+
lock: lease,
|
|
1050
|
+
};
|
|
582
1051
|
}
|
|
583
1052
|
|
|
584
|
-
export async function
|
|
1053
|
+
export async function checkFileLock(
|
|
585
1054
|
sessionId,
|
|
1055
|
+
filePath,
|
|
586
1056
|
{
|
|
587
1057
|
targetPath = process.cwd(),
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
} = {}
|
|
1058
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1059
|
+
request = requestJson,
|
|
1060
|
+
} = {},
|
|
591
1061
|
) {
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
async (registry) =>
|
|
603
|
-
Object.entries(registry.locks || {})
|
|
604
|
-
.map(([file, lockRecord]) => presentLock({ ...lockRecord, file }, { nowIso: normalizedNow }))
|
|
605
|
-
.filter(Boolean)
|
|
606
|
-
.sort((left, right) => parseEpoch(left.lockedAt, normalizedNow) - parseEpoch(right.lockedAt, normalizedNow))
|
|
1062
|
+
const normalizedFilePath = await canonicalizeFilePath(filePath, { targetPath });
|
|
1063
|
+
const leases = await listRemoteLeases(sessionId, {
|
|
1064
|
+
targetPath,
|
|
1065
|
+
resolveAuthSession,
|
|
1066
|
+
request,
|
|
1067
|
+
});
|
|
1068
|
+
return (
|
|
1069
|
+
leases.find((lease) => fileLeasePathCovers(lease.file, normalizedFilePath)) ||
|
|
1070
|
+
leases.find((lease) => fileLeasePathsOverlap(lease.file, normalizedFilePath)) ||
|
|
1071
|
+
null
|
|
607
1072
|
);
|
|
1073
|
+
}
|
|
608
1074
|
|
|
609
|
-
|
|
1075
|
+
export async function listFileLocks(
|
|
1076
|
+
sessionId,
|
|
1077
|
+
{
|
|
1078
|
+
targetPath = process.cwd(),
|
|
1079
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1080
|
+
request = requestJson,
|
|
1081
|
+
} = {},
|
|
1082
|
+
) {
|
|
1083
|
+
return listRemoteLeases(sessionId, {
|
|
1084
|
+
targetPath,
|
|
1085
|
+
resolveAuthSession,
|
|
1086
|
+
request,
|
|
1087
|
+
});
|
|
610
1088
|
}
|
|
611
1089
|
|
|
612
1090
|
export async function releaseFileLocksForAgent(
|
|
@@ -615,64 +1093,89 @@ export async function releaseFileLocksForAgent(
|
|
|
615
1093
|
{
|
|
616
1094
|
reason = "agent_killed",
|
|
617
1095
|
targetPath = process.cwd(),
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
1096
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1097
|
+
request = requestJson,
|
|
1098
|
+
requestMutation = requestJsonMutation,
|
|
1099
|
+
} = {},
|
|
621
1100
|
) {
|
|
1101
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
622
1102
|
const normalizedAgentId = normalizeAgentId(agentId);
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1103
|
+
const normalizedReason =
|
|
1104
|
+
normalizeBoundedText(reason || "agent_killed", {
|
|
1105
|
+
field: "reason",
|
|
1106
|
+
maxLength: 128,
|
|
1107
|
+
}) || "agent_killed";
|
|
1108
|
+
const store = await readCapabilityStore(normalizedSessionId, { targetPath });
|
|
1109
|
+
const claims = store.claims.filter(
|
|
1110
|
+
(claim) => normalizeAgentId(claim.holderId) === normalizedAgentId,
|
|
1111
|
+
);
|
|
1112
|
+
const released = [];
|
|
1113
|
+
const failures = [];
|
|
1114
|
+
for (const claim of claims) {
|
|
1115
|
+
try {
|
|
1116
|
+
const response = await releaseCapabilityClaim(
|
|
1117
|
+
normalizedSessionId,
|
|
1118
|
+
normalizedAgentId,
|
|
1119
|
+
claim,
|
|
1120
|
+
{
|
|
1121
|
+
reason: normalizedReason,
|
|
1122
|
+
targetPath,
|
|
1123
|
+
resolveAuthSession,
|
|
1124
|
+
requestMutation,
|
|
1125
|
+
},
|
|
1126
|
+
);
|
|
1127
|
+
if (response.released) {
|
|
1128
|
+
released.push(presentLease(response.lease || { path: claim.path }));
|
|
642
1129
|
}
|
|
643
|
-
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
failures.push({
|
|
1132
|
+
leaseId: claim.leaseId,
|
|
1133
|
+
file: claim.path,
|
|
1134
|
+
code: normalizeString(error?.code) || "release_failed",
|
|
1135
|
+
});
|
|
644
1136
|
}
|
|
645
|
-
|
|
1137
|
+
}
|
|
646
1138
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
targetPath,
|
|
662
|
-
nowIso: normalizedNow,
|
|
663
|
-
}
|
|
1139
|
+
let unresolved = [];
|
|
1140
|
+
let authority = {
|
|
1141
|
+
ok: true,
|
|
1142
|
+
authoritative: true,
|
|
1143
|
+
code: null,
|
|
1144
|
+
};
|
|
1145
|
+
try {
|
|
1146
|
+
const active = await listRemoteLeases(normalizedSessionId, {
|
|
1147
|
+
targetPath,
|
|
1148
|
+
resolveAuthSession,
|
|
1149
|
+
request,
|
|
1150
|
+
});
|
|
1151
|
+
unresolved = active.filter(
|
|
1152
|
+
(lease) => lease.agentId === normalizedAgentId,
|
|
664
1153
|
);
|
|
665
|
-
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
authority = {
|
|
1156
|
+
ok: false,
|
|
1157
|
+
authoritative: false,
|
|
1158
|
+
code: normalizeString(error?.code) || "FILE_LEASE_LIST_FAILED",
|
|
1159
|
+
};
|
|
666
1160
|
}
|
|
667
|
-
|
|
668
1161
|
return {
|
|
669
|
-
releasedCount:
|
|
670
|
-
released
|
|
671
|
-
|
|
672
|
-
|
|
1162
|
+
releasedCount: released.length,
|
|
1163
|
+
released,
|
|
1164
|
+
failures,
|
|
1165
|
+
unresolved,
|
|
1166
|
+
unresolvedKnown: authority.ok,
|
|
1167
|
+
authority,
|
|
1168
|
+
events: [],
|
|
1169
|
+
expiredEvents: [],
|
|
673
1170
|
};
|
|
674
1171
|
}
|
|
675
1172
|
|
|
676
1173
|
export {
|
|
677
1174
|
DEFAULT_FILE_LOCK_TTL_SECONDS,
|
|
1175
|
+
MAX_FILE_LOCK_TTL_SECONDS,
|
|
1176
|
+
MIN_FILE_LOCK_TTL_SECONDS,
|
|
1177
|
+
fileLeasePathCovers,
|
|
1178
|
+
fileLeasePathsOverlap,
|
|
1179
|
+
canonicalizeFilePath,
|
|
1180
|
+
normalizeFilePath,
|
|
678
1181
|
};
|