@lmzhen/dsh-evolution-core 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/lib/index.js +271 -521
- package/lib/types/errors.d.ts +2 -1
- package/lib/types/guards.d.ts +23 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/io.d.ts +44 -103
- package/lib/types/params.d.ts +10 -0
- package/lib/types/platform-services.d.ts +2 -2
- package/lib/types/preset-composition.d.ts +84 -1
- package/lib/types/skill-row-cells.d.ts +2 -0
- package/lib/types/skill-store.d.ts +9 -49
- package/package.json +1 -1
- package/persisted-write-inventory.json +3 -3
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { parse } from "yaml";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
4
5
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { scopeOf } from "@deepseek-ai/dsh-scope";
|
|
9
9
|
import { z } from "zod";
|
|
@@ -1157,6 +1157,34 @@ function bodyCost(content) {
|
|
|
1157
1157
|
};
|
|
1158
1158
|
}
|
|
1159
1159
|
//#endregion
|
|
1160
|
+
//#region lib/types/guards.js
|
|
1161
|
+
/**
|
|
1162
|
+
* The family's plain-object predicate, in ONE place (v46 G7.1 / audit A8).
|
|
1163
|
+
*
|
|
1164
|
+
* The audit found the same guard — "a non-null object that is not an array" — rewritten fifteen
|
|
1165
|
+
* times across nine packages, four times inside core itself, and with two byte-identical `asRecord`
|
|
1166
|
+
* copies in evolution-policy and evolution-threat. This module is the single definition; callers
|
|
1167
|
+
* import it instead of spelling the clause again.
|
|
1168
|
+
*
|
|
1169
|
+
* @module @lmzhen/dsh-evolution-core
|
|
1170
|
+
*/
|
|
1171
|
+
/**
|
|
1172
|
+
* Is this a non-null, non-array object (what JSON calls an object)?
|
|
1173
|
+
* @param value - the value to test.
|
|
1174
|
+
* @returns whether it is a plain object.
|
|
1175
|
+
*/
|
|
1176
|
+
function isRecord(value) {
|
|
1177
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Read a value as a record, or undefined when it is not one.
|
|
1181
|
+
* @param value - the value to read.
|
|
1182
|
+
* @returns the value typed as a record, or undefined.
|
|
1183
|
+
*/
|
|
1184
|
+
function asRecord(value) {
|
|
1185
|
+
return isRecord(value) ? value : void 0;
|
|
1186
|
+
}
|
|
1187
|
+
//#endregion
|
|
1160
1188
|
//#region lib/types/probe.js
|
|
1161
1189
|
function probePresent(value) {
|
|
1162
1190
|
return {
|
|
@@ -1356,37 +1384,6 @@ function evolutionIoAdapter(provider) {
|
|
|
1356
1384
|
}
|
|
1357
1385
|
};
|
|
1358
1386
|
}
|
|
1359
|
-
/**
|
|
1360
|
-
* F-367 (②): lock paths whose release (the finally `rm`) failed. The next write
|
|
1361
|
-
* to the same file proactively recycles our own leftover lock — the holder is
|
|
1362
|
-
* us, so a leftover is stale by definition. Module-level by design: it must
|
|
1363
|
-
* survive across `nodeEvolutionIo()` instances for the self-heal to be
|
|
1364
|
-
* effective. (Not a pure function — the cross-call state is the intent.)
|
|
1365
|
-
* V4-05: this set — not an mtime heuristic — is the ONLY signal that a
|
|
1366
|
-
* same-pid lock is a leftover rather than a live in-process task, so it is the
|
|
1367
|
-
* sole gate for the self-pid recycle branch. Exported (read-only in practice)
|
|
1368
|
-
* so `io.spec.ts` can drive the self-heal path deterministically.
|
|
1369
|
-
*/
|
|
1370
|
-
const pendingSelfCleanup = /* @__PURE__ */ new Map();
|
|
1371
|
-
/** P2-7 (v11)/P3-10 (v14): record a failed release so the next write to this
|
|
1372
|
-
* path self-heals it, keeping the map's 64-entry cap — a never-re-touched path
|
|
1373
|
-
* must not pin it, while a path whose lock still EXISTS is the last to drop
|
|
1374
|
-
* (dropping it disables the self-heal for a lock that is really there). */
|
|
1375
|
-
const recordPendingSelfCleanup = async (lock, token) => {
|
|
1376
|
-
if (pendingSelfCleanup.size >= 64) {
|
|
1377
|
-
let droppable;
|
|
1378
|
-
for (const candidate of pendingSelfCleanup.keys()) {
|
|
1379
|
-
if (candidate === lock) continue;
|
|
1380
|
-
if (await readFile(candidate, "utf8").then(() => false, () => true)) {
|
|
1381
|
-
droppable = candidate;
|
|
1382
|
-
break;
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
const victim = droppable ?? pendingSelfCleanup.keys().next().value;
|
|
1386
|
-
if (victim !== void 0) pendingSelfCleanup.delete(victim);
|
|
1387
|
-
}
|
|
1388
|
-
pendingSelfCleanup.set(lock, token);
|
|
1389
|
-
};
|
|
1390
1387
|
const RENAME_RETRY_BASE_MS = 50;
|
|
1391
1388
|
const RENAME_RETRY_MAX_DELAY_MS = 800;
|
|
1392
1389
|
const RENAME_RETRY_MAX_ATTEMPTS = 6;
|
|
@@ -1514,73 +1511,14 @@ function isProcessAlive(pid) {
|
|
|
1514
1511
|
return error?.code === "EPERM";
|
|
1515
1512
|
}
|
|
1516
1513
|
}
|
|
1517
|
-
/**
|
|
1518
|
-
*
|
|
1519
|
-
|
|
1520
|
-
* consume these two constants instead of repeating the literals. */
|
|
1521
|
-
const LOCK_SUFFIX = ".lock";
|
|
1522
|
-
/** Writer-lock body shape: a decimal pid, a colon, then the claim token. A
|
|
1523
|
-
* torn body (no parsable pid) still matches the `\\d+:` prefix rule only when
|
|
1524
|
-
* the pid part is intact, which is what the takeover probe needs. The capture
|
|
1525
|
-
* group feeds `parseLockBody` (v20 A-1: skill-store's sweepers consume this
|
|
1526
|
-
* helper instead of re-inlining a third regex copy — F-17 single-source). */
|
|
1527
|
-
const LOCK_BODY_RE = /^(\d+):[0-9a-f]*$/;
|
|
1528
|
-
/** Parse a writer-lock body into its holder pid. `null` when the body does
|
|
1529
|
-
* not have the `pid:token` shape at all (e.g. a user support file named
|
|
1530
|
-
* `*.lock`) — callers leave such files alone. A shape-matching body always
|
|
1531
|
-
* yields a number, possibly `0` (state corruption / hand edit): there is no
|
|
1532
|
-
* pid 0, so a `0` holder must be treated as DEAD by the caller —
|
|
1533
|
-
* `isProcessAlive(0)` signals the caller's own process group on POSIX and
|
|
1534
|
-
* answers true, so probe sites must guard `holder > 0` before probing. */
|
|
1535
|
-
function parseLockBody(body) {
|
|
1536
|
-
const match = LOCK_BODY_RE.exec(body.trim());
|
|
1537
|
-
return match === null ? null : Number(match[1]);
|
|
1538
|
-
}
|
|
1539
|
-
/**
|
|
1540
|
-
* V27 G0.2 (EVO-IO-01): the write lock named by this claim is no longer ours
|
|
1541
|
-
* at the commit point — a takeover reclaimed it while we were inside the
|
|
1542
|
-
* critical section. The lock layer converts this into a retry of the whole
|
|
1543
|
-
* read-modify-write; it must never reach a caller as a successful write.
|
|
1544
|
-
*/
|
|
1545
|
-
var LostWriteLock = class extends Error {
|
|
1546
|
-
constructor() {
|
|
1547
|
-
super("evolution-io: the write lock was reclaimed before the commit");
|
|
1548
|
-
this.name = "LostWriteLock";
|
|
1549
|
-
}
|
|
1550
|
-
};
|
|
1514
|
+
/** How long an abandoned quarantine copy is kept for operator rescue. G2 S4: hoisted to module scope
|
|
1515
|
+
* (it used to live inside `nodeEvolutionIo`, in the region the lock protocol occupied). */
|
|
1516
|
+
const CORRUPT_SWEEP_AGE_MS = 168 * 36e5;
|
|
1551
1517
|
/** V27 G1.4: every quarantine-copy name this family has ever produced —
|
|
1552
1518
|
* `<file>.corrupt` (current fixed name), `<file>.corrupt.<epoch>`, and the
|
|
1553
1519
|
* v10-05 legacy `<file>.corrupt-<epoch>-<rand>` series. A user support file
|
|
1554
1520
|
* keeps a final extension (`.corrupt-backup.md`) and therefore stays. */
|
|
1555
1521
|
const CORRUPT_COPY_RE = /\.corrupt(\.\d+|-\d+-[0-9a-z]+)?$/;
|
|
1556
|
-
/** V27 G1.1: the three takeover windows, as protocol constants at ONE place
|
|
1557
|
-
* (the inline copies that used to live in the acquisition loop, plus the dead
|
|
1558
|
-
* branch's bare `1000`, are gone). */
|
|
1559
|
-
/** A named holder that is gone, past this age, is reclaimed. Fits inside the
|
|
1560
|
-
* `lockAttempts * 50ms` retry budget so a dead holder's lock is reachable
|
|
1561
|
-
* within one budget (v19 gate arithmetic: budget >= 2 x threshold). */
|
|
1562
|
-
const DEAD_LOCK_TAKEOVER_MS = 1e3;
|
|
1563
|
-
/** No body at all: nothing attributes the lock to a holder, so this is the one
|
|
1564
|
-
* branch that cannot probe liveness — it must outlast any plausible stall
|
|
1565
|
-
* between create and body write (V27 G0.2: 1s was below what a loaded machine
|
|
1566
|
-
* actually took, which let a peer delete a live holder's lock). */
|
|
1567
|
-
const EMPTY_LOCK_TAKEOVER_MS = 3e4;
|
|
1568
|
-
/** A body with no parseable pid (crash mid-write): 1h, far above any legal hold
|
|
1569
|
-
* and far below "forever". */
|
|
1570
|
-
const LOCK_TEAR_TAKEOVER_MS = 36e5;
|
|
1571
|
-
/** A2 (audit P1-2): even a lock whose holder pid probes ALIVE is reclaimable
|
|
1572
|
-
* past this age. Liveness-by-pid cannot distinguish the original holder from
|
|
1573
|
-
* an unrelated process the OS later assigned the same pid, so the plain alive
|
|
1574
|
-
* probe let one recycled pid brick every writer of one state file forever.
|
|
1575
|
-
* No write in this family holds a lock for more than minutes (the longest is
|
|
1576
|
-
* the ~120s review window), so a day-old "alive" lock is a recycled pid, not
|
|
1577
|
-
* a live writer. */
|
|
1578
|
-
const ALIVE_LOCK_TAKEOVER_MS = 864e5;
|
|
1579
|
-
/** P2-27 (v37): the commit-point ownership re-read. A transient read failure
|
|
1580
|
-
* (EACCES/EMFILE/antivirus hold) must not abort a valid RMW, so the read is
|
|
1581
|
-
* retried in place; only a still-unreadable lock fails the attempt. */
|
|
1582
|
-
const OWNERSHIP_READ_ATTEMPTS = 3;
|
|
1583
|
-
const OWNERSHIP_READ_RETRY_MS = 20;
|
|
1584
1522
|
/**
|
|
1585
1523
|
* V27 G1.3: the error `commitTmp` throws when the rename landed but the parent
|
|
1586
1524
|
* directory fsync failed — the bytes ARE visible, only their durability is
|
|
@@ -1593,39 +1531,12 @@ function isCommittedWarning(error) {
|
|
|
1593
1531
|
return error?.committed === true;
|
|
1594
1532
|
}
|
|
1595
1533
|
/**
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
* pid still probes alive but whose lock is older than the alive
|
|
1603
|
-
* window (A2: a recycled pid must not hold the file forever);
|
|
1604
|
-
* - `empty` no body at all: nothing attributes it to a holder, so only the
|
|
1605
|
-
* wide `emptyAfterMs` window may reclaim it;
|
|
1606
|
-
* - `corrupt` a body with no parseable pid (a crash mid-write), past the 1h
|
|
1607
|
-
* tear threshold.
|
|
1608
|
-
* The age thresholds compare against `nowMs - mtimeMs` with `>` so a lock whose
|
|
1609
|
-
* age EQUALS the threshold is not yet reclaimed (the boundary the v19 gate fix
|
|
1610
|
-
* pinned).
|
|
1611
|
-
*/
|
|
1612
|
-
function decideTakeover(probe) {
|
|
1613
|
-
const age = (probe.nowMs ?? Date.now()) - probe.mtimeMs;
|
|
1614
|
-
const holder = Number(probe.body.split(":")[0] ?? "");
|
|
1615
|
-
const namedHolder = Number.isInteger(holder) && holder > 0;
|
|
1616
|
-
if (probe.body === "") return age > (probe.emptyAfterMs ?? 3e4) ? "empty" : "none";
|
|
1617
|
-
if (!namedHolder) return age > (probe.corruptAfterMs ?? 36e5) ? "corrupt" : "none";
|
|
1618
|
-
if (probe.alive(holder)) return age > (probe.aliveAfterMs ?? 864e5) ? "dead" : "none";
|
|
1619
|
-
return age > (probe.deadAfterMs ?? 1e3) ? "dead" : "none";
|
|
1620
|
-
}
|
|
1621
|
-
/**
|
|
1622
|
-
* Build the Node IO backend. `lockAttempts` scales the write-lock retry budget
|
|
1623
|
-
* (attempts × 50ms); the default 40 (~2s, rc.69) covers production contention,
|
|
1624
|
-
* while contention TESTS on a loaded runner may raise it (e.g. 240 ≈ 12s) —
|
|
1625
|
-
* V10-06 integration: full-suite parallel load made 32-way takeover bursts
|
|
1626
|
-
* exceed the default budget and fail loud.
|
|
1627
|
-
*/
|
|
1628
|
-
function nodeEvolutionIo(lockAttempts = 40) {
|
|
1534
|
+
* Build the Node IO backend: plain fs promises, durable tmp + rename commits (crash atomicity) and
|
|
1535
|
+
* ONE concurrency guarantee of its own — writes to the same path are serialized in-process (see
|
|
1536
|
+
* `inProcessWrite`). G2 S4 removed the cross-process write lock and its takeover protocol; the
|
|
1537
|
+
* plugin path gets cross-process safety from the platform provider (`ctx.fs`).
|
|
1538
|
+
*/
|
|
1539
|
+
function nodeEvolutionIo() {
|
|
1629
1540
|
/**
|
|
1630
1541
|
* v43 S1-4: delegates to the canonical `isMissingPath` (probe.ts) — one
|
|
1631
1542
|
* definition for the whole family instead of a second copy of the same two
|
|
@@ -1640,201 +1551,34 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1640
1551
|
/** True when the pid is alive (single source: `isProcessAlive`). */
|
|
1641
1552
|
const isAlive = isProcessAlive;
|
|
1642
1553
|
/**
|
|
1643
|
-
*
|
|
1644
|
-
*
|
|
1645
|
-
*
|
|
1646
|
-
*
|
|
1647
|
-
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
*/
|
|
1651
|
-
const TICKET_STALE_MS = 5e3;
|
|
1652
|
-
/**
|
|
1653
|
-
* Cross-process write lock (claw `withFileLock` parity): an O_EXCL lock file
|
|
1654
|
-
* guards the atomic write. A >1s-old lock is taken over ONLY after probing
|
|
1655
|
-
* the holder pid it carries (rc.66): a LIVE holder is never stolen, so a
|
|
1656
|
-
* slow writer no longer loses its lock to a peer at the threshold (the
|
|
1657
|
-
* takeover is the only best-effort surface; the retry budget fails loud —
|
|
1658
|
-
* rc.65 — instead of ever proceeding unlocked). Budget = 40 * 50ms (~2s,
|
|
1659
|
-
* rc.69): 8-writer contention bursts on a loaded CI runner exceed 10
|
|
1660
|
-
* attempts (500ms), and a fail-loud throw was observed instead of a clean
|
|
1661
|
-
* serialization.
|
|
1662
|
-
* 0.3.17 (E-8): acquisition and the task are now SEPARATE try blocks — a
|
|
1663
|
-
* task error (win32 rename/EBUSY surfaces as EPERM) used to be mistaken for
|
|
1664
|
-
* lock contention, retried up to 40x and finally reported as
|
|
1665
|
-
* "could not acquire write lock" while the real cause was hidden.
|
|
1666
|
-
* 0.3.17 (E-8a): the takeover threshold (1000ms) now fits INSIDE the ~2s
|
|
1667
|
-
* retry budget (budget >= 2 x threshold), so a dead holder's lock is
|
|
1668
|
-
* actually recoverable within one budget instead of being arithmetically
|
|
1669
|
-
* unreachable.
|
|
1670
|
-
* 0.3.21 (F-101), V4-04: takeover re-reads the lock right before acting and
|
|
1671
|
-
* only proceeds when the content still names the dead pid — a peer that
|
|
1672
|
-
* acquired the lock after our stale probe wrote its own pid, and deleting a
|
|
1673
|
-
* LIVE lock is the double-hold (concurrent task) the probe must prevent. The
|
|
1674
|
-
* re-read is necessary but not sufficient: two peers can both pass it, so the
|
|
1675
|
-
* commit itself is an atomic rename to a unique name (only one peer's rename
|
|
1676
|
-
* can succeed; the loser's source is gone). A naive rm lets the later peer
|
|
1677
|
-
* delete the winner's freshly re-acquired live lock.
|
|
1678
|
-
* 0.3.21 (F-367), V4-05: a self-pid lock is recycled ONLY when the failed
|
|
1679
|
-
* release was recorded in pendingSelfCleanup — never on age alone, because an
|
|
1680
|
-
* mtime over 1s is indistinguishable from a long task still executing in this
|
|
1681
|
-
* process, and recycling that live lock would double-hold it. A failure to
|
|
1682
|
-
* release in finally is recorded so the next write self-heals.
|
|
1683
|
-
* V10-07 (P2-1): a third takeover shape — a TORN body (non-empty, no
|
|
1684
|
-
* parseable pid) — is taken over after a wide 1h threshold with a
|
|
1685
|
-
* console.warn; before this branch such a lock blocked every future writer
|
|
1686
|
-
* forever.
|
|
1687
|
-
* V27 G0.2 (EVO-IO-01): the CONTRACT for `task` is therefore: commit only
|
|
1688
|
-
* after calling the `assertOwned` callback it receives, immediately before the
|
|
1689
|
-
* rename (or delete) that publishes the result. A takeover may reclaim a lock
|
|
1690
|
-
* whose holder is alive — an empty body cannot be attributed to a pid, and a
|
|
1691
|
-
* peer can read a stale stat — and the holder cannot see it from the handle it
|
|
1692
|
-
* opened (on POSIX its write lands on the UNLINKED inode). With the guard, a
|
|
1693
|
-
* reclaimed claim aborts the RMW (`LostWriteLock`, converted into a retry
|
|
1694
|
-
* here) instead of overwriting the current holder's result; without it, two
|
|
1695
|
-
* writers commit and one update is silently lost.
|
|
1554
|
+
* G2 S4: the family write-lock protocol is retired. Concurrency is the PLATFORM’s job now — the
|
|
1555
|
+
* plugin path requires `ctx.fs`, whose writes are versioned — so this backend keeps exactly one
|
|
1556
|
+
* guarantee of its own: writes to the SAME path inside THIS process are serialized (a promise
|
|
1557
|
+
* chain per path: no timers, no lock files, no takeover windows). Cross-process RMW safety comes
|
|
1558
|
+
* from the provider; a standalone caller sharing a directory across processes must not rely on
|
|
1559
|
+
* this backend alone. The durable tmp + rename commit stays — crash atomicity and mutual
|
|
1560
|
+
* exclusion are different concerns.
|
|
1696
1561
|
*/
|
|
1697
|
-
const
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
if (code === "EEXIST" || code === "EPERM") {} else {
|
|
1707
|
-
await rm(lock, { force: true }).catch(() => {});
|
|
1708
|
-
throw error;
|
|
1709
|
-
}
|
|
1710
|
-
try {
|
|
1711
|
-
const st = await stat(lock);
|
|
1712
|
-
const holderRead = await readFile(lock, "utf8").then((body) => ({
|
|
1713
|
-
ok: true,
|
|
1714
|
-
body
|
|
1715
|
-
}), () => ({
|
|
1716
|
-
ok: false,
|
|
1717
|
-
body: ""
|
|
1718
|
-
}));
|
|
1719
|
-
if (!holderRead.ok) continue;
|
|
1720
|
-
const holderContent = holderRead.body;
|
|
1721
|
-
const holder = Number(holderContent.split(":")[0] ?? "");
|
|
1722
|
-
if (holder === process.pid && pendingSelfCleanup.has(lock)) {
|
|
1723
|
-
const token = pendingSelfCleanup.get(lock);
|
|
1724
|
-
if (await readFile(lock, "utf8").catch(() => "") === token) try {
|
|
1725
|
-
await rm(lock, { force: true });
|
|
1726
|
-
pendingSelfCleanup.delete(lock);
|
|
1727
|
-
} catch {}
|
|
1728
|
-
continue;
|
|
1729
|
-
}
|
|
1730
|
-
const decision = decideTakeover({
|
|
1731
|
-
body: holderContent,
|
|
1732
|
-
mtimeMs: st.mtimeMs,
|
|
1733
|
-
alive: isAlive
|
|
1734
|
-
});
|
|
1735
|
-
if (decision !== "none") {
|
|
1736
|
-
console.warn(`evolution-io: taking over write lock ${lock} (branch=stale${decision[0]?.toUpperCase()}${decision.slice(1)}, body=${JSON.stringify(holderContent)}, ageMs=${Date.now() - st.mtimeMs}, holderPid=${Number.isInteger(holder) && holder > 0 ? holder : "none"})`);
|
|
1737
|
-
const currentRead = await readFile(lock, "utf8").then((body) => ({
|
|
1738
|
-
ok: true,
|
|
1739
|
-
body
|
|
1740
|
-
}), () => ({
|
|
1741
|
-
ok: false,
|
|
1742
|
-
body: ""
|
|
1743
|
-
}));
|
|
1744
|
-
if (!currentRead.ok) continue;
|
|
1745
|
-
if (currentRead.body === holderContent) {
|
|
1746
|
-
const ticket = `${lock}.next`;
|
|
1747
|
-
try {
|
|
1748
|
-
const ticketBody = await readFile(ticket, "utf8").catch(() => "");
|
|
1749
|
-
const ticketMtime = await stat(ticket).then((s) => s.mtimeMs, () => 0);
|
|
1750
|
-
const ticketHolder = Number(ticketBody.split(":")[0] ?? "");
|
|
1751
|
-
if ((!Number.isInteger(ticketHolder) || ticketHolder <= 0 || !isAlive(ticketHolder) || Date.now() - ticketMtime > TICKET_STALE_MS) && (ticketBody !== "" || Date.now() - ticketMtime > TICKET_STALE_MS)) await rm(ticket, { force: true }).catch(() => {});
|
|
1752
|
-
} catch {}
|
|
1753
|
-
try {
|
|
1754
|
-
await writeFile(ticket, `${process.pid}:${randomBytes(4).toString("hex")}`, { flag: "wx" });
|
|
1755
|
-
} catch {
|
|
1756
|
-
continue;
|
|
1757
|
-
}
|
|
1758
|
-
const verifyRead = await readFile(lock, "utf8").then((body) => ({
|
|
1759
|
-
ok: true,
|
|
1760
|
-
body
|
|
1761
|
-
}), () => ({
|
|
1762
|
-
ok: false,
|
|
1763
|
-
body: ""
|
|
1764
|
-
}));
|
|
1765
|
-
if (!verifyRead.ok) {
|
|
1766
|
-
await rm(ticket, { force: true }).catch(() => {});
|
|
1767
|
-
continue;
|
|
1768
|
-
}
|
|
1769
|
-
const verify = verifyRead.body;
|
|
1770
|
-
if (verify === holderContent) {
|
|
1771
|
-
const verifyHolder = Number(verify.split(":")[0] ?? "");
|
|
1772
|
-
if (!(Number.isInteger(verifyHolder) && verifyHolder > 0 && isAlive(verifyHolder))) await rm(lock, { force: true }).catch(() => {});
|
|
1773
|
-
}
|
|
1774
|
-
await rm(ticket, { force: true }).catch(() => {});
|
|
1775
|
-
continue;
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
} catch {
|
|
1779
|
-
continue;
|
|
1780
|
-
}
|
|
1781
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1782
|
-
continue;
|
|
1783
|
-
}
|
|
1784
|
-
const assertOwned = async () => {
|
|
1785
|
-
let body = "";
|
|
1786
|
-
let readable = false;
|
|
1787
|
-
for (let read = 0; read < OWNERSHIP_READ_ATTEMPTS && !readable; read += 1) {
|
|
1788
|
-
if (read > 0) await new Promise((resolve) => setTimeout(resolve, OWNERSHIP_READ_RETRY_MS));
|
|
1789
|
-
const attempt = await readFile(lock, "utf8").then((value) => ({
|
|
1790
|
-
ok: true,
|
|
1791
|
-
value
|
|
1792
|
-
}), () => ({
|
|
1793
|
-
ok: false,
|
|
1794
|
-
value: ""
|
|
1795
|
-
}));
|
|
1796
|
-
if (attempt.ok) {
|
|
1797
|
-
body = attempt.value;
|
|
1798
|
-
readable = true;
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
if (!readable) {
|
|
1802
|
-
console.warn(`evolution-io: write lock ${lock} could not be read at the commit point (ownership unverified, ours: ${JSON.stringify(myClaim)}) — aborting this read-modify-write and retrying under a fresh acquisition`);
|
|
1803
|
-
throw new LostWriteLock();
|
|
1804
|
-
}
|
|
1805
|
-
if (body !== myClaim) {
|
|
1806
|
-
console.warn(`evolution-io: write lock ${lock} was reclaimed by another writer before the commit (on disk now: ${JSON.stringify(body)}, ours: ${JSON.stringify(myClaim)}) — aborting this read-modify-write and retrying under a fresh acquisition`);
|
|
1807
|
-
throw new LostWriteLock();
|
|
1808
|
-
}
|
|
1809
|
-
};
|
|
1810
|
-
try {
|
|
1811
|
-
return await task(assertOwned);
|
|
1812
|
-
} catch (error) {
|
|
1813
|
-
if (error instanceof LostWriteLock) continue;
|
|
1814
|
-
throw error;
|
|
1815
|
-
} finally {
|
|
1816
|
-
const mine = await readFile(lock, "utf8").then((body) => ({
|
|
1817
|
-
ok: true,
|
|
1818
|
-
body
|
|
1819
|
-
}), (error) => ({
|
|
1820
|
-
ok: false,
|
|
1821
|
-
missing: isMissing(error)
|
|
1822
|
-
}));
|
|
1823
|
-
if (!mine.ok) {
|
|
1824
|
-
if (!mine.missing) await recordPendingSelfCleanup(lock, myClaim);
|
|
1825
|
-
} else if (mine.body === myClaim) await rm(lock, {
|
|
1826
|
-
force: true,
|
|
1827
|
-
maxRetries: 20,
|
|
1828
|
-
retryDelay: 100
|
|
1829
|
-
}).catch(() => recordPendingSelfCleanup(lock, myClaim));
|
|
1830
|
-
}
|
|
1562
|
+
const queues = /* @__PURE__ */ new Map();
|
|
1563
|
+
const inProcessWrite = async (path, run) => {
|
|
1564
|
+
const result = (queues.get(path) ?? Promise.resolve()).then(run, run);
|
|
1565
|
+
const settled = result.then(() => {}, () => {});
|
|
1566
|
+
queues.set(path, settled);
|
|
1567
|
+
try {
|
|
1568
|
+
return await result;
|
|
1569
|
+
} finally {
|
|
1570
|
+
if (queues.get(path) === settled) queues.delete(path);
|
|
1831
1571
|
}
|
|
1832
|
-
throw new Error(`could not acquire write lock for ${path} after ${lockAttempts} attempts`);
|
|
1833
1572
|
};
|
|
1834
|
-
/**
|
|
1835
|
-
*
|
|
1836
|
-
*
|
|
1837
|
-
*
|
|
1573
|
+
/**
|
|
1574
|
+
* Sweep transient files a crashed writer left behind, lazily: once per write, for the directory
|
|
1575
|
+
* that write is about to touch.
|
|
1576
|
+
* `<target>.<pid>.<rand>.tmp` — the durable-write tmp. This process's own leftover is recycled
|
|
1577
|
+
* immediately (a live writer's tmp is a current write, not a crash artifact); a foreign tmp
|
|
1578
|
+
* needs an age past an hour AND a dead holder.
|
|
1579
|
+
* `.corrupt*` — quarantine copies past their rescue window.
|
|
1580
|
+
* G2 S4: the lock and takeover-ticket branches are gone with the write-lock protocol.
|
|
1581
|
+
*/
|
|
1838
1582
|
const sweepStaleTmps = async (path) => {
|
|
1839
1583
|
const dir = dirname(path);
|
|
1840
1584
|
const base = basename(path);
|
|
@@ -1844,12 +1588,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1844
1588
|
} catch {
|
|
1845
1589
|
return;
|
|
1846
1590
|
}
|
|
1847
|
-
const prefix = `${base}.`;
|
|
1848
|
-
const lockName = `${base}${LOCK_SUFFIX}`;
|
|
1849
|
-
const ticketName = `${lockName}.next`;
|
|
1850
|
-
const CORRUPT_SWEEP_AGE_MS = 168 * 36e5;
|
|
1851
1591
|
for (const name of entries) {
|
|
1852
|
-
if (!name.startsWith(prefix) || name === lockName) continue;
|
|
1853
1592
|
const tmpMatch = /^(.*)\.(\d+)\.([0-9a-f]+)\.tmp$/.exec(name);
|
|
1854
1593
|
if (tmpMatch !== null && tmpMatch[1] === base) {
|
|
1855
1594
|
const tmpPath = join(dir, name);
|
|
@@ -1861,19 +1600,7 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1861
1600
|
} catch {}
|
|
1862
1601
|
continue;
|
|
1863
1602
|
}
|
|
1864
|
-
if (name
|
|
1865
|
-
const ticketPath = join(dir, name);
|
|
1866
|
-
try {
|
|
1867
|
-
const body = await readFile(ticketPath, "utf8").catch(() => "");
|
|
1868
|
-
const holder = Number(body.split(":")[0] ?? "");
|
|
1869
|
-
const st = await stat(ticketPath);
|
|
1870
|
-
const dead = !Number.isInteger(holder) || holder <= 0 || !isAlive(holder);
|
|
1871
|
-
const old = Date.now() - st.mtimeMs > TICKET_STALE_MS;
|
|
1872
|
-
if (dead || old) await rm(ticketPath, { force: true });
|
|
1873
|
-
} catch {}
|
|
1874
|
-
continue;
|
|
1875
|
-
}
|
|
1876
|
-
if (CORRUPT_COPY_RE.test(name)) {
|
|
1603
|
+
if (name.startsWith(base + ".") && CORRUPT_COPY_RE.test(name)) {
|
|
1877
1604
|
const corruptPath = join(dir, name);
|
|
1878
1605
|
try {
|
|
1879
1606
|
const st = await stat(corruptPath);
|
|
@@ -1893,16 +1620,14 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1893
1620
|
},
|
|
1894
1621
|
async writeText(path, content) {
|
|
1895
1622
|
await mkdir(dirname(path), { recursive: true });
|
|
1896
|
-
await
|
|
1623
|
+
await inProcessWrite(path, async () => {
|
|
1897
1624
|
await sweepStaleTmps(path);
|
|
1898
|
-
|
|
1899
|
-
await assertOwned();
|
|
1900
|
-
await commitTmp(tmp, path);
|
|
1625
|
+
await commitTmp(await writeDurableTmp(path, content), path);
|
|
1901
1626
|
});
|
|
1902
1627
|
},
|
|
1903
1628
|
async transact(path, task) {
|
|
1904
1629
|
await mkdir(dirname(path), { recursive: true });
|
|
1905
|
-
await
|
|
1630
|
+
await inProcessWrite(path, async () => {
|
|
1906
1631
|
await sweepStaleTmps(path);
|
|
1907
1632
|
let current;
|
|
1908
1633
|
try {
|
|
@@ -1913,14 +1638,11 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1913
1638
|
}
|
|
1914
1639
|
const next = await task(current);
|
|
1915
1640
|
if (next === null) {
|
|
1916
|
-
await assertOwned();
|
|
1917
1641
|
await rm(path, { force: true });
|
|
1918
1642
|
return;
|
|
1919
1643
|
}
|
|
1920
1644
|
if (next === current) return;
|
|
1921
|
-
|
|
1922
|
-
await assertOwned();
|
|
1923
|
-
await commitTmp(tmp, path);
|
|
1645
|
+
await commitTmp(await writeDurableTmp(path, next), path);
|
|
1924
1646
|
});
|
|
1925
1647
|
},
|
|
1926
1648
|
async remove(path) {
|
|
@@ -1983,6 +1705,121 @@ function nodeEvolutionIo(lockAttempts = 40) {
|
|
|
1983
1705
|
}
|
|
1984
1706
|
};
|
|
1985
1707
|
}
|
|
1708
|
+
const defaultFs = {
|
|
1709
|
+
writeFileSync: (path, data) => {
|
|
1710
|
+
writeFileSync(path, data);
|
|
1711
|
+
},
|
|
1712
|
+
existsSync: (path) => existsSync(path),
|
|
1713
|
+
copyFileSync: (from, to) => {
|
|
1714
|
+
copyFileSync(from, to);
|
|
1715
|
+
},
|
|
1716
|
+
renameSync: (from, to) => {
|
|
1717
|
+
renameSync(from, to);
|
|
1718
|
+
},
|
|
1719
|
+
rmSync: (path, options) => {
|
|
1720
|
+
rmSync(path, options);
|
|
1721
|
+
},
|
|
1722
|
+
mtime: (path) => {
|
|
1723
|
+
try {
|
|
1724
|
+
return statSync(path).mtimeMs;
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
if (isMissingPath(error)) return null;
|
|
1727
|
+
throw error;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
/**
|
|
1732
|
+
* S6.3 (E-40): atomically replace a set of files within one directory.
|
|
1733
|
+
* Each file is staged to a sibling `<name>.tmp`, then renamed into place
|
|
1734
|
+
* (atomic on the same filesystem); a pre-existing file keeps a single
|
|
1735
|
+
* `<name>.bak`. A failure at ANY staging step removes the staged temps and
|
|
1736
|
+
* leaves the previous files untouched — a half-written target is impossible
|
|
1737
|
+
* up to the staging and backup phases.
|
|
1738
|
+
*
|
|
1739
|
+
* F-211: the rename COMMIT phase does not carry the same guarantee. When a
|
|
1740
|
+
* filesystem refuses to overwrite the destination, the code removes it and
|
|
1741
|
+
* retries the rename; if the retry ALSO fails the destination is gone. The
|
|
1742
|
+
* single `.bak` created above still holds the previous content, so the target
|
|
1743
|
+
* is restored from it (best-effort) before the original rename error is
|
|
1744
|
+
* rethrown, so a failed commit never leaves the file missing.
|
|
1745
|
+
*
|
|
1746
|
+
* V4-17: the `.bak` is refreshed to the committed content after every
|
|
1747
|
+
* successful commit, so a later failed commit always recovers to the most
|
|
1748
|
+
* recent good generation (a once-at-first-install `.bak` could otherwise
|
|
1749
|
+
* restore content several installs old). The recovery error names the `.bak`
|
|
1750
|
+
* source (and mtime when available) so the caller knows the generation it
|
|
1751
|
+
* restored from.
|
|
1752
|
+
*/
|
|
1753
|
+
function atomicWriteFiles(targetDir, writes, fs = defaultFs, logger = console.warn) {
|
|
1754
|
+
const stage = (name) => join(targetDir, `${name}.tmp`);
|
|
1755
|
+
const dupes = [...new Set(writes.map((write) => write.name).filter((name, index, all) => all.indexOf(name) !== index))];
|
|
1756
|
+
if (dupes.length > 0) throw new Error(`atomicWriteFiles: duplicate input names: ${dupes.join(", ")}`);
|
|
1757
|
+
try {
|
|
1758
|
+
for (const { name, content } of writes) fs.writeFileSync(stage(name), content);
|
|
1759
|
+
for (const { name } of writes) {
|
|
1760
|
+
const finalPath = join(targetDir, name);
|
|
1761
|
+
const bakPath = join(targetDir, `${name}.bak`);
|
|
1762
|
+
if (fs.existsSync(finalPath) && !fs.existsSync(bakPath)) fs.copyFileSync(finalPath, bakPath);
|
|
1763
|
+
}
|
|
1764
|
+
const committed = [];
|
|
1765
|
+
for (const { name } of writes) {
|
|
1766
|
+
const finalPath = join(targetDir, name);
|
|
1767
|
+
const bakPath = join(targetDir, `${name}.bak`);
|
|
1768
|
+
const tmp = stage(name);
|
|
1769
|
+
try {
|
|
1770
|
+
fs.renameSync(tmp, finalPath);
|
|
1771
|
+
committed.push(name);
|
|
1772
|
+
} catch {
|
|
1773
|
+
try {
|
|
1774
|
+
fs.rmSync(finalPath, { force: true });
|
|
1775
|
+
} catch (removeError) {
|
|
1776
|
+
const already = committed.length > 0 ? `; already committed: ${committed.join(", ")}` : "";
|
|
1777
|
+
throw new Error(`atomicWriteFiles: could not remove "${name}" to replace it (${removeError instanceof Error ? removeError.message : String(removeError)})${already}`);
|
|
1778
|
+
}
|
|
1779
|
+
try {
|
|
1780
|
+
fs.renameSync(tmp, finalPath);
|
|
1781
|
+
committed.push(name);
|
|
1782
|
+
} catch (renameError) {
|
|
1783
|
+
const already = committed.length > 0 ? `; already committed: ${committed.join(", ")}` : "";
|
|
1784
|
+
if (fs.existsSync(bakPath)) {
|
|
1785
|
+
try {
|
|
1786
|
+
fs.copyFileSync(bakPath, finalPath);
|
|
1787
|
+
} catch {
|
|
1788
|
+
throw new Error(`atomicWriteFiles: "${name}" was removed but recovery from ${bakPath} failed (${renameError instanceof Error ? renameError.message : String(renameError)}); verify the file manually${already}`);
|
|
1789
|
+
}
|
|
1790
|
+
const bakMtime = fs.mtime?.(bakPath);
|
|
1791
|
+
const when = bakMtime !== null && bakMtime !== void 0 && Number.isFinite(bakMtime) ? ` (bak mtime ${new Date(bakMtime).toISOString()})` : "";
|
|
1792
|
+
throw new Error(`atomicWriteFiles: "${name}" was removed during commit and restored from the last good generation ${bakPath}${when}; original error: ${renameError instanceof Error ? renameError.message : String(renameError)}${already}`);
|
|
1793
|
+
}
|
|
1794
|
+
throw new Error(`${renameError instanceof Error ? renameError.message : String(renameError)}${already}`);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
for (const { name } of writes) {
|
|
1799
|
+
const finalPath = join(targetDir, name);
|
|
1800
|
+
try {
|
|
1801
|
+
if (fs.existsSync(finalPath)) fs.copyFileSync(finalPath, join(targetDir, `${name}.bak`));
|
|
1802
|
+
} catch (refreshError) {
|
|
1803
|
+
logger(`atomicWriteFiles: committed "${name}" but failed to refresh its .bak (${refreshError instanceof Error ? refreshError.message : String(refreshError)}); a later failed commit will recover to an OLDER generation`);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
const cleanupFailures = [];
|
|
1808
|
+
for (const { name } of writes) try {
|
|
1809
|
+
fs.rmSync(stage(name), {
|
|
1810
|
+
force: true,
|
|
1811
|
+
recursive: true
|
|
1812
|
+
});
|
|
1813
|
+
} catch (cleanupError) {
|
|
1814
|
+
cleanupFailures.push(`${name}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
1815
|
+
}
|
|
1816
|
+
if (cleanupFailures.length > 0) {
|
|
1817
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
1818
|
+
throw new Error(`${original} (staged-temp cleanup also failed: ${cleanupFailures.join("; ")})`);
|
|
1819
|
+
}
|
|
1820
|
+
throw error;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1986
1823
|
//#endregion
|
|
1987
1824
|
//#region lib/types/usage.js
|
|
1988
1825
|
/**
|
|
@@ -2093,7 +1930,7 @@ async function mutateUsage(root, io, task, options = {}) {
|
|
|
2093
1930
|
let parsed = null;
|
|
2094
1931
|
try {
|
|
2095
1932
|
const probe = JSON.parse(current);
|
|
2096
|
-
if (
|
|
1933
|
+
if (isRecord(probe)) parsed = probe;
|
|
2097
1934
|
} catch {
|
|
2098
1935
|
parsed = null;
|
|
2099
1936
|
}
|
|
@@ -3221,7 +3058,7 @@ async function readJsonBody(req) {
|
|
|
3221
3058
|
*/
|
|
3222
3059
|
async function readJsonObject(req) {
|
|
3223
3060
|
const body = await readJsonBody(req);
|
|
3224
|
-
return
|
|
3061
|
+
return isRecord(body) ? body : null;
|
|
3225
3062
|
}
|
|
3226
3063
|
//#endregion
|
|
3227
3064
|
//#region lib/types/env.js
|
|
@@ -3252,7 +3089,7 @@ function allowRowCollisions(env = process.env) {
|
|
|
3252
3089
|
//#endregion
|
|
3253
3090
|
//#region lib/types/errors.js
|
|
3254
3091
|
/**
|
|
3255
|
-
* THE error-code table: E-301…E-
|
|
3092
|
+
* THE error-code table: E-301…E-322 with the exact text each one emits (E-317…E-319 are the
|
|
3256
3093
|
* write-admission gates' own refusals).
|
|
3257
3094
|
*
|
|
3258
3095
|
* The codes used to be spelled out at 33 call sites across two packages, so one
|
|
@@ -3289,6 +3126,7 @@ const EVOLUTION_ERRORS = Object.freeze({
|
|
|
3289
3126
|
"e-317-skill-write-not-confirmed": "E-317: skill \"%a1%\" was not %a2% — the confirmation prompt was declined or dismissed, so nothing was written. The prompt appears on every create and bare delete; repeat the call only if the operator asks for it.",
|
|
3290
3127
|
"e-318-skill-write-without-a-read": "E-318: skill \"%a1%\" was not read in this session, so this write is refused. Read it with the `skill` tool first (a read that failed does not count), then repeat the write.",
|
|
3291
3128
|
"e-320-the-profile-home-is-not": "E-320: the profile home is not reported by this host, so the legacy settings document cannot be located. /evolution migrate reads `<profile home>/settings.yaml.imported`; run it from a profile-launched session.",
|
|
3129
|
+
"e-322-a-parameter-would-break-a": "E-322: %a1% must be at least %a2%, but this write sets it to %a3%. Nothing was written.",
|
|
3292
3130
|
"e-321-no-io-provider-is": "E-321: no evolution-io provider is mounted, so the legacy settings document cannot be read. Mount the evolution-io row (evolution-host/evolution-all) and retry.",
|
|
3293
3131
|
"e-319-skill-write-confirm-timed-out": "E-319: skill \"%a1%\" was not %a2% — the confirmation prompt went unanswered for %a3% second(s), so the write was cancelled. Nobody was there to decide: set `skillWriteConfirm` to `auto` to write without a prompt, keep `ask` to wait until someone answers, or raise `skillWriteConfirmTimeoutSeconds`."
|
|
3294
3132
|
});
|
|
@@ -4532,7 +4370,7 @@ async function recordMutation(root, io, record, cap = 500) {
|
|
|
4532
4370
|
console.warn(`mutation audit record dropped: ${mutationsFile(root)} is malformed and was not overwritten`);
|
|
4533
4371
|
return current;
|
|
4534
4372
|
}
|
|
4535
|
-
if (
|
|
4373
|
+
if (isRecord(parsed)) {
|
|
4536
4374
|
const version = parsed.version;
|
|
4537
4375
|
if (typeof version === "number" && version > 1) {
|
|
4538
4376
|
console.warn(`mutation audit record dropped: ${mutationsFile(root)} declares version ${version} (newer than 1); not overwritten`);
|
|
@@ -4596,16 +4434,23 @@ function composePresetComposition(standardComposition, deltaComposition) {
|
|
|
4596
4434
|
* test can keep — the entries are now data, and a divergence is impossible.
|
|
4597
4435
|
*/
|
|
4598
4436
|
const ROW_OVERRIDES_URL = new URL("../row-overrides.json", import.meta.url);
|
|
4599
|
-
|
|
4437
|
+
/** One cache per PATH: the installer reads the same file through its own root, and a single-slot
|
|
4438
|
+
* cache would hand the second caller the first caller's bytes. */
|
|
4439
|
+
const overridesCache = /* @__PURE__ */ new Map();
|
|
4600
4440
|
/**
|
|
4601
4441
|
* Read and validate the shared override table.
|
|
4442
|
+
* G6.1/M1: the path is a PARAMETER (default: the table beside this module). The source installer
|
|
4443
|
+
* runs the generated port from `scripts/`, so its own root has to be passed in — a module-relative
|
|
4444
|
+
* read would look for `scripts/../row-overrides.json` and fail.
|
|
4445
|
+
* @param path - the table to read.
|
|
4602
4446
|
* @returns the entries, in file order. A malformed table fails LOUD: a composer
|
|
4603
4447
|
* that silently skipped an entry would ship a preset running the platform
|
|
4604
4448
|
* default while nothing downstream reported it.
|
|
4605
4449
|
*/
|
|
4606
|
-
function loadRowOverrides() {
|
|
4607
|
-
|
|
4608
|
-
|
|
4450
|
+
function loadRowOverrides(path = fileURLToPath(ROW_OVERRIDES_URL)) {
|
|
4451
|
+
const cached = overridesCache.get(path);
|
|
4452
|
+
if (cached !== void 0) return cached;
|
|
4453
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
4609
4454
|
if (!Array.isArray(parsed)) throw new Error("evolution-core: row-overrides.json must be an array of override entries");
|
|
4610
4455
|
const entries = [];
|
|
4611
4456
|
for (const [index, entry] of parsed.entries()) {
|
|
@@ -4618,7 +4463,7 @@ function loadRowOverrides() {
|
|
|
4618
4463
|
missingReason: candidate.missingReason
|
|
4619
4464
|
});
|
|
4620
4465
|
}
|
|
4621
|
-
|
|
4466
|
+
overridesCache.set(path, entries);
|
|
4622
4467
|
return entries;
|
|
4623
4468
|
}
|
|
4624
4469
|
/**
|
|
@@ -4737,7 +4582,7 @@ function composePresetRow(baseComposition, deltaComposition, identity) {
|
|
|
4737
4582
|
}
|
|
4738
4583
|
/** The patch-entry key that ADDS a row: a plain entry overrides the row with the same id, so a
|
|
4739
4584
|
* preset the platform does not ship has to arrive inside an `insert` entry
|
|
4740
|
-
* (`
|
|
4585
|
+
* (the platform's own `user-patches.spec.ts` proves the override-vs-insert split). */
|
|
4741
4586
|
const PATCH_INSERT_KEY = "insert";
|
|
4742
4587
|
/**
|
|
4743
4588
|
* Wrap a composed row as the patch entry a profile patch needs.
|
|
@@ -6411,7 +6256,7 @@ function renderLongRunTable() {
|
|
|
6411
6256
|
/**
|
|
6412
6257
|
* Every platform service the family reaches for with `ctx.get`, and what degrades without it.
|
|
6413
6258
|
*
|
|
6414
|
-
* O-2 (v46 audit): the family probes
|
|
6259
|
+
* O-2 (v46 audit): the family probes 28 platform services optionally — the platform has no
|
|
6415
6260
|
* formalized "optional dependency", so an ABSENT service is a silent downgrade and the diagnostic
|
|
6416
6261
|
* surface is the only place a reader can learn about it. `/evolution doctor` used to judge six of
|
|
6417
6262
|
* them; this table is the single source it judges the rest from, and the arch guard cross-checks it
|
|
@@ -6421,7 +6266,7 @@ function renderLongRunTable() {
|
|
|
6421
6266
|
* `client` probes run in the browser half, where the host has no view of the seats.
|
|
6422
6267
|
* @module @lmzhen/dsh-evolution-core
|
|
6423
6268
|
*/
|
|
6424
|
-
/** The
|
|
6269
|
+
/** The 28 probes, in service-name order (the guard checks this list against the tree). */
|
|
6425
6270
|
const PLATFORM_SERVICE_PROBES = Object.freeze([
|
|
6426
6271
|
{
|
|
6427
6272
|
service: "agents",
|
|
@@ -6458,6 +6303,16 @@ const PLATFORM_SERVICE_PROBES = Object.freeze([
|
|
|
6458
6303
|
feature: "every durable read and write (the IO seam)",
|
|
6459
6304
|
side: "host"
|
|
6460
6305
|
},
|
|
6306
|
+
{
|
|
6307
|
+
service: "fs",
|
|
6308
|
+
feature: "the platform filesystem the evolution-io-fs backend maps the seam onto (locks + writeText(expected) version CAS)",
|
|
6309
|
+
side: "host"
|
|
6310
|
+
},
|
|
6311
|
+
{
|
|
6312
|
+
service: "hmr",
|
|
6313
|
+
feature: "the preset install read-merge-write commit (the platform HMR lock serializes it)",
|
|
6314
|
+
side: "host"
|
|
6315
|
+
},
|
|
6461
6316
|
{
|
|
6462
6317
|
service: "evolutionMemoryBudget",
|
|
6463
6318
|
feature: "the memory-budget divergence section of this report",
|
|
@@ -8007,6 +7862,13 @@ function versionRowCells(row) {
|
|
|
8007
7862
|
tone: "tertiary",
|
|
8008
7863
|
slot: "meta"
|
|
8009
7864
|
});
|
|
7865
|
+
if (row.hash !== void 0 && row.hash !== "") title.push({
|
|
7866
|
+
key: "hash",
|
|
7867
|
+
literal: row.hash.slice(0, 7),
|
|
7868
|
+
tone: "tertiary",
|
|
7869
|
+
slot: "meta",
|
|
7870
|
+
note: row.hash
|
|
7871
|
+
});
|
|
8010
7872
|
const meta = [];
|
|
8011
7873
|
if (row.summary !== void 0 && row.summary !== "") meta.push({
|
|
8012
7874
|
key: "summary",
|
|
@@ -8055,6 +7917,13 @@ function skillRowCells(skill) {
|
|
|
8055
7917
|
tone: "tertiary",
|
|
8056
7918
|
slot: "meta"
|
|
8057
7919
|
}];
|
|
7920
|
+
if (skill.protectedBy !== null) meta.push({
|
|
7921
|
+
key: "protectedBy",
|
|
7922
|
+
literal: skill.protectedBy,
|
|
7923
|
+
tone: "tertiary",
|
|
7924
|
+
slot: "meta",
|
|
7925
|
+
note: skill.protectedBy
|
|
7926
|
+
});
|
|
8058
7927
|
if (skill.age !== null) meta.push({
|
|
8059
7928
|
key: "age",
|
|
8060
7929
|
copy: ageCopyKey(skill.age),
|
|
@@ -9002,7 +8871,7 @@ function elapsedSince(at, nowMs) {
|
|
|
9002
8871
|
* itself in `this.serial` (archive/restoreFromArchive deliberately do not).
|
|
9003
8872
|
* 2. **Per-directory write lock** (io.ts LOCK_*) — cross-process mutual
|
|
9004
8873
|
* exclusion plus in-process crash ownership (tickets, takeover). Checked
|
|
9005
|
-
* with `
|
|
8874
|
+
* with `supportDirsUnlistable` before any destructive move (archive/restore/
|
|
9006
8875
|
* snapshot); held inside transactIo by byte writers.
|
|
9007
8876
|
* 3. **CAS baseline (`expected:`)** — any read whose bytes feed a later write
|
|
9008
8877
|
* must either live inside the serial section that commits the write, or
|
|
@@ -9239,15 +9108,6 @@ function skillDir(root, name) {
|
|
|
9239
9108
|
function markerEntryName(marker) {
|
|
9240
9109
|
return `.${marker}`;
|
|
9241
9110
|
}
|
|
9242
|
-
/** F-17 (v18): the root-level lock files a DESTRUCTIVE MOVER must treat as an
|
|
9243
|
-
* active writer (skill body + the two marker writers). Single source with
|
|
9244
|
-
* `markerEntryName`/`LOCK_SUFFIX` so a renamed marker cannot silently drop out
|
|
9245
|
-
* of the ghost-writer probe. */
|
|
9246
|
-
const MARKER_LOCK_NAMES = [
|
|
9247
|
-
`SKILL.md${LOCK_SUFFIX}`,
|
|
9248
|
-
`.pinned${LOCK_SUFFIX}`,
|
|
9249
|
-
`.hermes-managed${LOCK_SUFFIX}`
|
|
9250
|
-
];
|
|
9251
9111
|
/** v23 (ML-1): `.archive` retention window (see pruneExpiredArchives). */
|
|
9252
9112
|
const ARCHIVE_RETENTION_DAYS = 365;
|
|
9253
9113
|
function markerPath(dir, marker) {
|
|
@@ -9326,7 +9186,6 @@ function validateSupportPath(filePath) {
|
|
|
9326
9186
|
if (parts.length < 2) return "Provide a file name, not just a directory.";
|
|
9327
9187
|
for (const part of parts.slice(1)) {
|
|
9328
9188
|
if (!SUPPORT_FILE_NAME_RE.test(part)) return `Unsupported file name "${part}" — use lowercase letters, digits, dots, hyphens, and underscores (leading letter or digit).`;
|
|
9329
|
-
if (part.toLowerCase().endsWith(".lock")) return `Unsupported file name "${part}" — the .lock suffix is reserved for the writer-lock protocol.`;
|
|
9330
9189
|
if (part.toLowerCase().endsWith(".corrupt") || part.toLowerCase().endsWith(".tmp")) return `Unsupported file name "${part}" — the .corrupt/.tmp suffixes are reserved for the state/IO protocols.`;
|
|
9331
9190
|
const stem = part.split(".")[0]?.toLowerCase() ?? "";
|
|
9332
9191
|
if (WIN32_RESERVED_DEVICE_NAMES.has(stem)) return `Unsupported file name "${part}" — a Windows reserved device name.`;
|
|
@@ -9506,16 +9365,14 @@ var SkillLibrary = class {
|
|
|
9506
9365
|
* found no SKILL.md — remove the possibly-resurrected directory ONLY when it
|
|
9507
9366
|
* holds nothing but this path's own write-lock file. The BR-5 rule from
|
|
9508
9367
|
* setPinnedCore/createCore applies unchanged: a concurrent mover can land a
|
|
9509
|
-
* full directory between the list probe and the remove, so
|
|
9510
|
-
*
|
|
9511
|
-
* away. Best-effort: a failed cleanup leaves the "not found" result
|
|
9368
|
+
* full directory between the list probe and the remove, so ANY content at all (support files, a
|
|
9369
|
+
* fresh restore) must never be recursed away. Best-effort: a failed cleanup leaves the "not found" result
|
|
9512
9370
|
* unchanged (the operator-facing ghost-dir refusal is fail-loud already).
|
|
9513
9371
|
*/
|
|
9514
9372
|
async cleanupGhostDir(skillFilePath) {
|
|
9515
9373
|
const dir = dirname(skillFilePath);
|
|
9516
|
-
const lockName = `${basename(skillFilePath)}${LOCK_SUFFIX}`;
|
|
9517
9374
|
try {
|
|
9518
|
-
if ((await this.io.list(dir)).
|
|
9375
|
+
if ((await this.io.list(dir)).length > 0) return;
|
|
9519
9376
|
await this.io.remove(dir);
|
|
9520
9377
|
} catch {}
|
|
9521
9378
|
}
|
|
@@ -10380,110 +10237,19 @@ var SkillLibrary = class {
|
|
|
10380
10237
|
* P2 (v16): the write-lock probe for the DESTRUCTIVE MOVERS (archive /
|
|
10381
10238
|
* restoreFromArchive). A byte-writer mid-flight is the ghost-generator —
|
|
10382
10239
|
* after the move its transact commit re-creates `<dir>/…` (mkdir
|
|
10383
|
-
* recursive) and the tree ends half-archived.
|
|
10384
|
-
*
|
|
10385
|
-
*
|
|
10386
|
-
*
|
|
10387
|
-
*
|
|
10388
|
-
* and restore would later move the residue back into the live root).
|
|
10389
|
-
* Coverage: `SKILL.md.lock` (update/patch of the body) plus one level of
|
|
10390
|
-
* each support dir (write_file's lock sits next to its file). Residual:
|
|
10391
|
-
* NESTED support-subdir locks and the probe→rename TOCTOU itself remain
|
|
10392
|
-
* fail-safe (renameWithRetry rides the write out; the writer's locked
|
|
10393
|
-
* re-read refuses on the moved-away file). v37 (P2-8): a residue `.lock` whose
|
|
10394
|
-
* holder is GONE is not a writer — `decideTakeover` owns that verdict.
|
|
10240
|
+
* recursive) and the tree ends half-archived.
|
|
10241
|
+
* G2 S4: the writer-lock probe that used to answer this question is retired — writes go through
|
|
10242
|
+
* the provider's versioned writes, so a writer that raced the move fails its own write loudly
|
|
10243
|
+
* instead of landing on a moved-away path. What REMAINS load-bearing here is the fail-closed
|
|
10244
|
+
* listing rule, which is about the filesystem misbehaving rather than about writers.
|
|
10395
10245
|
*/
|
|
10396
|
-
async
|
|
10397
|
-
const
|
|
10398
|
-
|
|
10399
|
-
for (const supportDir of SUPPORT_DIRS) {
|
|
10400
|
-
let entries;
|
|
10401
|
-
try {
|
|
10402
|
-
entries = await this.io.list(join(dir, supportDir));
|
|
10403
|
-
} catch {
|
|
10404
|
-
return true;
|
|
10405
|
-
}
|
|
10406
|
-
for (const entry of entries) {
|
|
10407
|
-
if (!entry.endsWith(".lock")) continue;
|
|
10408
|
-
if (await this.isWriterLock(join(dir, supportDir, entry))) return true;
|
|
10409
|
-
}
|
|
10410
|
-
}
|
|
10411
|
-
return false;
|
|
10412
|
-
}
|
|
10413
|
-
/** P2 (v17): a `*.lock` file counts as a writer lock only when its body
|
|
10414
|
-
* carries the io layer's protocol — `pid:token`, a bare pid, or an empty body
|
|
10415
|
-
* (a creator between create and body write); anything else is user content
|
|
10416
|
-
* (suffix-only matching refused archiving and deleted user files on restore).
|
|
10417
|
-
* v37 (P2-8): the verdict is `decideTakeover`'s, so the mover refuses exactly
|
|
10418
|
-
* the lock the io layer refuses to reclaim, the 30s empty window included. */
|
|
10419
|
-
async isWriterLock(lockPath) {
|
|
10420
|
-
let body;
|
|
10421
|
-
try {
|
|
10422
|
-
body = await this.io.readText(lockPath);
|
|
10246
|
+
async supportDirsUnlistable(dir) {
|
|
10247
|
+
for (const supportDir of SUPPORT_DIRS) try {
|
|
10248
|
+
await this.io.list(join(dir, supportDir));
|
|
10423
10249
|
} catch {
|
|
10424
10250
|
return true;
|
|
10425
10251
|
}
|
|
10426
|
-
|
|
10427
|
-
const trimmed = body.trim();
|
|
10428
|
-
if (trimmed !== "" && !/^\d+$/.test(trimmed) && !LOCK_BODY_RE.test(trimmed)) return false;
|
|
10429
|
-
let mtimeMs = null;
|
|
10430
|
-
try {
|
|
10431
|
-
mtimeMs = await this.io.mtime?.(lockPath) ?? null;
|
|
10432
|
-
} catch {
|
|
10433
|
-
mtimeMs = null;
|
|
10434
|
-
}
|
|
10435
|
-
return decideTakeover({
|
|
10436
|
-
body,
|
|
10437
|
-
mtimeMs: mtimeMs ?? Date.now(),
|
|
10438
|
-
alive: isProcessAlive
|
|
10439
|
-
}) === "none";
|
|
10440
|
-
}
|
|
10441
|
-
/** P2 (v16): best-effort removal of lock residue inside a RESTORED tree.
|
|
10442
|
-
* A1-2/A1-7 (v18): the sweep now covers the marker locks the probe checks
|
|
10443
|
-
* (`SKILL.md.lock`/`.pinned.lock`/`.hermes-managed.lock`) and only removes
|
|
10444
|
-
* a lock whose holder pid is NOT alive — a live writer's lock is never
|
|
10445
|
-
* stolen by the sweep. A dead-pid residue would otherwise permanently
|
|
10446
|
-
* refuse archive/restore. */
|
|
10447
|
-
async deleteStrandedLocks(dir) {
|
|
10448
|
-
for (const markerLock of MARKER_LOCK_NAMES) await this.sweepLockIfStranded(join(dir, markerLock));
|
|
10449
|
-
for (const supportDir of SUPPORT_DIRS) {
|
|
10450
|
-
let entries = [];
|
|
10451
|
-
try {
|
|
10452
|
-
entries = await this.io.list(join(dir, supportDir));
|
|
10453
|
-
} catch {
|
|
10454
|
-
continue;
|
|
10455
|
-
}
|
|
10456
|
-
for (const entry of entries) if (entry.endsWith(".lock")) await this.sweepLockIfStranded(join(dir, supportDir, entry));
|
|
10457
|
-
}
|
|
10458
|
-
}
|
|
10459
|
-
/** R2 follow-up (audit of A2): the recycled-pid window from io's
|
|
10460
|
-
* `ALIVE_LOCK_TAKEOVER_MS`, applied to the stranded-lock sweepers. A lock
|
|
10461
|
-
* whose holder pid probes alive but whose mtime is older than the alive
|
|
10462
|
-
* window is a recycled pid, not a live writer (no write in this family
|
|
10463
|
-
* holds a lock for more than minutes) — without this, one recycled pid
|
|
10464
|
-
* blocked whole-tree snapshot recovery forever with a "retry once the
|
|
10465
|
-
* write completes" message that could never become true. A backend without
|
|
10466
|
-
* `mtime` keeps the conservative refuse-on-alive posture. */
|
|
10467
|
-
async lockHolderAgedOut(lockPath) {
|
|
10468
|
-
const mtime = await this.io.mtime?.(lockPath).catch(() => null);
|
|
10469
|
-
return typeof mtime === "number" && Date.now() - mtime > 864e5;
|
|
10470
|
-
}
|
|
10471
|
-
/** Remove `lockPath` only when its body has the writer-lock `pid:token`
|
|
10472
|
-
* shape AND the holder pid is not alive (or is an aged-out recycled pid —
|
|
10473
|
-
* R2 follow-up); anything else (a user support file or a live writer's
|
|
10474
|
-
* lock) is left untouched. */
|
|
10475
|
-
async sweepLockIfStranded(lockPath) {
|
|
10476
|
-
const lockProbe = await probeText(this.io, lockPath);
|
|
10477
|
-
if (isUnknown(lockProbe)) {
|
|
10478
|
-
console.warn(`skill-store: could not read ${lockPath} to check whether its holder is alive (${lockProbe.reason}); leaving the lock in place`);
|
|
10479
|
-
return;
|
|
10480
|
-
}
|
|
10481
|
-
if (!isPresent(lockProbe)) return;
|
|
10482
|
-
const body = lockProbe.value;
|
|
10483
|
-
const pid = parseLockBody(body);
|
|
10484
|
-
if (pid === null) return;
|
|
10485
|
-
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid) && !await this.lockHolderAgedOut(lockPath)) return;
|
|
10486
|
-
await this.io.remove(lockPath).catch(() => {});
|
|
10252
|
+
return false;
|
|
10487
10253
|
}
|
|
10488
10254
|
/** A1-4 (v18): a manifest-declared name is copied with `join(root, name)`;
|
|
10489
10255
|
* only a single, non-traversing path component is safe. Dotfiles
|
|
@@ -10494,22 +10260,6 @@ var SkillLibrary = class {
|
|
|
10494
10260
|
if (typeof name !== "string") return false;
|
|
10495
10261
|
return name !== "" && name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") && basename(name) === name;
|
|
10496
10262
|
}
|
|
10497
|
-
/** A1-7 (v18): a root-level lock whose holder is alive must refuse the
|
|
10498
|
-
* restore; a dead residue is swept so a crashed writer cannot block
|
|
10499
|
-
* recovery. A non-lock body shape is left alone (user file). */
|
|
10500
|
-
async refuseLiveLockOrSweep(lockPath, label) {
|
|
10501
|
-
let body;
|
|
10502
|
-
try {
|
|
10503
|
-
body = await this.io.readText(lockPath);
|
|
10504
|
-
} catch (error) {
|
|
10505
|
-
throw new Error(`snapshot restore refused: cannot verify ${label} (${error instanceof Error ? error.message : String(error)}); a live writer may hold it`);
|
|
10506
|
-
}
|
|
10507
|
-
if (body === null) return;
|
|
10508
|
-
const pid = parseLockBody(body);
|
|
10509
|
-
if (pid === null) return;
|
|
10510
|
-
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid) && !await this.lockHolderAgedOut(lockPath)) throw new Error(`snapshot restore refused: ${label} is being written (write lock present); retry once the write completes`);
|
|
10511
|
-
await this.io.remove(lockPath).catch(() => {});
|
|
10512
|
-
}
|
|
10513
10263
|
async archive(rawName, options = {}) {
|
|
10514
10264
|
const name = rawName.trim();
|
|
10515
10265
|
const badName = this.badName(name, { allowReserved: true });
|
|
@@ -10556,9 +10306,9 @@ var SkillLibrary = class {
|
|
|
10556
10306
|
message: `Skill "${name}" is a symlink; refusing to archive it.`
|
|
10557
10307
|
};
|
|
10558
10308
|
}
|
|
10559
|
-
if (await this.
|
|
10309
|
+
if (await this.supportDirsUnlistable(dir)) return {
|
|
10560
10310
|
ok: false,
|
|
10561
|
-
message: `Skill "${name}"
|
|
10311
|
+
message: `Skill "${name}" could not be checked for writers (a support directory could not be listed); retry archiving.`
|
|
10562
10312
|
};
|
|
10563
10313
|
const commitProtection = await this.deleteProtection(name, options);
|
|
10564
10314
|
if (commitProtection) return {
|
|
@@ -11188,16 +10938,15 @@ var SkillLibrary = class {
|
|
|
11188
10938
|
message: `Archived entry "${chosen}" is a symlink; refusing to restore it.`
|
|
11189
10939
|
};
|
|
11190
10940
|
}
|
|
11191
|
-
if (await this.
|
|
10941
|
+
if (await this.supportDirsUnlistable(dest)) return {
|
|
11192
10942
|
ok: false,
|
|
11193
|
-
message: `Skill "${name}"
|
|
10943
|
+
message: `Skill "${name}" could not be checked for writers (a support directory could not be listed); retry restoring.`
|
|
11194
10944
|
};
|
|
11195
10945
|
const moveFailure = await this.moveDir(source, dest);
|
|
11196
10946
|
if (moveFailure !== void 0) return {
|
|
11197
10947
|
ok: false,
|
|
11198
10948
|
message: `Restore of "${name}" from .archive failed: ${moveFailure}`
|
|
11199
10949
|
};
|
|
11200
|
-
await this.deleteStrandedLocks(dest);
|
|
11201
10950
|
for (const marker of [".archive-reason", ".archive-name"]) if (await this.io.exists(join(dest, marker))) await this.io.remove(join(dest, marker));
|
|
11202
10951
|
const restoredProbe = await probeText(this.io, join(dest, "SKILL.md"));
|
|
11203
10952
|
if (isUnknown(restoredProbe)) console.warn(`evolution-skill-store: the restored SKILL.md of "${name}" could not be read back (${restoredProbe.reason}); the restore is recorded without after-bytes`);
|
|
@@ -11445,8 +11194,8 @@ var SkillLibrary = class {
|
|
|
11445
11194
|
const names = await listNames(this.root, this.io);
|
|
11446
11195
|
const skipped = [];
|
|
11447
11196
|
const copyable = [];
|
|
11448
|
-
for (const name of names) if (await this.
|
|
11449
|
-
console.warn(`skill-store: snapshot skipped "${name}" —
|
|
11197
|
+
for (const name of names) if (await this.supportDirsUnlistable(this.dirOf(name))) {
|
|
11198
|
+
console.warn(`skill-store: snapshot skipped "${name}" — its support directory could not be listed; the skill is recorded as skipped in the manifest`);
|
|
11450
11199
|
skipped.push(name);
|
|
11451
11200
|
} else copyable.push(name);
|
|
11452
11201
|
const copyFailure = (await Promise.allSettled(copyable.map(async (name) => {
|
|
@@ -11454,7 +11203,7 @@ var SkillLibrary = class {
|
|
|
11454
11203
|
}))).find((result) => result.status === "rejected");
|
|
11455
11204
|
if (copyFailure) throw copyFailure.reason;
|
|
11456
11205
|
const suspect = [];
|
|
11457
|
-
for (const name of copyable) if (await this.
|
|
11206
|
+
for (const name of copyable) if (await this.supportDirsUnlistable(this.dirOf(name))) suspect.push(name);
|
|
11458
11207
|
if (suspect.length > 0) {
|
|
11459
11208
|
for (const name of suspect) console.warn(`skill-store: snapshot demoted "${name}" to skipped — a write lock appeared while its copy ran; the copied bytes are suspect`);
|
|
11460
11209
|
for (const name of suspect) copyable.splice(copyable.indexOf(name), 1);
|
|
@@ -11713,14 +11462,9 @@ var SkillLibrary = class {
|
|
|
11713
11462
|
}
|
|
11714
11463
|
for (const entry of rootEntries) {
|
|
11715
11464
|
if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json" || entry === ".curator-suppressed.json" || entry === ".history") continue;
|
|
11716
|
-
if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) {
|
|
11717
|
-
await this.refuseLiveLockOrSweep(join(this.root, entry), entry);
|
|
11718
|
-
continue;
|
|
11719
|
-
}
|
|
11720
11465
|
const dir = join(this.root, entry);
|
|
11721
11466
|
if (await this.io.exists(join(dir, "SKILL.md"))) {
|
|
11722
|
-
await this.
|
|
11723
|
-
if (await this.hasWriteLock(dir)) throw new Error(`snapshot restore refused: skill "${entry}" is being written (write lock present); retry once the write completes`);
|
|
11467
|
+
if (await this.supportDirsUnlistable(dir)) throw new Error(`snapshot restore refused: skill "${entry}" is being written (write lock present); retry once the write completes`);
|
|
11724
11468
|
}
|
|
11725
11469
|
}
|
|
11726
11470
|
const restoresSuppressed = manifest.sidecars.includes(".curator-suppressed.json");
|
|
@@ -11728,7 +11472,6 @@ var SkillLibrary = class {
|
|
|
11728
11472
|
if (entry === ".archive" || entry === ".backups" || entry === ".mutations.json") continue;
|
|
11729
11473
|
if (entry === ".history") continue;
|
|
11730
11474
|
if (entry === ".curator-suppressed.json" && restoresSuppressed) continue;
|
|
11731
|
-
if (entry.endsWith(".lock") || entry.endsWith(`.lock.next`)) continue;
|
|
11732
11475
|
await this.io.remove(join(this.root, entry));
|
|
11733
11476
|
}
|
|
11734
11477
|
for (const name of manifest.skills) await this.io.copy(join(snapshotPath, name), join(this.root, name));
|
|
@@ -11740,10 +11483,7 @@ var SkillLibrary = class {
|
|
|
11740
11483
|
await this.io.copy(join(snapshotPath, ".archive"), archiveRoot);
|
|
11741
11484
|
} else if (manifest.hasArchive === false) await this.io.remove(archiveRoot);
|
|
11742
11485
|
}
|
|
11743
|
-
for (const entry of await this.io.list(this.root))
|
|
11744
|
-
if (entry.startsWith(".")) continue;
|
|
11745
|
-
await this.deleteStrandedLocks(join(this.root, entry));
|
|
11746
|
-
}
|
|
11486
|
+
for (const entry of await this.io.list(this.root)) if (entry.startsWith(".")) continue;
|
|
11747
11487
|
}
|
|
11748
11488
|
};
|
|
11749
11489
|
function newSkillLibrary(options) {
|
|
@@ -12358,6 +12098,7 @@ const PARAM_EXPOSURE = Object.freeze([
|
|
|
12358
12098
|
control: "number",
|
|
12359
12099
|
unit: "天",
|
|
12360
12100
|
values: "",
|
|
12101
|
+
relation: { atLeast: "staleAfterDays" },
|
|
12361
12102
|
card: "evolution-curator"
|
|
12362
12103
|
},
|
|
12363
12104
|
{
|
|
@@ -12999,6 +12740,23 @@ const PARAM_EXPOSURE = Object.freeze([
|
|
|
12999
12740
|
values: "",
|
|
13000
12741
|
card: "evolution-approval"
|
|
13001
12742
|
},
|
|
12743
|
+
{
|
|
12744
|
+
id: "approvalConfirm",
|
|
12745
|
+
group: "write-caps",
|
|
12746
|
+
tier: "E3",
|
|
12747
|
+
authority: "cordis",
|
|
12748
|
+
owner: "evolution-approval",
|
|
12749
|
+
applies: "live",
|
|
12750
|
+
docAnchor: "PARAMETERS.md#write-caps",
|
|
12751
|
+
summary: "What the confirmation before an APPROVE does: write straight through, ask first, or treat an unanswered prompt as a cancel. The same three words skillWriteConfirm uses for a skill write.",
|
|
12752
|
+
label: "批准前先确认",
|
|
12753
|
+
hint: "点「批准」时要不要再确认一次:直接执行 / 先确认 / 等待超时即取消。默认直接执行(不打断批量批准)。",
|
|
12754
|
+
control: "select",
|
|
12755
|
+
unit: "",
|
|
12756
|
+
values: "auto|ask|timeout",
|
|
12757
|
+
valueLabels: "直接执行|先确认|等待超时即取消",
|
|
12758
|
+
card: "evolution-approval"
|
|
12759
|
+
},
|
|
13002
12760
|
{
|
|
13003
12761
|
id: "qualityWarnThreshold",
|
|
13004
12762
|
group: "deployment",
|
|
@@ -13269,14 +13027,6 @@ function readNumberParam(carrier, id) {
|
|
|
13269
13027
|
* @module @lmzhen/dsh-evolution-core
|
|
13270
13028
|
*/
|
|
13271
13029
|
/**
|
|
13272
|
-
* Whether a parsed value is a plain mapping (a settings section).
|
|
13273
|
-
* @param value - the parsed value.
|
|
13274
|
-
* @returns true for a non-null, non-array object.
|
|
13275
|
-
*/
|
|
13276
|
-
function isSection(value) {
|
|
13277
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13278
|
-
}
|
|
13279
|
-
/**
|
|
13280
13030
|
* The key names one row may be written with: its own E3 rows, plus the deprecated
|
|
13281
13031
|
* spellings that still resolve to them. An unknown key is refused instead of written —
|
|
13282
13032
|
* the platform validates a write against the row's schema, so writing it would fail the
|
|
@@ -13307,7 +13057,7 @@ function planNamespaceMigration(documentText) {
|
|
|
13307
13057
|
foreign: [],
|
|
13308
13058
|
skipped: []
|
|
13309
13059
|
};
|
|
13310
|
-
if (!
|
|
13060
|
+
if (!isRecord(parsed)) throw new Error("the settings document is not a mapping; refusing to migrate it");
|
|
13311
13061
|
const legacy = new Map(NAMESPACE_MIGRATIONS.map((entry) => [entry.namespace, entry.rowId]));
|
|
13312
13062
|
const entries = [];
|
|
13313
13063
|
const empty = [];
|
|
@@ -13315,7 +13065,7 @@ function planNamespaceMigration(documentText) {
|
|
|
13315
13065
|
for (const [namespace, section] of Object.entries(parsed)) {
|
|
13316
13066
|
const rowId = legacy.get(namespace);
|
|
13317
13067
|
if (rowId === void 0) continue;
|
|
13318
|
-
if (!
|
|
13068
|
+
if (!isRecord(section)) throw new Error("settings section `" + namespace + "` is not a mapping; refusing to migrate it");
|
|
13319
13069
|
const allowed = writableKeys(rowId);
|
|
13320
13070
|
const values = [];
|
|
13321
13071
|
for (const [key, value] of Object.entries(section)) {
|
|
@@ -13689,4 +13439,4 @@ function foldUntrustedLines(text, limit) {
|
|
|
13689
13439
|
return limit > 0 ? marked.slice(0, limit) : marked;
|
|
13690
13440
|
}
|
|
13691
13441
|
//#endregion
|
|
13692
|
-
export { AGENT_PRESET_PACKAGE, AGENT_PRESET_ROOT_HINT,
|
|
13442
|
+
export { AGENT_PRESET_PACKAGE, AGENT_PRESET_ROOT_HINT, AUTHORING_DESCRIPTION_BAR, AUTHORING_SPLIT_LINE_CHARS, BASELINE_ACTION, CITATION_SUPPORT_DIRS, COMBINED_REVIEW_PLAN_PROMPT, COMBINED_REVIEW_PROMPT, COMPLETION_SKILL_REVIEW_PROMPT, CONTENT_SPLIT_HINT, COST_ASCII_TOKEN_HIGH, COST_ASCII_TOKEN_LOW, COST_CJK_TOKEN_HIGH, COST_CJK_TOKEN_LOW, CURATOR_DRY_RUN_BANNER, CURATOR_PROMPT, DEDUP_MAX_PAIR_COMPARISONS, DEDUP_SIMILARITY_THRESHOLD, DEFAULT_ARCHIVE_AFTER_DAYS, DEFAULT_ARCHIVE_RETENTION_POLICY, DEFAULT_CITATION_POLICY, DEFAULT_CITATION_REF_BUDGET, DEFAULT_CONSOLIDATION_FAILURES, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, DEFAULT_CURATOR_INTERVAL_HOURS, DEFAULT_CURATOR_MODEL, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, DEFAULT_HEALTH_THRESHOLDS, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MEMORY_REVIEW_MODEL, DEFAULT_MIN_IDLE_HOURS, DEFAULT_MUTATION_CAP, DEFAULT_PROBE_TIMEOUT_MS, DEFAULT_REFERENCE_REWRITE_POLICY, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_RUN_RECORDS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_SKILL_VERSION_KEEP, DEFAULT_STALE_AFTER_DAYS, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_SUPPORT_FILE_CHAR_POLICY, DEFAULT_SUPPORT_READ_TOOL_NAMES, DEFAULT_THREAT_MAX_SCAN_CHARS, DEFAULT_USER_CHAR_LIMIT, DISPATCH_EVENT_TYPES, DRIFT_MAX_LINE_CHARS, DRIFT_SIGNALS_VERSION, DRIFT_SIGNAL_NOUNS, DSH_AUTHORING_STANDARDS, EMPTY_PATCH_SEED, ENTRY_DELIMITER, EVENT_ARCHIVE_PREFIX, EVENT_LOG_RETAIN_ARCHIVES, EVENT_LOG_ROTATE_AT, EVENT_LOG_VERSION, EVOLUTION_ERRORS, EVOLUTION_WRITE_TOOLS, EvolutionGateSet, FAMILY_SESSION_TOOL_NAMES, FORBIDDEN_CONTROL_KEYS, FUZZY_MAX_PATTERN_CHARS, FUZZY_MAX_WORK, HEALTH_STAMP_RE, HISTORY_DIR, HISTORY_INDEX_VERSION, HOOK_LINE_RE, INSTANCE_KEYS, KEEP_LINE_RE, LONG_RUNS, LOW_QUALITY_THRESHOLD, MAINTAIN_OUTPUT_INSTRUCTION, MAINTAIN_PROMPT, MAX_DESCRIPTION_LENGTH, MAX_REQUEST_BODY_BYTES, MAX_RESTRUCTURE_MOVES, MAX_SKILL_CONTENT_CHARS, MAX_SKILL_FILE_BYTES, MAX_SKILL_NAME_LENGTH, MAX_SUPPORT_READ_PATHS, MAX_TIMER_DELAY_MS, MEMORY_GUIDANCE_SECTION_ORDER, MEMORY_REVIEW_PROMPT, MIN_STAMP_BODY_CHARS, MUTATIONS_FILE_VERSION, MemoryStore, NAMESPACE_MIGRATIONS, NATIVE_CALL_EVENT, NATIVE_RESULT_EVENT, PARAM_ALIASES, PARAM_EXPOSURE, PARAM_GROUP_LABELS, PARAM_NAMESPACES, PATTERN_OVERLAP, PLATFORM_CATALOG_DESCRIPTION_DEFAULT, PLATFORM_ESTIMATE_CHARS_PER_TOKEN, PLATFORM_SERVICE_PROBES, POLICY_STAGE_DEFAULTS, PROMPT_BUNDLE, PROMPT_BUNDLE_ID, PROMPT_BUNDLE_VERSION, PROTECTED_BUILTIN_SKILLS, PTC_DISPATCH_EVENT, PTC_DISPATCH_START_EVENT, QUALITY_WEIGHTS, READ_REQUIRED_ACTIONS, RESTRUCTURE_TARGET_RE, RETENTION_FEEDBACK_KEEP_RATIO, RETENTION_FEEDBACK_MIN_CHARS, RUNS_SCHEMA_VERSION, SKILLS_GUIDANCE, SKILLS_GUIDANCE_SECTION_ORDER, SKILL_ACTION_REQUIRED_FIELDS, SKILL_NAME_RE, SKILL_REVIEW_PLAN_PROMPT, SKILL_REVIEW_PROMPT, SNAPSHOT_EXTRA_NAME_RE, SUMMARY_DUPLICATE_HINT_THRESHOLD, SUPPORT_DIRS, SUPPRESSED_FILE_VERSION, SkillLibrary, TEXT_DIFF_MAX_CHARS, TEXT_DIFF_MAX_LINES, THREAT_EXEMPTION_HINT, TOKEN_ASCII_WEIGHT, TOKEN_CJK_WEIGHT, ToolDispatchNormalizer, ToolDispatchPayloadError, UNTRUSTED_LINE_BREAK, UNTRUSTED_LINE_MARK, UPSTREAM_LIMIT_CHARS_PER_TOKEN, VERSION_SUMMARY_MAX_CHARS, absentHostServices, advanceReview, affinity, ageCopyKey, allowRowCollisions, anchorVerdict, appendEvolutionEvent, applyCuratorLifecycleFields, applyCuratorMetaFields, applyNamespaceMigration, applyReferenceRewrite, asRecord, assertDispatchPayload, assertSkillsRootAliasRetired, assessStructureHealth, atomicWriteFiles, authoringFeedback, basePresetPlugins, blobPath, bodyCost, buildCuratorRunReport, buildLearnPrompt, bumpPatch, bumpSupportRead, bumpUse, bumpView, callingScope, canonicalWriteId, claimInstance, clampOnce, clampedNumber, classifyNamespaceMigration, clearReviewChannel, collectReadSkillNames, composePresetComposition, composePresetEntry, composePresetInsert, composePresetRow, compositionRowIds, computeDedupGroups, computeDriftSignals, computeLifecycleTransitions, computePrefixClusters, computeQualityScores, computeScopeView, contentHash, contentRetentionFeedback, countDispatches, createGateSet, daysSinceIso, declaredReportStamp, denseBlockSequence, describeReferenceRewrite, dispatchKeyOf, dispatchStepOf, displayBodyOf, duplicateHeadings, elapsedSince, emptyRecord, entryTarget, errorText, evaluateThreat, eventsFile, evidenceKindIndex, evolutionActivityProjection, evolutionEventPayloadIssue, evolutionEvidenceProjection, evolutionHome, evolutionIoAdapter, evolutionReadsProjection, evolutionRoot, evolutionSignalsProjection, filterUnreadSkillOps, findDriftSignal, foldCuratorFields, foldToolDispatches, foldTurnEvent, foldTurnEvents, foldUntrustedLines, frontmatterBlock, frontmatterCatalogInvalid, fuzzyPatch, getRecord, historyIndexFile, historyRoot, identity, idleDays, initialTurnFold, installEvolutionProjections, instanceClaimKey, instanceClaimedWriteSites, instanceHolder, isAbsent, isBookkeepingFrame, isCommittedWarning, isDeprecatedParamId, isFileShapedPath, isLoopbackRequest, isMissingPath, isPresent, isProcessAlive, isProgramToolName, isRecord, isReviewChannelSession, isSkillReadToolName, isSkillToolName, isUnknown, isUnreadWrite, latestActivityAt, latestVersionAt, lifecycleCandidate, listDirectories, listEventArchives, liveBool, liveEnum, liveNumber, liveString, loadMutations, loadRowOverrides, loadSuppressedNames, loadUsage, loadVersionContent, loadVersions, makeSerialQueue, mapProbe, markAgentCreated, markReviewChannel, markerEntryName, memoryRoot, mergePresetRow, missingSupportPointers, mutateUsage, mutationsFile, narrowNameMatches, nearDuplicateSummaries, neutralizePromptVariables, newRunRegistry, newSkillLibrary, nextHistoryIndex, nodeEvolutionIo, normalizeFrontmatter, normalizeUsageRecord, normalizeVersionSummary, observeEvent, orderVersions, overlongLines, paramNamespace, paramRowId, paramSettingsId, parseCuratorNominations, parseEvolutionEvents, parseFrontmatter, parseHistoryIndex, partitionVersions, payloadText, persistedWriteSite, persistedWriteSites, pickWithPolicy, planNamespaceMigration, planReferenceRewrite, planRehoming, policyStageLimits, presetPatchText, presetRowBlock, presetRowBody, presetRowId, probeAbsent, probeDesktopScope, probeList, probeMtime, probePresent, probeReason, probeText, probeUnknown, projectionText, readDispatchSignal, readEvolutionEvents, readEvolutionTimeline, readHistoryIndex, readJsonBody, readJsonObject, readNumberParam, readParam, readingStats, recordMutation, recordVersions, redactSecrets, relatedSkillNames, releaseInstance, removePresetRow, renameWithRetry, renderCuratorReportMarkdown, renderLongRunTable, reportTime, reportsSweepLockTarget, resolveBasePresetPatch, resolveCitations, resolveExecOrigins, resolveOrigins, resolveParamId, resolveRootConfig, resolveSkillsRoot, retainEventArchives, retirementReport, reviewPrompt, rowBlockSpan, rowSiblingEnd, runsFile, saveSuppressedNames, saveUsage, scanBodyHooks, scanContentThreats, scanMemoryThreats, scanThreats, scopedProbeReport, sessionAudited, sessionEvidenceIndex, sessionLastEventTime, sessionReadNames, sessionReadSkillNames, sessionSeesFamilyTools, sessionTurnSignals, skillReadNameOf, skillRowCells, skillStateKey, skillsRoot, supportFileReadOf, suppressedFile, sweepReports, sweepReviewChannelSessions, textDiffFacts, tokenLineFor, transactIo, transactTaskGuard, trimPatternBoundaries, trimTrailingBlankLines, unhookedSupportPointers, unwrapLive, updateSuppressedNames, usageFile, usageObserved, userSetKeys, validateFrontmatter, validateRestructureTarget, valueOr, verifyPromptBundle, versionActionKind, versionRowCells, versionSummaryPrompt, versionTarget, vocabulary, writeDurableTmp, writeJson, writePathSymbols, yamlPlainScalarNeedsQuotes };
|