leerness 1.36.183 → 1.36.185
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/CHANGELOG.md +8 -0
- package/LICENSE +21 -21
- package/README.ko.md +15 -8
- package/README.md +25 -7
- package/SECURITY.md +66 -66
- package/bin/leerness.js +1184 -206
- package/docs/role-agent-routing-v2.md +411 -0
- package/lib/agents.js +1236 -100
- package/lib/diagnostics.js +184 -184
- package/lib/file-leases.js +845 -0
- package/lib/health.js +350 -350
- package/lib/io.js +152 -1
- package/lib/mcp-tools.js +121 -104
- package/lib/pure-utils.js +52 -4
- package/lib/referee.js +270 -270
- package/lib/review-request.js +339 -339
- package/lib/role-agent-schema.js +1179 -0
- package/lib/role-catalog.js +31 -1
- package/lib/role-fallback.js +1611 -0
- package/lib/role-store.js +730 -0
- package/lib/routing.js +109 -21
- package/lib/session-close.js +49 -1
- package/package.json +10 -5
- package/scripts/command-flags-probe.js +40 -3
- package/scripts/e2e.js +121 -44
- package/scripts/file-lease-probe.js +1139 -0
- package/scripts/i18n-next-cluster-probe.js +436 -5
- package/scripts/installed-cleanroom-probe.js +39 -12
- package/scripts/lint.js +73 -73
- package/scripts/provider-capacity-probe.js +425 -0
- package/scripts/release-runtime-probe.js +16 -1
- package/scripts/role-agent-schema-probe.js +834 -0
- package/scripts/role-fallback-probe.js +2010 -0
- package/scripts/role-store-loader-probe.js +882 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { TextDecoder } = require('util');
|
|
7
|
+
const { writeUtf8 } = require('./io');
|
|
8
|
+
const { isValidKey } = require('./session-presence');
|
|
9
|
+
const { stripDefaultIgnorables } = require('./pure-utils');
|
|
10
|
+
|
|
11
|
+
const SCHEMA_VERSION = 1;
|
|
12
|
+
const STORE_REL = Object.freeze(['.leerness', 'cache', 'file-leases.json']);
|
|
13
|
+
const DEFAULT_TTL_SECONDS = 300;
|
|
14
|
+
const MIN_TTL_SECONDS = 30;
|
|
15
|
+
const MAX_TTL_SECONDS = 1800;
|
|
16
|
+
const MAX_STORE_BYTES = 1024 * 1024;
|
|
17
|
+
const MAX_LEASES = 1000;
|
|
18
|
+
const LEASE_ID_RE = /^lease-[0-9a-f]{16}$/;
|
|
19
|
+
const IDENTITY_RE = /^fs:[0-9]+:[0-9]+$/;
|
|
20
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
|
|
21
|
+
const WINDOWS_DEVICE_RE = /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])$/i;
|
|
22
|
+
|
|
23
|
+
class LeaseError extends Error {
|
|
24
|
+
constructor(code, message, details = null) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'LeaseError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.details = details;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _fail(code, message, details) {
|
|
33
|
+
throw new LeaseError(code, message, details);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _isInside(root, candidate) {
|
|
37
|
+
if (process.platform === 'win32') {
|
|
38
|
+
const parent = path.resolve(root);
|
|
39
|
+
const child = path.resolve(candidate);
|
|
40
|
+
const parentParsed = path.parse(parent);
|
|
41
|
+
const childParsed = path.parse(child);
|
|
42
|
+
if (parentParsed.root.toLowerCase() !== childParsed.root.toLowerCase()) return false;
|
|
43
|
+
const parentParts = parent.slice(parentParsed.root.length).split(path.sep).filter(Boolean);
|
|
44
|
+
const childParts = child.slice(childParsed.root.length).split(path.sep).filter(Boolean);
|
|
45
|
+
if (childParts.length < parentParts.length) return false;
|
|
46
|
+
let observedParent = parentParsed.root;
|
|
47
|
+
for (let i = 0; i < parentParts.length; i++) {
|
|
48
|
+
if (parentParts[i] !== childParts[i]) {
|
|
49
|
+
if (parentParts[i].toLowerCase() !== childParts[i].toLowerCase()) return false;
|
|
50
|
+
// Per-directory case sensitivity can make Repo and repo distinct even
|
|
51
|
+
// though path.win32.relative treats them as aliases.
|
|
52
|
+
if (!_directoryCaseInsensitive(observedParent)) return false;
|
|
53
|
+
}
|
|
54
|
+
observedParent = path.join(observedParent, parentParts[i]);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
const rel = path.relative(root, candidate);
|
|
59
|
+
return rel === '' || (!path.isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${path.sep}`));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _realpath(value) {
|
|
63
|
+
return fs.realpathSync.native ? fs.realpathSync.native(value) : fs.realpathSync(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _alternateCase(name) {
|
|
67
|
+
const text = String(name);
|
|
68
|
+
let offset = 0;
|
|
69
|
+
for (const char of text) {
|
|
70
|
+
const lower = char.toLowerCase();
|
|
71
|
+
const upper = char.toUpperCase();
|
|
72
|
+
let flipped = null;
|
|
73
|
+
if (char === lower && upper !== char) flipped = upper;
|
|
74
|
+
else if (char === upper && lower !== char) flipped = lower;
|
|
75
|
+
if (flipped && flipped !== char) return text.slice(0, offset) + flipped + text.slice(offset + char.length);
|
|
76
|
+
offset += char.length;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Derive lookup semantics from an existing entry in the containing directory.
|
|
82
|
+
// This handles default case-insensitive macOS volumes and Windows directories
|
|
83
|
+
// with per-directory case sensitivity. Empty/unreadable directories use the
|
|
84
|
+
// conservative platform default so aliases cannot acquire two owners.
|
|
85
|
+
function _directoryCaseInsensitive(realDir) {
|
|
86
|
+
try {
|
|
87
|
+
const entries = fs.readdirSync(realDir, { withFileTypes: true })
|
|
88
|
+
.slice().sort((a, b) => a.name.localeCompare(b.name));
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const alternate = _alternateCase(entry.name);
|
|
91
|
+
if (!alternate || alternate === entry.name) continue;
|
|
92
|
+
const originalPath = path.join(realDir, entry.name);
|
|
93
|
+
const alternatePath = path.join(realDir, alternate);
|
|
94
|
+
let original;
|
|
95
|
+
try { original = fs.lstatSync(originalPath, { bigint: true }); }
|
|
96
|
+
catch { continue; }
|
|
97
|
+
try {
|
|
98
|
+
const observed = fs.lstatSync(alternatePath, { bigint: true });
|
|
99
|
+
return original.dev === observed.dev && original.ino === observed.ino;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error && error.code === 'ENOENT') return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
// No observation means no safe basis for splitting ownership. Collapsing case
|
|
106
|
+
// aliases can cause a false conflict on a case-sensitive empty directory, but
|
|
107
|
+
// guessing case-sensitive can grant two owners for one eventual file on Linux
|
|
108
|
+
// casefold directories. Exact-file coordination fails closed here.
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _rootContext(root) {
|
|
113
|
+
const lexicalRoot = path.resolve(String(root || process.cwd()));
|
|
114
|
+
let stat;
|
|
115
|
+
try { stat = fs.statSync(lexicalRoot); }
|
|
116
|
+
catch (error) { _fail('project_unavailable', 'Project root is unavailable.', { error: error && error.code || 'ENOENT' }); }
|
|
117
|
+
if (!stat.isDirectory()) _fail('project_not_directory', 'Project root is not a directory.');
|
|
118
|
+
let realRoot;
|
|
119
|
+
try { realRoot = _realpath(lexicalRoot); }
|
|
120
|
+
catch (error) { _fail('project_unavailable', 'Project root cannot be resolved.', { error: error && error.code || 'EREALPATH' }); }
|
|
121
|
+
return { lexicalRoot, realRoot };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function _safeExistingDirectory(rootContext, lexicalDir, missingCode, invalidCode) {
|
|
125
|
+
let lst;
|
|
126
|
+
try { lst = fs.lstatSync(lexicalDir); }
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error && error.code === 'ENOENT') _fail(missingCode, 'Required project directory is missing.');
|
|
129
|
+
_fail(invalidCode, 'Required project directory cannot be inspected.', { error: error && error.code || 'ELSTAT' });
|
|
130
|
+
}
|
|
131
|
+
let realDir;
|
|
132
|
+
try { realDir = _realpath(lexicalDir); }
|
|
133
|
+
catch (error) { _fail(invalidCode, 'Required project directory cannot be resolved.', { error: error && error.code || 'EREALPATH' }); }
|
|
134
|
+
let stat;
|
|
135
|
+
try { stat = fs.statSync(realDir); }
|
|
136
|
+
catch (error) { _fail(invalidCode, 'Required project directory cannot be inspected.', { error: error && error.code || 'ESTAT' }); }
|
|
137
|
+
if (!lst.isDirectory() && !lst.isSymbolicLink()) _fail(invalidCode, 'Required project path is not a directory.');
|
|
138
|
+
if (!stat.isDirectory()) _fail(invalidCode, 'Required project path does not resolve to a directory.');
|
|
139
|
+
if (!_isInside(rootContext.realRoot, realDir)) _fail('path_escape', 'Required project directory resolves outside the project root.');
|
|
140
|
+
return realDir;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function _storePath(root, options = {}) {
|
|
144
|
+
const ctx = _rootContext(root);
|
|
145
|
+
const harnessLex = path.join(ctx.lexicalRoot, '.leerness');
|
|
146
|
+
let harnessLstat;
|
|
147
|
+
try { harnessLstat = fs.lstatSync(harnessLex); }
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error && error.code === 'ENOENT') _fail('not_initialized', 'Leerness is not initialized in this project.');
|
|
150
|
+
_fail('workspace_invalid', 'Leerness workspace cannot be inspected.', { error: error && error.code || 'ELSTAT' });
|
|
151
|
+
}
|
|
152
|
+
if (harnessLstat.isSymbolicLink()) _fail('workspace_linked', 'The Leerness workspace directory must not be a symbolic link or junction.');
|
|
153
|
+
_safeExistingDirectory(ctx, harnessLex, 'not_initialized', 'workspace_invalid');
|
|
154
|
+
|
|
155
|
+
const cacheLex = path.join(harnessLex, 'cache');
|
|
156
|
+
let cacheReal;
|
|
157
|
+
try {
|
|
158
|
+
let cacheLstat;
|
|
159
|
+
try { cacheLstat = fs.lstatSync(cacheLex); }
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error && error.code === 'ENOENT') _fail('cache_missing', 'Lease cache directory is missing.');
|
|
162
|
+
_fail('cache_invalid', 'Lease cache directory cannot be inspected.', { error: error && error.code || 'ELSTAT' });
|
|
163
|
+
}
|
|
164
|
+
if (cacheLstat.isSymbolicLink()) _fail('cache_symlink', 'Lease cache directory must not be a symbolic link or junction.');
|
|
165
|
+
cacheReal = _safeExistingDirectory(ctx, cacheLex, 'cache_missing', 'cache_invalid');
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (!(error instanceof LeaseError) || error.code !== 'cache_missing' || !options.create) throw error;
|
|
168
|
+
try { fs.mkdirSync(cacheLex, { recursive: true }); }
|
|
169
|
+
catch (mkdirError) { _fail('cache_unavailable', 'Lease cache directory could not be created.', { error: mkdirError && mkdirError.code || 'EMKDIR' }); }
|
|
170
|
+
const createdCacheLstat = fs.lstatSync(cacheLex);
|
|
171
|
+
if (createdCacheLstat.isSymbolicLink()) _fail('cache_symlink', 'Lease cache directory must not be a symbolic link or junction.');
|
|
172
|
+
cacheReal = _safeExistingDirectory(ctx, cacheLex, 'cache_missing', 'cache_invalid');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Use the resolved in-project cache directory for later reads, writes, and locks.
|
|
176
|
+
// This removes ordinary lexical aliases. It is not a security fence against a malicious
|
|
177
|
+
// process replacing filesystem objects concurrently; the lease itself is advisory.
|
|
178
|
+
const store = path.join(cacheReal, 'file-leases.json');
|
|
179
|
+
let storeLstat = null;
|
|
180
|
+
try { storeLstat = fs.lstatSync(store); }
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (!(error && error.code === 'ENOENT')) _fail('store_unreadable', 'Lease store cannot be inspected.', { error: error && error.code || 'ELSTAT' });
|
|
183
|
+
}
|
|
184
|
+
if (storeLstat) {
|
|
185
|
+
if (storeLstat.isSymbolicLink()) _fail('store_symlink', 'Lease store must not be a symbolic link.');
|
|
186
|
+
if (!storeLstat.isFile()) _fail('store_invalid', 'Lease store is not a regular file.');
|
|
187
|
+
if (Number(storeLstat.nlink) > 1) _fail('store_hard_link', 'Lease store must not be hard-linked.');
|
|
188
|
+
let realStore;
|
|
189
|
+
try { realStore = _realpath(store); }
|
|
190
|
+
catch (error) { _fail('store_unreadable', 'Lease store cannot be resolved.', { error: error && error.code || 'EREALPATH' }); }
|
|
191
|
+
if (!_isInside(ctx.realRoot, realStore)) _fail('path_escape', 'Lease store resolves outside the project root.');
|
|
192
|
+
}
|
|
193
|
+
return { ...ctx, store };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function storePath(root) {
|
|
197
|
+
return _storePath(root, { create: false }).store;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _iso(value) {
|
|
201
|
+
if (typeof value !== 'string') return null;
|
|
202
|
+
const ms = Date.parse(value);
|
|
203
|
+
if (!Number.isFinite(ms)) return null;
|
|
204
|
+
const canonical = new Date(ms).toISOString();
|
|
205
|
+
return canonical === value ? canonical : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function _safeText(value, max) {
|
|
209
|
+
if (value == null) return null;
|
|
210
|
+
const text = stripDefaultIgnorables(value, { visibleIdentity: true }).replace(CONTROL_RE, ' ').trim();
|
|
211
|
+
return text ? text.slice(0, max) : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeSessionKey(value) {
|
|
215
|
+
const key = String(value == null ? '' : value).trim().toLowerCase();
|
|
216
|
+
if (!isValidKey(key)) _fail('invalid_session_key', 'A stable session key is required (8-64 alphanumeric, underscore, or hyphen characters).');
|
|
217
|
+
return key;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeTtlSeconds(value) {
|
|
221
|
+
const raw = value == null || value === '' ? DEFAULT_TTL_SECONDS : Number(value);
|
|
222
|
+
if (!Number.isInteger(raw) || raw < MIN_TTL_SECONDS || raw > MAX_TTL_SECONDS) {
|
|
223
|
+
_fail('invalid_ttl', `TTL must be an integer from ${MIN_TTL_SECONDS} to ${MAX_TTL_SECONDS} seconds.`, {
|
|
224
|
+
min: MIN_TTL_SECONDS,
|
|
225
|
+
max: MAX_TTL_SECONDS,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return raw;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _invalidWindowsPathPart(part) {
|
|
232
|
+
if (!part || /[. ]$/.test(part) || /[<>:"/\\|?*]/.test(part)) return true;
|
|
233
|
+
return WINDOWS_DEVICE_RE.test(part.split('.')[0]);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// JavaScript has no direct Unicode simple-case-folding API. Filesystem case tables
|
|
237
|
+
// are one-code-point mappings, so never apply compatibility or multi-code-point
|
|
238
|
+
// expansions here (`ß -> SS`, `ff -> FF`, and similar pairs can be distinct NTFS
|
|
239
|
+
// names). Upper-then-lower for one-code-point results still joins aliases such as
|
|
240
|
+
// final sigma with sigma. NFC conservatively joins canonically equivalent missing
|
|
241
|
+
// paths without collapsing compatibility characters.
|
|
242
|
+
function _conservativeCaseFold(value) {
|
|
243
|
+
let text = String(value == null ? '' : value);
|
|
244
|
+
try { text = text.normalize('NFC'); } catch {}
|
|
245
|
+
text = Array.from(text, character => {
|
|
246
|
+
let upper;
|
|
247
|
+
try { upper = character.toLocaleUpperCase('und'); }
|
|
248
|
+
catch { upper = character.toUpperCase(); }
|
|
249
|
+
if (Array.from(upper).length !== 1) {
|
|
250
|
+
let lower;
|
|
251
|
+
try { lower = character.toLocaleLowerCase('und'); }
|
|
252
|
+
catch { lower = character.toLowerCase(); }
|
|
253
|
+
return Array.from(lower).length === 1 ? lower : character;
|
|
254
|
+
}
|
|
255
|
+
let folded;
|
|
256
|
+
try { folded = upper.toLocaleLowerCase('und'); }
|
|
257
|
+
catch { folded = upper.toLowerCase(); }
|
|
258
|
+
return Array.from(folded).length === 1 ? folded : upper;
|
|
259
|
+
}).join('');
|
|
260
|
+
try { text = text.normalize('NFC'); } catch {}
|
|
261
|
+
return text;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function _hasUnsafePathFormatting(value) {
|
|
265
|
+
const raw = String(value == null ? '' : value);
|
|
266
|
+
return stripDefaultIgnorables(raw, { visibleIdentity: true }) !== raw;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function _existingAncestor(target) {
|
|
270
|
+
const suffix = [];
|
|
271
|
+
let current = target;
|
|
272
|
+
for (;;) {
|
|
273
|
+
try {
|
|
274
|
+
const lstat = fs.lstatSync(current);
|
|
275
|
+
return { current, suffix, lstat };
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (!(error && error.code === 'ENOENT')) _fail('file_unreadable', 'Lease target cannot be inspected.', { error: error && error.code || 'ELSTAT' });
|
|
278
|
+
}
|
|
279
|
+
const parent = path.dirname(current);
|
|
280
|
+
if (parent === current) _fail('file_unresolvable', 'Lease target has no resolvable ancestor.');
|
|
281
|
+
suffix.unshift(path.basename(current));
|
|
282
|
+
current = parent;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function resolveTarget(root, file) {
|
|
287
|
+
const ctx = _rootContext(root);
|
|
288
|
+
if (typeof file !== 'string' || !file.trim() || file.length > 2048) {
|
|
289
|
+
_fail('invalid_file', 'Lease target must be a non-empty file path no longer than 2048 characters.');
|
|
290
|
+
}
|
|
291
|
+
CONTROL_RE.lastIndex = 0;
|
|
292
|
+
if (CONTROL_RE.test(file) || _hasUnsafePathFormatting(file)) {
|
|
293
|
+
CONTROL_RE.lastIndex = 0;
|
|
294
|
+
_fail('invalid_file', 'Lease target must not contain terminal, line-control, bidi, or invisible format characters.');
|
|
295
|
+
}
|
|
296
|
+
CONTROL_RE.lastIndex = 0;
|
|
297
|
+
const input = file;
|
|
298
|
+
const absoluteInput = path.isAbsolute(input);
|
|
299
|
+
const lexicalTarget = path.resolve(ctx.lexicalRoot, input);
|
|
300
|
+
if (!absoluteInput && !_isInside(ctx.lexicalRoot, lexicalTarget)) {
|
|
301
|
+
_fail('path_escape', 'Relative lease target escapes the project root.');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const ancestor = _existingAncestor(lexicalTarget);
|
|
305
|
+
let ancestorReal;
|
|
306
|
+
try { ancestorReal = _realpath(ancestor.current); }
|
|
307
|
+
catch (error) {
|
|
308
|
+
_fail(ancestor.lstat.isSymbolicLink() ? 'broken_symlink' : 'file_unresolvable',
|
|
309
|
+
'Lease target cannot be resolved.', { error: error && error.code || 'EREALPATH' });
|
|
310
|
+
}
|
|
311
|
+
let ancestorStat;
|
|
312
|
+
try { ancestorStat = fs.statSync(ancestorReal, { bigint: true }); }
|
|
313
|
+
catch (error) { _fail('file_unreadable', 'Lease target ancestor cannot be inspected.', { error: error && error.code || 'ESTAT' }); }
|
|
314
|
+
if (ancestor.suffix.length > 0 && !ancestorStat.isDirectory()) {
|
|
315
|
+
_fail('parent_not_directory', 'A parent component of the lease target is not a directory.');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const realTarget = ancestor.suffix.length > 0
|
|
319
|
+
? path.resolve(ancestorReal, ...ancestor.suffix)
|
|
320
|
+
: ancestorReal;
|
|
321
|
+
if (!_isInside(ctx.realRoot, realTarget)) _fail('path_escape', 'Lease target resolves outside the project root.');
|
|
322
|
+
|
|
323
|
+
let exists = ancestor.suffix.length === 0;
|
|
324
|
+
let identityKey = null;
|
|
325
|
+
if (exists) {
|
|
326
|
+
let targetStat;
|
|
327
|
+
try { targetStat = fs.statSync(realTarget, { bigint: true }); }
|
|
328
|
+
catch (error) { _fail('file_unreadable', 'Lease target cannot be inspected.', { error: error && error.code || 'ESTAT' }); }
|
|
329
|
+
if (!targetStat.isFile()) _fail('target_not_file', 'Lease target must be a regular file or a missing in-project file.');
|
|
330
|
+
if (targetStat.ino !== 0n) identityKey = `fs:${targetStat.dev.toString()}:${targetStat.ino.toString()}`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const relative = path.relative(ctx.realRoot, realTarget).split(path.sep).join('/');
|
|
334
|
+
if (!relative || relative === '.') _fail('target_not_file', 'Project root cannot be leased as a file.');
|
|
335
|
+
if (relative.length > 2048) _fail('invalid_file', 'Canonical lease target exceeds 2048 characters.');
|
|
336
|
+
if (process.platform === 'win32' && relative.split('/').some(_invalidWindowsPathPart)) {
|
|
337
|
+
_fail('invalid_file', 'Windows lease targets must not use device names, invalid filename characters, alternate-data-stream syntax, or trailing dots/spaces.');
|
|
338
|
+
}
|
|
339
|
+
const caseDirectory = exists ? path.dirname(realTarget) : ancestorReal;
|
|
340
|
+
const caseInsensitive = _directoryCaseInsensitive(caseDirectory);
|
|
341
|
+
let normalizedRelative = relative;
|
|
342
|
+
try { normalizedRelative = normalizedRelative.normalize('NFC'); } catch {}
|
|
343
|
+
const pathKey = caseInsensitive ? _conservativeCaseFold(normalizedRelative) : normalizedRelative;
|
|
344
|
+
return {
|
|
345
|
+
file: relative,
|
|
346
|
+
pathKey,
|
|
347
|
+
identityKey,
|
|
348
|
+
exists,
|
|
349
|
+
lexicalPath: lexicalTarget,
|
|
350
|
+
resolvedPath: realTarget,
|
|
351
|
+
caseInsensitive,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function _emptyStore() {
|
|
356
|
+
return { schemaVersion: SCHEMA_VERSION, updatedAt: null, leases: [] };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function _validateLease(root, raw, index, seenIds, seenPaths, seenIdentities, options = {}) {
|
|
360
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) _fail('store_invalid', `Lease row ${index} is not an object.`);
|
|
361
|
+
const allowed = new Set(['leaseId', 'file', 'pathKey', 'identityKey', 'sessionKey', 'acquiredAt', 'renewedAt', 'expiresAt', 'note']);
|
|
362
|
+
for (const key of Object.keys(raw)) if (!allowed.has(key)) _fail('store_invalid', `Lease row ${index} contains an unknown field.`, { field: key });
|
|
363
|
+
if (!LEASE_ID_RE.test(raw.leaseId)) _fail('store_invalid', `Lease row ${index} has an invalid leaseId.`);
|
|
364
|
+
if (seenIds.has(raw.leaseId)) _fail('store_invalid', 'Lease store contains duplicate lease IDs.');
|
|
365
|
+
seenIds.add(raw.leaseId);
|
|
366
|
+
if (typeof raw.file !== 'string' || !raw.file || raw.file.length > 2048 || CONTROL_RE.test(raw.file) || _hasUnsafePathFormatting(raw.file)) {
|
|
367
|
+
CONTROL_RE.lastIndex = 0;
|
|
368
|
+
_fail('store_invalid', `Lease row ${index} has an invalid file.`);
|
|
369
|
+
}
|
|
370
|
+
CONTROL_RE.lastIndex = 0;
|
|
371
|
+
const fileParts = raw.file.split('/');
|
|
372
|
+
const invalidForPlatform = process.platform === 'win32'
|
|
373
|
+
&& (path.win32.isAbsolute(raw.file) || raw.file.includes('\\') || fileParts.some(_invalidWindowsPathPart));
|
|
374
|
+
if (path.posix.isAbsolute(raw.file)
|
|
375
|
+
|| fileParts.some(part => !part || part === '.' || part === '..')
|
|
376
|
+
|| invalidForPlatform) {
|
|
377
|
+
_fail('store_invalid', `Lease row ${index} has a non-canonical file path.`);
|
|
378
|
+
}
|
|
379
|
+
if (typeof raw.pathKey !== 'string' || !raw.pathKey || raw.pathKey.length > 2048 || CONTROL_RE.test(raw.pathKey) || _hasUnsafePathFormatting(raw.pathKey)) {
|
|
380
|
+
CONTROL_RE.lastIndex = 0;
|
|
381
|
+
_fail('store_invalid', `Lease row ${index} has an invalid pathKey.`);
|
|
382
|
+
}
|
|
383
|
+
CONTROL_RE.lastIndex = 0;
|
|
384
|
+
if (raw.identityKey !== null && !IDENTITY_RE.test(raw.identityKey)) _fail('store_invalid', `Lease row ${index} has an invalid identityKey.`);
|
|
385
|
+
let sessionKey;
|
|
386
|
+
try { sessionKey = normalizeSessionKey(raw.sessionKey); }
|
|
387
|
+
catch { _fail('store_invalid', `Lease row ${index} has an invalid sessionKey.`); }
|
|
388
|
+
const acquiredAt = _iso(raw.acquiredAt);
|
|
389
|
+
const renewedAt = _iso(raw.renewedAt);
|
|
390
|
+
const expiresAt = _iso(raw.expiresAt);
|
|
391
|
+
if (!acquiredAt || !renewedAt || !expiresAt) _fail('store_invalid', `Lease row ${index} has an invalid timestamp.`);
|
|
392
|
+
const acquiredMs = Date.parse(acquiredAt);
|
|
393
|
+
const renewedMs = Date.parse(renewedAt);
|
|
394
|
+
const expiresMs = Date.parse(expiresAt);
|
|
395
|
+
const ttlMs = expiresMs - renewedMs;
|
|
396
|
+
if (renewedMs < acquiredMs || ttlMs < MIN_TTL_SECONDS * 1000 || ttlMs > MAX_TTL_SECONDS * 1000) {
|
|
397
|
+
_fail('store_invalid', `Lease row ${index} has an invalid time order or TTL.`);
|
|
398
|
+
}
|
|
399
|
+
if (raw.note !== null && (typeof raw.note !== 'string' || raw.note.length > 200 || CONTROL_RE.test(raw.note))) {
|
|
400
|
+
CONTROL_RE.lastIndex = 0;
|
|
401
|
+
_fail('store_invalid', `Lease row ${index} has an invalid note.`);
|
|
402
|
+
}
|
|
403
|
+
CONTROL_RE.lastIndex = 0;
|
|
404
|
+
const expired = Number.isFinite(options.nowMs) && expiresMs <= options.nowMs;
|
|
405
|
+
let resolvedStored;
|
|
406
|
+
try { resolvedStored = resolveTarget(root, raw.file); }
|
|
407
|
+
catch (error) {
|
|
408
|
+
if (!expired) {
|
|
409
|
+
_fail('store_invalid', `Lease row ${index} no longer resolves to a safe project file.`, {
|
|
410
|
+
cause: error && error.code || 'target-invalid',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
resolvedStored = null;
|
|
414
|
+
}
|
|
415
|
+
const expectedPathKey = resolvedStored ? resolvedStored.pathKey : raw.pathKey;
|
|
416
|
+
// Accept the two historical serializations, then canonicalize in memory to
|
|
417
|
+
// the containing filesystem's observed behavior. A later mutation writes it back.
|
|
418
|
+
let normalizedFile = raw.file;
|
|
419
|
+
try { normalizedFile = normalizedFile.normalize('NFC'); } catch {}
|
|
420
|
+
const compatiblePathKeys = new Set([
|
|
421
|
+
expectedPathKey,
|
|
422
|
+
raw.file,
|
|
423
|
+
raw.file.toLowerCase(),
|
|
424
|
+
normalizedFile,
|
|
425
|
+
normalizedFile.toLowerCase(),
|
|
426
|
+
_conservativeCaseFold(raw.file),
|
|
427
|
+
_conservativeCaseFold(normalizedFile),
|
|
428
|
+
]);
|
|
429
|
+
if (!compatiblePathKeys.has(raw.pathKey)) _fail('store_invalid', `Lease row ${index} pathKey does not match file.`);
|
|
430
|
+
// Unicode/case folding is deliberately conservative for paths that do not
|
|
431
|
+
// exist yet. Some filesystems can nevertheless hold two existing names that
|
|
432
|
+
// fold to the same path key. Preserve those rows only when both have distinct,
|
|
433
|
+
// observed file identities; an unknown identity remains fail-closed.
|
|
434
|
+
if (!expired) {
|
|
435
|
+
const pathIdentities = seenPaths.get(expectedPathKey);
|
|
436
|
+
if (pathIdentities) {
|
|
437
|
+
if (!raw.identityKey || pathIdentities.has(null) || pathIdentities.has(raw.identityKey)) {
|
|
438
|
+
_fail('store_invalid', 'Lease store contains an ambiguous duplicate path key.');
|
|
439
|
+
}
|
|
440
|
+
pathIdentities.add(raw.identityKey);
|
|
441
|
+
} else {
|
|
442
|
+
seenPaths.set(expectedPathKey, new Set([raw.identityKey || null]));
|
|
443
|
+
}
|
|
444
|
+
if (raw.identityKey) {
|
|
445
|
+
if (seenIdentities.has(raw.identityKey)) _fail('store_invalid', 'Lease store contains duplicate file identities.');
|
|
446
|
+
seenIdentities.add(raw.identityKey);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
leaseId: raw.leaseId,
|
|
451
|
+
file: raw.file,
|
|
452
|
+
pathKey: expectedPathKey,
|
|
453
|
+
identityKey: raw.identityKey,
|
|
454
|
+
sessionKey,
|
|
455
|
+
acquiredAt,
|
|
456
|
+
renewedAt,
|
|
457
|
+
expiresAt,
|
|
458
|
+
note: raw.note,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function readStore(root, options = {}) {
|
|
463
|
+
const location = _storePath(root, { create: false });
|
|
464
|
+
let stat;
|
|
465
|
+
try { stat = fs.lstatSync(location.store); }
|
|
466
|
+
catch (error) {
|
|
467
|
+
if (error && error.code === 'ENOENT') return { state: 'missing', store: _emptyStore(), raw: null, path: location.store };
|
|
468
|
+
_fail('store_unreadable', 'Lease store cannot be read.', { error: error && error.code || 'ESTAT' });
|
|
469
|
+
}
|
|
470
|
+
if (stat.isSymbolicLink() || !stat.isFile()) _fail('store_invalid', 'Lease store is not a regular file.');
|
|
471
|
+
if (Number(stat.nlink) > 1) _fail('store_hard_link', 'Lease store must not be hard-linked.');
|
|
472
|
+
if (stat.size > MAX_STORE_BYTES) _fail('store_too_large', 'Lease store exceeds the safety limit.', { maxBytes: MAX_STORE_BYTES });
|
|
473
|
+
let bytes;
|
|
474
|
+
try { bytes = fs.readFileSync(location.store); }
|
|
475
|
+
catch (error) { _fail('store_unreadable', 'Lease store cannot be read.', { error: error && error.code || 'EREAD' }); }
|
|
476
|
+
let raw;
|
|
477
|
+
try { raw = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
|
|
478
|
+
catch { _fail('store_invalid_utf8', 'Lease store is not valid UTF-8.'); }
|
|
479
|
+
let parsed;
|
|
480
|
+
try { parsed = JSON.parse(raw); }
|
|
481
|
+
catch { _fail('store_corrupt', 'Lease store is not valid JSON.'); }
|
|
482
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) _fail('store_invalid', 'Lease store root is invalid.');
|
|
483
|
+
const topAllowed = new Set(['schemaVersion', 'updatedAt', 'leases']);
|
|
484
|
+
for (const key of Object.keys(parsed)) if (!topAllowed.has(key)) _fail('store_invalid', 'Lease store contains an unknown top-level field.', { field: key });
|
|
485
|
+
if (parsed.schemaVersion !== SCHEMA_VERSION) _fail('store_version_unsupported', 'Lease store schema version is unsupported.', { schemaVersion: parsed.schemaVersion });
|
|
486
|
+
if (parsed.updatedAt !== null && !_iso(parsed.updatedAt)) _fail('store_invalid', 'Lease store updatedAt is invalid.');
|
|
487
|
+
if (!Array.isArray(parsed.leases) || parsed.leases.length > MAX_LEASES) _fail('store_invalid', 'Lease store leases array is invalid.');
|
|
488
|
+
const seenIds = new Set();
|
|
489
|
+
const seenPaths = new Map();
|
|
490
|
+
const seenIdentities = new Set();
|
|
491
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
492
|
+
const leases = parsed.leases.map((lease, index) => _validateLease(
|
|
493
|
+
root, lease, index, seenIds, seenPaths, seenIdentities, { nowMs },
|
|
494
|
+
));
|
|
495
|
+
return {
|
|
496
|
+
state: 'ok',
|
|
497
|
+
store: { schemaVersion: SCHEMA_VERSION, updatedAt: parsed.updatedAt, leases },
|
|
498
|
+
raw,
|
|
499
|
+
path: location.store,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function _readStoreForMutation(root, options = {}) {
|
|
504
|
+
let location;
|
|
505
|
+
try { location = _storePath(root, { create: true }); }
|
|
506
|
+
catch (error) { throw error; }
|
|
507
|
+
let read;
|
|
508
|
+
try { read = readStore(root, options); }
|
|
509
|
+
catch (error) { throw error; }
|
|
510
|
+
return { ...read, path: location.store };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function _currentObservedIdentity(root, lease) {
|
|
514
|
+
try {
|
|
515
|
+
const current = resolveTarget(root, lease.file);
|
|
516
|
+
return current.exists ? current.identityKey : null;
|
|
517
|
+
} catch { return null; }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function _observedIdentities(root, lease) {
|
|
521
|
+
const identities = new Set();
|
|
522
|
+
if (lease.identityKey) identities.add(lease.identityKey);
|
|
523
|
+
// A missing or replaced file may acquire a new identity after the lease was
|
|
524
|
+
// recorded. Compare both historical and current identity without rewriting
|
|
525
|
+
// the row during an unrelated read/mutation.
|
|
526
|
+
const current = _currentObservedIdentity(root, lease);
|
|
527
|
+
if (current) identities.add(current);
|
|
528
|
+
return identities;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function _matchesTarget(root, lease, target) {
|
|
532
|
+
if (target.identityKey) {
|
|
533
|
+
const observed = _observedIdentities(root, lease);
|
|
534
|
+
if (observed.has(target.identityKey)) return true;
|
|
535
|
+
// When both sides expose different identities, that evidence is stronger
|
|
536
|
+
// than a conservative Unicode/case-fold collision in the lexical path key.
|
|
537
|
+
if (observed.size > 0) return false;
|
|
538
|
+
}
|
|
539
|
+
return lease.pathKey === target.pathKey;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function _promoteCollidingPathIdentities(root, leases, target) {
|
|
543
|
+
if (!target.identityKey) return;
|
|
544
|
+
for (const lease of leases) {
|
|
545
|
+
if (lease.pathKey !== target.pathKey || lease.identityKey) continue;
|
|
546
|
+
const current = _currentObservedIdentity(root, lease);
|
|
547
|
+
if (!current || current === target.identityKey) continue;
|
|
548
|
+
const sharedElsewhere = leases.some(other => other !== lease
|
|
549
|
+
&& _observedIdentities(root, other).has(current));
|
|
550
|
+
if (sharedElsewhere) {
|
|
551
|
+
_fail('lease_state_ambiguous', 'A colliding path key has an identity shared by another active lease; acquisition was refused.');
|
|
552
|
+
}
|
|
553
|
+
// Persist the now-observable identity before admitting another existing file
|
|
554
|
+
// with the same conservative path key, keeping subsequent reads unambiguous.
|
|
555
|
+
lease.identityKey = current;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function _withLeaseLock(options, targetPath, fn) {
|
|
560
|
+
let callbackCompleted = false;
|
|
561
|
+
try {
|
|
562
|
+
return options.withLock(targetPath, () => {
|
|
563
|
+
const value = fn();
|
|
564
|
+
callbackCompleted = true;
|
|
565
|
+
return value;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
if (error instanceof LeaseError) throw error;
|
|
570
|
+
if (error && /^E_LOCK/.test(String(error.code || ''))) {
|
|
571
|
+
_fail('lease_lock_unavailable', 'The lease store lock could not be acquired or released safely.', {
|
|
572
|
+
lockCode: String(error.code),
|
|
573
|
+
mutationMayHaveApplied: callbackCompleted,
|
|
574
|
+
...(Number.isFinite(error.waitedMs) ? { waitedMs: error.waitedMs } : {}),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function _active(lease, nowMs) {
|
|
582
|
+
return Date.parse(lease.expiresAt) > nowMs;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function _publicLease(lease, nowMs) {
|
|
586
|
+
const remainingMs = Math.max(0, Date.parse(lease.expiresAt) - nowMs);
|
|
587
|
+
return {
|
|
588
|
+
leaseId: lease.leaseId,
|
|
589
|
+
file: lease.file,
|
|
590
|
+
sessionKey: lease.sessionKey,
|
|
591
|
+
acquiredAt: lease.acquiredAt,
|
|
592
|
+
renewedAt: lease.renewedAt,
|
|
593
|
+
expiresAt: lease.expiresAt,
|
|
594
|
+
note: lease.note,
|
|
595
|
+
status: remainingMs > 0 ? 'active' : 'expired',
|
|
596
|
+
remainingSeconds: Math.ceil(remainingMs / 1000),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function _writeStore(file, leases, nowIso) {
|
|
601
|
+
if (!Array.isArray(leases) || leases.length > MAX_LEASES) {
|
|
602
|
+
_fail('lease_limit_reached', 'Lease store has reached its safety limit.', { maxLeases: MAX_LEASES });
|
|
603
|
+
}
|
|
604
|
+
const data = { schemaVersion: SCHEMA_VERSION, updatedAt: nowIso, leases };
|
|
605
|
+
const text = JSON.stringify(data, null, 2) + '\n';
|
|
606
|
+
const attemptedBytes = Buffer.byteLength(text, 'utf8');
|
|
607
|
+
if (attemptedBytes > MAX_STORE_BYTES) {
|
|
608
|
+
_fail('lease_store_size_limit', 'Lease store update would exceed the safety byte limit.', {
|
|
609
|
+
maxBytes: MAX_STORE_BYTES, attemptedBytes,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
try { writeUtf8(file, text); }
|
|
613
|
+
catch (error) {
|
|
614
|
+
_fail('store_write_failed', 'Lease store could not be written atomically.', { error: error && error.code || 'EWRITE' });
|
|
615
|
+
}
|
|
616
|
+
return data;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function _newLeaseId(existing, idFactory) {
|
|
620
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
621
|
+
const token = idFactory ? String(idFactory()) : crypto.randomBytes(8).toString('hex');
|
|
622
|
+
const leaseId = token.startsWith('lease-') ? token : `lease-${token}`;
|
|
623
|
+
if (LEASE_ID_RE.test(leaseId) && !existing.has(leaseId)) return leaseId;
|
|
624
|
+
}
|
|
625
|
+
_fail('lease_id_unavailable', 'A unique lease ID could not be generated.');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function acquire(root, file, sessionKey, options = {}) {
|
|
629
|
+
if (typeof options.withLock !== 'function') _fail('lock_unavailable', 'Lease acquisition requires the shared lock gateway.');
|
|
630
|
+
const session = normalizeSessionKey(sessionKey);
|
|
631
|
+
const ttlSeconds = normalizeTtlSeconds(options.ttlSeconds);
|
|
632
|
+
// Validate once before creating the cache, then observe again inside the store lock.
|
|
633
|
+
// A contender may wait while a missing path becomes a hard link; using only the
|
|
634
|
+
// pre-lock snapshot could admit two owners for the same file identity.
|
|
635
|
+
resolveTarget(root, file);
|
|
636
|
+
const fixedNowMs = Number.isFinite(options.nowMs) ? options.nowMs : null;
|
|
637
|
+
const note = _safeText(options.note, 200);
|
|
638
|
+
let result = null;
|
|
639
|
+
const location = _storePath(root, { create: true });
|
|
640
|
+
_withLeaseLock(options, location.store, () => {
|
|
641
|
+
const target = resolveTarget(root, file);
|
|
642
|
+
// TTL starts when the critical section is actually entered, not when lock waiting
|
|
643
|
+
// began. The shared lock may wait longer than the minimum 30-second TTL.
|
|
644
|
+
const nowMs = fixedNowMs == null ? Date.now() : fixedNowMs;
|
|
645
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
646
|
+
const expiresAt = new Date(nowMs + ttlSeconds * 1000).toISOString();
|
|
647
|
+
const loaded = _readStoreForMutation(root, { nowMs });
|
|
648
|
+
const active = loaded.store.leases.filter(lease => _active(lease, nowMs));
|
|
649
|
+
const expiredPruned = loaded.store.leases.length - active.length;
|
|
650
|
+
const matches = active.filter(lease => _matchesTarget(root, lease, target));
|
|
651
|
+
const conflicting = matches.find(lease => lease.sessionKey !== session);
|
|
652
|
+
if (conflicting) {
|
|
653
|
+
_fail('lease_conflict', 'The file already has an active lease owned by another session.', {
|
|
654
|
+
conflict: _publicLease(conflicting, nowMs),
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
const latestHistoryMs = matches.reduce((latest, row) => Math.max(latest, Date.parse(row.acquiredAt), Date.parse(row.renewedAt)), -Infinity);
|
|
658
|
+
if (matches.length && latestHistoryMs > nowMs) {
|
|
659
|
+
_fail('lease_clock_regression', 'Current time is earlier than the existing lease history; renewal was refused to preserve timestamp ordering.', {
|
|
660
|
+
earliestSafeAt: new Date(latestHistoryMs).toISOString(),
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
let lease;
|
|
664
|
+
let renewed = false;
|
|
665
|
+
let mergedLeaseCount = 0;
|
|
666
|
+
if (matches.length) {
|
|
667
|
+
renewed = true;
|
|
668
|
+
// Legacy/missing-file aliases can converge to one identity later. If this session
|
|
669
|
+
// already owns more than one matching row, merge them deterministically instead
|
|
670
|
+
// of writing duplicate identity keys that would corrupt the next read.
|
|
671
|
+
const ordered = matches.slice().sort((a, b) => a.acquiredAt.localeCompare(b.acquiredAt) || a.leaseId.localeCompare(b.leaseId));
|
|
672
|
+
const matching = ordered[0];
|
|
673
|
+
const acquiredAt = ordered.reduce((oldest, row) => row.acquiredAt < oldest ? row.acquiredAt : oldest, matching.acquiredAt);
|
|
674
|
+
mergedLeaseCount = Math.max(0, ordered.length - 1);
|
|
675
|
+
lease = {
|
|
676
|
+
...matching,
|
|
677
|
+
file: target.file,
|
|
678
|
+
pathKey: target.pathKey,
|
|
679
|
+
identityKey: target.identityKey || _currentObservedIdentity(root, matching) || matching.identityKey,
|
|
680
|
+
acquiredAt,
|
|
681
|
+
renewedAt: nowIso,
|
|
682
|
+
expiresAt,
|
|
683
|
+
note: note == null ? matching.note : note,
|
|
684
|
+
};
|
|
685
|
+
const removeIds = new Set(ordered.map(row => row.leaseId));
|
|
686
|
+
for (let i = active.length - 1; i >= 0; i--) if (removeIds.has(active[i].leaseId)) active.splice(i, 1);
|
|
687
|
+
active.push(lease);
|
|
688
|
+
} else {
|
|
689
|
+
if (active.length >= MAX_LEASES) {
|
|
690
|
+
_fail('lease_limit_reached', 'Lease store has reached its safety limit.', { maxLeases: MAX_LEASES });
|
|
691
|
+
}
|
|
692
|
+
_promoteCollidingPathIdentities(root, active, target);
|
|
693
|
+
const ids = new Set(active.map(row => row.leaseId));
|
|
694
|
+
lease = {
|
|
695
|
+
leaseId: _newLeaseId(ids, options.idFactory),
|
|
696
|
+
file: target.file,
|
|
697
|
+
pathKey: target.pathKey,
|
|
698
|
+
identityKey: target.identityKey,
|
|
699
|
+
sessionKey: session,
|
|
700
|
+
acquiredAt: nowIso,
|
|
701
|
+
renewedAt: nowIso,
|
|
702
|
+
expiresAt,
|
|
703
|
+
note,
|
|
704
|
+
};
|
|
705
|
+
active.push(lease);
|
|
706
|
+
}
|
|
707
|
+
_writeStore(location.store, active, nowIso);
|
|
708
|
+
result = {
|
|
709
|
+
ok: true,
|
|
710
|
+
action: renewed ? 'renewed' : 'acquired',
|
|
711
|
+
lease: _publicLease(lease, nowMs),
|
|
712
|
+
target: { file: target.file, exists: target.exists, identityObserved: !!target.identityKey },
|
|
713
|
+
ttlSeconds,
|
|
714
|
+
expiredPruned,
|
|
715
|
+
mergedLeaseCount,
|
|
716
|
+
storePath: STORE_REL.join('/'),
|
|
717
|
+
advisoryOnly: true,
|
|
718
|
+
ambientWarnings: false,
|
|
719
|
+
};
|
|
720
|
+
});
|
|
721
|
+
return result;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function release(root, leaseId, sessionKey, options = {}) {
|
|
725
|
+
if (typeof options.withLock !== 'function') _fail('lock_unavailable', 'Lease release requires the shared lock gateway.');
|
|
726
|
+
const session = normalizeSessionKey(sessionKey);
|
|
727
|
+
if (!LEASE_ID_RE.test(String(leaseId || ''))) _fail('invalid_lease_id', 'A canonical lease ID is required.');
|
|
728
|
+
const fixedNowMs = Number.isFinite(options.nowMs) ? options.nowMs : null;
|
|
729
|
+
let result = null;
|
|
730
|
+
let location;
|
|
731
|
+
try { location = _storePath(root, { create: false }); }
|
|
732
|
+
catch (error) {
|
|
733
|
+
if (error instanceof LeaseError && error.code === 'cache_missing') _fail('lease_not_found', 'Lease ID was not found.');
|
|
734
|
+
throw error;
|
|
735
|
+
}
|
|
736
|
+
_withLeaseLock(options, location.store, () => {
|
|
737
|
+
const nowMs = fixedNowMs == null ? Date.now() : fixedNowMs;
|
|
738
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
739
|
+
const loaded = readStore(root, { nowMs });
|
|
740
|
+
const found = loaded.store.leases.find(lease => lease.leaseId === leaseId);
|
|
741
|
+
if (!found) _fail('lease_not_found', 'Lease ID was not found.');
|
|
742
|
+
if (found.sessionKey !== session) {
|
|
743
|
+
_fail('lease_owner_mismatch', 'Only the owning session may release this lease.', {
|
|
744
|
+
lease: _publicLease(found, nowMs),
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
const remaining = loaded.store.leases.filter(lease => lease.leaseId !== leaseId && _active(lease, nowMs));
|
|
748
|
+
const expiredPruned = loaded.store.leases.length - remaining.length - 1;
|
|
749
|
+
_writeStore(location.store, remaining, nowIso);
|
|
750
|
+
result = {
|
|
751
|
+
ok: true,
|
|
752
|
+
action: 'released',
|
|
753
|
+
lease: _publicLease(found, nowMs),
|
|
754
|
+
expiredPruned: Math.max(0, expiredPruned),
|
|
755
|
+
storePath: STORE_REL.join('/'),
|
|
756
|
+
advisoryOnly: true,
|
|
757
|
+
ambientWarnings: false,
|
|
758
|
+
};
|
|
759
|
+
});
|
|
760
|
+
return result;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function list(root, options = {}) {
|
|
764
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
765
|
+
const session = options.sessionKey == null ? null : normalizeSessionKey(options.sessionKey);
|
|
766
|
+
let loaded;
|
|
767
|
+
try { loaded = readStore(root, { nowMs }); }
|
|
768
|
+
catch (error) {
|
|
769
|
+
if (error instanceof LeaseError && error.code === 'cache_missing') loaded = { state: 'missing', store: _emptyStore(), path: path.join(path.resolve(root), ...STORE_REL) };
|
|
770
|
+
else throw error;
|
|
771
|
+
}
|
|
772
|
+
const allRows = loaded.store.leases.map(lease => _publicLease(lease, nowMs));
|
|
773
|
+
const filtered = allRows.filter(lease => (!session || lease.sessionKey === session) && (options.all || lease.status === 'active'));
|
|
774
|
+
return {
|
|
775
|
+
ok: true,
|
|
776
|
+
action: 'list',
|
|
777
|
+
storeState: loaded.state,
|
|
778
|
+
storePath: STORE_REL.join('/'),
|
|
779
|
+
totalStored: allRows.length,
|
|
780
|
+
active: allRows.filter(lease => lease.status === 'active').length,
|
|
781
|
+
expired: allRows.filter(lease => lease.status === 'expired').length,
|
|
782
|
+
shown: filtered.length,
|
|
783
|
+
sessionKey: session,
|
|
784
|
+
includesExpired: !!options.all,
|
|
785
|
+
leases: filtered,
|
|
786
|
+
readOnly: true,
|
|
787
|
+
advisoryOnly: true,
|
|
788
|
+
ambientWarnings: false,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function check(root, file, options = {}) {
|
|
793
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
794
|
+
const session = options.sessionKey == null ? null : normalizeSessionKey(options.sessionKey);
|
|
795
|
+
const target = resolveTarget(root, file);
|
|
796
|
+
let loaded;
|
|
797
|
+
try { loaded = readStore(root, { nowMs }); }
|
|
798
|
+
catch (error) {
|
|
799
|
+
if (error instanceof LeaseError && error.code === 'cache_missing') loaded = { state: 'missing', store: _emptyStore(), path: path.join(path.resolve(root), ...STORE_REL) };
|
|
800
|
+
else throw error;
|
|
801
|
+
}
|
|
802
|
+
const matches = loaded.store.leases.filter(lease => _matchesTarget(root, lease, target));
|
|
803
|
+
const activeMatches = matches.filter(lease => _active(lease, nowMs));
|
|
804
|
+
// Two paths can be leased independently while missing and later become hard links.
|
|
805
|
+
// A peer match must dominate a same-session match; choosing the first row could
|
|
806
|
+
// otherwise report the file as available while another session still owns it.
|
|
807
|
+
const peer = session ? activeMatches.find(lease => lease.sessionKey !== session) : activeMatches[0];
|
|
808
|
+
const self = session ? activeMatches.find(lease => lease.sessionKey === session) : null;
|
|
809
|
+
const active = peer || self || null;
|
|
810
|
+
const owned = !!(active && session && !peer && active.sessionKey === session);
|
|
811
|
+
return {
|
|
812
|
+
ok: true,
|
|
813
|
+
action: 'check',
|
|
814
|
+
storeState: loaded.state,
|
|
815
|
+
storePath: STORE_REL.join('/'),
|
|
816
|
+
target: { file: target.file, exists: target.exists, identityObserved: !!target.identityKey },
|
|
817
|
+
available: !active || owned,
|
|
818
|
+
ownedBySession: owned,
|
|
819
|
+
lease: active ? _publicLease(active, nowMs) : null,
|
|
820
|
+
expiredMatches: matches.filter(lease => !_active(lease, nowMs)).length,
|
|
821
|
+
readOnly: true,
|
|
822
|
+
advisoryOnly: true,
|
|
823
|
+
ambientWarnings: false,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
module.exports = {
|
|
828
|
+
LeaseError,
|
|
829
|
+
SCHEMA_VERSION,
|
|
830
|
+
STORE_REL,
|
|
831
|
+
DEFAULT_TTL_SECONDS,
|
|
832
|
+
MIN_TTL_SECONDS,
|
|
833
|
+
MAX_TTL_SECONDS,
|
|
834
|
+
MAX_STORE_BYTES,
|
|
835
|
+
MAX_LEASES,
|
|
836
|
+
storePath,
|
|
837
|
+
normalizeSessionKey,
|
|
838
|
+
normalizeTtlSeconds,
|
|
839
|
+
resolveTarget,
|
|
840
|
+
readStore,
|
|
841
|
+
acquire,
|
|
842
|
+
release,
|
|
843
|
+
list,
|
|
844
|
+
check,
|
|
845
|
+
};
|