@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
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/** G2 opaque UnitTransaction. Core owns prepare/commit/verify/rollback lifecycle. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { acquireTreeWriteLock } from "./tree-lock.js";
|
|
4
|
+
import { readUnitEvidence, readUnitReceipt, readUnitSnapshot, unitStorageKey, writeUnitEvidence, writeUnitReceipt, writeUnitSnapshot, } from "./unit-storage.js";
|
|
5
|
+
import { readUnitJournal, writeUnitJournal } from "./unit-journal.js";
|
|
6
|
+
function canonical(value) {
|
|
7
|
+
if (value === null || typeof value !== "object")
|
|
8
|
+
return JSON.stringify(value);
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return "[" + value.map(canonical).join(",") + "]";
|
|
11
|
+
const object = value;
|
|
12
|
+
return ("{" +
|
|
13
|
+
Object.keys(object)
|
|
14
|
+
.sort()
|
|
15
|
+
.map((key) => JSON.stringify(key) + ":" + canonical(object[key]))
|
|
16
|
+
.join(",") +
|
|
17
|
+
"}");
|
|
18
|
+
}
|
|
19
|
+
function digest(plan) {
|
|
20
|
+
return createHash("sha256").update(canonical(plan)).digest("hex");
|
|
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
|
+
}
|
|
36
|
+
export class UnitTransactions {
|
|
37
|
+
options;
|
|
38
|
+
prepared = new Map();
|
|
39
|
+
commits = new Map();
|
|
40
|
+
now;
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.options = options;
|
|
43
|
+
this.now = options.now ?? (() => Date.now());
|
|
44
|
+
}
|
|
45
|
+
unitId(plan) {
|
|
46
|
+
return plan.runId + ":" + createHash("sha256").update(plan.pairKey).digest("hex").slice(0, 16);
|
|
47
|
+
}
|
|
48
|
+
async snapshot(plan, unitId, planDigest) {
|
|
49
|
+
const targetBefore = await this.options.backend.readSkill(plan.target);
|
|
50
|
+
const supportBefore = await Promise.all(plan.supportFiles.map(async (file) => ({
|
|
51
|
+
path: file.path,
|
|
52
|
+
content: await this.options.backend.readSupportFile(plan.target, file.path),
|
|
53
|
+
})));
|
|
54
|
+
const sourceBefore = await Promise.all(plan.sources
|
|
55
|
+
.filter((source) => source !== plan.target)
|
|
56
|
+
.map(async (name) => ({ name, body: (await this.options.backend.readSkill(name)) ?? "" })));
|
|
57
|
+
return {
|
|
58
|
+
schemaVersion: 1,
|
|
59
|
+
unitId,
|
|
60
|
+
planDigest,
|
|
61
|
+
runId: plan.runId,
|
|
62
|
+
target: plan.target,
|
|
63
|
+
targetExists: plan.targetExists,
|
|
64
|
+
targetBefore,
|
|
65
|
+
targetAfter: plan.body,
|
|
66
|
+
supportBefore,
|
|
67
|
+
supportAfter: plan.supportFiles,
|
|
68
|
+
sourceBefore,
|
|
69
|
+
origin: plan.origin,
|
|
70
|
+
summary: plan.summary,
|
|
71
|
+
...(plan.validationPlan === undefined ? {} : { validationPlan: plan.validationPlan }),
|
|
72
|
+
createdAt: this.now(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
async prepare(plan) {
|
|
76
|
+
const unitId = this.unitId(plan);
|
|
77
|
+
const planDigest = digest(plan);
|
|
78
|
+
const existing = this.prepared.get(unitId);
|
|
79
|
+
if (existing !== undefined)
|
|
80
|
+
return existing.digest === planDigest
|
|
81
|
+
? { ok: true, prepared: { unitId, planDigest } }
|
|
82
|
+
: { ok: false, code: "unit-conflict", message: "unitId already prepared with a different plan" };
|
|
83
|
+
const snapshot = await this.snapshot(plan, unitId, planDigest);
|
|
84
|
+
const path = await writeUnitSnapshot(this.options.io, this.options.home, snapshot);
|
|
85
|
+
const journal = {
|
|
86
|
+
schemaVersion: 1,
|
|
87
|
+
unitId,
|
|
88
|
+
runId: plan.runId,
|
|
89
|
+
planDigest,
|
|
90
|
+
target: plan.target,
|
|
91
|
+
sources: plan.sources,
|
|
92
|
+
phase: "prepared",
|
|
93
|
+
storageKey: unitStorageKey(unitId),
|
|
94
|
+
snapshotSha256: createHash("sha256")
|
|
95
|
+
.update(JSON.stringify(snapshot, null, 2) + "\n")
|
|
96
|
+
.digest("hex"),
|
|
97
|
+
updatedAt: this.now(),
|
|
98
|
+
};
|
|
99
|
+
await writeUnitJournal(this.options.io, this.options.home, journal);
|
|
100
|
+
await this.options.onPhase?.("prepared");
|
|
101
|
+
this.prepared.set(unitId, {
|
|
102
|
+
plan,
|
|
103
|
+
digest: planDigest,
|
|
104
|
+
at: this.now(),
|
|
105
|
+
snapshotSha256: createHash("sha256")
|
|
106
|
+
.update(JSON.stringify(snapshot, null, 2) + "\n")
|
|
107
|
+
.digest("hex"),
|
|
108
|
+
});
|
|
109
|
+
void path;
|
|
110
|
+
return { ok: true, prepared: { unitId, planDigest } };
|
|
111
|
+
}
|
|
112
|
+
async commit(prepared, options) {
|
|
113
|
+
const running = this.commits.get(prepared.unitId);
|
|
114
|
+
if (running !== undefined)
|
|
115
|
+
return await running;
|
|
116
|
+
const task = this.commitOnce(prepared, options);
|
|
117
|
+
this.commits.set(prepared.unitId, task);
|
|
118
|
+
try {
|
|
119
|
+
return await task;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
this.commits.delete(prepared.unitId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async commitOnce(prepared, options) {
|
|
126
|
+
const existing = await readUnitEvidence(this.options.io, this.options.home, prepared.unitId);
|
|
127
|
+
if (existing !== null)
|
|
128
|
+
return existing.planDigest === prepared.planDigest
|
|
129
|
+
? { ok: true, evidence: existing }
|
|
130
|
+
: { ok: false, code: "unit-conflict", message: "unit already committed with a different digest" };
|
|
131
|
+
const stored = this.prepared.get(prepared.unitId);
|
|
132
|
+
if (stored === undefined || stored.digest !== prepared.planDigest)
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
code: "unit-plan-missing",
|
|
136
|
+
message: "prepared plan is not in this process; manual recovery required",
|
|
137
|
+
};
|
|
138
|
+
if (options?.signal?.aborted === true)
|
|
139
|
+
return { ok: false, code: "unit-commit-failed", message: "cancelled before commit" };
|
|
140
|
+
const journalBase = {
|
|
141
|
+
schemaVersion: 1,
|
|
142
|
+
unitId: prepared.unitId,
|
|
143
|
+
runId: stored.plan.runId,
|
|
144
|
+
planDigest: stored.digest,
|
|
145
|
+
target: stored.plan.target,
|
|
146
|
+
sources: stored.plan.sources,
|
|
147
|
+
storageKey: unitStorageKey(prepared.unitId),
|
|
148
|
+
snapshotSha256: stored.snapshotSha256,
|
|
149
|
+
updatedAt: this.now(),
|
|
150
|
+
};
|
|
151
|
+
let lock;
|
|
152
|
+
let claim;
|
|
153
|
+
const checkpoints = [];
|
|
154
|
+
const writePhase = async (phase, checkpointsNext = checkpoints) => {
|
|
155
|
+
if (lock !== undefined && !(await lock.assertHeld()))
|
|
156
|
+
throw new Error("unit-fence-lost");
|
|
157
|
+
await writeUnitJournal(this.options.io, this.options.home, {
|
|
158
|
+
...journalBase,
|
|
159
|
+
phase,
|
|
160
|
+
checkpoints: checkpointsNext,
|
|
161
|
+
});
|
|
162
|
+
await this.options.onPhase?.(phase);
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
const acquired = await acquireTreeWriteLock({ io: this.options.io, home: this.options.home, waitMs: this.options.waitMs ?? 5000 });
|
|
166
|
+
if (!acquired.ok)
|
|
167
|
+
return { ok: false, code: "unit-fence-failed", message: acquired.message ?? acquired.reason };
|
|
168
|
+
lock = acquired.lock;
|
|
169
|
+
if (!(await lock.assertHeld()))
|
|
170
|
+
return { ok: false, code: "unit-fence-failed", message: "tree fence lost" };
|
|
171
|
+
this.options.onLock?.("tree");
|
|
172
|
+
claim = await this.options.claim.acquire(stored.plan.target, stored.plan.runId);
|
|
173
|
+
this.options.onLock?.("claim");
|
|
174
|
+
await writeUnitJournal(this.options.io, this.options.home, { ...journalBase, phase: "target-pending" });
|
|
175
|
+
const result = await this.options.backend.commit({ ...stored.plan, unitId: prepared.unitId }, async (phase, detail) => {
|
|
176
|
+
if (phase === "support-pending") {
|
|
177
|
+
await writePhase("support-pending");
|
|
178
|
+
await this.options.onPhase?.("support-pending");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (phase === "target-applied") {
|
|
182
|
+
await this.options.onPhase?.("target-applied");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (phase === "unit-written") {
|
|
186
|
+
if (lock !== undefined && !(await lock.assertHeld()))
|
|
187
|
+
throw new Error("unit-fence-lost");
|
|
188
|
+
await writeUnitReceipt(this.options.io, this.options.home, {
|
|
189
|
+
schemaVersion: 1,
|
|
190
|
+
unitId: prepared.unitId,
|
|
191
|
+
runId: stored.plan.runId,
|
|
192
|
+
planDigest: stored.digest,
|
|
193
|
+
target: stored.plan.target,
|
|
194
|
+
archived: stored.plan.sources.filter((source) => source !== stored.plan.target),
|
|
195
|
+
at: this.now(),
|
|
196
|
+
});
|
|
197
|
+
await writePhase("unit-written");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (phase === "source-archiving" && detail?.source !== undefined) {
|
|
201
|
+
checkpoints.push({
|
|
202
|
+
source: detail.source,
|
|
203
|
+
phase: "archived",
|
|
204
|
+
hash: createHash("sha256")
|
|
205
|
+
.update(stored.plan.expectedSourceBodies.find((item) => item.name === detail.source)?.body ?? "")
|
|
206
|
+
.digest("hex"),
|
|
207
|
+
at: this.now(),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
await writePhase(phase, checkpoints);
|
|
211
|
+
}, options?.signal, async () => lock !== undefined && (await lock.assertHeld()));
|
|
212
|
+
if (!result.ok) {
|
|
213
|
+
await writePhase("failed");
|
|
214
|
+
return { ok: false, code: result.code ?? "unit-commit-failed", message: result.message };
|
|
215
|
+
}
|
|
216
|
+
const targetAfter = (await this.options.backend.readSkill(stored.plan.target)) ?? "";
|
|
217
|
+
const evidence = {
|
|
218
|
+
schemaVersion: 1,
|
|
219
|
+
unitId: prepared.unitId,
|
|
220
|
+
runId: stored.plan.runId,
|
|
221
|
+
planDigest: stored.digest,
|
|
222
|
+
state: "complete",
|
|
223
|
+
target: {
|
|
224
|
+
before: stored.plan.expectedTargetBody,
|
|
225
|
+
after: targetAfter,
|
|
226
|
+
supportDigest: createHash("sha256").update(JSON.stringify(stored.plan.supportFiles)).digest("hex"),
|
|
227
|
+
created: !stored.plan.targetExists,
|
|
228
|
+
},
|
|
229
|
+
sources: stored.plan.sources
|
|
230
|
+
.filter((source) => source !== stored.plan.target)
|
|
231
|
+
.map((name) => ({
|
|
232
|
+
name,
|
|
233
|
+
before: stored.plan.expectedSourceBodies.find((item) => item.name === name)?.body ?? "",
|
|
234
|
+
after: null,
|
|
235
|
+
archived: (result.archived ?? []).includes(name),
|
|
236
|
+
absorbedInto: stored.plan.target,
|
|
237
|
+
})),
|
|
238
|
+
mutations: result.mutations ?? 0,
|
|
239
|
+
...(stored.plan.validationPlan === undefined ? {} : { validationPlan: stored.plan.validationPlan }),
|
|
240
|
+
at: this.now(),
|
|
241
|
+
};
|
|
242
|
+
if (!(await lock.assertHeld()))
|
|
243
|
+
throw new Error("unit-fence-lost");
|
|
244
|
+
const evidenceSha256 = evidenceDigest(evidence);
|
|
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
|
+
});
|
|
250
|
+
await writePhase("committed", checkpoints);
|
|
251
|
+
this.prepared.delete(prepared.unitId);
|
|
252
|
+
return { ok: true, evidence };
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
code: message === "unit-fence-lost" ? "unit-fence-failed" : "unit-claim-failed",
|
|
259
|
+
message,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
await claim?.release();
|
|
264
|
+
await lock?.release();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async verify(ref) {
|
|
268
|
+
if ("unitId" in ref)
|
|
269
|
+
return await readUnitEvidence(this.options.io, this.options.home, ref.unitId);
|
|
270
|
+
const all = await Promise.all([...this.prepared.keys()].map(async (id) => readUnitEvidence(this.options.io, this.options.home, id)));
|
|
271
|
+
const found = all.filter((item) => item !== null && item.runId === ref.runId);
|
|
272
|
+
const only = found[0];
|
|
273
|
+
return found.length === 0 ? null : found.length === 1 ? (only ?? null) : found;
|
|
274
|
+
}
|
|
275
|
+
async finalize(unitId) {
|
|
276
|
+
const existing = await readUnitEvidence(this.options.io, this.options.home, unitId);
|
|
277
|
+
if (existing !== null)
|
|
278
|
+
return { ok: true, evidence: existing };
|
|
279
|
+
const snapshot = await readUnitSnapshot(this.options.io, this.options.home, unitId);
|
|
280
|
+
const journal = await readUnitJournal(this.options.io, this.options.home, unitId);
|
|
281
|
+
const receipt = await readUnitReceipt(this.options.io, this.options.home, unitId);
|
|
282
|
+
if (snapshot === null || journal === null || (journal.phase !== "committed" && receipt === null))
|
|
283
|
+
return { ok: false, code: "unit-rollback-blocked", message: "unit is not committed; manual recovery required" };
|
|
284
|
+
if (receipt !== null &&
|
|
285
|
+
(receipt.unitId !== unitId ||
|
|
286
|
+
receipt.runId !== snapshot.runId ||
|
|
287
|
+
receipt.planDigest !== snapshot.planDigest ||
|
|
288
|
+
receipt.target !== snapshot.target))
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
code: "unit-rollback-blocked",
|
|
292
|
+
message: "unit receipt does not match snapshot; manual recovery required",
|
|
293
|
+
};
|
|
294
|
+
const checkpoints = journal.checkpoints ?? [];
|
|
295
|
+
const missing = snapshot.sourceBefore.filter((source) => !checkpoints.some((item) => item.source === source.name));
|
|
296
|
+
if (missing.length > 0)
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
code: "unit-rollback-blocked",
|
|
300
|
+
message: "source checkpoint missing: " + missing.map((item) => item.name).join(","),
|
|
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" };
|
|
312
|
+
const evidence = {
|
|
313
|
+
schemaVersion: 1,
|
|
314
|
+
unitId,
|
|
315
|
+
runId: snapshot.runId,
|
|
316
|
+
planDigest: snapshot.planDigest,
|
|
317
|
+
state: "complete",
|
|
318
|
+
target: {
|
|
319
|
+
before: snapshot.targetBefore,
|
|
320
|
+
after: (await this.options.backend.readSkill(snapshot.target)) ?? "",
|
|
321
|
+
supportDigest: createHash("sha256").update(JSON.stringify(snapshot.supportAfter)).digest("hex"),
|
|
322
|
+
created: !snapshot.targetExists,
|
|
323
|
+
},
|
|
324
|
+
sources: snapshot.sourceBefore.map((source) => ({
|
|
325
|
+
name: source.name,
|
|
326
|
+
before: source.body,
|
|
327
|
+
after: null,
|
|
328
|
+
archived: checkpoints.some((item) => item.source === source.name),
|
|
329
|
+
absorbedInto: snapshot.target,
|
|
330
|
+
})),
|
|
331
|
+
mutations: 0,
|
|
332
|
+
...(snapshot.validationPlan === undefined ? {} : { validationPlan: snapshot.validationPlan }),
|
|
333
|
+
at: this.now(),
|
|
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" };
|
|
337
|
+
await writeUnitEvidence(this.options.io, this.options.home, evidence);
|
|
338
|
+
if (journal.phase !== "committed")
|
|
339
|
+
await writeUnitJournal(this.options.io, this.options.home, { ...journal, phase: "committed", checkpoints, updatedAt: this.now() });
|
|
340
|
+
return { ok: true, evidence };
|
|
341
|
+
}
|
|
342
|
+
async rollForward(unitId) {
|
|
343
|
+
const existing = await readUnitEvidence(this.options.io, this.options.home, unitId);
|
|
344
|
+
if (existing !== null)
|
|
345
|
+
return { ok: true, evidence: existing };
|
|
346
|
+
const snapshot = await readUnitSnapshot(this.options.io, this.options.home, unitId);
|
|
347
|
+
const journal = await readUnitJournal(this.options.io, this.options.home, unitId);
|
|
348
|
+
if (snapshot === null || journal === null || !["target-written", "support-pending", "source-archiving", "sources-archived", "unit-written"].includes(journal.phase))
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
code: "unit-rollback-blocked",
|
|
352
|
+
message: "unit is not roll-forwardable; manual recovery required",
|
|
353
|
+
};
|
|
354
|
+
const checkpoints = journal.checkpoints ?? [];
|
|
355
|
+
const missing = snapshot.sourceBefore.filter((source) => !checkpoints.some((item) => item.source === source.name));
|
|
356
|
+
if (missing.length > 0)
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
code: "unit-rollback-blocked",
|
|
360
|
+
message: "source checkpoint missing: " + missing.map((item) => item.name).join(","),
|
|
361
|
+
};
|
|
362
|
+
const targetAfter = await this.options.backend.readSkill(snapshot.target);
|
|
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" };
|
|
372
|
+
const evidence = {
|
|
373
|
+
schemaVersion: 1,
|
|
374
|
+
unitId,
|
|
375
|
+
runId: snapshot.runId,
|
|
376
|
+
planDigest: snapshot.planDigest,
|
|
377
|
+
state: "complete",
|
|
378
|
+
target: {
|
|
379
|
+
before: snapshot.targetBefore,
|
|
380
|
+
after: targetAfter,
|
|
381
|
+
supportDigest: createHash("sha256").update(JSON.stringify(snapshot.supportAfter)).digest("hex"),
|
|
382
|
+
created: !snapshot.targetExists,
|
|
383
|
+
},
|
|
384
|
+
sources: snapshot.sourceBefore.map((source) => ({
|
|
385
|
+
name: source.name,
|
|
386
|
+
before: source.body,
|
|
387
|
+
after: null,
|
|
388
|
+
archived: checkpoints.some((item) => item.source === source.name),
|
|
389
|
+
absorbedInto: snapshot.target,
|
|
390
|
+
})),
|
|
391
|
+
mutations: 0,
|
|
392
|
+
...(snapshot.validationPlan === undefined ? {} : { validationPlan: snapshot.validationPlan }),
|
|
393
|
+
at: this.now(),
|
|
394
|
+
};
|
|
395
|
+
await writeUnitEvidence(this.options.io, this.options.home, evidence);
|
|
396
|
+
await writeUnitJournal(this.options.io, this.options.home, { ...journal, phase: "committed", checkpoints, updatedAt: this.now() });
|
|
397
|
+
return { ok: true, evidence };
|
|
398
|
+
}
|
|
399
|
+
async rollback(ref) {
|
|
400
|
+
const evidence = await readUnitEvidence(this.options.io, this.options.home, ref.unitId);
|
|
401
|
+
const receipt = await readUnitReceipt(this.options.io, this.options.home, ref.unitId);
|
|
402
|
+
if (evidence !== null || receipt !== null)
|
|
403
|
+
return { ok: false, code: "unit-committed", message: "committed unit cannot roll back" };
|
|
404
|
+
const snapshot = await readUnitSnapshot(this.options.io, this.options.home, ref.unitId);
|
|
405
|
+
if (snapshot === null)
|
|
406
|
+
return { ok: false, code: "unit-rollback-blocked", message: "snapshot missing" };
|
|
407
|
+
const journal = await readUnitJournal(this.options.io, this.options.home, ref.unitId);
|
|
408
|
+
const acquired = await acquireTreeWriteLock({ io: this.options.io, home: this.options.home, waitMs: this.options.waitMs ?? 5000 });
|
|
409
|
+
if (!acquired.ok)
|
|
410
|
+
return { ok: false, code: "unit-fence-failed", message: acquired.message ?? acquired.reason };
|
|
411
|
+
let claim;
|
|
412
|
+
try {
|
|
413
|
+
if (!(await acquired.lock.assertHeld()))
|
|
414
|
+
return { ok: false, code: "unit-fence-failed", message: "tree fence lost" };
|
|
415
|
+
this.options.onLock?.("tree");
|
|
416
|
+
claim = await this.options.claim.acquire(snapshot.target, snapshot.runId);
|
|
417
|
+
this.options.onLock?.("claim");
|
|
418
|
+
const currentTarget = await this.options.backend.readSkill(snapshot.target);
|
|
419
|
+
if (currentTarget !== snapshot.targetBefore && currentTarget !== (snapshot.targetAfter ?? null))
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
code: "unit-rollback-blocked",
|
|
423
|
+
message: "target drifted since snapshot; manual recovery required",
|
|
424
|
+
};
|
|
425
|
+
for (const source of snapshot.sourceBefore) {
|
|
426
|
+
const current = await this.options.backend.readSkill(source.name);
|
|
427
|
+
if (current !== null && current !== source.body)
|
|
428
|
+
return { ok: false, code: "unit-rollback-blocked", message: "source drifted since snapshot: " + source.name };
|
|
429
|
+
}
|
|
430
|
+
if (!(await acquired.lock.assertHeld()))
|
|
431
|
+
return { ok: false, code: "unit-fence-failed", message: "tree fence lost before target restore" };
|
|
432
|
+
await this.options.backend.restoreTarget(snapshot.target, snapshot.targetBefore);
|
|
433
|
+
for (const file of snapshot.supportBefore) {
|
|
434
|
+
if (!(await acquired.lock.assertHeld()))
|
|
435
|
+
return { ok: false, code: "unit-fence-failed", message: "tree fence lost before support restore" };
|
|
436
|
+
await this.options.backend.restoreSupportFile?.(snapshot.target, file.path, file.content);
|
|
437
|
+
}
|
|
438
|
+
for (const source of snapshot.sourceBefore) {
|
|
439
|
+
if (!(await acquired.lock.assertHeld()))
|
|
440
|
+
return { ok: false, code: "unit-fence-failed", message: "tree fence lost before source restore" };
|
|
441
|
+
await this.options.backend.restoreSource(source.name, source.body);
|
|
442
|
+
}
|
|
443
|
+
if (journal !== null)
|
|
444
|
+
await writeUnitJournal(this.options.io, this.options.home, { ...journal, phase: "rolled-back", updatedAt: this.now() });
|
|
445
|
+
const evidenceRecord = {
|
|
446
|
+
schemaVersion: 1,
|
|
447
|
+
unitId: ref.unitId,
|
|
448
|
+
runId: snapshot.runId,
|
|
449
|
+
planDigest: snapshot.planDigest,
|
|
450
|
+
state: "target-only",
|
|
451
|
+
target: { before: snapshot.targetBefore, after: snapshot.targetBefore ?? "", supportDigest: "", created: false },
|
|
452
|
+
sources: snapshot.sourceBefore.map((source) => ({
|
|
453
|
+
name: source.name,
|
|
454
|
+
before: source.body,
|
|
455
|
+
after: null,
|
|
456
|
+
archived: false,
|
|
457
|
+
absorbedInto: snapshot.target,
|
|
458
|
+
})),
|
|
459
|
+
mutations: 0,
|
|
460
|
+
at: this.now(),
|
|
461
|
+
};
|
|
462
|
+
return { ok: true, evidence: evidenceRecord };
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
await claim?.release();
|
|
466
|
+
await acquired.lock.release();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
async cancel(unitId, reason) {
|
|
470
|
+
const journal = await readUnitJournal(this.options.io, this.options.home, unitId);
|
|
471
|
+
if (journal === null)
|
|
472
|
+
return { ok: false, phase: "none" };
|
|
473
|
+
if (journal.phase === "prepared" && this.prepared.has(unitId)) {
|
|
474
|
+
this.prepared.delete(unitId);
|
|
475
|
+
return { ok: true, phase: "prepared" };
|
|
476
|
+
}
|
|
477
|
+
if (journal.phase === "target-pending" ||
|
|
478
|
+
journal.phase === "target-written" ||
|
|
479
|
+
journal.phase === "source-archiving" ||
|
|
480
|
+
journal.phase === "sources-archived") {
|
|
481
|
+
const rolled = await this.rollback({ unitId, reason });
|
|
482
|
+
return { ok: rolled.ok, phase: journal.phase };
|
|
483
|
+
}
|
|
484
|
+
return { ok: false, phase: journal.phase };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
//# sourceMappingURL=unit-transaction.js.map
|
package/package.json
CHANGED
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
{
|
|
176
176
|
"id": "merge-state",
|
|
177
177
|
"path": "<evolutionHome>/merge/merge-state.json",
|
|
178
|
-
"writer": "evolution-merge/src/
|
|
178
|
+
"writer": "evolution-merge/src/domain/pair-ledger.ts",
|
|
179
179
|
"serializedBy": "transact",
|
|
180
180
|
"marker": "transactIo(io, mergeStatePath(home)",
|
|
181
181
|
"state": [],
|
|
@@ -190,6 +190,96 @@
|
|
|
190
190
|
"state": [],
|
|
191
191
|
"note": "The L0-L2 gate cache. A cache is still a durable write and goes through the same transaction seam."
|
|
192
192
|
},
|
|
193
|
+
{
|
|
194
|
+
"id": "reports-retention-lock",
|
|
195
|
+
"path": "<evolutionHome>/reports/.retention",
|
|
196
|
+
"writer": "evolution-merge/src/index.ts",
|
|
197
|
+
"serializedBy": "transact",
|
|
198
|
+
"marker": "transactIo(io, reportsSweepLockTarget()",
|
|
199
|
+
"state": [],
|
|
200
|
+
"note": "The report sweep retention lock; startup sweeps merge-* reports under this one transaction."
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"id": "merge-reconcile-report",
|
|
204
|
+
"path": "<evolutionHome>/merge/reconcile.json",
|
|
205
|
+
"writer": "evolution-merge/src/domain/reconcile.ts",
|
|
206
|
+
"serializedBy": "transact",
|
|
207
|
+
"marker": "transactIo(options.io, reconcileReportPath(options.home)",
|
|
208
|
+
"state": [],
|
|
209
|
+
"note": "The reconcile report snapshot, written once per reconcile action set."
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"id": "merge-run-meta",
|
|
213
|
+
"path": "<evolutionHome>/merge/run-meta/<runId>.json",
|
|
214
|
+
"writer": "evolution-merge/src/domain/run-coordinator.ts",
|
|
215
|
+
"serializedBy": "transact",
|
|
216
|
+
"marker": "transactIo(io, runMetaPath(home, meta.runId)",
|
|
217
|
+
"state": [],
|
|
218
|
+
"note": "One merge run metadata record; phase and approval transitions go through the same transaction."
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"id": "merge-state-schema4",
|
|
222
|
+
"path": "<evolutionHome>/merge/merge-state.json",
|
|
223
|
+
"writer": "evolution-merge/src/domain/schema4.ts",
|
|
224
|
+
"serializedBy": "transact",
|
|
225
|
+
"marker": "transactIo(io, schema4StatePath(home)",
|
|
226
|
+
"state": [],
|
|
227
|
+
"note": "The schema4 state writer; the same file as merge-state, guarded by the readiness gate."
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"id": "merge-migration-report",
|
|
231
|
+
"path": "<evolutionHome>/merge/merge-state.migration.report.json",
|
|
232
|
+
"writer": "evolution-merge/src/domain/state-bootstrap.ts",
|
|
233
|
+
"serializedBy": "transact",
|
|
234
|
+
"marker": "transactIo(io, migrationReportPath(home)",
|
|
235
|
+
"state": [],
|
|
236
|
+
"note": "Migration report for the v3 to v4 bootstrap."
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"id": "merge-state-bootstrap",
|
|
240
|
+
"path": "<evolutionHome>/merge/merge-state.json",
|
|
241
|
+
"writer": "evolution-merge/src/domain/state-bootstrap.ts",
|
|
242
|
+
"serializedBy": "transact",
|
|
243
|
+
"marker": "transactIo(io, mergeStatePath(home), async (current)",
|
|
244
|
+
"state": [],
|
|
245
|
+
"note": "The bootstrap migration write of the state file."
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"id": "merge-v2-backup",
|
|
249
|
+
"path": "<evolutionHome>/merge/merge-state.v2.bak.json",
|
|
250
|
+
"writer": "evolution-merge/src/domain/state-bootstrap.ts",
|
|
251
|
+
"serializedBy": "transact",
|
|
252
|
+
"marker": "transactIo(io, backup,",
|
|
253
|
+
"state": [],
|
|
254
|
+
"note": "The v2 backup is written through the transaction once before migration."
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"id": "merge-migration-journal",
|
|
258
|
+
"path": "<evolutionHome>/merge/merge-state.migration.json",
|
|
259
|
+
"writer": "evolution-merge/src/domain/state-bootstrap.ts",
|
|
260
|
+
"serializedBy": "transact",
|
|
261
|
+
"marker": "transactIo(io, migrationJournalPath(home)",
|
|
262
|
+
"state": [],
|
|
263
|
+
"note": "The migration journal; a resumable record of the v3 to v4 move."
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"id": "merge-quarantine",
|
|
267
|
+
"path": "<evolutionHome>/merge/merge-state.quarantine.json",
|
|
268
|
+
"writer": "evolution-merge/src/domain/state-bootstrap.ts",
|
|
269
|
+
"serializedBy": "transact",
|
|
270
|
+
"marker": "transactIo(io, quarantinePath(home)",
|
|
271
|
+
"state": [],
|
|
272
|
+
"note": "Rows the migration could not map, preserved for manual repair."
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"id": "merge-undo-journal",
|
|
276
|
+
"path": "<evolutionHome>/merge/undo-journal/<runId>.json",
|
|
277
|
+
"writer": "evolution-merge/src/domain/undo-journal.ts",
|
|
278
|
+
"serializedBy": "transact",
|
|
279
|
+
"marker": "transactIo(io, undoJournalPath(home, journal.runId)",
|
|
280
|
+
"state": [],
|
|
281
|
+
"note": "Per-run undo progress; recovery reads it to finish a partial undo."
|
|
282
|
+
},
|
|
193
283
|
{
|
|
194
284
|
"id": "merge-tree-lock-acquire",
|
|
195
285
|
"path": "<evolutionHome>/merge/.tree-lock",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { n as errorText } from "./errors-Br2-uUSK.js";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import * as yaml from "js-yaml";
|
|
6
6
|
//#region lib/types/package-identity.js
|