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
package/lib/io.js
CHANGED
|
@@ -7,6 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const cp = require('child_process');
|
|
10
|
+
const crypto = require('crypto');
|
|
10
11
|
|
|
11
12
|
// 1.10.2 (UR-0146): quiet 모드 — 사람용 출력(log/ok/warn) 억제. init --json 등에서 큰 핸들러의 다수 log 를 비침투적으로 묵음 → 순수 JSON 1개만 출력.
|
|
12
13
|
// fail/failJson(오류)은 묵음 대상 아님(에러는 항상 노출). setQuiet 로 토글, 호출부 finally 에서 반드시 복구.
|
|
@@ -175,6 +176,10 @@ function _patchFsForDryRun() {
|
|
|
175
176
|
}
|
|
176
177
|
}
|
|
177
178
|
function setDryRunGuard(on) { _dryGuard = !!on; if (on) _patchFsForDryRun(); }
|
|
179
|
+
// File-descriptor based writers cannot be intercepted by the fs path mutator
|
|
180
|
+
// wrappers. Call this before opening a descriptor so --dry-run retains its
|
|
181
|
+
// process-wide zero-persistent-write contract.
|
|
182
|
+
function assertWriteAllowed(p) { _dryCheck(p); }
|
|
178
183
|
// ⚠ `mkdirp` 은 가드하지 **않는다**. 한 번 넣었다가 부작용을 재서 뺀다 —
|
|
179
184
|
// `_withLock` 이 락 디렉토리를 만드는 것까지 막혀 락 획득이 실패하고 **무보호 진행**으로 떨어졌다
|
|
180
185
|
// (실측: `state start --dry-run` → "락 획득 실패 — 보호 없이 진행합니다"). 빈 디렉토리는 사용자 데이터가
|
|
@@ -484,4 +489,150 @@ function _renameWithRetry(from, to) {
|
|
|
484
489
|
function append(p, s) { _dryCheck(p); mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
|
|
485
490
|
function rel(root, p) { return path.relative(root, p).replace(/\\/g, '/') || '.'; }
|
|
486
491
|
|
|
487
|
-
|
|
492
|
+
function _hardLinkIdentityEqual(a, b) {
|
|
493
|
+
return !!(a && b)
|
|
494
|
+
&& String(a.dev) === String(b.dev)
|
|
495
|
+
&& String(a.ino) === String(b.ino);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function _lstatState(p) {
|
|
499
|
+
try { return { exists: true, stat: fs.lstatSync(p), error: null }; }
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (error && error.code === 'ENOENT') return { exists: false, stat: null, error: null };
|
|
502
|
+
return { exists: null, stat: null, error };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// A failed alias detach means the caller must not observe success. Remove the
|
|
507
|
+
// just-installed canonical name only when both names still identify the exact
|
|
508
|
+
// committed inode and its bytes are unchanged. Existing-file callers can then
|
|
509
|
+
// restore their captured predecessor; create callers leave the canonical path
|
|
510
|
+
// absent. A random quarantine rename is the last exact-path fallback when a
|
|
511
|
+
// platform refuses both unlink spellings.
|
|
512
|
+
function _rollbackCommittedHardLink(alias, live, expected) {
|
|
513
|
+
const liveState = _lstatState(live);
|
|
514
|
+
if (liveState.exists === false) return { ok: true, canonicalState: 'absent', artifact: null };
|
|
515
|
+
const aliasState = _lstatState(alias);
|
|
516
|
+
if (liveState.exists !== true || aliasState.exists !== true
|
|
517
|
+
|| liveState.stat.isSymbolicLink() || !liveState.stat.isFile()
|
|
518
|
+
|| aliasState.stat.isSymbolicLink() || !aliasState.stat.isFile()
|
|
519
|
+
|| !_hardLinkIdentityEqual(liveState.stat, aliasState.stat)) {
|
|
520
|
+
return { ok: false, canonicalState: 'unknown', artifact: null, error: liveState.error || aliasState.error || null };
|
|
521
|
+
}
|
|
522
|
+
if (expected) {
|
|
523
|
+
try {
|
|
524
|
+
if (!fs.readFileSync(live).equals(expected) || !fs.readFileSync(alias).equals(expected)) {
|
|
525
|
+
return { ok: false, canonicalState: 'changed', artifact: null, error: null };
|
|
526
|
+
}
|
|
527
|
+
} catch (error) {
|
|
528
|
+
return { ok: false, canonicalState: 'unknown', artifact: null, error };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let removalError = null;
|
|
533
|
+
try { fs.unlinkSync(live); }
|
|
534
|
+
catch (error) { removalError = error; }
|
|
535
|
+
if (_lstatState(live).exists === true) {
|
|
536
|
+
try { fs.rmSync(live, { force: true }); }
|
|
537
|
+
catch (error) { removalError = removalError || error; }
|
|
538
|
+
}
|
|
539
|
+
if (_lstatState(live).exists === true) {
|
|
540
|
+
const quarantine = `${live}.rollback-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
541
|
+
try {
|
|
542
|
+
fs.renameSync(live, quarantine);
|
|
543
|
+
try { fs.unlinkSync(quarantine); }
|
|
544
|
+
catch {
|
|
545
|
+
try { fs.rmSync(quarantine, { force: true }); } catch {}
|
|
546
|
+
}
|
|
547
|
+
const postRename = _lstatState(live);
|
|
548
|
+
return postRename.exists === false
|
|
549
|
+
? { ok: true, canonicalState: 'absent', artifact: _lstatState(quarantine).exists === true ? quarantine : null }
|
|
550
|
+
: { ok: false, canonicalState: 'present', artifact: quarantine, error: removalError };
|
|
551
|
+
} catch (error) {
|
|
552
|
+
removalError = removalError || error;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const after = _lstatState(live);
|
|
556
|
+
if (after.exists === false) return { ok: true, canonicalState: 'absent', artifact: null };
|
|
557
|
+
if (after.exists === true && !after.stat.isSymbolicLink() && after.stat.isFile()
|
|
558
|
+
&& Number(after.stat.nlink) === 1
|
|
559
|
+
&& (!expected || fs.readFileSync(live).equals(expected))) {
|
|
560
|
+
return { ok: true, canonicalState: 'detached', artifact: null };
|
|
561
|
+
}
|
|
562
|
+
return { ok: false, canonicalState: after.exists === true ? 'present' : 'unknown', artifact: null, error: after.error || removalError };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// An exclusive hard-link install is useful when rename-without-replace is not
|
|
566
|
+
// available, but the live inode must not depend on successfully unlinking the
|
|
567
|
+
// prepared alias. If unlink fails after commit, atomically replace that alias
|
|
568
|
+
// with a different inode; this drops the live inode back to nlink=1 even when
|
|
569
|
+
// the harmless replacement artifact cannot itself be removed.
|
|
570
|
+
function detachCommittedHardLink(aliasPath, livePath, expectedBytes) {
|
|
571
|
+
const alias = path.resolve(aliasPath);
|
|
572
|
+
const live = path.resolve(livePath);
|
|
573
|
+
let firstCleanupError = null;
|
|
574
|
+
try {
|
|
575
|
+
fs.unlinkSync(alias);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
firstCleanupError = error;
|
|
578
|
+
// Some transient/platform wrappers fail unlink but permit the equivalent
|
|
579
|
+
// exact-path removal. This is only the random prepared alias, never user data.
|
|
580
|
+
try { fs.rmSync(alias, { force: true }); } catch {}
|
|
581
|
+
if (fs.existsSync(alias)) {
|
|
582
|
+
const detacher = `${alias}.detach-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
583
|
+
let detacherExists = false;
|
|
584
|
+
try {
|
|
585
|
+
fs.writeFileSync(detacher, Buffer.alloc(0), { flag: 'wx', mode: 0o600 });
|
|
586
|
+
detacherExists = true;
|
|
587
|
+
// Replacing the alias unlinks it from the committed inode as part of
|
|
588
|
+
// rename, while leaving the live pathname and its bytes untouched.
|
|
589
|
+
fs.renameSync(detacher, alias);
|
|
590
|
+
detacherExists = false;
|
|
591
|
+
} catch (detachError) {
|
|
592
|
+
try { if (detacherExists) fs.rmSync(detacher, { force: true }); } catch {}
|
|
593
|
+
const expected = Buffer.isBuffer(expectedBytes) ? expectedBytes : null;
|
|
594
|
+
const rollback = _rollbackCommittedHardLink(alias, live, expected);
|
|
595
|
+
// Another actor may have detached the alias between our failed rename
|
|
596
|
+
// and rollback probe. A valid nlink=1 live file already satisfies the
|
|
597
|
+
// commit contract, so this narrow state remains a successful recovery.
|
|
598
|
+
if (rollback.ok && rollback.canonicalState === 'detached') {
|
|
599
|
+
return { cleanupRecovered: true, cleanupArtifact: fs.existsSync(alias) ? alias : null };
|
|
600
|
+
}
|
|
601
|
+
const failure = new Error(`committed hard-link alias could not be detached: ${alias}`);
|
|
602
|
+
failure.code = 'E_HARDLINK_DETACH_FAILED';
|
|
603
|
+
failure.cleanupError = (detachError && detachError.message) || String(detachError);
|
|
604
|
+
failure.canonicalRolledBack = rollback.ok && rollback.canonicalState === 'absent';
|
|
605
|
+
failure.rollbackState = rollback.canonicalState;
|
|
606
|
+
failure.rollbackArtifact = rollback.artifact || null;
|
|
607
|
+
failure.rollbackError = rollback.error && (rollback.error.message || String(rollback.error));
|
|
608
|
+
failure.cause = firstCleanupError;
|
|
609
|
+
throw failure;
|
|
610
|
+
}
|
|
611
|
+
// The path now names an unrelated empty inode. Failure to remove this
|
|
612
|
+
// cleanup artifact is non-fatal and cannot poison the committed file.
|
|
613
|
+
try { fs.rmSync(alias, { force: true }); } catch {}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
let stat;
|
|
617
|
+
try { stat = fs.lstatSync(live); }
|
|
618
|
+
catch (error) {
|
|
619
|
+
const failure = new Error(`committed file disappeared while detaching its prepared alias: ${live}`);
|
|
620
|
+
failure.code = 'E_HARDLINK_DETACH_FAILED';
|
|
621
|
+
failure.cause = error;
|
|
622
|
+
throw failure;
|
|
623
|
+
}
|
|
624
|
+
const expected = Buffer.isBuffer(expectedBytes) ? expectedBytes : null;
|
|
625
|
+
if (stat.isSymbolicLink() || !stat.isFile() || Number(stat.nlink) !== 1
|
|
626
|
+
|| (expected && !fs.readFileSync(live).equals(expected))) {
|
|
627
|
+
const failure = new Error(`committed file failed post-detach validation: ${live}`);
|
|
628
|
+
failure.code = 'E_HARDLINK_DETACH_FAILED';
|
|
629
|
+
failure.cleanupError = firstCleanupError && firstCleanupError.message;
|
|
630
|
+
throw failure;
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
cleanupRecovered: !!firstCleanupError,
|
|
634
|
+
cleanupArtifact: fs.existsSync(alias) ? alias : null,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, writeBufferIfUnchanged, append, rel, setDryRunGuard, assertWriteAllowed, mkdirpRaw, detachCommittedHardLink };
|