@lmzhen/dsh-evolution-core 0.30.17 → 0.30.19
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/lib/composition/index.js +2 -2
- package/lib/{composition-D8Y3-ROd.js → composition-DSesmTvf.js} +3 -3
- package/lib/{http-routes-ifJKGnu6.js → http-routes-D1Ty6nnh.js} +2 -0
- package/lib/http-routes.js +1 -1
- package/lib/index.js +3205 -2080
- package/lib/subagent/index.js +1 -1
- package/lib/types/composition/composer.js +1 -1
- package/lib/types/frontmatter.js +1 -1
- package/lib/types/http-routes.d.ts +2 -0
- package/lib/types/http-routes.js +2 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/index.js +5 -0
- package/lib/types/params.js +1 -2
- package/lib/types/platform-services.js +1 -1
- package/lib/types/redact.js +2 -2
- package/lib/types/run-registry.d.ts +27 -6
- package/lib/types/run-registry.js +58 -15
- package/lib/types/skill-history.d.ts +4 -0
- package/lib/types/skill-revision.d.ts +37 -0
- package/lib/types/skill-revision.js +45 -0
- package/lib/types/skill-store.d.ts +43 -1
- package/lib/types/skill-store.js +235 -9
- package/lib/types/tree-lock.d.ts +3 -0
- package/lib/types/tree-lock.js +13 -5
- package/lib/types/unit-backend.d.ts +5 -0
- package/lib/types/unit-backend.js +55 -0
- package/lib/types/unit-journal.d.ts +29 -0
- package/lib/types/unit-journal.js +51 -0
- package/lib/types/unit-recovery.d.ts +5 -0
- package/lib/types/unit-recovery.js +12 -0
- package/lib/types/unit-storage.d.ts +95 -0
- package/lib/types/unit-storage.js +124 -0
- package/lib/types/unit-transaction.d.ts +220 -0
- package/lib/types/unit-transaction.js +487 -0
- package/package.json +1 -1
- package/persisted-write-inventory.json +91 -1
- package/lib/{ensure-MEYMj9jn.js → ensure-BuWsjPGD.js} +1 -1
package/lib/types/skill-store.js
CHANGED
|
@@ -48,10 +48,11 @@ import { makeSerialQueue } from "./serial.js";
|
|
|
48
48
|
import { h2Sections } from "./document-body.js";
|
|
49
49
|
import { diffFindings } from "./skill-lint.js";
|
|
50
50
|
import { contentHash, loadMutations, recordMutation } from "./mutations.js";
|
|
51
|
+
import { revisionFingerprint, revisionSupportDigest } from "./skill-revision.js";
|
|
51
52
|
import { contentRetentionFeedback, HISTORY_DIR, loadVersionContent, loadVersions, recordVersions } from "./skill-history.js";
|
|
52
|
-
import { suppressedFile, usageFile } from "./usage.js";
|
|
53
|
+
import { getRecord, mutateUsage, suppressedFile, usageFile } from "./usage.js";
|
|
53
54
|
import { assessStructureHealth, DEFAULT_HEALTH_THRESHOLDS, lintBody } from "./skill-health.js";
|
|
54
|
-
import { CONTENT_SPLIT_HINT, DEFAULT_SKILL_VERSION_KEEP, SKILL_NAME_RE, SUPPORT_DIRS } from "./constants.js";
|
|
55
|
+
import { CONTENT_SPLIT_HINT, DEFAULT_SKILL_VERSION_KEEP, PROTECTED_BUILTIN_SKILLS, SKILL_NAME_RE, SUPPORT_DIRS } from "./constants.js";
|
|
55
56
|
/**
|
|
56
57
|
* The CLASS boundary default authority (G1/1.1, Stage 2).
|
|
57
58
|
*
|
|
@@ -472,6 +473,12 @@ function serialForRoot(root) {
|
|
|
472
473
|
ROOT_SERIAL.set(key, created);
|
|
473
474
|
return created;
|
|
474
475
|
}
|
|
476
|
+
/** Same-content test for two revision anchors (absent only equals absent). */
|
|
477
|
+
function sameRevisionAnchor(expected, live) {
|
|
478
|
+
if (expected.kind !== live.kind)
|
|
479
|
+
return false;
|
|
480
|
+
return expected.kind === 'absent' || expected.sha256 === live.sha256;
|
|
481
|
+
}
|
|
475
482
|
export class SkillLibrary {
|
|
476
483
|
root;
|
|
477
484
|
limits;
|
|
@@ -861,7 +868,55 @@ export class SkillLibrary {
|
|
|
861
868
|
}
|
|
862
869
|
}
|
|
863
870
|
/**
|
|
864
|
-
|
|
871
|
+
* Content anchor of one live skill: name + body + every support file under SUPPORT_DIRS.
|
|
872
|
+
*
|
|
873
|
+
* This is the commit-side counterpart of the decision-time anchor recorded by the merge
|
|
874
|
+
* domain (see skill-revision.ts): both sides call the same fingerprint, so a changed
|
|
875
|
+
* source, target or support file cannot slip past the commit CAS.
|
|
876
|
+
* @param rawName - caller-supplied skill name (trimmed here).
|
|
877
|
+
* @returns present anchor, or absent when the skill has no SKILL.md.
|
|
878
|
+
*/
|
|
879
|
+
async revisionFingerprint(rawName) {
|
|
880
|
+
const name = rawName.trim();
|
|
881
|
+
if (this.badName(name) !== null)
|
|
882
|
+
return null;
|
|
883
|
+
let body;
|
|
884
|
+
try {
|
|
885
|
+
body = await this.io.readText(join(this.dirOf(name), 'SKILL.md'));
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
if (error?.code === 'EISDIR')
|
|
889
|
+
return null;
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
if (body === null)
|
|
893
|
+
return null;
|
|
894
|
+
return revisionFingerprint(name, body, await this.liveSupportFiles(name));
|
|
895
|
+
}
|
|
896
|
+
/** Content anchor of one live skill; the hash a decision records and a commit re-checks. */
|
|
897
|
+
async revisionAnchor(rawName) {
|
|
898
|
+
const fingerprint = await this.revisionFingerprint(rawName);
|
|
899
|
+
return fingerprint === null ? { kind: 'absent' } : { kind: 'present', sha256: contentHash(fingerprint) };
|
|
900
|
+
}
|
|
901
|
+
/** Every support file of one live skill, in listing order; unreadable/missing files fail loud. */
|
|
902
|
+
async liveSupportFiles(rawName) {
|
|
903
|
+
const name = rawName.trim();
|
|
904
|
+
if (this.badName(name) !== null)
|
|
905
|
+
return [];
|
|
906
|
+
const listed = await this.listSupportFiles(name);
|
|
907
|
+
if (isUnknown(listed))
|
|
908
|
+
throw new Error('support file list is unknown: ' + name);
|
|
909
|
+
if (!isPresent(listed))
|
|
910
|
+
return [];
|
|
911
|
+
const dir = this.dirOf(name);
|
|
912
|
+
return await Promise.all(listed.value.map(async (path) => {
|
|
913
|
+
const content = await this.io.readText(join(dir, ...path.replace(/\\/g, '/').split('/').filter(Boolean)));
|
|
914
|
+
if (content === null)
|
|
915
|
+
throw new Error('support file disappeared: ' + name + '/' + path);
|
|
916
|
+
return { path, content };
|
|
917
|
+
}));
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
865
920
|
* Single path-building choke point (rc.42 audit P2-5): every directory path
|
|
866
921
|
|
|
867
922
|
* is built from the TRIMMED name, so a name that passes `badName` (which
|
|
@@ -949,6 +1004,13 @@ export class SkillLibrary {
|
|
|
949
1004
|
}
|
|
950
1005
|
return null;
|
|
951
1006
|
}
|
|
1007
|
+
/** Whether the target directory exists at all (a SKILL.md-less ghost still occupies the name). */
|
|
1008
|
+
async skillDirExists(rawName) {
|
|
1009
|
+
const name = rawName.trim();
|
|
1010
|
+
if (this.badName(name) !== null)
|
|
1011
|
+
return false;
|
|
1012
|
+
return await this.io.exists(this.dirOf(name));
|
|
1013
|
+
}
|
|
952
1014
|
async isManaged(rawName) {
|
|
953
1015
|
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
954
1016
|
const name = rawName.trim();
|
|
@@ -1247,6 +1309,18 @@ export class SkillLibrary {
|
|
|
1247
1309
|
return [];
|
|
1248
1310
|
return await loadVersions(this.root, this.io, name);
|
|
1249
1311
|
}
|
|
1312
|
+
/** Versions from the latest merge baseline onward; old versions stay listable via listVersions. */
|
|
1313
|
+
async listBaselineVersions(rawName) {
|
|
1314
|
+
const versions = await this.listVersions(rawName);
|
|
1315
|
+
let start = -1;
|
|
1316
|
+
for (let index = versions.length - 1; index >= 0; index -= 1) {
|
|
1317
|
+
if (versions[index]?.unitHead?.baseline === true) {
|
|
1318
|
+
start = index;
|
|
1319
|
+
break;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return start < 0 ? versions : versions.slice(start);
|
|
1323
|
+
}
|
|
1250
1324
|
/**
|
|
1251
1325
|
* One version's content, read back from the content-addressed blob store.
|
|
1252
1326
|
* @param rawName - the skill's name.
|
|
@@ -1260,6 +1334,50 @@ export class SkillLibrary {
|
|
|
1260
1334
|
return null;
|
|
1261
1335
|
return await loadVersionContent(this.root, this.io, name, v);
|
|
1262
1336
|
}
|
|
1337
|
+
/** Read an archived skill's human-readable reason, exact or stamped. */
|
|
1338
|
+
async readArchiveReason(rawName) {
|
|
1339
|
+
const name = rawName.trim();
|
|
1340
|
+
if (this.badName(name) !== null)
|
|
1341
|
+
return null;
|
|
1342
|
+
const archiveRoot = join(this.root, '.archive');
|
|
1343
|
+
const exact = await this.io.readText(join(archiveRoot, name, '.archive-reason'));
|
|
1344
|
+
if (exact !== null)
|
|
1345
|
+
return exact.trim();
|
|
1346
|
+
const entries = await this.io.list(archiveRoot).catch(() => []);
|
|
1347
|
+
for (const entry of [...entries].reverse()) {
|
|
1348
|
+
if (entry !== name && !entry.startsWith(name + '-'))
|
|
1349
|
+
continue;
|
|
1350
|
+
const marker = await this.io.readText(join(archiveRoot, entry, '.archive-name'));
|
|
1351
|
+
if (marker !== null && marker.trim() !== name)
|
|
1352
|
+
continue;
|
|
1353
|
+
const reason = await this.io.readText(join(archiveRoot, entry, '.archive-reason'));
|
|
1354
|
+
if (reason !== null)
|
|
1355
|
+
return reason.trim();
|
|
1356
|
+
}
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
/** Read an archived skill body (SKILL.md) by its original name, exact or stamped. */
|
|
1360
|
+
async readArchived(rawName) {
|
|
1361
|
+
const name = rawName.trim();
|
|
1362
|
+
if (this.badName(name) !== null)
|
|
1363
|
+
return null;
|
|
1364
|
+
const archiveRoot = join(this.root, '.archive');
|
|
1365
|
+
const exact = await this.io.readText(join(archiveRoot, name, 'SKILL.md'));
|
|
1366
|
+
if (exact !== null)
|
|
1367
|
+
return exact;
|
|
1368
|
+
const entries = await this.io.list(archiveRoot).catch(() => []);
|
|
1369
|
+
for (const entry of [...entries].reverse()) {
|
|
1370
|
+
if (entry !== name && !entry.startsWith(name + '-'))
|
|
1371
|
+
continue;
|
|
1372
|
+
const marker = await this.io.readText(join(archiveRoot, entry, '.archive-name'));
|
|
1373
|
+
if (marker !== null && marker.trim() !== name)
|
|
1374
|
+
continue;
|
|
1375
|
+
const text = await this.io.readText(join(archiveRoot, entry, 'SKILL.md'));
|
|
1376
|
+
if (text !== null)
|
|
1377
|
+
return text;
|
|
1378
|
+
}
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1263
1381
|
/**
|
|
1264
1382
|
* Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
|
|
1265
1383
|
* deletion, from background-review writes, and from the lifecycle — a
|
|
@@ -1477,7 +1595,7 @@ export class SkillLibrary {
|
|
|
1477
1595
|
// marker never landed" inside the fsync/durability wrapper, producing a
|
|
1478
1596
|
// self-contradicting operator-facing message.
|
|
1479
1597
|
let markerWarning = '';
|
|
1480
|
-
if (origin !== 'foreground') {
|
|
1598
|
+
if (origin !== 'foreground' || options.managed === true) {
|
|
1481
1599
|
// v29 SK-05: the marker write gets the same committed-only tolerance as
|
|
1482
1600
|
// every other write in this file (A1-15) — a post-rename dir-fsync
|
|
1483
1601
|
// failure means the MARKER landed; rejecting the create as a plain
|
|
@@ -1805,6 +1923,19 @@ export class SkillLibrary {
|
|
|
1805
1923
|
&& !name.includes('/') && !name.includes('\\')
|
|
1806
1924
|
&& basename(name) === name;
|
|
1807
1925
|
}
|
|
1926
|
+
/** Mark merge-absorbed sources archived in the usage sidecar (FR-DATA-07). */
|
|
1927
|
+
async foldArchivedUsage(sources, at) {
|
|
1928
|
+
try {
|
|
1929
|
+
await mutateUsage(this.root, this.io, map => {
|
|
1930
|
+
for (const name of sources) {
|
|
1931
|
+
const record = getRecord(map, name);
|
|
1932
|
+
record.state = 'archived';
|
|
1933
|
+
record.archived_at = at;
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
catch { /* lifecycle telemetry is best-effort; the archive already landed */ }
|
|
1938
|
+
}
|
|
1808
1939
|
async archive(rawName, options = {}, origin = this.authority.origin) {
|
|
1809
1940
|
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
1810
1941
|
const name = rawName.trim();
|
|
@@ -1942,9 +2073,18 @@ export class SkillLibrary {
|
|
|
1942
2073
|
if (bad)
|
|
1943
2074
|
return { ok: false, message: bad };
|
|
1944
2075
|
}
|
|
2076
|
+
if (PROTECTED_BUILTIN_SKILLS.has(target))
|
|
2077
|
+
return { ok: false, message: `目标技能「${target}」是受保护的内置技能。` };
|
|
2078
|
+
const protectedSource = sources.find(name => PROTECTED_BUILTIN_SKILLS.has(name));
|
|
2079
|
+
if (protectedSource !== undefined)
|
|
2080
|
+
return { ok: false, message: `来源技能「${protectedSource}」是受保护的内置技能。` };
|
|
2081
|
+
const limits = plan.limits ?? this.limits;
|
|
1945
2082
|
return await this.serial(async () => {
|
|
1946
2083
|
const dir = this.dirOf(target);
|
|
1947
2084
|
const targetPath = join(dir, 'SKILL.md');
|
|
2085
|
+
if (!plan.targetExists && await this.io.exists(dir)) {
|
|
2086
|
+
return { ok: false, message: `目标目录「${target}」已存在但不是技能;拒绝把 SKILL.md 写进同名目录。` };
|
|
2087
|
+
}
|
|
1948
2088
|
const currentTarget = await this.io.readText(targetPath);
|
|
1949
2089
|
if (plan.targetExists !== (currentTarget !== null)) {
|
|
1950
2090
|
return { ok: false, message: plan.targetExists ? `目标技能「${target}」不存在。` : `目标技能「${target}」已存在。` };
|
|
@@ -1952,6 +2092,12 @@ export class SkillLibrary {
|
|
|
1952
2092
|
if (currentTarget !== plan.expectedTargetBody) {
|
|
1953
2093
|
return { ok: false, message: `目标技能「${target}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
|
|
1954
2094
|
}
|
|
2095
|
+
if (plan.targetExists && !(await this.isManaged(target))) {
|
|
2096
|
+
return { ok: false, message: `目标技能「${target}」不是 family-managed;拒绝提交。` };
|
|
2097
|
+
}
|
|
2098
|
+
if (await this.io.exists(markerPath(dir, 'pinned'))) {
|
|
2099
|
+
return { ok: false, message: `目标技能「${target}」已被固定;拒绝提交。` };
|
|
2100
|
+
}
|
|
1955
2101
|
const targetProtection = await this.writeProtection(target, plan.origin);
|
|
1956
2102
|
if (targetProtection)
|
|
1957
2103
|
return { ok: false, message: `技能「${target}」受保护(${targetProtection})。` };
|
|
@@ -1971,7 +2117,38 @@ export class SkillLibrary {
|
|
|
1971
2117
|
return { ok: false, message: `来源技能「${source}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
|
|
1972
2118
|
sourceBodies.set(source, body);
|
|
1973
2119
|
}
|
|
1974
|
-
|
|
2120
|
+
// Decision-time CAS (FR-EXEC-12/GAP-CAND-02): the body compare above only covers
|
|
2121
|
+
// SKILL.md; a changed support file (or a changed target body recorded with different
|
|
2122
|
+
// formatting) would still slip through. Re-hash the LIVE tree with the same canonical
|
|
2123
|
+
// fingerprint the decision recorded and refuse before the first write.
|
|
2124
|
+
if (plan.targetRevision !== undefined) {
|
|
2125
|
+
const liveTargetRevision = await this.revisionAnchor(target);
|
|
2126
|
+
if (!sameRevisionAnchor(plan.targetRevision, liveTargetRevision)) {
|
|
2127
|
+
return { ok: false, code: 'target-changed', message: `目标技能「${target}」的内容在决策之后被改过;拒绝提交(未写入任何内容)。` };
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
if (plan.sourceRevisions !== undefined) {
|
|
2131
|
+
for (const [name, expected] of Object.entries(plan.sourceRevisions)) {
|
|
2132
|
+
if (!sources.includes(name))
|
|
2133
|
+
continue;
|
|
2134
|
+
const live = await this.revisionAnchor(name);
|
|
2135
|
+
if (!sameRevisionAnchor(expected, live)) {
|
|
2136
|
+
return { ok: false, code: 'source-changed', message: `来源技能「${name}」的内容在决策之后被改过;拒绝提交(未写入任何内容)。` };
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
if (plan.supportDigest !== undefined) {
|
|
2141
|
+
const fingerprints = [];
|
|
2142
|
+
for (const name of [target, ...sources]) {
|
|
2143
|
+
const fingerprint = await this.revisionFingerprint(name);
|
|
2144
|
+
if (fingerprint !== null)
|
|
2145
|
+
fingerprints.push(fingerprint);
|
|
2146
|
+
}
|
|
2147
|
+
if (revisionSupportDigest(fingerprints) !== plan.supportDigest) {
|
|
2148
|
+
return { ok: false, code: 'support-changed', message: '参与合并的技能支撑文件在决策之后被改过;拒绝提交(未写入任何内容)。' };
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
const validation = validateFrontmatter(plan.body, target, limits);
|
|
1975
2152
|
if (validation)
|
|
1976
2153
|
return { ok: false, message: validation };
|
|
1977
2154
|
const supportPaths = new Set();
|
|
@@ -1983,8 +2160,8 @@ export class SkillLibrary {
|
|
|
1983
2160
|
if (supportPaths.has(file.path))
|
|
1984
2161
|
return { ok: false, message: `支撑文件重复:${file.path}` };
|
|
1985
2162
|
supportPaths.add(file.path);
|
|
1986
|
-
if (Buffer.byteLength(file.content, 'utf8') >
|
|
1987
|
-
return { ok: false, message: `支撑文件超过 ${
|
|
2163
|
+
if (Buffer.byteLength(file.content, 'utf8') > limits.maxSkillFileBytes)
|
|
2164
|
+
return { ok: false, message: `支撑文件超过 ${limits.maxSkillFileBytes} 字节:${file.path}` };
|
|
1988
2165
|
const threat = this.contentThreatBlock(file.content);
|
|
1989
2166
|
if (threat)
|
|
1990
2167
|
return { ok: false, message: threat };
|
|
@@ -2000,7 +2177,7 @@ export class SkillLibrary {
|
|
|
2000
2177
|
if (!existing)
|
|
2001
2178
|
return { ok: false, message: `正文引用了未提供的支撑文件「${ref}」;拒绝提交。` };
|
|
2002
2179
|
}
|
|
2003
|
-
const unitId = 'u-' + Date.now().toString(36) + '-' + (nextUnitSeq += 1).toString(36);
|
|
2180
|
+
const unitId = plan.unitId ?? ('u-' + Date.now().toString(36) + '-' + (nextUnitSeq += 1).toString(36));
|
|
2004
2181
|
const supportMoves = plan.supportFiles.map((file) => {
|
|
2005
2182
|
const previous = supportPrevious.get(file.path) ?? null;
|
|
2006
2183
|
return {
|
|
@@ -2012,6 +2189,8 @@ export class SkillLibrary {
|
|
|
2012
2189
|
};
|
|
2013
2190
|
});
|
|
2014
2191
|
const unitHead = {
|
|
2192
|
+
...(plan.runId === undefined ? {} : { runId: plan.runId }),
|
|
2193
|
+
baseline: true,
|
|
2015
2194
|
moves: supportMoves,
|
|
2016
2195
|
sources: [{ skill: target }, ...sources.map(source => ({ skill: source }))],
|
|
2017
2196
|
};
|
|
@@ -2081,6 +2260,9 @@ export class SkillLibrary {
|
|
|
2081
2260
|
expected: supportPrevious.get(file.path) ?? null,
|
|
2082
2261
|
})),
|
|
2083
2262
|
];
|
|
2263
|
+
await plan.onPhase?.('target-pending');
|
|
2264
|
+
if (plan.assertFence !== undefined && !(await plan.assertFence()))
|
|
2265
|
+
return { ok: false, message: 'tree fence lost before target write' };
|
|
2084
2266
|
const result = await this.applyTreeChange({
|
|
2085
2267
|
name: target,
|
|
2086
2268
|
origin: plan.origin,
|
|
@@ -2094,13 +2276,28 @@ export class SkillLibrary {
|
|
|
2094
2276
|
});
|
|
2095
2277
|
if (!result.ok)
|
|
2096
2278
|
return rollbackMessage(result.message, []);
|
|
2279
|
+
await plan.onPhase?.('target-applied');
|
|
2280
|
+
await plan.onPhase?.('target-written');
|
|
2281
|
+
if (plan.signal?.aborted) {
|
|
2282
|
+
const stuck = await rollbackTarget();
|
|
2283
|
+
return rollbackMessage('cancelled before sources archived', stuck);
|
|
2284
|
+
}
|
|
2097
2285
|
for (const source of sources) {
|
|
2286
|
+
if (plan.signal?.aborted) {
|
|
2287
|
+
const stuck = await rollbackSources();
|
|
2288
|
+
stuck.push(...await rollbackTarget());
|
|
2289
|
+
return rollbackMessage('cancelled before next source archive', stuck);
|
|
2290
|
+
}
|
|
2098
2291
|
const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
|
|
2099
2292
|
if (fresh !== sourceBodies.get(source)) {
|
|
2100
2293
|
const stuck = await rollbackSources();
|
|
2101
2294
|
stuck.push(...await rollbackTarget());
|
|
2102
2295
|
return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
|
|
2103
2296
|
}
|
|
2297
|
+
if (plan.assertFence !== undefined && !(await plan.assertFence())) {
|
|
2298
|
+
return { ok: false, message: 'tree fence lost before source archive; recovery required' };
|
|
2299
|
+
}
|
|
2300
|
+
await plan.onPhase?.('source-archiving', { source });
|
|
2104
2301
|
const archivedResult = await this.archive(source, {
|
|
2105
2302
|
absorbedInto: target,
|
|
2106
2303
|
unitId,
|
|
@@ -2114,12 +2311,31 @@ export class SkillLibrary {
|
|
|
2114
2311
|
}
|
|
2115
2312
|
archived.push(source);
|
|
2116
2313
|
}
|
|
2314
|
+
await plan.onPhase?.('sources-archived');
|
|
2315
|
+
await this.foldArchivedUsage(sources, new Date().toISOString());
|
|
2316
|
+
await plan.onPhase?.('unit-written');
|
|
2117
2317
|
return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
|
|
2118
2318
|
}
|
|
2119
|
-
|
|
2319
|
+
await plan.onPhase?.('target-pending');
|
|
2320
|
+
if (plan.assertFence !== undefined && !(await plan.assertFence()))
|
|
2321
|
+
return { ok: false, message: 'tree fence lost before target write' };
|
|
2322
|
+
const created = await this.createCore(target, plan.body, plan.origin, { unitId, unitHead, managed: true });
|
|
2120
2323
|
if (!created.ok)
|
|
2121
2324
|
return { ok: false, message: created.message };
|
|
2325
|
+
await plan.onPhase?.('target-applied');
|
|
2326
|
+
await plan.onPhase?.('target-written');
|
|
2327
|
+
if (plan.signal?.aborted) {
|
|
2328
|
+
const targetRollback = await this.archive(target, { reason: 'merge cancelled' }, plan.origin);
|
|
2329
|
+
return rollbackMessage('cancelled before sources archived', targetRollback.ok ? [] : [target]);
|
|
2330
|
+
}
|
|
2122
2331
|
for (const source of sources) {
|
|
2332
|
+
if (plan.signal?.aborted) {
|
|
2333
|
+
const stuck = await rollbackSources();
|
|
2334
|
+
const targetRollback = await this.archive(target, { reason: 'merge cancelled' }, plan.origin);
|
|
2335
|
+
if (!targetRollback.ok)
|
|
2336
|
+
stuck.push(target);
|
|
2337
|
+
return rollbackMessage('cancelled before next source archive', stuck);
|
|
2338
|
+
}
|
|
2123
2339
|
const fresh = await this.io.readText(join(this.dirOf(source), 'SKILL.md'));
|
|
2124
2340
|
if (fresh !== sourceBodies.get(source)) {
|
|
2125
2341
|
const stuck = await rollbackSources();
|
|
@@ -2128,6 +2344,10 @@ export class SkillLibrary {
|
|
|
2128
2344
|
stuck.push(target);
|
|
2129
2345
|
return rollbackMessage(`来源技能「${source}」在归档前被改过`, stuck);
|
|
2130
2346
|
}
|
|
2347
|
+
if (plan.assertFence !== undefined && !(await plan.assertFence())) {
|
|
2348
|
+
return { ok: false, message: 'tree fence lost before source archive; recovery required' };
|
|
2349
|
+
}
|
|
2350
|
+
await plan.onPhase?.('source-archiving', { source });
|
|
2131
2351
|
const result = await this.archive(source, {
|
|
2132
2352
|
absorbedInto: target,
|
|
2133
2353
|
unitId,
|
|
@@ -2144,6 +2364,10 @@ export class SkillLibrary {
|
|
|
2144
2364
|
archived.push(source);
|
|
2145
2365
|
}
|
|
2146
2366
|
if (plan.supportFiles.length > 0) {
|
|
2367
|
+
await plan.onPhase?.('support-pending');
|
|
2368
|
+
if (plan.assertFence !== undefined && !(await plan.assertFence())) {
|
|
2369
|
+
return { ok: false, message: 'tree fence lost before support write; recovery required' };
|
|
2370
|
+
}
|
|
2147
2371
|
const writes = plan.supportFiles.map(file => ({
|
|
2148
2372
|
target: join(dir, ...file.path.replace(/\\/g, '/').split('/').filter(Boolean)),
|
|
2149
2373
|
content: file.content,
|
|
@@ -2168,6 +2392,8 @@ export class SkillLibrary {
|
|
|
2168
2392
|
return rollbackMessage(result.message, stuck);
|
|
2169
2393
|
}
|
|
2170
2394
|
}
|
|
2395
|
+
await plan.onPhase?.('sources-archived');
|
|
2396
|
+
await plan.onPhase?.('unit-written');
|
|
2171
2397
|
return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
|
|
2172
2398
|
});
|
|
2173
2399
|
}
|
package/lib/types/tree-lock.d.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import type { EvolutionIoLike } from './io.ts';
|
|
2
2
|
export interface TreeLockRecord {
|
|
3
3
|
readonly token: string;
|
|
4
|
+
readonly epoch: number;
|
|
4
5
|
readonly pid: number;
|
|
5
6
|
readonly startedAt: number;
|
|
6
7
|
}
|
|
7
8
|
export interface TreeLock {
|
|
8
9
|
readonly token: string;
|
|
10
|
+
readonly epoch: number;
|
|
9
11
|
readonly path: string;
|
|
10
12
|
readonly acquiredAt: number;
|
|
11
13
|
release(): Promise<void>;
|
|
14
|
+
assertHeld(): Promise<boolean>;
|
|
12
15
|
}
|
|
13
16
|
export type TreeLockAcquireResult = {
|
|
14
17
|
readonly ok: true;
|
package/lib/types/tree-lock.js
CHANGED
|
@@ -25,7 +25,7 @@ function parseLock(raw) {
|
|
|
25
25
|
const value = JSON.parse(raw);
|
|
26
26
|
if (typeof value.token !== 'string' || typeof value.pid !== 'number' || typeof value.startedAt !== 'number')
|
|
27
27
|
return undefined;
|
|
28
|
-
return { token: value.token, pid: value.pid, startedAt: value.startedAt };
|
|
28
|
+
return { token: value.token, epoch: typeof value.epoch === 'number' ? value.epoch : 0, pid: value.pid, startedAt: value.startedAt };
|
|
29
29
|
}
|
|
30
30
|
catch {
|
|
31
31
|
return undefined;
|
|
@@ -40,6 +40,7 @@ export async function readTreeLock(io, home) {
|
|
|
40
40
|
async function tryAcquire(io, home, now) {
|
|
41
41
|
const path = treeLockFile(home);
|
|
42
42
|
let token;
|
|
43
|
+
let epoch;
|
|
43
44
|
let holder;
|
|
44
45
|
await transactIo(io, path, (current) => {
|
|
45
46
|
const existing = parseLock(current);
|
|
@@ -47,11 +48,12 @@ async function tryAcquire(io, home, now) {
|
|
|
47
48
|
holder = existing;
|
|
48
49
|
return current;
|
|
49
50
|
}
|
|
50
|
-
// Absent, corrupt, our own stale row, or a dead owner: take it.
|
|
51
|
+
// Absent, corrupt, our own stale row, or a dead owner: take it and bump the epoch.
|
|
51
52
|
token = randomUUID();
|
|
52
|
-
|
|
53
|
+
epoch = (existing?.epoch ?? 0) + 1;
|
|
54
|
+
return JSON.stringify({ token, epoch, pid: process.pid, startedAt: now() });
|
|
53
55
|
});
|
|
54
|
-
return token === undefined ? (holder === undefined ? {} : { holder }) : { token };
|
|
56
|
+
return token === undefined ? (holder === undefined ? {} : { holder }) : { token, epoch: epoch ?? 0 };
|
|
55
57
|
}
|
|
56
58
|
/**
|
|
57
59
|
* Acquire the tree write lock, waiting up to `waitMs`.
|
|
@@ -71,19 +73,25 @@ export async function acquireTreeWriteLock(options) {
|
|
|
71
73
|
const attempt = await tryAcquire(options.io, options.home, now);
|
|
72
74
|
if (attempt.token !== undefined) {
|
|
73
75
|
const token = attempt.token;
|
|
76
|
+
const epoch = attempt.epoch ?? 0;
|
|
74
77
|
const path = treeLockFile(options.home);
|
|
75
78
|
let released = false;
|
|
76
79
|
const lock = {
|
|
77
80
|
token,
|
|
81
|
+
epoch,
|
|
78
82
|
path,
|
|
79
83
|
acquiredAt: now(),
|
|
84
|
+
async assertHeld() {
|
|
85
|
+
const current = parseLock(await options.io.readText(path));
|
|
86
|
+
return current?.token === token && current.epoch === epoch;
|
|
87
|
+
},
|
|
80
88
|
async release() {
|
|
81
89
|
if (released)
|
|
82
90
|
return;
|
|
83
91
|
released = true;
|
|
84
92
|
await transactIo(options.io, path, (current) => {
|
|
85
93
|
const existing = parseLock(current);
|
|
86
|
-
return existing?.token === token ? null : current;
|
|
94
|
+
return existing?.token === token && existing.epoch === epoch ? null : current;
|
|
87
95
|
});
|
|
88
96
|
},
|
|
89
97
|
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** G2 adapter: core UnitBackend over the existing SkillLibrary. */
|
|
2
|
+
import type { SkillLibrary } from './skill-store.ts';
|
|
3
|
+
import type { UnitBackend } from './unit-transaction.ts';
|
|
4
|
+
export declare function skillLibraryUnitBackend(skills: SkillLibrary): UnitBackend;
|
|
5
|
+
//# sourceMappingURL=unit-backend.d.ts.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function skillLibraryUnitBackend(skills) {
|
|
2
|
+
return {
|
|
3
|
+
async readSkill(name) { return await skills.read(name); },
|
|
4
|
+
async readSupportFile(name, path) {
|
|
5
|
+
const probe = skills.readSupportFile;
|
|
6
|
+
return typeof probe === 'function' ? await probe.call(skills, name, path) : null;
|
|
7
|
+
},
|
|
8
|
+
async commit(plan, onPhase, signal, assertFence) {
|
|
9
|
+
const result = await skills.commitMerge({
|
|
10
|
+
...(plan.runId === undefined ? {} : { runId: plan.runId }),
|
|
11
|
+
...(plan.unitId === undefined ? {} : { unitId: plan.unitId }),
|
|
12
|
+
target: plan.target,
|
|
13
|
+
targetExists: plan.targetExists,
|
|
14
|
+
sources: plan.sources,
|
|
15
|
+
body: plan.body,
|
|
16
|
+
supportFiles: plan.supportFiles,
|
|
17
|
+
expectedTargetBody: plan.expectedTargetBody,
|
|
18
|
+
expectedSourceBodies: plan.expectedSourceBodies,
|
|
19
|
+
origin: plan.origin,
|
|
20
|
+
summary: plan.summary,
|
|
21
|
+
...(plan.skillLimits === undefined ? {} : { limits: plan.skillLimits }),
|
|
22
|
+
...(plan.targetRevision === undefined ? {} : { targetRevision: plan.targetRevision }),
|
|
23
|
+
...(plan.sourceRevisions === undefined ? {} : { sourceRevisions: plan.sourceRevisions }),
|
|
24
|
+
...(plan.supportDigest === undefined ? {} : { supportDigest: plan.supportDigest }),
|
|
25
|
+
...(onPhase === undefined ? {} : { onPhase }),
|
|
26
|
+
...(signal === undefined ? {} : { signal }),
|
|
27
|
+
...(assertFence === undefined ? {} : { assertFence }),
|
|
28
|
+
});
|
|
29
|
+
return { ok: result.ok, message: result.message, ...(result.code === undefined ? {} : { code: result.code }), ...(result.archived === undefined ? {} : { archived: result.archived }), ...(result.mutations === undefined ? {} : { mutations: result.mutations }) };
|
|
30
|
+
},
|
|
31
|
+
async restoreTarget(name, body) {
|
|
32
|
+
if (body === null) {
|
|
33
|
+
await skills.archive(name, { reason: 'unit-rollback' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const result = await skills.update(name, body);
|
|
37
|
+
if (!result.ok)
|
|
38
|
+
throw new Error(result.message);
|
|
39
|
+
},
|
|
40
|
+
async restoreSupportFile(name, path, content) {
|
|
41
|
+
const result = content === null ? await skills.removeSupportFile(name, path) : await skills.writeSupportFile(name, path, content);
|
|
42
|
+
if (!result.ok)
|
|
43
|
+
throw new Error(result.message);
|
|
44
|
+
},
|
|
45
|
+
async restoreSource(name, body) {
|
|
46
|
+
const restored = await skills.restoreFromArchive(name);
|
|
47
|
+
if (restored.ok)
|
|
48
|
+
return;
|
|
49
|
+
const result = await skills.update(name, body);
|
|
50
|
+
if (!result.ok)
|
|
51
|
+
throw new Error(result.message);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=unit-backend.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { EvolutionIoLike } from './io.ts';
|
|
2
|
+
export declare const UNIT_JOURNAL_SCHEMA_VERSION = 1;
|
|
3
|
+
export declare const UNIT_JOURNAL_PHASES: readonly ["prepared", "target-pending", "target-written", "source-archiving", "sources-archived", "support-pending", "unit-written", "committed", "failed", "rolled-back"];
|
|
4
|
+
export type UnitJournalPhase = typeof UNIT_JOURNAL_PHASES[number];
|
|
5
|
+
export interface UnitJournalCheckpoint {
|
|
6
|
+
readonly source: string;
|
|
7
|
+
readonly phase: 'archived';
|
|
8
|
+
readonly hash: string;
|
|
9
|
+
readonly at: number;
|
|
10
|
+
}
|
|
11
|
+
export interface UnitJournal {
|
|
12
|
+
readonly schemaVersion: typeof UNIT_JOURNAL_SCHEMA_VERSION;
|
|
13
|
+
readonly unitId: string;
|
|
14
|
+
readonly runId: string;
|
|
15
|
+
readonly planDigest: string;
|
|
16
|
+
readonly target: string;
|
|
17
|
+
readonly sources: readonly string[];
|
|
18
|
+
readonly phase: UnitJournalPhase;
|
|
19
|
+
readonly storageKey: string;
|
|
20
|
+
readonly snapshotSha256: string;
|
|
21
|
+
readonly checkpoints?: readonly UnitJournalCheckpoint[];
|
|
22
|
+
readonly updatedAt: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function unitJournalPath(home: string, unitId: string): string;
|
|
25
|
+
export declare function unitJournalPhaseRank(phase: UnitJournalPhase): number;
|
|
26
|
+
export declare function assertUnitJournalTransition(from: UnitJournalPhase, to: UnitJournalPhase): void;
|
|
27
|
+
export declare function writeUnitJournal(io: EvolutionIoLike, home: string, journal: UnitJournal): Promise<string>;
|
|
28
|
+
export declare function readUnitJournal(io: EvolutionIoLike, home: string, unitId: string): Promise<UnitJournal | null>;
|
|
29
|
+
//# sourceMappingURL=unit-journal.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** G2 unit journal: intent/phase/checkpoint only, never evidence bodies. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export const UNIT_JOURNAL_SCHEMA_VERSION = 1;
|
|
5
|
+
export const UNIT_JOURNAL_PHASES = [
|
|
6
|
+
'prepared',
|
|
7
|
+
'target-pending',
|
|
8
|
+
'target-written',
|
|
9
|
+
'source-archiving',
|
|
10
|
+
'sources-archived',
|
|
11
|
+
'support-pending',
|
|
12
|
+
'unit-written',
|
|
13
|
+
'committed',
|
|
14
|
+
'failed',
|
|
15
|
+
'rolled-back',
|
|
16
|
+
];
|
|
17
|
+
export function unitJournalPath(home, unitId) {
|
|
18
|
+
return join(home, 'merge', 'unit-journals', createHash('sha256').update(unitId).digest('hex') + '.json');
|
|
19
|
+
}
|
|
20
|
+
export function unitJournalPhaseRank(phase) {
|
|
21
|
+
return UNIT_JOURNAL_PHASES.indexOf(phase);
|
|
22
|
+
}
|
|
23
|
+
export function assertUnitJournalTransition(from, to) {
|
|
24
|
+
if (to === 'failed' || to === 'rolled-back')
|
|
25
|
+
return;
|
|
26
|
+
if (from === 'failed' || from === 'rolled-back' || from === 'committed')
|
|
27
|
+
throw new Error('unit-journal-terminal:' + from);
|
|
28
|
+
if (unitJournalPhaseRank(to) <= unitJournalPhaseRank(from))
|
|
29
|
+
throw new Error('unit-journal-nonmonotonic:' + from + '->' + to);
|
|
30
|
+
}
|
|
31
|
+
export async function writeUnitJournal(io, home, journal) {
|
|
32
|
+
const path = unitJournalPath(home, journal.unitId);
|
|
33
|
+
const content = JSON.stringify(journal, null, 2) + '\n';
|
|
34
|
+
if (io.transact !== undefined)
|
|
35
|
+
await io.transact(path, () => content);
|
|
36
|
+
else
|
|
37
|
+
await io.writeText(path, content);
|
|
38
|
+
return path;
|
|
39
|
+
}
|
|
40
|
+
export async function readUnitJournal(io, home, unitId) {
|
|
41
|
+
const raw = await io.readText(unitJournalPath(home, unitId));
|
|
42
|
+
if (raw === null)
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(raw);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=unit-journal.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** G2 recovery arbitration: unit journal/evidence wins over a legacy commit journal. */
|
|
2
|
+
import type { EvolutionIoLike } from './io.ts';
|
|
3
|
+
export type UnitAuthority = 'unit' | 'legacy' | 'none';
|
|
4
|
+
export declare function resolveUnitAuthority(io: EvolutionIoLike, home: string, unitId: string, legacyExists: boolean): Promise<UnitAuthority>;
|
|
5
|
+
//# sourceMappingURL=unit-recovery.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readUnitEvidence, readUnitSnapshot } from "./unit-storage.js";
|
|
2
|
+
import { readUnitJournal } from "./unit-journal.js";
|
|
3
|
+
export async function resolveUnitAuthority(io, home, unitId, legacyExists) {
|
|
4
|
+
if (await readUnitEvidence(io, home, unitId) !== null)
|
|
5
|
+
return 'unit';
|
|
6
|
+
if (await readUnitJournal(io, home, unitId) !== null)
|
|
7
|
+
return 'unit';
|
|
8
|
+
if (await readUnitSnapshot(io, home, unitId) !== null)
|
|
9
|
+
return 'unit';
|
|
10
|
+
return legacyExists ? 'legacy' : 'none';
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=unit-recovery.js.map
|