@lmzhen/dsh-evolution-core 0.30.18 → 0.30.20
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/index.js +356 -45
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.js +1 -0
- package/lib/types/params.js +1 -1
- package/lib/types/platform-services.d.ts +1 -1
- package/lib/types/platform-services.js +2 -2
- package/lib/types/run-registry.d.ts +19 -6
- package/lib/types/run-registry.js +39 -16
- 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 +33 -1
- package/lib/types/skill-store.js +165 -6
- package/lib/types/unit-backend.js +7 -1
- package/lib/types/unit-journal.d.ts +1 -1
- package/lib/types/unit-journal.js +1 -0
- package/lib/types/unit-storage.d.ts +12 -0
- package/lib/types/unit-transaction.d.ts +27 -2
- package/lib/types/unit-transaction.js +50 -8
- package/package.json +1 -1
- package/persisted-write-inventory.json +91 -1
package/lib/types/skill-store.js
CHANGED
|
@@ -48,8 +48,9 @@ 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
55
|
import { CONTENT_SPLIT_HINT, DEFAULT_SKILL_VERSION_KEEP, PROTECTED_BUILTIN_SKILLS, SKILL_NAME_RE, SUPPORT_DIRS } from "./constants.js";
|
|
55
56
|
/**
|
|
@@ -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
|
|
@@ -1254,6 +1309,18 @@ export class SkillLibrary {
|
|
|
1254
1309
|
return [];
|
|
1255
1310
|
return await loadVersions(this.root, this.io, name);
|
|
1256
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
|
+
}
|
|
1257
1324
|
/**
|
|
1258
1325
|
* One version's content, read back from the content-addressed blob store.
|
|
1259
1326
|
* @param rawName - the skill's name.
|
|
@@ -1267,6 +1334,50 @@ export class SkillLibrary {
|
|
|
1267
1334
|
return null;
|
|
1268
1335
|
return await loadVersionContent(this.root, this.io, name, v);
|
|
1269
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
|
+
}
|
|
1270
1381
|
/**
|
|
1271
1382
|
* Pin or unpin a skill (`.pinned` marker). Pinned skills are protected from
|
|
1272
1383
|
* deletion, from background-review writes, and from the lifecycle — a
|
|
@@ -1812,6 +1923,19 @@ export class SkillLibrary {
|
|
|
1812
1923
|
&& !name.includes('/') && !name.includes('\\')
|
|
1813
1924
|
&& basename(name) === name;
|
|
1814
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
|
+
}
|
|
1815
1939
|
async archive(rawName, options = {}, origin = this.authority.origin) {
|
|
1816
1940
|
// One trim per entry: paths (dirOf), validation and messages all see the same name.
|
|
1817
1941
|
const name = rawName.trim();
|
|
@@ -1954,6 +2078,7 @@ export class SkillLibrary {
|
|
|
1954
2078
|
const protectedSource = sources.find(name => PROTECTED_BUILTIN_SKILLS.has(name));
|
|
1955
2079
|
if (protectedSource !== undefined)
|
|
1956
2080
|
return { ok: false, message: `来源技能「${protectedSource}」是受保护的内置技能。` };
|
|
2081
|
+
const limits = plan.limits ?? this.limits;
|
|
1957
2082
|
return await this.serial(async () => {
|
|
1958
2083
|
const dir = this.dirOf(target);
|
|
1959
2084
|
const targetPath = join(dir, 'SKILL.md');
|
|
@@ -1992,7 +2117,38 @@ export class SkillLibrary {
|
|
|
1992
2117
|
return { ok: false, message: `来源技能「${source}」在计划之后被改过;拒绝提交(未写入任何内容)。` };
|
|
1993
2118
|
sourceBodies.set(source, body);
|
|
1994
2119
|
}
|
|
1995
|
-
|
|
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);
|
|
1996
2152
|
if (validation)
|
|
1997
2153
|
return { ok: false, message: validation };
|
|
1998
2154
|
const supportPaths = new Set();
|
|
@@ -2004,8 +2160,8 @@ export class SkillLibrary {
|
|
|
2004
2160
|
if (supportPaths.has(file.path))
|
|
2005
2161
|
return { ok: false, message: `支撑文件重复:${file.path}` };
|
|
2006
2162
|
supportPaths.add(file.path);
|
|
2007
|
-
if (Buffer.byteLength(file.content, 'utf8') >
|
|
2008
|
-
return { ok: false, message: `支撑文件超过 ${
|
|
2163
|
+
if (Buffer.byteLength(file.content, 'utf8') > limits.maxSkillFileBytes)
|
|
2164
|
+
return { ok: false, message: `支撑文件超过 ${limits.maxSkillFileBytes} 字节:${file.path}` };
|
|
2009
2165
|
const threat = this.contentThreatBlock(file.content);
|
|
2010
2166
|
if (threat)
|
|
2011
2167
|
return { ok: false, message: threat };
|
|
@@ -2021,7 +2177,7 @@ export class SkillLibrary {
|
|
|
2021
2177
|
if (!existing)
|
|
2022
2178
|
return { ok: false, message: `正文引用了未提供的支撑文件「${ref}」;拒绝提交。` };
|
|
2023
2179
|
}
|
|
2024
|
-
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));
|
|
2025
2181
|
const supportMoves = plan.supportFiles.map((file) => {
|
|
2026
2182
|
const previous = supportPrevious.get(file.path) ?? null;
|
|
2027
2183
|
return {
|
|
@@ -2033,6 +2189,8 @@ export class SkillLibrary {
|
|
|
2033
2189
|
};
|
|
2034
2190
|
});
|
|
2035
2191
|
const unitHead = {
|
|
2192
|
+
...(plan.runId === undefined ? {} : { runId: plan.runId }),
|
|
2193
|
+
baseline: true,
|
|
2036
2194
|
moves: supportMoves,
|
|
2037
2195
|
sources: [{ skill: target }, ...sources.map(source => ({ skill: source }))],
|
|
2038
2196
|
};
|
|
@@ -2154,6 +2312,7 @@ export class SkillLibrary {
|
|
|
2154
2312
|
archived.push(source);
|
|
2155
2313
|
}
|
|
2156
2314
|
await plan.onPhase?.('sources-archived');
|
|
2315
|
+
await this.foldArchivedUsage(sources, new Date().toISOString());
|
|
2157
2316
|
await plan.onPhase?.('unit-written');
|
|
2158
2317
|
return { ok: true, message: `已把 ${sources.join(', ')} 合并进「${target}」。`, unitId, targetPath: dir, archived, mutations: 1 + sources.length };
|
|
2159
2318
|
}
|
|
@@ -7,6 +7,8 @@ export function skillLibraryUnitBackend(skills) {
|
|
|
7
7
|
},
|
|
8
8
|
async commit(plan, onPhase, signal, assertFence) {
|
|
9
9
|
const result = await skills.commitMerge({
|
|
10
|
+
...(plan.runId === undefined ? {} : { runId: plan.runId }),
|
|
11
|
+
...(plan.unitId === undefined ? {} : { unitId: plan.unitId }),
|
|
10
12
|
target: plan.target,
|
|
11
13
|
targetExists: plan.targetExists,
|
|
12
14
|
sources: plan.sources,
|
|
@@ -16,11 +18,15 @@ export function skillLibraryUnitBackend(skills) {
|
|
|
16
18
|
expectedSourceBodies: plan.expectedSourceBodies,
|
|
17
19
|
origin: plan.origin,
|
|
18
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 }),
|
|
19
25
|
...(onPhase === undefined ? {} : { onPhase }),
|
|
20
26
|
...(signal === undefined ? {} : { signal }),
|
|
21
27
|
...(assertFence === undefined ? {} : { assertFence }),
|
|
22
28
|
});
|
|
23
|
-
return { ok: result.ok, message: result.message, ...(result.archived === undefined ? {} : { archived: result.archived }), ...(result.mutations === undefined ? {} : { mutations: result.mutations }) };
|
|
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 }) };
|
|
24
30
|
},
|
|
25
31
|
async restoreTarget(name, body) {
|
|
26
32
|
if (body === null) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EvolutionIoLike } from './io.ts';
|
|
2
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", "committed", "failed", "rolled-back"];
|
|
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
4
|
export type UnitJournalPhase = typeof UNIT_JOURNAL_PHASES[number];
|
|
5
5
|
export interface UnitJournalCheckpoint {
|
|
6
6
|
readonly source: string;
|
|
@@ -23,6 +23,11 @@ export interface UnitSnapshot {
|
|
|
23
23
|
}[];
|
|
24
24
|
readonly origin: string;
|
|
25
25
|
readonly summary: string;
|
|
26
|
+
readonly validationPlan?: {
|
|
27
|
+
readonly planVersion: 1;
|
|
28
|
+
readonly stageDigest: string;
|
|
29
|
+
readonly revision: string;
|
|
30
|
+
};
|
|
26
31
|
readonly createdAt: number;
|
|
27
32
|
}
|
|
28
33
|
export interface UnitEvidenceRecord {
|
|
@@ -45,6 +50,11 @@ export interface UnitEvidenceRecord {
|
|
|
45
50
|
absorbedInto: string;
|
|
46
51
|
}[];
|
|
47
52
|
readonly mutations: number;
|
|
53
|
+
readonly validationPlan?: {
|
|
54
|
+
readonly planVersion: 1;
|
|
55
|
+
readonly stageDigest: string;
|
|
56
|
+
readonly revision: string;
|
|
57
|
+
};
|
|
48
58
|
readonly at: number;
|
|
49
59
|
}
|
|
50
60
|
export interface UnitIndexEntry {
|
|
@@ -76,6 +86,8 @@ export interface UnitReceiptRecord {
|
|
|
76
86
|
readonly target: string;
|
|
77
87
|
readonly archived: readonly string[];
|
|
78
88
|
readonly at: number;
|
|
89
|
+
/** Hash of the evidence written after this receipt; set on the final receipt. */
|
|
90
|
+
readonly evidenceSha256?: string;
|
|
79
91
|
}
|
|
80
92
|
export declare function unitReceiptPath(home: string, unitId: string): string;
|
|
81
93
|
export declare function writeUnitReceipt(io: EvolutionIoLike, home: string, receipt: UnitReceiptRecord): Promise<string>;
|
|
@@ -7,6 +7,8 @@ export interface UnitSupportFile {
|
|
|
7
7
|
}
|
|
8
8
|
export interface UnitPlan {
|
|
9
9
|
readonly runId: string;
|
|
10
|
+
/** Filled by the transaction before the backend write; history records it. */
|
|
11
|
+
readonly unitId?: string;
|
|
10
12
|
readonly pairKey: string;
|
|
11
13
|
readonly target: string;
|
|
12
14
|
readonly targetExists: boolean;
|
|
@@ -20,10 +22,32 @@ export interface UnitPlan {
|
|
|
20
22
|
}[];
|
|
21
23
|
readonly origin: "foreground" | "subagent" | "background_review";
|
|
22
24
|
readonly summary: string;
|
|
25
|
+
readonly skillLimits?: import("./limits.ts").SkillLimits;
|
|
26
|
+
readonly limitsDigest?: string;
|
|
27
|
+
readonly validationPlan?: {
|
|
28
|
+
readonly planVersion: 1;
|
|
29
|
+
readonly stageDigest: string;
|
|
30
|
+
readonly revision: string;
|
|
31
|
+
};
|
|
32
|
+
readonly targetRevision?: {
|
|
33
|
+
readonly kind: 'absent';
|
|
34
|
+
} | {
|
|
35
|
+
readonly kind: 'present';
|
|
36
|
+
readonly sha256: string;
|
|
37
|
+
};
|
|
38
|
+
readonly sourceRevisions?: Readonly<Record<string, {
|
|
39
|
+
readonly kind: 'absent';
|
|
40
|
+
} | {
|
|
41
|
+
readonly kind: 'present';
|
|
42
|
+
readonly sha256: string;
|
|
43
|
+
}>>;
|
|
44
|
+
readonly supportDigest?: string;
|
|
23
45
|
}
|
|
24
46
|
export interface UnitCommitResult {
|
|
25
47
|
readonly ok: boolean;
|
|
26
48
|
readonly message: string;
|
|
49
|
+
/** Typed backend refusal (revision CAS); absent means a generic commit failure. */
|
|
50
|
+
readonly code?: UnitErrorCode;
|
|
27
51
|
readonly archived?: readonly string[];
|
|
28
52
|
readonly mutations?: number;
|
|
29
53
|
}
|
|
@@ -55,7 +79,7 @@ export interface UnitTransactionOptions {
|
|
|
55
79
|
/** Gate instrumentation: tree fence then target claim. */
|
|
56
80
|
readonly onLock?: (lock: "tree" | "claim") => void;
|
|
57
81
|
}
|
|
58
|
-
export type UnitErrorCode = "unit-plan-missing" | "unit-conflict" | "unit-committed" | "unit-prepare-failed" | "unit-commit-failed" | "unit-claim-failed" | "unit-fence-failed" | "unit-rollback-blocked";
|
|
82
|
+
export type UnitErrorCode = "unit-plan-missing" | "unit-conflict" | "unit-committed" | "unit-prepare-failed" | "unit-commit-failed" | "unit-claim-failed" | "unit-fence-failed" | "unit-rollback-blocked" | "target-changed" | "source-changed" | "support-changed";
|
|
59
83
|
export interface UnitFailure {
|
|
60
84
|
readonly ok: false;
|
|
61
85
|
readonly code: UnitErrorCode;
|
|
@@ -101,6 +125,7 @@ export interface UnitTransaction {
|
|
|
101
125
|
phase: UnitJournalPhase | "none";
|
|
102
126
|
}>;
|
|
103
127
|
}
|
|
128
|
+
export declare function unitEvidenceDigest(evidence: UnitEvidenceRecord): string;
|
|
104
129
|
export declare class UnitTransactions implements UnitTransaction {
|
|
105
130
|
private readonly options;
|
|
106
131
|
private readonly prepared;
|
|
@@ -189,7 +214,7 @@ export declare class UnitTransactions implements UnitTransaction {
|
|
|
189
214
|
phase: "target-pending" | "target-written" | "source-archiving" | "sources-archived";
|
|
190
215
|
} | {
|
|
191
216
|
ok: boolean;
|
|
192
|
-
phase: "prepared" | "support-pending" | "committed" | "failed" | "rolled-back";
|
|
217
|
+
phase: "prepared" | "support-pending" | "unit-written" | "committed" | "failed" | "rolled-back";
|
|
193
218
|
}>;
|
|
194
219
|
}
|
|
195
220
|
//# sourceMappingURL=unit-transaction.d.ts.map
|
|
@@ -19,6 +19,20 @@ function canonical(value) {
|
|
|
19
19
|
function digest(plan) {
|
|
20
20
|
return createHash("sha256").update(canonical(plan)).digest("hex");
|
|
21
21
|
}
|
|
22
|
+
function canonicalJson(value) {
|
|
23
|
+
if (value === null || typeof value !== 'object')
|
|
24
|
+
return JSON.stringify(value);
|
|
25
|
+
if (Array.isArray(value))
|
|
26
|
+
return '[' + value.map(canonicalJson).join(',') + ']';
|
|
27
|
+
const object = value;
|
|
28
|
+
return '{' + Object.keys(object).sort().map(key => JSON.stringify(key) + ':' + canonicalJson(object[key])).join(',') + '}';
|
|
29
|
+
}
|
|
30
|
+
export function unitEvidenceDigest(evidence) {
|
|
31
|
+
return createHash("sha256").update(canonicalJson({ ...evidence, at: 0, mutations: 0 })).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
function evidenceDigest(evidence) {
|
|
34
|
+
return createHash("sha256").update(JSON.stringify({ ...evidence, at: 0, mutations: 0 }, null, 2) + "\n").digest("hex");
|
|
35
|
+
}
|
|
22
36
|
export class UnitTransactions {
|
|
23
37
|
options;
|
|
24
38
|
prepared = new Map();
|
|
@@ -54,6 +68,7 @@ export class UnitTransactions {
|
|
|
54
68
|
sourceBefore,
|
|
55
69
|
origin: plan.origin,
|
|
56
70
|
summary: plan.summary,
|
|
71
|
+
...(plan.validationPlan === undefined ? {} : { validationPlan: plan.validationPlan }),
|
|
57
72
|
createdAt: this.now(),
|
|
58
73
|
};
|
|
59
74
|
}
|
|
@@ -136,13 +151,13 @@ export class UnitTransactions {
|
|
|
136
151
|
let lock;
|
|
137
152
|
let claim;
|
|
138
153
|
const checkpoints = [];
|
|
139
|
-
const writePhase = async (phase, checkpointsNext) => {
|
|
154
|
+
const writePhase = async (phase, checkpointsNext = checkpoints) => {
|
|
140
155
|
if (lock !== undefined && !(await lock.assertHeld()))
|
|
141
156
|
throw new Error("unit-fence-lost");
|
|
142
157
|
await writeUnitJournal(this.options.io, this.options.home, {
|
|
143
158
|
...journalBase,
|
|
144
159
|
phase,
|
|
145
|
-
|
|
160
|
+
checkpoints: checkpointsNext,
|
|
146
161
|
});
|
|
147
162
|
await this.options.onPhase?.(phase);
|
|
148
163
|
};
|
|
@@ -157,7 +172,7 @@ export class UnitTransactions {
|
|
|
157
172
|
claim = await this.options.claim.acquire(stored.plan.target, stored.plan.runId);
|
|
158
173
|
this.options.onLock?.("claim");
|
|
159
174
|
await writeUnitJournal(this.options.io, this.options.home, { ...journalBase, phase: "target-pending" });
|
|
160
|
-
const result = await this.options.backend.commit(stored.plan, async (phase, detail) => {
|
|
175
|
+
const result = await this.options.backend.commit({ ...stored.plan, unitId: prepared.unitId }, async (phase, detail) => {
|
|
161
176
|
if (phase === "support-pending") {
|
|
162
177
|
await writePhase("support-pending");
|
|
163
178
|
await this.options.onPhase?.("support-pending");
|
|
@@ -179,7 +194,7 @@ export class UnitTransactions {
|
|
|
179
194
|
archived: stored.plan.sources.filter((source) => source !== stored.plan.target),
|
|
180
195
|
at: this.now(),
|
|
181
196
|
});
|
|
182
|
-
await
|
|
197
|
+
await writePhase("unit-written");
|
|
183
198
|
return;
|
|
184
199
|
}
|
|
185
200
|
if (phase === "source-archiving" && detail?.source !== undefined) {
|
|
@@ -196,7 +211,7 @@ export class UnitTransactions {
|
|
|
196
211
|
}, options?.signal, async () => lock !== undefined && (await lock.assertHeld()));
|
|
197
212
|
if (!result.ok) {
|
|
198
213
|
await writePhase("failed");
|
|
199
|
-
return { ok: false, code: "unit-commit-failed", message: result.message };
|
|
214
|
+
return { ok: false, code: result.code ?? "unit-commit-failed", message: result.message };
|
|
200
215
|
}
|
|
201
216
|
const targetAfter = (await this.options.backend.readSkill(stored.plan.target)) ?? "";
|
|
202
217
|
const evidence = {
|
|
@@ -221,11 +236,17 @@ export class UnitTransactions {
|
|
|
221
236
|
absorbedInto: stored.plan.target,
|
|
222
237
|
})),
|
|
223
238
|
mutations: result.mutations ?? 0,
|
|
239
|
+
...(stored.plan.validationPlan === undefined ? {} : { validationPlan: stored.plan.validationPlan }),
|
|
224
240
|
at: this.now(),
|
|
225
241
|
};
|
|
226
242
|
if (!(await lock.assertHeld()))
|
|
227
243
|
throw new Error("unit-fence-lost");
|
|
244
|
+
const evidenceSha256 = evidenceDigest(evidence);
|
|
228
245
|
await writeUnitEvidence(this.options.io, this.options.home, evidence);
|
|
246
|
+
await writeUnitReceipt(this.options.io, this.options.home, {
|
|
247
|
+
schemaVersion: 1, unitId: prepared.unitId, runId: stored.plan.runId, planDigest: stored.digest,
|
|
248
|
+
target: stored.plan.target, archived: stored.plan.sources.filter((source) => source !== stored.plan.target), at: this.now(), evidenceSha256,
|
|
249
|
+
});
|
|
229
250
|
await writePhase("committed", checkpoints);
|
|
230
251
|
this.prepared.delete(prepared.unitId);
|
|
231
252
|
return { ok: true, evidence };
|
|
@@ -278,6 +299,16 @@ export class UnitTransactions {
|
|
|
278
299
|
code: "unit-rollback-blocked",
|
|
279
300
|
message: "source checkpoint missing: " + missing.map((item) => item.name).join(","),
|
|
280
301
|
};
|
|
302
|
+
const liveTarget = await this.options.backend.readSkill(snapshot.target);
|
|
303
|
+
if (liveTarget === null || liveTarget !== (snapshot.targetAfter ?? null))
|
|
304
|
+
return { ok: false, code: "unit-rollback-blocked", message: "target live hash does not match the prepared target; manual recovery required" };
|
|
305
|
+
const liveSources = await Promise.all(snapshot.sourceBefore.map(async (source) => ({ name: source.name, body: await this.options.backend.readSkill(source.name) })));
|
|
306
|
+
const liveSource = liveSources.find(source => source.body !== null);
|
|
307
|
+
if (liveSource !== undefined)
|
|
308
|
+
return { ok: false, code: "unit-rollback-blocked", message: "source is still live: " + liveSource.name };
|
|
309
|
+
const liveSupport = await Promise.all(snapshot.supportAfter.map(async (file) => ({ path: file.path, content: await this.options.backend.readSupportFile(snapshot.target, file.path) })));
|
|
310
|
+
if (liveSupport.some(file => file.content !== (snapshot.supportAfter.find(item => item.path === file.path)?.content ?? null)))
|
|
311
|
+
return { ok: false, code: "unit-rollback-blocked", message: "support file live hash does not match the prepared support; manual recovery required" };
|
|
281
312
|
const evidence = {
|
|
282
313
|
schemaVersion: 1,
|
|
283
314
|
unitId,
|
|
@@ -298,8 +329,11 @@ export class UnitTransactions {
|
|
|
298
329
|
absorbedInto: snapshot.target,
|
|
299
330
|
})),
|
|
300
331
|
mutations: 0,
|
|
332
|
+
...(snapshot.validationPlan === undefined ? {} : { validationPlan: snapshot.validationPlan }),
|
|
301
333
|
at: this.now(),
|
|
302
334
|
};
|
|
335
|
+
if (receipt?.evidenceSha256 !== undefined && evidenceDigest(evidence) !== receipt.evidenceSha256)
|
|
336
|
+
return { ok: false, code: "unit-rollback-blocked", message: "receipt evidence hash does not match rebuilt evidence" };
|
|
303
337
|
await writeUnitEvidence(this.options.io, this.options.home, evidence);
|
|
304
338
|
if (journal.phase !== "committed")
|
|
305
339
|
await writeUnitJournal(this.options.io, this.options.home, { ...journal, phase: "committed", checkpoints, updatedAt: this.now() });
|
|
@@ -311,7 +345,7 @@ export class UnitTransactions {
|
|
|
311
345
|
return { ok: true, evidence: existing };
|
|
312
346
|
const snapshot = await readUnitSnapshot(this.options.io, this.options.home, unitId);
|
|
313
347
|
const journal = await readUnitJournal(this.options.io, this.options.home, unitId);
|
|
314
|
-
if (snapshot === null || journal === null ||
|
|
348
|
+
if (snapshot === null || journal === null || !["target-written", "support-pending", "source-archiving", "sources-archived", "unit-written"].includes(journal.phase))
|
|
315
349
|
return {
|
|
316
350
|
ok: false,
|
|
317
351
|
code: "unit-rollback-blocked",
|
|
@@ -326,8 +360,15 @@ export class UnitTransactions {
|
|
|
326
360
|
message: "source checkpoint missing: " + missing.map((item) => item.name).join(","),
|
|
327
361
|
};
|
|
328
362
|
const targetAfter = await this.options.backend.readSkill(snapshot.target);
|
|
329
|
-
if (targetAfter === null)
|
|
330
|
-
return { ok: false, code: "unit-rollback-blocked", message: "target missing; manual recovery required" };
|
|
363
|
+
if (targetAfter === null || targetAfter !== (snapshot.targetAfter ?? null))
|
|
364
|
+
return { ok: false, code: "unit-rollback-blocked", message: "target missing or drifted; manual recovery required" };
|
|
365
|
+
const liveSources = await Promise.all(snapshot.sourceBefore.map(async (source) => ({ name: source.name, body: await this.options.backend.readSkill(source.name) })));
|
|
366
|
+
const liveSource = liveSources.find(source => source.body !== null);
|
|
367
|
+
if (liveSource !== undefined)
|
|
368
|
+
return { ok: false, code: "unit-rollback-blocked", message: "source is still live: " + liveSource.name };
|
|
369
|
+
const liveSupport = await Promise.all(snapshot.supportAfter.map(async (file) => ({ path: file.path, content: await this.options.backend.readSupportFile(snapshot.target, file.path) })));
|
|
370
|
+
if (liveSupport.some(file => file.content !== (snapshot.supportAfter.find(item => item.path === file.path)?.content ?? null)))
|
|
371
|
+
return { ok: false, code: "unit-rollback-blocked", message: "support file live hash does not match the prepared support; manual recovery required" };
|
|
331
372
|
const evidence = {
|
|
332
373
|
schemaVersion: 1,
|
|
333
374
|
unitId,
|
|
@@ -348,6 +389,7 @@ export class UnitTransactions {
|
|
|
348
389
|
absorbedInto: snapshot.target,
|
|
349
390
|
})),
|
|
350
391
|
mutations: 0,
|
|
392
|
+
...(snapshot.validationPlan === undefined ? {} : { validationPlan: snapshot.validationPlan }),
|
|
351
393
|
at: this.now(),
|
|
352
394
|
};
|
|
353
395
|
await writeUnitEvidence(this.options.io, this.options.home, evidence);
|
package/package.json
CHANGED