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.
@@ -0,0 +1,730 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { TextDecoder } = require('util');
7
+ const { absRoot, mkdirp, writeBufferIfUnchanged, detachCommittedHardLink } = require('./io');
8
+ const schema = require('./role-agent-schema');
9
+ const { validateRoleDefinitionShape } = require('./role-fallback');
10
+
11
+ const STORE_RELATIVE_PATH = '.leerness/agent-roles.json';
12
+ const MAX_STORE_BYTES = 1024 * 1024;
13
+ const OUTPUT_SCHEMA_VERSION = 1;
14
+ const OBSERVATION = 'role-store-validation';
15
+ const VALIDATED_STATE = Symbol('validated-role-store-state');
16
+
17
+ function hasOwn(object, key) {
18
+ return !!object && Object.prototype.hasOwnProperty.call(object, key);
19
+ }
20
+
21
+ function defineOwn(object, key, value) {
22
+ Object.defineProperty(object, key, {
23
+ value,
24
+ enumerable: true,
25
+ configurable: true,
26
+ writable: true,
27
+ });
28
+ }
29
+
30
+ function cloneJson(value, fallback = null) {
31
+ try { return JSON.parse(JSON.stringify(value)); }
32
+ catch { return fallback; }
33
+ }
34
+
35
+ function sanitizeText(value, max = 240) {
36
+ return String(value == null ? '' : value)
37
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')
38
+ .replace(/\s+/g, ' ')
39
+ .trim()
40
+ .slice(0, max);
41
+ }
42
+
43
+ function safeRoleMap(value) {
44
+ const out = Object.create(null);
45
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return out;
46
+ for (const key of Object.keys(value)) {
47
+ const cloned = cloneJson(value[key]);
48
+ if (cloned !== null) defineOwn(out, key, cloned);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function rolesFile(root) {
54
+ return path.join(absRoot(root), '.leerness', 'agent-roles.json');
55
+ }
56
+
57
+ function _realpath(value) {
58
+ return typeof fs.realpathSync.native === 'function' ? fs.realpathSync.native(value) : fs.realpathSync(value);
59
+ }
60
+
61
+ function _isWithin(parent, child) {
62
+ const relative = path.relative(parent, child);
63
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
64
+ }
65
+
66
+ function _storeBoundaryContext(root) {
67
+ const absoluteRoot = absRoot(root);
68
+ const harness = path.join(absoluteRoot, '.leerness');
69
+ let stat;
70
+ try { stat = fs.lstatSync(harness); }
71
+ catch (error) {
72
+ if (error && error.code === 'ENOENT') {
73
+ return { problem: null, exists: false, absoluteRoot, harness, realRoot: null, realHarness: null, identity: null };
74
+ }
75
+ return { problem: { state: 'unreadable-parent', code: 'parent-stat-failed' }, exists: false, absoluteRoot, harness };
76
+ }
77
+ if (stat.isSymbolicLink()) {
78
+ return { problem: { state: 'linked-parent', code: 'linked-store-parent-rejected' }, exists: true, absoluteRoot, harness };
79
+ }
80
+ if (!stat.isDirectory()) {
81
+ return { problem: { state: 'invalid-parent-type', code: 'store-parent-not-directory' }, exists: true, absoluteRoot, harness };
82
+ }
83
+ try {
84
+ const realRoot = _realpath(absoluteRoot);
85
+ const realHarness = _realpath(harness);
86
+ if (!_isWithin(realRoot, realHarness)) {
87
+ return { problem: { state: 'linked-parent', code: 'store-parent-outside-root' }, exists: true, absoluteRoot, harness };
88
+ }
89
+ return {
90
+ problem: null,
91
+ exists: true,
92
+ absoluteRoot,
93
+ harness,
94
+ realRoot,
95
+ realHarness,
96
+ identity: _fileIdentity(stat),
97
+ };
98
+ } catch {
99
+ return { problem: { state: 'unreadable-parent', code: 'parent-realpath-failed' }, exists: true, absoluteRoot, harness };
100
+ }
101
+ }
102
+
103
+ function _storeBoundaryProblem(root) {
104
+ return _storeBoundaryContext(root).problem;
105
+ }
106
+
107
+ function _storeParentChangedError() {
108
+ const error = new Error('role store parent changed during operation');
109
+ error.code = 'E_STORE_PARENT_CHANGED';
110
+ return error;
111
+ }
112
+
113
+ function _assertStoreParentSnapshot(snapshot) {
114
+ if (!snapshot || snapshot.exists !== true || !snapshot.identity) throw _storeParentChangedError();
115
+ let current;
116
+ try { current = fs.lstatSync(snapshot.harness); }
117
+ catch { throw _storeParentChangedError(); }
118
+ if (current.isSymbolicLink() || !current.isDirectory()
119
+ || !_sameFileIdentity(current, snapshot.identity)) throw _storeParentChangedError();
120
+ let realHarness;
121
+ try { realHarness = _realpath(snapshot.harness); }
122
+ catch { throw _storeParentChangedError(); }
123
+ if (realHarness !== snapshot.realHarness || !_isWithin(snapshot.realRoot, realHarness)) {
124
+ throw _storeParentChangedError();
125
+ }
126
+ return current;
127
+ }
128
+
129
+ function _markValidated(state, internals = {}) {
130
+ Object.defineProperty(state, VALIDATED_STATE, { value: true, enumerable: false });
131
+ Object.defineProperty(state, 'rawBytes', {
132
+ value: internals.rawBytes || null, enumerable: false, configurable: false, writable: false,
133
+ });
134
+ Object.defineProperty(state, 'fileIdentity', {
135
+ value: internals.fileIdentity || null, enumerable: false, configurable: false, writable: false,
136
+ });
137
+ Object.defineProperty(state, 'parentSnapshot', {
138
+ value: internals.parentSnapshot || null, enumerable: false, configurable: false, writable: false,
139
+ });
140
+ return state;
141
+ }
142
+
143
+ function revisionForBytes(raw) {
144
+ return `sha256:${crypto.createHash('sha256').update(raw).digest('hex')}`;
145
+ }
146
+
147
+ function _fileIdentity(stat) {
148
+ return stat ? { dev: stat.dev, ino: stat.ino, nlink: stat.nlink, mode: stat.mode } : null;
149
+ }
150
+
151
+ function _sameFileIdentity(a, b) {
152
+ return !!a && !!b && String(a.dev) === String(b.dev) && String(a.ino) === String(b.ino);
153
+ }
154
+
155
+ function _sameReadSnapshot(a, b) {
156
+ return _sameFileIdentity(a, b)
157
+ && Number(a.size) === Number(b.size)
158
+ && Number(a.mtimeMs) === Number(b.mtimeMs)
159
+ && Number(a.ctimeMs) === Number(b.ctimeMs);
160
+ }
161
+
162
+ function validateExtendedRoles(document) {
163
+ const roles = document && document.roles && typeof document.roles === 'object' && !Array.isArray(document.roles)
164
+ ? document.roles
165
+ : {};
166
+ const problems = [];
167
+ for (const role of Object.keys(roles).sort()) {
168
+ const result = validateRoleDefinitionShape(role, roles[role], { allowLegacyProviderIds: true });
169
+ if (!result.ok) {
170
+ problems.push({
171
+ code: result.code || 'role-definition-invalid',
172
+ path: `$.legacyAssignments.roles.${role}${result.field ? `.${result.field}` : ''}`,
173
+ });
174
+ }
175
+ }
176
+ return problems;
177
+ }
178
+
179
+ function publicProblems(problems) {
180
+ return (Array.isArray(problems) ? problems : []).slice(0, 30).map(problem => ({
181
+ code: sanitizeText(problem && problem.code || 'store-invalid', 100),
182
+ path: sanitizeText(problem && problem.path || '$.legacyAssignments', 240),
183
+ }));
184
+ }
185
+
186
+ function messageFor(state, language = 'ko') {
187
+ const en = language === 'en';
188
+ if (!state || state.code === 'store_unreadable') {
189
+ return en
190
+ ? 'The role configuration file could not be read. Role reads, writes, and routing are stopped until the original file is inspected.'
191
+ : '역할 설정 파일을 읽을 수 없습니다 — 원본을 확인하기 전에는 역할 읽기·쓰기·라우팅을 중단합니다';
192
+ }
193
+ if (state.code === 'store_corrupt') {
194
+ return en
195
+ ? 'The role configuration JSON is corrupt. Role reads, writes, and routing are stopped until the original file is recovered.'
196
+ : '역할 설정 파일의 JSON이 손상되었습니다 — 원본을 복구하기 전에는 역할 읽기·쓰기·라우팅을 중단합니다';
197
+ }
198
+ return en
199
+ ? 'The role configuration shape is invalid. Role reads, writes, and routing are stopped until the original file is recovered.'
200
+ : '역할 설정 파일 형상이 유효하지 않습니다 — 원본을 복구하기 전에는 역할 읽기·쓰기·라우팅을 중단합니다';
201
+ }
202
+
203
+ function invalidState(code, state, problems, extra = {}) {
204
+ return {
205
+ ok: false,
206
+ code,
207
+ state,
208
+ file: STORE_RELATIVE_PATH,
209
+ problems: publicProblems(problems),
210
+ originalPreserved: true,
211
+ providerCommandsExecuted: false,
212
+ ...extra,
213
+ };
214
+ }
215
+
216
+ function missingState(file, parentSnapshot = null) {
217
+ const document = { schemaVersion: 1, roles: {} };
218
+ return _markValidated({
219
+ ok: true,
220
+ code: 'ok',
221
+ state: 'missing',
222
+ file: STORE_RELATIVE_PATH,
223
+ absoluteFile: file,
224
+ fileExists: false,
225
+ bytes: 0,
226
+ revision: 'missing',
227
+ document,
228
+ roles: safeRoleMap(document.roles),
229
+ validation: {
230
+ ok: true,
231
+ code: 'ok',
232
+ problems: [],
233
+ warnings: [],
234
+ unknownFields: [],
235
+ version: null,
236
+ unversioned: false,
237
+ roleKeys: [],
238
+ },
239
+ }, { parentSnapshot });
240
+ }
241
+
242
+ function readRoleStore(root) {
243
+ const file = rolesFile(root);
244
+ const boundaryContext = _storeBoundaryContext(root);
245
+ const boundary = boundaryContext.problem;
246
+ if (boundary) {
247
+ return invalidState(
248
+ boundary.state === 'unreadable-parent' ? 'store_unreadable' : 'store_invalid',
249
+ boundary.state,
250
+ [{ code: boundary.code, path: '$.legacyAssignments' }],
251
+ { absoluteFile: file },
252
+ );
253
+ }
254
+ let stat;
255
+ try { stat = fs.lstatSync(file); }
256
+ catch (error) {
257
+ if (error && error.code === 'ENOENT') return missingState(file, boundaryContext.exists ? boundaryContext : null);
258
+ return invalidState(
259
+ 'store_unreadable',
260
+ 'unreadable',
261
+ [{ code: 'file-stat-failed', path: '$.legacyAssignments' }],
262
+ { absoluteFile: file },
263
+ );
264
+ }
265
+
266
+ if (stat.isSymbolicLink() || !stat.isFile()) {
267
+ return invalidState(
268
+ 'store_invalid',
269
+ 'invalid-file-type',
270
+ [{ code: stat.isSymbolicLink() ? 'linked-store-rejected' : 'store-not-regular-file', path: '$.legacyAssignments' }],
271
+ { absoluteFile: file, bytes: stat.size },
272
+ );
273
+ }
274
+ // A hard-linked role store can be changed by another project without this
275
+ // project's lock and can therefore control routing across project boundaries.
276
+ // Reject it rather than treating shared file identity as project-local state.
277
+ if (Number(stat.nlink) > 1) {
278
+ return invalidState(
279
+ 'store_invalid',
280
+ 'hard-linked-file',
281
+ [{ code: 'hard-linked-store-rejected', path: '$.legacyAssignments' }],
282
+ { absoluteFile: file, bytes: stat.size },
283
+ );
284
+ }
285
+ if (stat.size > MAX_STORE_BYTES) {
286
+ return invalidState(
287
+ 'store_invalid',
288
+ 'too-large',
289
+ [{ code: 'store-too-large', path: '$.legacyAssignments' }],
290
+ { absoluteFile: file, bytes: stat.size, maxBytes: MAX_STORE_BYTES },
291
+ );
292
+ }
293
+
294
+ let raw;
295
+ let openedStat;
296
+ let fd = null;
297
+ try {
298
+ _assertStoreParentSnapshot(boundaryContext);
299
+ const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
300
+ fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
301
+ openedStat = fs.fstatSync(fd);
302
+ _assertStoreParentSnapshot(boundaryContext);
303
+ const pathStat = fs.lstatSync(file);
304
+ if (!openedStat.isFile() || pathStat.isSymbolicLink() || !pathStat.isFile()
305
+ || Number(openedStat.nlink) > 1 || Number(pathStat.nlink) > 1
306
+ || !_sameFileIdentity(openedStat, pathStat)) {
307
+ return invalidState(
308
+ 'store_invalid',
309
+ 'file-identity-changed',
310
+ [{ code: 'store-file-identity-changed', path: '$.legacyAssignments' }],
311
+ { absoluteFile: file, bytes: Number(openedStat.size) },
312
+ );
313
+ }
314
+ if (Number(openedStat.size) > MAX_STORE_BYTES) {
315
+ return invalidState(
316
+ 'store_invalid',
317
+ 'too-large',
318
+ [{ code: 'store-too-large', path: '$.legacyAssignments' }],
319
+ { absoluteFile: file, bytes: Number(openedStat.size), maxBytes: MAX_STORE_BYTES },
320
+ );
321
+ }
322
+ raw = fs.readFileSync(fd);
323
+ const afterHandle = fs.fstatSync(fd);
324
+ const afterPath = fs.lstatSync(file);
325
+ _assertStoreParentSnapshot(boundaryContext);
326
+ if (afterPath.isSymbolicLink() || !afterPath.isFile() || Number(afterPath.nlink) > 1
327
+ || !_sameReadSnapshot(openedStat, afterHandle)
328
+ || !_sameFileIdentity(afterHandle, afterPath)) {
329
+ return invalidState(
330
+ 'store_invalid',
331
+ 'changed-during-read',
332
+ [{ code: 'store-changed-during-read', path: '$.legacyAssignments' }],
333
+ { absoluteFile: file, bytes: raw.length },
334
+ );
335
+ }
336
+ stat = afterHandle;
337
+ } catch (error) {
338
+ return invalidState(
339
+ error && ['ELOOP', 'EMLINK', 'E_STORE_PARENT_CHANGED'].includes(error.code) ? 'store_invalid' : 'store_unreadable',
340
+ error && error.code === 'E_STORE_PARENT_CHANGED' ? 'parent-identity-changed' : 'unreadable',
341
+ [{ code: error && error.code === 'E_STORE_PARENT_CHANGED' ? 'store-parent-identity-changed' : 'file-read-failed', path: '$.legacyAssignments' }],
342
+ { absoluteFile: file, bytes: stat.size },
343
+ );
344
+ } finally {
345
+ if (fd !== null) {
346
+ try { fs.closeSync(fd); } catch {}
347
+ }
348
+ }
349
+ if (raw.length > MAX_STORE_BYTES) {
350
+ return invalidState(
351
+ 'store_invalid',
352
+ 'too-large',
353
+ [{ code: 'store-too-large', path: '$.legacyAssignments' }],
354
+ { absoluteFile: file, bytes: raw.length, maxBytes: MAX_STORE_BYTES },
355
+ );
356
+ }
357
+
358
+ let source;
359
+ try { source = new TextDecoder('utf-8', { fatal: true }).decode(raw); }
360
+ catch {
361
+ return invalidState(
362
+ 'store_invalid',
363
+ 'invalid-utf8',
364
+ [{ code: 'invalid-utf8', path: '$.legacyAssignments' }],
365
+ { absoluteFile: file, bytes: raw.length },
366
+ );
367
+ }
368
+
369
+ const parsed = schema.parseLegacyRoleStoreText(source);
370
+ if (!parsed.ok) {
371
+ return invalidState(
372
+ parsed.state === 'invalid-json' ? 'store_corrupt' : 'store_invalid',
373
+ parsed.state || 'invalid',
374
+ parsed.problems,
375
+ { absoluteFile: file, bytes: stat.size },
376
+ );
377
+ }
378
+
379
+ const extensionProblems = validateExtendedRoles(parsed.doc);
380
+ if (extensionProblems.length) {
381
+ return invalidState(
382
+ 'store_invalid',
383
+ 'invalid-role-definition',
384
+ extensionProblems,
385
+ { absoluteFile: file, bytes: raw.length, revision: revisionForBytes(raw) },
386
+ );
387
+ }
388
+
389
+ const document = cloneJson(parsed.doc, { schemaVersion: 1, roles: {} });
390
+ return _markValidated({
391
+ ok: true,
392
+ code: 'ok',
393
+ state: 'valid',
394
+ file: STORE_RELATIVE_PATH,
395
+ absoluteFile: file,
396
+ fileExists: true,
397
+ bytes: raw.length,
398
+ revision: revisionForBytes(raw),
399
+ document,
400
+ roles: safeRoleMap(document.roles),
401
+ validation: parsed.validation || parsed,
402
+ }, { rawBytes: Buffer.from(raw), fileIdentity: _fileIdentity(stat), parentSnapshot: boundaryContext });
403
+ }
404
+
405
+ class RoleStoreError extends Error {
406
+ constructor(state) {
407
+ super(messageFor(state));
408
+ this.name = 'RoleStoreError';
409
+ this.code = state.code;
410
+ this.roleStoreState = state;
411
+ }
412
+ }
413
+
414
+ function assertValid(state) {
415
+ if (!state.ok) throw new RoleStoreError(state);
416
+ return state;
417
+ }
418
+
419
+ function loadRoles(root) {
420
+ return assertValid(readRoleStore(root)).roles;
421
+ }
422
+
423
+ function publicError(state) {
424
+ return {
425
+ schemaVersion: OUTPUT_SCHEMA_VERSION,
426
+ observation: OBSERVATION,
427
+ code: state.code,
428
+ error: messageFor(state),
429
+ state: state.state,
430
+ file: state.file || STORE_RELATIVE_PATH,
431
+ problems: publicProblems(state.problems),
432
+ originalPreserved: true,
433
+ providerCommandsExecuted: false,
434
+ };
435
+ }
436
+
437
+ function _assertExpectedSnapshot(file, expectedBytes, expectedIdentity) {
438
+ const state = readRoleStore(path.dirname(path.dirname(file)));
439
+ if (!state.ok || state.revision !== revisionForBytes(expectedBytes)
440
+ || !_sameFileIdentity(state.fileIdentity, expectedIdentity)) {
441
+ const error = new Error('role store changed before compare-and-replace');
442
+ error.code = 'E_CONCURRENT_MODIFICATION';
443
+ throw error;
444
+ }
445
+ }
446
+
447
+ // POSIX lacks a portable rename-exchange/CAS primitive in Node. Capture the
448
+ // exact expected inode first, then install the prepared inode with an exclusive
449
+ // hard-link operation. A competing pathname writer wins rather than being
450
+ // overwritten; the displaced object is retained as recovery evidence.
451
+ function _writePosixIfUnchanged(file, expectedBytes, nextBytes, expectedIdentity, parentSnapshot) {
452
+ const scratch = fs.mkdtempSync(path.join(path.dirname(file), '.agent-roles-cas-'));
453
+ const next = path.join(scratch, 'next');
454
+ const before = path.join(scratch, 'before');
455
+ let displaced = false;
456
+ try {
457
+ fs.writeFileSync(next, nextBytes, { flag: 'wx', mode: Number(expectedIdentity.mode) & 0o777 });
458
+ _assertStoreParentSnapshot(parentSnapshot);
459
+ _assertExpectedSnapshot(file, expectedBytes, expectedIdentity);
460
+ fs.renameSync(file, before);
461
+ displaced = true;
462
+ _assertStoreParentSnapshot(parentSnapshot);
463
+ const captured = fs.lstatSync(before);
464
+ const capturedBytes = fs.readFileSync(before);
465
+ if (captured.isSymbolicLink() || !captured.isFile() || Number(captured.nlink) > 1
466
+ || !_sameFileIdentity(captured, expectedIdentity) || !capturedBytes.equals(expectedBytes)) {
467
+ const error = new Error('role store identity changed during compare-and-replace');
468
+ error.code = 'E_CONCURRENT_MODIFICATION';
469
+ error.backupFile = before;
470
+ throw error;
471
+ }
472
+ fs.linkSync(next, file);
473
+ detachCommittedHardLink(next, file, nextBytes);
474
+ return { backupFile: before };
475
+ } catch (error) {
476
+ if (displaced && !fs.existsSync(file)) {
477
+ // Restore by moving the captured inode back. A hard-link restore leaves
478
+ // `before` attached and makes the live store fail its own nlink=1 guard.
479
+ try { fs.renameSync(before, file); } catch {}
480
+ }
481
+ error.backupFile = error.backupFile || (fs.existsSync(before) ? before : null);
482
+ throw error;
483
+ }
484
+ }
485
+
486
+ function _writeRoleStoreIfUnchanged(file, expectedBytes, nextBytes, expectedIdentity, parentSnapshot) {
487
+ if (process.platform === 'win32') {
488
+ return writeBufferIfUnchanged(file, expectedBytes, nextBytes, { expectedIdentity });
489
+ }
490
+ return _writePosixIfUnchanged(file, expectedBytes, nextBytes, expectedIdentity, parentSnapshot);
491
+ }
492
+
493
+ function _writeNewRoleStore(file, nextBytes, parentSnapshot) {
494
+ const temporary = path.join(path.dirname(file), `.agent-roles-create-${process.pid}-${crypto.randomBytes(12).toString('hex')}`);
495
+ try {
496
+ fs.writeFileSync(temporary, nextBytes, { flag: 'wx', mode: 0o600 });
497
+ _assertStoreParentSnapshot(parentSnapshot);
498
+ // link is an exclusive install: unlike rename it cannot overwrite a writer
499
+ // that won the missing-store race. If the parent pathname was swapped, the
500
+ // unpredictable source is absent in the replacement directory and the call
501
+ // fails without creating the target there.
502
+ fs.linkSync(temporary, file);
503
+ detachCommittedHardLink(temporary, file, nextBytes);
504
+ _assertStoreParentSnapshot(parentSnapshot);
505
+ const installed = fs.lstatSync(file);
506
+ if (installed.isSymbolicLink() || !installed.isFile() || Number(installed.nlink) !== 1
507
+ || !fs.readFileSync(file).equals(nextBytes)) {
508
+ const error = new Error('new role store failed post-install validation');
509
+ error.code = 'E_CONCURRENT_MODIFICATION';
510
+ throw error;
511
+ }
512
+ } catch (error) {
513
+ try { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } catch {}
514
+ throw error;
515
+ }
516
+ }
517
+
518
+ function saveRoles(root, roles, validatedState) {
519
+ const file = rolesFile(root);
520
+ if (!validatedState || validatedState[VALIDATED_STATE] !== true || validatedState.ok !== true) {
521
+ throw new RoleStoreError(invalidState(
522
+ 'store_invalid',
523
+ 'validated-state-required',
524
+ [{ code: 'validated-role-store-state-required', path: '$.legacyAssignments' }],
525
+ { absoluteFile: file },
526
+ ));
527
+ }
528
+ const baseValidation = schema.validateLegacyRoleStore(validatedState.document);
529
+ if (!baseValidation.ok) {
530
+ throw new RoleStoreError(invalidState(
531
+ 'store_invalid',
532
+ 'invalid-base-document',
533
+ baseValidation.problems,
534
+ { absoluteFile: file },
535
+ ));
536
+ }
537
+ const document = cloneJson(validatedState.document, null);
538
+ if (!document || typeof document !== 'object' || Array.isArray(document)) {
539
+ throw new RoleStoreError(invalidState(
540
+ 'store_invalid',
541
+ 'invalid-base-document',
542
+ [{ code: 'document-not-object', path: '$.legacyAssignments' }],
543
+ { absoluteFile: file },
544
+ ));
545
+ }
546
+ document.schemaVersion = 1;
547
+ document.updatedAt = new Date().toISOString();
548
+ defineOwn(document, 'roles', safeRoleMap(roles));
549
+
550
+ const validation = schema.validateLegacyRoleStore(document);
551
+ const extensionProblems = validateExtendedRoles(document);
552
+ if (!validation.ok || extensionProblems.length) {
553
+ throw new RoleStoreError(invalidState(
554
+ 'store_invalid',
555
+ 'write-validation-failed',
556
+ [...(validation.problems || []), ...extensionProblems],
557
+ { absoluteFile: file },
558
+ ));
559
+ }
560
+
561
+ const serialized = JSON.stringify(document, null, 2) + '\n';
562
+ const attemptedBytes = Buffer.byteLength(serialized, 'utf8');
563
+ if (attemptedBytes > MAX_STORE_BYTES) {
564
+ throw new RoleStoreError(invalidState(
565
+ 'store_invalid',
566
+ 'write-too-large',
567
+ [{ code: 'store-too-large', path: '$.legacyAssignments' }],
568
+ { absoluteFile: file, bytes: attemptedBytes, maxBytes: MAX_STORE_BYTES },
569
+ ));
570
+ }
571
+
572
+ mkdirp(path.dirname(file));
573
+ const current = assertValid(readRoleStore(root));
574
+ if (current.revision !== validatedState.revision
575
+ || (current.fileExists && !_sameFileIdentity(current.fileIdentity, validatedState.fileIdentity))) {
576
+ throw new RoleStoreError(invalidState(
577
+ 'store_invalid',
578
+ 'stale-base-document',
579
+ [{ code: 'store-revision-changed', path: '$.legacyAssignments' }],
580
+ { absoluteFile: file, revision: current.revision },
581
+ ));
582
+ }
583
+ try {
584
+ const nextBytes = Buffer.from(serialized, 'utf8');
585
+ _assertStoreParentSnapshot(current.parentSnapshot);
586
+ if (!current.fileExists) {
587
+ _writeNewRoleStore(file, nextBytes, current.parentSnapshot);
588
+ } else {
589
+ if (!Buffer.isBuffer(validatedState.rawBytes) || !validatedState.fileIdentity) {
590
+ throw Object.assign(new Error('validated role-store snapshot is incomplete'), { code: 'E_CONCURRENT_MODIFICATION' });
591
+ }
592
+ _writeRoleStoreIfUnchanged(file, validatedState.rawBytes, nextBytes, validatedState.fileIdentity, current.parentSnapshot);
593
+ _assertStoreParentSnapshot(current.parentSnapshot);
594
+ }
595
+ } catch (error) {
596
+ throw new RoleStoreError(invalidState(
597
+ 'store_invalid',
598
+ 'concurrent-modification',
599
+ [{ code: 'store-revision-changed', path: '$.legacyAssignments' }],
600
+ {
601
+ absoluteFile: file,
602
+ revision: current.revision,
603
+ backupFile: error && error.backupFile ? error.backupFile : null,
604
+ },
605
+ ));
606
+ }
607
+ return file;
608
+ }
609
+
610
+ function updateRoles(root, mutate, withLock) {
611
+ if (typeof withLock !== 'function') throw new TypeError('withLock is required');
612
+ return withLock(rolesFile(root), () => {
613
+ const state = assertValid(readRoleStore(root));
614
+ const roles = safeRoleMap(state.roles);
615
+ if (mutate(roles) === false) return null;
616
+ return saveRoles(root, roles, state);
617
+ });
618
+ }
619
+
620
+ function setRoleAssignment(roles, role, assignment) {
621
+ const current = hasOwn(roles, role)
622
+ && roles[role]
623
+ && typeof roles[role] === 'object'
624
+ && !Array.isArray(roles[role])
625
+ ? cloneJson(roles[role], {})
626
+ : {};
627
+ current.provider = assignment.provider;
628
+ current.model = assignment.model == null ? null : assignment.model;
629
+ current.persona = assignment.persona || '';
630
+ defineOwn(roles, role, current);
631
+ }
632
+
633
+ function resolveRole(root, role) {
634
+ let roles;
635
+ try { roles = loadRoles(root); }
636
+ catch (error) {
637
+ if (error instanceof RoleStoreError) return { role, storeError: publicError(error.roleStoreState) };
638
+ throw error;
639
+ }
640
+ if (hasOwn(roles, role) && roles[role] && roles[role].provider) {
641
+ return {
642
+ role,
643
+ provider: roles[role].provider,
644
+ model: roles[role].model || null,
645
+ persona: roles[role].persona || '',
646
+ source: 'user',
647
+ };
648
+ }
649
+ return null;
650
+ }
651
+
652
+ function validationSummary(root) {
653
+ const state = readRoleStore(root);
654
+ if (!state.ok) return state;
655
+ const projection = schema.projectLegacyRoleStore(state.document);
656
+ if (!projection.ok) {
657
+ return invalidState(
658
+ 'store_invalid',
659
+ 'projection-invalid',
660
+ projection.problems,
661
+ { absoluteFile: state.absoluteFile, bytes: state.bytes },
662
+ );
663
+ }
664
+ const validation = state.validation || {};
665
+ return {
666
+ schemaVersion: OUTPUT_SCHEMA_VERSION,
667
+ observation: OBSERVATION,
668
+ ok: true,
669
+ state: state.state,
670
+ file: state.file,
671
+ fileExists: state.fileExists,
672
+ bytes: state.bytes,
673
+ revision: state.revision,
674
+ legacy: {
675
+ schemaVersion: state.fileExists ? validation.version : null,
676
+ unversioned: state.fileExists ? !!validation.unversioned : false,
677
+ roleCount: Object.keys(state.roles).length,
678
+ warningCount: (validation.warnings || []).length,
679
+ warnings: (validation.warnings || []).slice(0, 20),
680
+ unknownFieldCount: (validation.unknownFields || []).length,
681
+ },
682
+ projection: {
683
+ roleDefinitionCount: Object.keys(projection.roleDefinitions.roles).length,
684
+ agentCount: projection.agentInstances.agents.length,
685
+ pipelineCount: Object.keys(projection.routingPolicy.pipelines).length,
686
+ assignmentReady: projection.assignmentReady,
687
+ assignmentGaps: projection.assignmentGaps,
688
+ readinessScope: projection.readinessScope,
689
+ providerReadinessChecked: projection.providerReadinessChecked,
690
+ previewOnly: true,
691
+ runtimeWiringApplied: false,
692
+ },
693
+ nativeV2Validation: {
694
+ performed: false,
695
+ files: [
696
+ schema.STORE_FILES.roleDefinitions,
697
+ schema.STORE_FILES.agentInstances,
698
+ schema.STORE_FILES.routingPolicy,
699
+ ],
700
+ reason: 'legacy-projection-preview-only',
701
+ },
702
+ migrationApplied: false,
703
+ readOnly: true,
704
+ writesPerformed: false,
705
+ originalPreserved: true,
706
+ providerCommandsExecuted: false,
707
+ };
708
+ }
709
+
710
+ module.exports = {
711
+ STORE_RELATIVE_PATH,
712
+ MAX_STORE_BYTES,
713
+ OUTPUT_SCHEMA_VERSION,
714
+ OBSERVATION,
715
+ RoleStoreError,
716
+ hasOwn,
717
+ defineOwn,
718
+ safeRoleMap,
719
+ rolesFile,
720
+ revisionForBytes,
721
+ readRoleStore,
722
+ loadRoles,
723
+ saveRoles,
724
+ updateRoles,
725
+ setRoleAssignment,
726
+ resolveRole,
727
+ validationSummary,
728
+ messageFor,
729
+ publicError,
730
+ };