leerness 1.36.185 → 1.36.187
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 +17 -0
- package/README.ko.md +5 -0
- package/README.md +14 -4
- package/bin/leerness.js +291 -57
- package/docs/e2e-observation-contract.md +51 -0
- package/docs/runtime-compatibility-api.md +183 -0
- package/docs/state-paths-api.md +92 -0
- package/docs/state-scopes.md +266 -0
- package/docs/worktree-runtime-migration.md +278 -0
- package/lib/claims-baseline.js +1 -1
- package/lib/file-leases.js +2 -2
- package/lib/git.js +64 -15
- package/lib/io.js +9 -3
- package/lib/migrate.js +1 -1
- package/lib/preview-serve.js +5 -1
- package/lib/pure-utils.js +20 -0
- package/lib/role-fallback.js +3 -3
- package/lib/role-store.js +2 -2
- package/lib/runtime-layout.js +395 -0
- package/lib/runtime-writes.js +166 -0
- package/lib/session-close.js +1 -1
- package/lib/state-git.js +138 -0
- package/lib/state-inspect.js +39 -0
- package/lib/state-inventory.js +69 -0
- package/lib/state-paths.js +59 -0
- package/lib/workspace-dir.js +34 -10
- package/package.json +10 -6
- package/scripts/command-flags-probe.js +1 -1
- package/scripts/dead-flags-probe.js +1 -1
- package/scripts/e2e-child-diagnostics-probe.js +143 -0
- package/scripts/e2e-child-diagnostics.js +33 -0
- package/scripts/e2e-temp-scope-probe.js +127 -0
- package/scripts/e2e.js +108 -27
- package/scripts/encoding-selftest-probe.js +222 -0
- package/scripts/false-claim-probe.js +34 -3
- package/scripts/file-lease-probe.js +5 -2
- package/scripts/legacy-runtime-negative-probe.js +72 -0
- package/scripts/mutation-integrity-probe.js +3 -0
- package/scripts/role-fallback-probe.js +67 -6
- package/scripts/runtime-admission-probe.js +282 -0
- package/scripts/runtime-git-write-probe.js +116 -0
- package/scripts/runtime-layout-probe.js +1198 -0
- package/scripts/runtime-repl-probe.js +271 -0
- package/scripts/runtime-replacement-probe.js +190 -0
- package/scripts/runtime-write-probe.js +458 -0
- package/scripts/selftest-cleanup-probe.js +168 -0
- package/scripts/state-inspect-cli-probe.js +209 -0
- package/scripts/state-scopes-probe.js +590 -0
- package/scripts/workspace-dir-lock-order-probe.js +310 -35
- package/scripts/workspace-dir-migration-probe.js +17 -2
- package/scripts/workspace-selection-probe.js +230 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Compatibility-only admission: no descriptor creation, activation or fencing.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { TextDecoder } = require('util');
|
|
7
|
+
const SG = require('./state-git');
|
|
8
|
+
const W = require('./workspace-dir');
|
|
9
|
+
const MAX_DESCRIPTOR_BYTES = 16 * 1024;
|
|
10
|
+
const FIELDS = ['schema', 'schemaVersion', 'scope', 'generation', 'layout', 'requiredWriterProtocol'];
|
|
11
|
+
// Existing standalone module stores do not require a full initialized
|
|
12
|
+
// workspace. Reserve exact known names; an extra foreign name still refuses
|
|
13
|
+
// admission. This does not validate or read any of these stores' contents.
|
|
14
|
+
// Sources: state-inventory, role-store/fallback, and the named domain modules.
|
|
15
|
+
const STANDALONE_STORE_FILES = new Set([
|
|
16
|
+
'agent-roles.json', 'providers.json', 'execution-ledger.jsonl',
|
|
17
|
+
'decisions.json', 'decisions.md', 'lessons.json', 'lessons.md', 'task-log.md',
|
|
18
|
+
'routing-log.json', 'current-state.md', 'session-handoff.md',
|
|
19
|
+
'active-wakeups.json', 'auto-resume-plan.json', 'next-action-queue.json',
|
|
20
|
+
'pre-wake-report.json', 'last-handoff.json', 'bugfix-receipts.json',
|
|
21
|
+
'claims-baseline.json', 'previews.json', 'preview-config.json', 'referees.json',
|
|
22
|
+
'tech-profile.json', 'toggles.json', 'leerness-config.json', 'permissions.json',
|
|
23
|
+
'project-brief.md', 'plan.md', 'review-evidence.md', 'rules.md',
|
|
24
|
+
'teams.json', 'teams.md', 'secret-baseline.json',
|
|
25
|
+
// Existing init-free CLI outputs; individual command prerequisites remain
|
|
26
|
+
// enforced by their domains. See runtime-compatibility-api.md for producers.
|
|
27
|
+
'user-requests.json', 'shell-failures.json', 'platform-constraints.json',
|
|
28
|
+
'wakeup-history.json', 'agent-slash-commands.json', 'environment.json',
|
|
29
|
+
'agent-permissions.json', 'credentials.local.json', 'llm-bench-history.md',
|
|
30
|
+
'glossary.md', 'glossary.json', 'reuse-map.md', 'design-system.md',
|
|
31
|
+
'skill-suggestions.md', 'skill-auto-cache.json', 'provider-probe-cache.json',
|
|
32
|
+
'orchestrate-log.md', 'feature-graph.md', 'enforce.json', 'agent-reminders.md',
|
|
33
|
+
// Legacy memory deletion/restoration retains these exact Markdown stores.
|
|
34
|
+
'decisions.archive.md', 'lessons.archive.md', 'plan.archive.md', 'rules.archive.md',
|
|
35
|
+
]);
|
|
36
|
+
// Exact output directories of existing init-free producers: preview builders,
|
|
37
|
+
// API/ordinary skills, personas, review prompts, incident storage and skill
|
|
38
|
+
// publish staging. Admission checks only their kind, not contents or domain
|
|
39
|
+
// validity. Recognizing a staging directory never authorizes a publication.
|
|
40
|
+
const STANDALONE_STORE_DIRECTORIES = new Set([
|
|
41
|
+
'previews', 'api-skills', 'skills', 'personas', 'skills-export', 'reviews',
|
|
42
|
+
'incidents', 'skills-publish', 'skills-publish-tarball',
|
|
43
|
+
]);
|
|
44
|
+
// role-store's POSIX CAS retains mkdtemp(".agent-roles-cas-")/before.
|
|
45
|
+
// Recognize that exact producer shape only beside its regular store. This
|
|
46
|
+
// grants no trust, cleanup permission, or content access to recovery data.
|
|
47
|
+
const ROLE_RECOVERY_DIRECTORY = /^\.agent-roles-cas-[A-Za-z0-9]{6}$/;
|
|
48
|
+
// _withLock reserves <store>.lock directories and releases them through
|
|
49
|
+
// <store>.lock.release-TOKEN (16 random bytes in lowercase hex). io.writeUtf8 writes
|
|
50
|
+
// <store>.tmp-PID-SEQ and may retain JSON originals as
|
|
51
|
+
// <store>.corrupt-TIME-PID-SEQ. The live file can be absent during either
|
|
52
|
+
// first installation or corruption evacuation; the exact known base name
|
|
53
|
+
// supplies the producer association without reading private store contents.
|
|
54
|
+
const STORE_AUXILIARY = /^(.+)\.(lock(?:\.release-[a-f0-9]{32})?|tmp-[1-9][0-9]*-[1-9][0-9]*|corrupt-[1-9][0-9]*-[1-9][0-9]*-[1-9][0-9]*)$/;
|
|
55
|
+
// Exclusive first-install files from role-store and role-fallback, respectively.
|
|
56
|
+
// io.detachCommittedHardLink may create one empty detacher beside that exact
|
|
57
|
+
// prepared alias when both alias removal methods fail after installation.
|
|
58
|
+
const STORE_CREATE_FILE = /^\.(?:agent-roles|execution-ledger)-create-[1-9][0-9]*-[a-f0-9]{24}(?:\.detach-[1-9][0-9]*-[a-f0-9]{16})?$/;
|
|
59
|
+
const fail = reasonCode => { throw Object.assign(new Error('Runtime layout could not be accepted.'), { reasonCode }); };
|
|
60
|
+
const envValue = name => {
|
|
61
|
+
const values = Object.keys(process.env).filter(key => key.toUpperCase() === name).map(key => process.env[key]);
|
|
62
|
+
if (new Set(values).size > 1) fail('environment_ambiguous');
|
|
63
|
+
return values[0] || '';
|
|
64
|
+
};
|
|
65
|
+
const checkDiscoveryOverrides = () => {
|
|
66
|
+
const across = envValue('GIT_DISCOVERY_ACROSS_FILESYSTEM');
|
|
67
|
+
if (envValue('GIT_CEILING_DIRECTORIES') || (across && !/^(0|false|no|off)$/i.test(across))) {
|
|
68
|
+
fail('discovery_override_unsupported');
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const statMaybe = file => {
|
|
72
|
+
try { return fs.lstatSync(file, { bigint: true }); }
|
|
73
|
+
catch (error) { if (error.code === 'ENOENT') return null; fail('layout_unreadable'); }
|
|
74
|
+
};
|
|
75
|
+
const sameIdentity = (a, b) => a && b && a.dev === b.dev && a.ino === b.ino
|
|
76
|
+
&& a.mode === b.mode && a.nlink === b.nlink;
|
|
77
|
+
|
|
78
|
+
// An init target may not exist yet. Canonicalize only its existing ancestor.
|
|
79
|
+
function projectLocation(value) {
|
|
80
|
+
if (typeof value !== 'string' || !value.trim() || /[\x00-\x1f\x7f]/.test(value)) fail('path_invalid');
|
|
81
|
+
let current = path.resolve(value);
|
|
82
|
+
const suffix = [];
|
|
83
|
+
for (let depth = 0; depth < 128; depth++) {
|
|
84
|
+
const stat = statMaybe(current);
|
|
85
|
+
if (stat) {
|
|
86
|
+
const ancestor = SG.canonicalDirectory(current);
|
|
87
|
+
return { root: path.join(ancestor, ...suffix), ancestor };
|
|
88
|
+
}
|
|
89
|
+
const parent = path.dirname(current);
|
|
90
|
+
if (parent === current) fail('path_not_found');
|
|
91
|
+
suffix.unshift(path.basename(current));
|
|
92
|
+
current = parent;
|
|
93
|
+
}
|
|
94
|
+
fail('path_depth_limit');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasBareMarker(root) {
|
|
98
|
+
const device = fs.statSync(root, { bigint: true }).dev;
|
|
99
|
+
let current = root;
|
|
100
|
+
for (let depth = 0; depth < 128; depth++) {
|
|
101
|
+
if (statMaybe(path.join(current, 'HEAD')) && statMaybe(path.join(current, 'objects'))
|
|
102
|
+
&& (statMaybe(path.join(current, 'refs')) || statMaybe(path.join(current, 'config')))) return true;
|
|
103
|
+
const parent = path.dirname(current);
|
|
104
|
+
if (parent === current || fs.statSync(parent, { bigint: true }).dev !== device) return false;
|
|
105
|
+
current = parent;
|
|
106
|
+
}
|
|
107
|
+
fail('path_depth_limit');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function optionalGit(root) {
|
|
111
|
+
try { return SG.resolveGitTopology(root); }
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code !== 'git_missing') throw error;
|
|
114
|
+
// No executable is acceptable only for positively observed non-repositories.
|
|
115
|
+
if (SG.hasRepositoryMarker(root, process.env) || hasBareMarker(root)) fail('git_missing');
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A topology snapshot saves Git invocations, not admission decisions. In
|
|
121
|
+
// particular, creating Git metadata after a non-Git reader was constructed
|
|
122
|
+
// must not keep admitting the old project-local backend.
|
|
123
|
+
function verifyTopology(snapshot, current) {
|
|
124
|
+
if (!snapshot.git) {
|
|
125
|
+
if (SG.hasRepositoryMarker(current.ancestor, process.env) || hasBareMarker(current.ancestor)) {
|
|
126
|
+
fail('topology_changed');
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const { worktreeRoot, gitDir } = snapshot.git;
|
|
131
|
+
for (const location of snapshot.topologyDirectories) {
|
|
132
|
+
const stat = statMaybe(location.file);
|
|
133
|
+
if (!stat || !stat.isDirectory() || stat.isSymbolicLink()
|
|
134
|
+
|| stat.dev !== location.stat.dev || stat.ino !== location.stat.ino
|
|
135
|
+
|| SG.canonicalDirectory(location.file) !== location.file) fail('topology_changed');
|
|
136
|
+
}
|
|
137
|
+
let directory = current.ancestor;
|
|
138
|
+
for (let depth = 0; depth < 128; depth++) {
|
|
139
|
+
if (directory === worktreeRoot) {
|
|
140
|
+
const marker = statMaybe(path.join(directory, '.git'));
|
|
141
|
+
const before = snapshot.gitMarker;
|
|
142
|
+
if (!marker || marker.isSymbolicLink() || !before || marker.dev !== before.dev || marker.ino !== before.ino
|
|
143
|
+
|| marker.isFile() !== before.isFile() || (marker.isFile() && (marker.size !== before.size
|
|
144
|
+
|| marker.mtimeNs !== before.mtimeNs || marker.ctimeNs !== before.ctimeNs))) fail('topology_changed');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (statMaybe(path.join(directory, '.git'))) fail('topology_changed');
|
|
148
|
+
const parent = path.dirname(directory);
|
|
149
|
+
if (parent === directory) fail('topology_changed');
|
|
150
|
+
directory = parent;
|
|
151
|
+
}
|
|
152
|
+
fail('path_depth_limit');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check each component without following workspace/descriptor parent links.
|
|
156
|
+
function checkedPath(base, parts) {
|
|
157
|
+
let file = base;
|
|
158
|
+
const parents = [];
|
|
159
|
+
for (let i = -1; i < parts.length; i++) {
|
|
160
|
+
if (i >= 0) file = path.join(file, parts[i]);
|
|
161
|
+
const stat = statMaybe(file);
|
|
162
|
+
if (!stat) return { file: path.join(base, ...parts), stat: null, parents };
|
|
163
|
+
if (stat.isSymbolicLink()) fail('layout_linked');
|
|
164
|
+
if (i < parts.length - 1) {
|
|
165
|
+
if (!stat.isDirectory()) fail('layout_parent_invalid');
|
|
166
|
+
parents.push({ file, stat });
|
|
167
|
+
} else return { file, stat, parents };
|
|
168
|
+
}
|
|
169
|
+
fail('layout_path_invalid');
|
|
170
|
+
}
|
|
171
|
+
const verifyParents = parents => {
|
|
172
|
+
for (const before of parents) {
|
|
173
|
+
const after = statMaybe(before.file);
|
|
174
|
+
if (!sameIdentity(before.stat, after) || !after.isDirectory()) fail('layout_changed');
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const assertRegular = stat => {
|
|
178
|
+
if (!stat || !stat.isFile()) fail('descriptor_not_regular');
|
|
179
|
+
if (stat.nlink !== 1n) fail('descriptor_hard_linked');
|
|
180
|
+
if (stat.size > BigInt(MAX_DESCRIPTOR_BYTES)) fail('descriptor_too_large');
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function readDescriptor(location) {
|
|
184
|
+
assertRegular(location.stat);
|
|
185
|
+
let fd;
|
|
186
|
+
try {
|
|
187
|
+
// A replacement with a FIFO between lstat and open must not block the
|
|
188
|
+
// reader before fstat can reject it as non-regular (where supported).
|
|
189
|
+
fd = fs.openSync(location.file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)
|
|
190
|
+
| (fs.constants.O_NONBLOCK || 0));
|
|
191
|
+
const opened = fs.fstatSync(fd, { bigint: true });
|
|
192
|
+
assertRegular(opened);
|
|
193
|
+
if (!sameIdentity(opened, location.stat)) fail('layout_changed');
|
|
194
|
+
const bytes = Buffer.alloc(MAX_DESCRIPTOR_BYTES + 1);
|
|
195
|
+
let length = 0;
|
|
196
|
+
while (length < bytes.length) {
|
|
197
|
+
const count = fs.readSync(fd, bytes, length, bytes.length - length, length);
|
|
198
|
+
if (!count) break;
|
|
199
|
+
length += count;
|
|
200
|
+
}
|
|
201
|
+
if (length > MAX_DESCRIPTOR_BYTES) fail('descriptor_too_large');
|
|
202
|
+
const after = fs.fstatSync(fd, { bigint: true });
|
|
203
|
+
const named = statMaybe(location.file);
|
|
204
|
+
if (!sameIdentity(opened, after) || !sameIdentity(after, named) || named.isSymbolicLink()
|
|
205
|
+
|| opened.size !== BigInt(length) || after.size !== opened.size
|
|
206
|
+
|| after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs) fail('layout_changed');
|
|
207
|
+
verifyParents(location.parents);
|
|
208
|
+
try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, length)); }
|
|
209
|
+
catch { fail('descriptor_invalid_utf8'); }
|
|
210
|
+
} finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function decodeDescriptor(text, scope) {
|
|
214
|
+
let data;
|
|
215
|
+
try { data = JSON.parse(text); } catch { fail('descriptor_invalid_json'); }
|
|
216
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) fail('descriptor_invalid');
|
|
217
|
+
const keys = Object.keys(data);
|
|
218
|
+
if (keys.length !== FIELDS.length || keys.some(key => !FIELDS.includes(key))) fail('descriptor_invalid');
|
|
219
|
+
// The schema is flat and scalar. Count syntactic keys, decoding escapes first,
|
|
220
|
+
// so duplicate or escaped-duplicate keys cannot be hidden by JSON.parse.
|
|
221
|
+
const sourceKeys = [...text.matchAll(/"((?:[^"\\]|\\.)*)"\s*:/g)].map(match => JSON.parse(`"${match[1]}"`));
|
|
222
|
+
if (sourceKeys.length !== keys.length || new Set(sourceKeys).size !== keys.length) fail('descriptor_duplicate_key');
|
|
223
|
+
if (typeof data.schema !== 'string' || !Number.isSafeInteger(data.schemaVersion) || data.schemaVersion < 1
|
|
224
|
+
|| typeof data.scope !== 'string' || !Number.isSafeInteger(data.generation) || data.generation < 1
|
|
225
|
+
|| typeof data.layout !== 'string' || !Number.isSafeInteger(data.requiredWriterProtocol)
|
|
226
|
+
|| data.requiredWriterProtocol < 1) fail('descriptor_invalid');
|
|
227
|
+
if (data.scope !== scope) fail('descriptor_scope_mismatch');
|
|
228
|
+
if (data.schema !== 'leerness.runtime-layout/v1' || data.schemaVersion !== 1
|
|
229
|
+
|| data.requiredWriterProtocol !== 1 || data.layout !== 'legacy') fail('layout_unsupported');
|
|
230
|
+
return data;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function standaloneEntryAllowed(directory, name) {
|
|
234
|
+
const knownDirectory = STANDALONE_STORE_DIRECTORIES.has(name);
|
|
235
|
+
const recovery = ROLE_RECOVERY_DIRECTORY.test(name);
|
|
236
|
+
const auxiliary = STORE_AUXILIARY.exec(name);
|
|
237
|
+
const knownAuxiliary = auxiliary && STANDALONE_STORE_FILES.has(auxiliary[1])
|
|
238
|
+
&& (!auxiliary[2].startsWith('corrupt-') || auxiliary[1].endsWith('.json'));
|
|
239
|
+
// Unknown names fail even if they disappear after workspace enumeration.
|
|
240
|
+
if (!STANDALONE_STORE_FILES.has(name) && !knownDirectory && !recovery && !knownAuxiliary && !STORE_CREATE_FILE.test(name)) return false;
|
|
241
|
+
const stat = statMaybe(path.join(directory, name));
|
|
242
|
+
// A competing producer can release its lock or rename its temporary file
|
|
243
|
+
// between readdir and lstat. Absence introduces no foreign object.
|
|
244
|
+
if (!stat) return true;
|
|
245
|
+
if (stat.isSymbolicLink()) return false;
|
|
246
|
+
if (recovery) {
|
|
247
|
+
const store = statMaybe(path.join(directory, 'agent-roles.json'));
|
|
248
|
+
return stat.isDirectory() && store && store.isFile() && !store.isSymbolicLink();
|
|
249
|
+
}
|
|
250
|
+
return knownDirectory || (knownAuxiliary && auxiliary[2].startsWith('lock')) ? stat.isDirectory() : stat.isFile();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function validateWorkspace(root) {
|
|
254
|
+
const snapshot = W.inspectWorkspace(root);
|
|
255
|
+
if (snapshot.legacy.inspectionError || snapshot.canonical.inspectionError) fail('workspace_unreadable');
|
|
256
|
+
W.selectWorkspaceDirName(snapshot, envValue('LEERNESS_WORKSPACE_DIR'));
|
|
257
|
+
if (snapshot.legacy.exists && !snapshot.legacy.live && snapshot.legacy.entries.length) fail('workspace_ambiguous');
|
|
258
|
+
if (snapshot.canonical.foreign) {
|
|
259
|
+
const standalone = snapshot.canonical.isDirectory && snapshot.canonical.nonSubstrateEntries
|
|
260
|
+
.every(name => standaloneEntryAllowed(snapshot.canonical.abs, name));
|
|
261
|
+
// These stores have their own authority; they are not the migratable
|
|
262
|
+
// pre-existing structured-state substrate beside a live legacy store.
|
|
263
|
+
if (!standalone || snapshot.legacy.live) fail('workspace_ambiguous');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function disposition(reasonCode, scope = 'unknown', observedLayout = 'unknown') {
|
|
268
|
+
const compatible = reasonCode === 'legacy_absent' || reasonCode === 'legacy_supported';
|
|
269
|
+
return { schema: 'leerness.runtime-compatibility/v1', schemaVersion: 1, ok: compatible, compatible,
|
|
270
|
+
writeDisposition: compatible ? 'allowed' : 'blocked', reasonCode, scope, observedLayout,
|
|
271
|
+
supportedWriterProtocol: 1, activationSupported: false, workspaceAdmission: 'operation-start' };
|
|
272
|
+
}
|
|
273
|
+
function failureReport(error, scope) {
|
|
274
|
+
const known = error instanceof SG.StatePathError || error instanceof W.WorkspaceDirectoryError;
|
|
275
|
+
const reason = error.reasonCode || (known ? String(error.code).replace(/^E_/, '').toLowerCase() : 'layout_unreadable');
|
|
276
|
+
return disposition(reason, scope, reason === 'layout_unsupported' ? 'unsupported' : 'unknown');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function inspectSnapshot(snapshot) {
|
|
280
|
+
const { root, ancestor, git, scope } = snapshot;
|
|
281
|
+
try {
|
|
282
|
+
checkDiscoveryOverrides();
|
|
283
|
+
// A newly-created target may no longer be the path admitted by this snapshot.
|
|
284
|
+
const current = projectLocation(root);
|
|
285
|
+
if (current.root !== root) fail('layout_changed');
|
|
286
|
+
const ancestorNow = SG.canonicalDirectory(ancestor);
|
|
287
|
+
if (ancestorNow !== ancestor) fail('layout_changed');
|
|
288
|
+
verifyTopology(snapshot, current);
|
|
289
|
+
// Ownership/selection was admitted when this operation started. A legacy
|
|
290
|
+
// init or lock-owned workspace migration creates temporary names and may
|
|
291
|
+
// briefly be dual-live. Do not reclassify its own intermediate contents.
|
|
292
|
+
// Each fixed path walk below still checks fresh directory kinds/links and
|
|
293
|
+
// runtime indicators. A new public inspection makes a new admission.
|
|
294
|
+
const canonical = name => checkedPath(root, [W.CANONICAL_WORKSPACE_DIR, 'cache', name]);
|
|
295
|
+
const alternate = name => checkedPath(root, [W.LEGACY_WORKSPACE_DIR, 'cache', name]);
|
|
296
|
+
if (alternate('state-layout.json').stat || alternate('state-runtime').stat) fail('alternate_layout_present');
|
|
297
|
+
const localDescriptor = canonical('state-layout.json');
|
|
298
|
+
const localRuntime = canonical('state-runtime');
|
|
299
|
+
if (git && (localDescriptor.stat || localRuntime.stat)) fail('backend_transition_unresolved');
|
|
300
|
+
if (!git && localRuntime.stat) fail('runtime_without_compatible_layout');
|
|
301
|
+
const location = git ? checkedPath(git.gitDir, ['leerness', 'layout.json']) : localDescriptor;
|
|
302
|
+
if (git && checkedPath(git.gitDir, ['leerness', 'projects']).stat) fail('runtime_without_compatible_layout');
|
|
303
|
+
if (!location.stat) return disposition('legacy_absent', scope, 'legacy');
|
|
304
|
+
decodeDescriptor(readDescriptor(location), scope);
|
|
305
|
+
return disposition('legacy_supported', scope, 'legacy');
|
|
306
|
+
} catch (error) { return failureReport(error, scope); }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function createRuntimeCompatibilityReader(root = process.cwd()) {
|
|
310
|
+
try {
|
|
311
|
+
checkDiscoveryOverrides();
|
|
312
|
+
const location = projectLocation(root);
|
|
313
|
+
const git = optionalGit(location.ancestor);
|
|
314
|
+
validateWorkspace(location.root);
|
|
315
|
+
const topologyDirectories = git ? [...new Set([git.worktreeRoot, git.gitDir])]
|
|
316
|
+
.map(file => ({ file, stat: statMaybe(file) })) : [];
|
|
317
|
+
if (topologyDirectories.some(value => !value.stat)) fail('topology_changed');
|
|
318
|
+
const gitMarker = git ? statMaybe(path.join(git.worktreeRoot, '.git')) : null;
|
|
319
|
+
const snapshot = Object.freeze({ ...location, git, topologyDirectories, gitMarker,
|
|
320
|
+
scope: git ? 'worktree' : 'project-local' });
|
|
321
|
+
return () => inspectSnapshot(snapshot);
|
|
322
|
+
} catch (error) { return () => failureReport(error, 'unknown'); }
|
|
323
|
+
}
|
|
324
|
+
const inspectRuntimeCompatibility = root => createRuntimeCompatibilityReader(root)();
|
|
325
|
+
// Internal writer identity: existing aliases and Windows namespaced paths must
|
|
326
|
+
// share one operation admission. Preserve the reader's bounded failure taxonomy.
|
|
327
|
+
function canonicalRuntimeRoot(root) {
|
|
328
|
+
try { return projectLocation(root).root; }
|
|
329
|
+
catch (error) {
|
|
330
|
+
throw Object.assign(new Error('Runtime project identity could not be accepted.'), {
|
|
331
|
+
code: 'E_RUNTIME_LAYOUT_INCOMPATIBLE', reasonCode: failureReport(error, 'unknown').reasonCode,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// Only new writer operations join an existing legacy migration. Waiting is
|
|
336
|
+
// read-only: lock presence never grants ownership or admits a conflicting
|
|
337
|
+
// workspace. Public diagnosis above remains immediate and unchanged.
|
|
338
|
+
function createRuntimeWriterAdmission(root = process.cwd(), lockWaitMs) {
|
|
339
|
+
let waitedForLock = false;
|
|
340
|
+
const clock = () => Number(process.hrtime.bigint() / 1000000n);
|
|
341
|
+
const started = clock();
|
|
342
|
+
const result = reader => ({ reader, waitedForLock, lockWaitedMs: waitedForLock ? clock() - started : 0 });
|
|
343
|
+
try {
|
|
344
|
+
const location = projectLocation(root);
|
|
345
|
+
const lock = path.join(location.root, '.leerness-workspace-migration.lock');
|
|
346
|
+
const configured = lockWaitMs === undefined ? envValue('LEERNESS_WORKSPACE_MIGRATION_LOCK_WAIT_MS') : lockWaitMs;
|
|
347
|
+
const parsed = configured === '' || configured === undefined ? NaN : Number(configured);
|
|
348
|
+
const budget = Number.isFinite(parsed) ? Math.max(0, Math.min(60000, parsed)) : 10000;
|
|
349
|
+
const deadline = started + budget;
|
|
350
|
+
const contention = () => {
|
|
351
|
+
waitedForLock = true;
|
|
352
|
+
if (clock() >= deadline) fail('workspace_dir_migration_locked');
|
|
353
|
+
};
|
|
354
|
+
const pending = () => {
|
|
355
|
+
const stat = statMaybe(lock);
|
|
356
|
+
if (!stat) return false;
|
|
357
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) fail('workspace_dir_migration_locked');
|
|
358
|
+
return true;
|
|
359
|
+
};
|
|
360
|
+
let refreshedConflict = false;
|
|
361
|
+
for (;;) {
|
|
362
|
+
while (pending()) {
|
|
363
|
+
contention();
|
|
364
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, Math.min(25, deadline - clock())));
|
|
365
|
+
}
|
|
366
|
+
const reader = createRuntimeCompatibilityReader(location.root);
|
|
367
|
+
const report = reader();
|
|
368
|
+
const workspaceConflict = ['workspace_dir_conflict', 'workspace_ambiguous'].includes(report.reasonCode);
|
|
369
|
+
// Once observed, an incompatible descriptor/topology is terminal for
|
|
370
|
+
// this operation, even if another process removes it before check().
|
|
371
|
+
if (!report.compatible && !workspaceConflict) return result(() => report);
|
|
372
|
+
// A peer can start while the snapshot is being classified. Only after
|
|
373
|
+
// retaining any observed non-workspace denial may we join and refresh.
|
|
374
|
+
if (pending()) { contention(); continue; }
|
|
375
|
+
// A peer may also finish AND release between those observations. One
|
|
376
|
+
// fresh classification closes that stale-conflict path without turning
|
|
377
|
+
// genuine persistent conflicts into a retry loop or permission grant.
|
|
378
|
+
if (!refreshedConflict && workspaceConflict) {
|
|
379
|
+
refreshedConflict = true;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
return result(reader);
|
|
383
|
+
}
|
|
384
|
+
} catch (error) { return result(() => failureReport(error, 'unknown')); }
|
|
385
|
+
}
|
|
386
|
+
function assertRuntimeWriteAllowed(root) {
|
|
387
|
+
const report = createRuntimeWriterAdmission(root).reader();
|
|
388
|
+
if (report.compatible) return report;
|
|
389
|
+
throw Object.assign(new Error('Runtime layout is incompatible; no metadata write is allowed.'), {
|
|
390
|
+
code: 'E_RUNTIME_LAYOUT_INCOMPATIBLE', reasonCode: report.reasonCode,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = { MAX_DESCRIPTOR_BYTES, createRuntimeCompatibilityReader, inspectRuntimeCompatibility,
|
|
395
|
+
assertRuntimeWriteAllowed, createRuntimeWriterAdmission, canonicalRuntimeRoot };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 호환 릴리스의 관측 기반 쓰기 경계. 원자 fencing/activation 프로토콜이 아니다.
|
|
4
|
+
// topology만 한 작업 동안 재사용하고 descriptor/indicator는 매 쓰기마다 다시 읽는다.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { fileURLToPath } = require('url');
|
|
8
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
9
|
+
const operations = new AsyncLocalStorage();
|
|
10
|
+
const descriptors = new Map();
|
|
11
|
+
let installed = false;
|
|
12
|
+
|
|
13
|
+
function key(root) {
|
|
14
|
+
const value = path.resolve(root);
|
|
15
|
+
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function metadataRoot(value) {
|
|
19
|
+
if (typeof value === 'number' || value == null) return null;
|
|
20
|
+
const input = value instanceof URL ? fileURLToPath(value) : Buffer.isBuffer(value) ? value.toString() : value;
|
|
21
|
+
if (typeof input !== 'string') return null;
|
|
22
|
+
const absolute = path.resolve(input);
|
|
23
|
+
const parsed = path.parse(absolute);
|
|
24
|
+
const parts = absolute.slice(parsed.root.length).split(path.sep);
|
|
25
|
+
const W = require('./workspace-dir');
|
|
26
|
+
const index = parts.findIndex(part => [W.CANONICAL_WORKSPACE_DIR, W.LEGACY_WORKSPACE_DIR].includes(part.toLowerCase()));
|
|
27
|
+
return index < 0 ? null : path.join(parsed.root, ...parts.slice(0, index));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function canonicalRoot(operation, root) {
|
|
31
|
+
if (operation.failure) throw operation.failure;
|
|
32
|
+
try { return require('./runtime-layout').canonicalRuntimeRoot(root); }
|
|
33
|
+
catch (error) { return reject(operation, { reasonCode: error.reasonCode || 'layout_unreadable' }); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function addReader(operation, root, lockWaitMs) {
|
|
37
|
+
// Re-resolve each target: caching the spelling of a junction would retain
|
|
38
|
+
// stale authority after retargeting. Bind both the key and reader to this
|
|
39
|
+
// same canonical path, including Node 18's internal \\?\ rollback paths.
|
|
40
|
+
const canonical = canonicalRoot(operation, root);
|
|
41
|
+
const id = key(canonical);
|
|
42
|
+
if (!operation.readers.has(id)) {
|
|
43
|
+
const { createRuntimeWriterAdmission } = require('./runtime-layout');
|
|
44
|
+
const admission = createRuntimeWriterAdmission(canonical, lockWaitMs);
|
|
45
|
+
operation.readers.set(id, admission.reader);
|
|
46
|
+
operation.migrationWaits.set(id, { waitedForLock: admission.waitedForLock, lockWaitedMs: admission.lockWaitedMs });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function reject(operation, report) {
|
|
51
|
+
const error = Object.assign(new Error(`Runtime layout blocks writes (${report.reasonCode}). Run state compatibility to diagnose; no activation is supported.`), {
|
|
52
|
+
code: 'E_RUNTIME_LAYOUT_INCOMPATIBLE', reasonCode: report.reasonCode,
|
|
53
|
+
});
|
|
54
|
+
operation.failure = error;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function check(operation, targets = []) {
|
|
59
|
+
if (!operation) return;
|
|
60
|
+
if (operation.failure) throw operation.failure;
|
|
61
|
+
for (const target of targets) {
|
|
62
|
+
const root = metadataRoot(target);
|
|
63
|
+
if (root) addReader(operation, root);
|
|
64
|
+
}
|
|
65
|
+
for (const reader of operation.readers.values()) {
|
|
66
|
+
const report = reader();
|
|
67
|
+
if (report.writeDisposition !== 'allowed') reject(operation, report);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assertCurrentRuntimeWrite(target) {
|
|
72
|
+
const operation = operations.getStore();
|
|
73
|
+
if (operation) check(operation, target == null ? [] : [target]);
|
|
74
|
+
}
|
|
75
|
+
function runtimeWriteRoots() { const operation = operations.getStore(); return operation ? [...operation.readers.keys()] : []; }
|
|
76
|
+
function runtimeMigrationWait(root) {
|
|
77
|
+
const operation = operations.getStore();
|
|
78
|
+
return (operation && operation.migrationWaits.get(key(canonicalRoot(operation, root))))
|
|
79
|
+
|| { waitedForLock: false, lockWaitedMs: 0 };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// best-effort catch가 거부를 성공으로 바꾸지 않게 래치한다. async도 같은 scope다.
|
|
83
|
+
function withRuntimeWrites(root, fn, options = {}) {
|
|
84
|
+
const parent = operations.getStore();
|
|
85
|
+
if (parent && !options.fresh) {
|
|
86
|
+
addReader(parent, root, options.migrationLockWaitMs);
|
|
87
|
+
check(parent);
|
|
88
|
+
return fn();
|
|
89
|
+
}
|
|
90
|
+
const operation = { readers: new Map(), migrationWaits: new Map(), failure: null };
|
|
91
|
+
addReader(operation, root, options.migrationLockWaitMs);
|
|
92
|
+
check(operation);
|
|
93
|
+
installRuntimeWriteInterception();
|
|
94
|
+
return operations.run(operation, () => {
|
|
95
|
+
const finish = value => { if (operation.failure) throw operation.failure; return value; };
|
|
96
|
+
const failed = error => { throw operation.failure || error; };
|
|
97
|
+
try {
|
|
98
|
+
const result = fn();
|
|
99
|
+
return result && typeof result.then === 'function' ? result.then(finish, failed) : finish(result);
|
|
100
|
+
} catch (error) { return failed(error); }
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function withRuntimePathWrite(target, fn) {
|
|
105
|
+
const operation = operations.getStore();
|
|
106
|
+
if (operation) { check(operation, [target]); return fn(); }
|
|
107
|
+
const root = metadataRoot(target);
|
|
108
|
+
return root ? withRuntimeWrites(root, fn) : fn();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function projectWriter(fn) {
|
|
112
|
+
return function (...args) { return withRuntimeWrites(args[0], () => fn.apply(this, args)); };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pathWriter(fn) {
|
|
116
|
+
return function (...args) { return withRuntimePathWrite(args[0], () => fn.apply(this, args)); };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function writingFlags(flags) {
|
|
120
|
+
if (typeof flags === 'number') return !!(flags & (fs.constants.O_WRONLY | fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_APPEND));
|
|
121
|
+
return typeof flags === 'string' && /[wa+]/.test(flags);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 제품은 동기 path/FD writer를 사용한다. import만으로 host fs를 바꾸지 않고
|
|
125
|
+
// 첫 명시적 writer에서 설치하며, operation 밖의 외부 편집은 이 경계의 대상이 아니다.
|
|
126
|
+
function installRuntimeWriteInterception() {
|
|
127
|
+
if (installed) return;
|
|
128
|
+
installed = true;
|
|
129
|
+
const both = new Set(['renameSync', 'copyFileSync', 'cpSync', 'linkSync', 'symlinkSync']);
|
|
130
|
+
const mutations = ['writeFileSync', 'appendFileSync', 'unlinkSync', 'rmSync', 'rmdirSync',
|
|
131
|
+
'renameSync', 'copyFileSync', 'cpSync', 'mkdirSync', 'mkdtempSync', 'mkdtempDisposableSync', 'chmodSync', 'utimesSync',
|
|
132
|
+
'symlinkSync', 'linkSync', 'truncateSync', 'writeSync', 'writevSync', 'ftruncateSync', 'fchmodSync', 'futimesSync'];
|
|
133
|
+
for (const name of mutations) {
|
|
134
|
+
const original = fs[name];
|
|
135
|
+
if (typeof original !== 'function') continue;
|
|
136
|
+
fs[name] = function (...args) {
|
|
137
|
+
const operation = operations.getStore();
|
|
138
|
+
check(operation, both.has(name) ? args.slice(0, 2) : [args[0]]);
|
|
139
|
+
if (typeof args[0] === 'number') {
|
|
140
|
+
const opened = descriptors.get(args[0]);
|
|
141
|
+
if (opened && opened !== operation) {
|
|
142
|
+
try { check(opened); }
|
|
143
|
+
catch (error) { if (operation) operation.failure = error; throw error; }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return original.apply(this, args);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const open = fs.openSync;
|
|
150
|
+
fs.openSync = function (file, flags, ...args) {
|
|
151
|
+
const operation = operations.getStore();
|
|
152
|
+
if (writingFlags(flags)) check(operation, [file]);
|
|
153
|
+
const fd = open.call(this, file, flags, ...args);
|
|
154
|
+
if (operation && writingFlags(flags)) descriptors.set(fd, operation);
|
|
155
|
+
return fd;
|
|
156
|
+
};
|
|
157
|
+
const close = fs.closeSync;
|
|
158
|
+
fs.closeSync = function (fd) {
|
|
159
|
+
const result = close.call(this, fd);
|
|
160
|
+
descriptors.delete(fd);
|
|
161
|
+
return result;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { withRuntimeWrites, withRuntimePathWrite, assertCurrentRuntimeWrite,
|
|
166
|
+
installRuntimeWriteInterception, projectWriter, pathWriter, runtimeWriteRoots, runtimeMigrationWait };
|
package/lib/session-close.js
CHANGED
|
@@ -799,4 +799,4 @@ function sessionClose(root, opts = {}, deps = {}) {
|
|
|
799
799
|
}
|
|
800
800
|
}
|
|
801
801
|
|
|
802
|
-
module.exports = { sessionClose, _upsertAutoLines, _executionProvenanceSummary }; // 1.36.98: 보존 규칙을 selftest 가 직접 검증할 수 있게 노출
|
|
802
|
+
module.exports = { sessionClose: require('./runtime-writes').projectWriter(sessionClose), _upsertAutoLines, _executionProvenanceSummary }; // 1.36.98: 보존 규칙을 selftest 가 직접 검증할 수 있게 노출
|