@lmzhen/dsh-evolution-merge 0.40.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,566 @@
1
+ /** G2 opaque UnitTransaction. Core owns prepare/commit/verify/rollback lifecycle. */
2
+ import { createHash } from "node:crypto";
3
+ import { acquireTreeWriteLock } from '@lmzhen/dsh-evolution-core';
4
+ import { readUnitEvidence, readUnitReceipt, readUnitIndex, readUnitSnapshot, unitStorageKey, writeUnitEvidence, writeUnitReceipt, writeUnitSnapshot, } from '@lmzhen/dsh-evolution-core/write';
5
+ import { readUnitJournal, writeUnitJournal } from '@lmzhen/dsh-evolution-core/write';
6
+ function canonicalJson(value) {
7
+ if (value === null || typeof value !== 'object')
8
+ return JSON.stringify(value);
9
+ if (Array.isArray(value))
10
+ return '[' + value.map(canonicalJson).join(',') + ']';
11
+ const object = value;
12
+ return '{' + Object.keys(object).sort().map(key => JSON.stringify(key) + ':' + canonicalJson(object[key])).join(',') + '}';
13
+ }
14
+ function sameTarget(a, b) {
15
+ return (a ?? '').trimEnd() === (b ?? '').trimEnd();
16
+ }
17
+ function digest(plan) {
18
+ return createHash("sha256").update(canonicalJson(plan)).digest("hex");
19
+ }
20
+ export function unitEvidenceDigest(evidence) {
21
+ return createHash("sha256").update(canonicalJson({ ...evidence, at: 0, mutations: 0 })).digest("hex");
22
+ }
23
+ export class MergeUnitTransactions {
24
+ options;
25
+ prepared = new Map();
26
+ commits = new Map();
27
+ now;
28
+ constructor(options) {
29
+ this.options = options;
30
+ this.now = options.now ?? (() => Date.now());
31
+ }
32
+ unitId(plan) {
33
+ // DEC-D5: the caller may provide the unitId and still get idempotent prepare/commit.
34
+ // Deriving one remains the fallback for callers that do not.
35
+ return plan.unitId ?? plan.runId + ":" + createHash("sha256").update(plan.pairKey).digest("hex").slice(0, 16);
36
+ }
37
+ async snapshot(plan, unitId, planDigest) {
38
+ const targetBefore = await this.options.backend.readSkill(plan.target);
39
+ const supportBefore = await Promise.all(plan.supportFiles.map(async (file) => ({
40
+ path: file.path,
41
+ content: await this.options.backend.readSupportFile(plan.target, file.path),
42
+ })));
43
+ const sourceBefore = await Promise.all(plan.sources
44
+ .filter((source) => source !== plan.target)
45
+ .map(async (name) => ({ name, body: (await this.options.backend.readSkill(name)) ?? "" })));
46
+ return {
47
+ schemaVersion: 1,
48
+ unitId,
49
+ planDigest,
50
+ runId: plan.runId,
51
+ target: plan.target,
52
+ targetExists: plan.targetExists,
53
+ targetBefore,
54
+ targetAfter: plan.body,
55
+ supportBefore,
56
+ supportAfter: plan.supportFiles,
57
+ sourceBefore,
58
+ origin: plan.origin,
59
+ summary: plan.summary,
60
+ ...(plan.validationPlan === undefined ? {} : { validationPlan: plan.validationPlan }),
61
+ createdAt: this.now(),
62
+ };
63
+ }
64
+ async prepare(plan) {
65
+ const unitId = this.unitId(plan);
66
+ const planDigest = digest(plan);
67
+ const existing = this.prepared.get(unitId);
68
+ if (existing !== undefined)
69
+ return existing.digest === planDigest
70
+ ? { ok: true, prepared: { unitId, planDigest } }
71
+ : { ok: false, code: "unit-conflict", message: "unitId already prepared with a different plan" };
72
+ const snapshot = await this.snapshot(plan, unitId, planDigest);
73
+ const path = await writeUnitSnapshot(this.options.io, this.options.home, snapshot);
74
+ const journal = {
75
+ schemaVersion: 1,
76
+ unitId,
77
+ runId: plan.runId,
78
+ planDigest,
79
+ target: plan.target,
80
+ sources: plan.sources,
81
+ phase: "prepared",
82
+ storageKey: unitStorageKey(unitId),
83
+ snapshotSha256: createHash("sha256")
84
+ .update(JSON.stringify(snapshot, null, 2) + "\n")
85
+ .digest("hex"),
86
+ updatedAt: this.now(),
87
+ };
88
+ await writeUnitJournal(this.options.io, this.options.home, journal);
89
+ await this.options.onPhase?.("prepared");
90
+ this.prepared.set(unitId, {
91
+ plan,
92
+ digest: planDigest,
93
+ at: this.now(),
94
+ snapshotSha256: createHash("sha256")
95
+ .update(JSON.stringify(snapshot, null, 2) + "\n")
96
+ .digest("hex"),
97
+ });
98
+ void path;
99
+ return { ok: true, prepared: { unitId, planDigest } };
100
+ }
101
+ async commit(prepared, options) {
102
+ const running = this.commits.get(prepared.unitId);
103
+ if (running !== undefined)
104
+ return await running;
105
+ const run = () => this.commitOnce(prepared, options);
106
+ const task = this.options.serial === undefined ? run() : this.options.serial(run);
107
+ this.commits.set(prepared.unitId, task);
108
+ try {
109
+ return await task;
110
+ }
111
+ finally {
112
+ this.commits.delete(prepared.unitId);
113
+ }
114
+ }
115
+ async commitOnce(prepared, options) {
116
+ const existing = await readUnitEvidence(this.options.io, this.options.home, prepared.unitId);
117
+ if (existing !== null)
118
+ return existing.planDigest === prepared.planDigest
119
+ ? { ok: true, evidence: existing }
120
+ : { ok: false, code: "unit-conflict", message: "unit already committed with a different digest" };
121
+ const stored = this.prepared.get(prepared.unitId);
122
+ if (stored === undefined || stored.digest !== prepared.planDigest)
123
+ return {
124
+ ok: false,
125
+ code: "unit-plan-missing",
126
+ message: "prepared plan is not in this process; manual recovery required",
127
+ };
128
+ if (options?.signal?.aborted === true)
129
+ return { ok: false, code: "unit-commit-failed", message: "cancelled before commit" };
130
+ if (this.options.isActive !== undefined && !(await this.options.isActive(stored.plan.runId))) {
131
+ return { ok: false, code: "unit-run-inactive", message: "run is no longer active; refusing to commit" };
132
+ }
133
+ const journalBase = {
134
+ schemaVersion: 1,
135
+ unitId: prepared.unitId,
136
+ runId: stored.plan.runId,
137
+ planDigest: stored.digest,
138
+ target: stored.plan.target,
139
+ sources: stored.plan.sources,
140
+ storageKey: unitStorageKey(prepared.unitId),
141
+ snapshotSha256: stored.snapshotSha256,
142
+ updatedAt: this.now(),
143
+ };
144
+ let lock;
145
+ let fenceLease;
146
+ let claim;
147
+ let stage = 'fence';
148
+ const checkpoints = [];
149
+ const writePhase = async (phase, checkpointsNext = checkpoints) => {
150
+ if (lock !== undefined && !(await lock.assertHeld()))
151
+ throw new Error("unit-fence-lost");
152
+ await writeUnitJournal(this.options.io, this.options.home, {
153
+ ...journalBase,
154
+ phase,
155
+ checkpoints: checkpointsNext,
156
+ });
157
+ await this.options.onPhase?.(phase);
158
+ };
159
+ try {
160
+ if (this.options.fence !== undefined) {
161
+ fenceLease = await this.options.fence.acquire();
162
+ lock = fenceLease.lock;
163
+ this.options.onLock?.("tree");
164
+ }
165
+ else {
166
+ const acquired = await acquireTreeWriteLock({ io: this.options.io, home: this.options.home, waitMs: this.options.waitMs ?? 5000 });
167
+ if (!acquired.ok)
168
+ return { ok: false, code: "unit-fence-failed", message: acquired.message ?? acquired.reason };
169
+ lock = acquired.lock;
170
+ this.options.onLock?.("tree");
171
+ }
172
+ if (!(await lock.assertHeld()))
173
+ return { ok: false, code: "unit-fence-failed", message: "tree fence lost" };
174
+ stage = 'claim';
175
+ claim = await this.options.claim.acquire(stored.plan.target, stored.plan.runId);
176
+ this.options.onLock?.("claim");
177
+ // Re-read under the fence: another instance may have committed while we queued.
178
+ const committedWhileWaiting = await readUnitEvidence(this.options.io, this.options.home, prepared.unitId);
179
+ if (committedWhileWaiting !== null) {
180
+ return committedWhileWaiting.planDigest === prepared.planDigest
181
+ ? { ok: true, evidence: committedWhileWaiting }
182
+ : { ok: false, code: "unit-conflict", message: "unit already committed with a different digest" };
183
+ }
184
+ stage = 'commit';
185
+ await writeUnitJournal(this.options.io, this.options.home, { ...journalBase, phase: "target-pending" });
186
+ const result = await this.options.backend.commit({ ...stored.plan, unitId: prepared.unitId }, async (phase, detail) => {
187
+ if (phase === "support-pending") {
188
+ await writePhase("support-pending");
189
+ await this.options.onPhase?.("support-pending");
190
+ return;
191
+ }
192
+ if (phase === "target-applied") {
193
+ await this.options.onPhase?.("target-applied");
194
+ return;
195
+ }
196
+ if (phase === "unit-written") {
197
+ if (lock !== undefined && !(await lock.assertHeld()))
198
+ throw new Error("unit-fence-lost");
199
+ await writeUnitReceipt(this.options.io, this.options.home, {
200
+ schemaVersion: 1,
201
+ unitId: prepared.unitId,
202
+ runId: stored.plan.runId,
203
+ planDigest: stored.digest,
204
+ target: stored.plan.target,
205
+ archived: stored.plan.sources.filter((source) => source !== stored.plan.target),
206
+ at: this.now(),
207
+ });
208
+ await writePhase("unit-written");
209
+ return;
210
+ }
211
+ if (phase === "source-archiving" && detail?.source !== undefined) {
212
+ checkpoints.push({
213
+ source: detail.source,
214
+ phase: "archived",
215
+ hash: createHash("sha256")
216
+ .update(stored.plan.expectedSourceBodies.find((item) => item.name === detail.source)?.body ?? "")
217
+ .digest("hex"),
218
+ at: this.now(),
219
+ });
220
+ }
221
+ await writePhase(phase, checkpoints);
222
+ }, options?.signal, async () => lock !== undefined && (await lock.assertHeld()), this.options.serial === undefined ? undefined : { serialized: true });
223
+ if (!result.ok) {
224
+ await writePhase("failed");
225
+ return { ok: false, code: result.code ?? "unit-commit-failed", message: result.message };
226
+ }
227
+ const targetAfter = (await this.options.backend.readSkill(stored.plan.target)) ?? "";
228
+ const evidence = {
229
+ schemaVersion: 1,
230
+ unitId: prepared.unitId,
231
+ runId: stored.plan.runId,
232
+ planDigest: stored.digest,
233
+ state: "complete",
234
+ target: {
235
+ before: stored.plan.expectedTargetBody,
236
+ after: targetAfter,
237
+ supportDigest: createHash("sha256").update(JSON.stringify(stored.plan.supportFiles)).digest("hex"),
238
+ created: !stored.plan.targetExists,
239
+ },
240
+ sources: stored.plan.sources
241
+ .filter((source) => source !== stored.plan.target)
242
+ .map((name) => ({
243
+ name,
244
+ before: stored.plan.expectedSourceBodies.find((item) => item.name === name)?.body ?? "",
245
+ after: null,
246
+ archived: (result.archived ?? []).includes(name),
247
+ absorbedInto: stored.plan.target,
248
+ })),
249
+ mutations: result.mutations ?? 0,
250
+ ...(stored.plan.validationPlan === undefined ? {} : { validationPlan: stored.plan.validationPlan }),
251
+ at: this.now(),
252
+ };
253
+ if (!(await lock.assertHeld()))
254
+ throw new Error("unit-fence-lost");
255
+ const evidenceSha256 = unitEvidenceDigest(evidence);
256
+ await writeUnitEvidence(this.options.io, this.options.home, evidence);
257
+ await writeUnitReceipt(this.options.io, this.options.home, {
258
+ schemaVersion: 1, unitId: prepared.unitId, runId: stored.plan.runId, planDigest: stored.digest,
259
+ target: stored.plan.target, archived: stored.plan.sources.filter((source) => source !== stored.plan.target), at: this.now(), evidenceSha256,
260
+ });
261
+ await writePhase("committed", checkpoints);
262
+ this.prepared.delete(prepared.unitId);
263
+ return { ok: true, evidence };
264
+ }
265
+ catch (error) {
266
+ const message = error instanceof Error ? error.message : String(error);
267
+ return {
268
+ ok: false,
269
+ code: message === "unit-fence-lost"
270
+ ? "unit-fence-failed"
271
+ : stage === "claim"
272
+ ? "unit-claim-failed"
273
+ : stage === "fence"
274
+ ? "unit-fence-failed"
275
+ : "unit-commit-failed",
276
+ message,
277
+ };
278
+ }
279
+ finally {
280
+ try {
281
+ await claim?.release();
282
+ }
283
+ finally {
284
+ if (fenceLease !== undefined)
285
+ await fenceLease.release();
286
+ else
287
+ await lock?.release();
288
+ }
289
+ }
290
+ }
291
+ async verify(ref) {
292
+ if ("unitId" in ref)
293
+ return await readUnitEvidence(this.options.io, this.options.home, ref.unitId);
294
+ const entries = (await readUnitIndex(this.options.io, this.options.home)).filter(entry => entry.runId === ref.runId);
295
+ const all = await Promise.all(entries.map(async (entry) => readUnitEvidence(this.options.io, this.options.home, entry.unitId)));
296
+ const found = all.filter((item) => item !== null && item.runId === ref.runId);
297
+ const only = found[0];
298
+ return found.length === 0 ? null : found.length === 1 ? (only ?? null) : found;
299
+ }
300
+ async withRecoveryFence(task) {
301
+ if (this.options.fence === undefined)
302
+ return await task();
303
+ const lease = await this.options.fence.acquire();
304
+ try {
305
+ return await task();
306
+ }
307
+ finally {
308
+ await lease.release();
309
+ }
310
+ }
311
+ async finalize(unitId) {
312
+ return await this.withRecoveryFence(async () => this.finalizeUnlocked(unitId));
313
+ }
314
+ async finalizeUnlocked(unitId) {
315
+ const existing = await readUnitEvidence(this.options.io, this.options.home, unitId);
316
+ if (existing !== null)
317
+ return { ok: true, evidence: existing };
318
+ const snapshot = await readUnitSnapshot(this.options.io, this.options.home, unitId);
319
+ const journal = await readUnitJournal(this.options.io, this.options.home, unitId);
320
+ const receipt = await readUnitReceipt(this.options.io, this.options.home, unitId);
321
+ if (snapshot === null || journal === null || (journal.phase !== "committed" && receipt === null))
322
+ return { ok: false, code: "unit-rollback-blocked", message: "unit is not committed; manual recovery required" };
323
+ if (receipt !== null &&
324
+ (receipt.unitId !== unitId ||
325
+ receipt.runId !== snapshot.runId ||
326
+ receipt.planDigest !== snapshot.planDigest ||
327
+ receipt.target !== snapshot.target))
328
+ return {
329
+ ok: false,
330
+ code: "unit-rollback-blocked",
331
+ message: "unit receipt does not match snapshot; manual recovery required",
332
+ };
333
+ const checkpoints = journal.checkpoints ?? [];
334
+ const missing = snapshot.sourceBefore.filter((source) => !checkpoints.some((item) => item.source === source.name));
335
+ if (missing.length > 0)
336
+ return {
337
+ ok: false,
338
+ code: "unit-rollback-blocked",
339
+ message: "source checkpoint missing: " + missing.map((item) => item.name).join(","),
340
+ };
341
+ const liveTarget = await this.options.backend.readSkill(snapshot.target);
342
+ if (liveTarget === null || !sameTarget(liveTarget, snapshot.targetAfter))
343
+ return { ok: false, code: "unit-rollback-blocked", message: "target live hash does not match the prepared target; manual recovery required" };
344
+ const liveSources = await Promise.all(snapshot.sourceBefore.map(async (source) => ({ name: source.name, body: await this.options.backend.readSkill(source.name) })));
345
+ const liveSource = liveSources.find(source => source.body !== null);
346
+ if (liveSource !== undefined)
347
+ return { ok: false, code: "unit-rollback-blocked", message: "source is still live: " + liveSource.name };
348
+ let liveSupport = await Promise.all(snapshot.supportAfter.map(async (file) => ({ path: file.path, content: await this.options.backend.readSupportFile(snapshot.target, file.path) })));
349
+ const supportMatches = () => liveSupport.every(file => file.content === (snapshot.supportAfter.find(item => item.path === file.path)?.content ?? null));
350
+ if (!supportMatches()) {
351
+ // Crash after the sources are archived but before the support files land: finish the
352
+ // idempotent support write from the prepared snapshot, then re-check.
353
+ if (!["support-pending", "sources-archived", "unit-written"].includes(journal.phase) || this.options.backend.restoreSupportFile === undefined)
354
+ return { ok: false, code: "unit-rollback-blocked", message: "support file live hash does not match the prepared support; manual recovery required" };
355
+ for (const file of snapshot.supportAfter)
356
+ await this.options.backend.restoreSupportFile(snapshot.target, file.path, file.content);
357
+ liveSupport = await Promise.all(snapshot.supportAfter.map(async (file) => ({ path: file.path, content: await this.options.backend.readSupportFile(snapshot.target, file.path) })));
358
+ if (!supportMatches())
359
+ return { ok: false, code: "unit-rollback-blocked", message: "support file could not be completed from the prepared snapshot" };
360
+ }
361
+ const evidence = {
362
+ schemaVersion: 1,
363
+ unitId,
364
+ runId: snapshot.runId,
365
+ planDigest: snapshot.planDigest,
366
+ state: "complete",
367
+ target: {
368
+ before: snapshot.targetBefore,
369
+ after: (await this.options.backend.readSkill(snapshot.target)) ?? "",
370
+ supportDigest: createHash("sha256").update(JSON.stringify(snapshot.supportAfter)).digest("hex"),
371
+ created: !snapshot.targetExists,
372
+ },
373
+ sources: snapshot.sourceBefore.map((source) => ({
374
+ name: source.name,
375
+ before: source.body,
376
+ after: null,
377
+ archived: checkpoints.some((item) => item.source === source.name),
378
+ absorbedInto: snapshot.target,
379
+ })),
380
+ mutations: 0,
381
+ ...(snapshot.validationPlan === undefined ? {} : { validationPlan: snapshot.validationPlan }),
382
+ at: this.now(),
383
+ };
384
+ if (receipt?.evidenceSha256 !== undefined && unitEvidenceDigest(evidence) !== receipt.evidenceSha256)
385
+ return { ok: false, code: "unit-rollback-blocked", message: "receipt evidence hash does not match rebuilt evidence" };
386
+ await writeUnitEvidence(this.options.io, this.options.home, evidence);
387
+ if (journal.phase !== "committed")
388
+ await writeUnitJournal(this.options.io, this.options.home, { ...journal, phase: "committed", checkpoints, updatedAt: this.now() });
389
+ return { ok: true, evidence };
390
+ }
391
+ async rollForward(unitId) {
392
+ return await this.withRecoveryFence(async () => this.rollForwardUnlocked(unitId));
393
+ }
394
+ async rollForwardUnlocked(unitId) {
395
+ const existing = await readUnitEvidence(this.options.io, this.options.home, unitId);
396
+ if (existing !== null)
397
+ return { ok: true, evidence: existing };
398
+ const snapshot = await readUnitSnapshot(this.options.io, this.options.home, unitId);
399
+ const journal = await readUnitJournal(this.options.io, this.options.home, unitId);
400
+ if (snapshot === null || journal === null || !["target-written", "support-pending", "source-archiving", "sources-archived", "unit-written"].includes(journal.phase))
401
+ return {
402
+ ok: false,
403
+ code: "unit-rollback-blocked",
404
+ message: "unit is not roll-forwardable; manual recovery required",
405
+ };
406
+ const checkpoints = journal.checkpoints ?? [];
407
+ const missing = snapshot.sourceBefore.filter((source) => !checkpoints.some((item) => item.source === source.name));
408
+ if (missing.length > 0)
409
+ return {
410
+ ok: false,
411
+ code: "unit-rollback-blocked",
412
+ message: "source checkpoint missing: " + missing.map((item) => item.name).join(","),
413
+ };
414
+ const targetAfter = await this.options.backend.readSkill(snapshot.target);
415
+ if (targetAfter === null || !sameTarget(targetAfter, snapshot.targetAfter))
416
+ return { ok: false, code: "unit-rollback-blocked", message: "target missing or drifted; manual recovery required" };
417
+ const liveSources = await Promise.all(snapshot.sourceBefore.map(async (source) => ({ name: source.name, body: await this.options.backend.readSkill(source.name) })));
418
+ const liveSource = liveSources.find(source => source.body !== null);
419
+ if (liveSource !== undefined)
420
+ return { ok: false, code: "unit-rollback-blocked", message: "source is still live: " + liveSource.name };
421
+ let liveSupport = await Promise.all(snapshot.supportAfter.map(async (file) => ({ path: file.path, content: await this.options.backend.readSupportFile(snapshot.target, file.path) })));
422
+ const supportMatches = () => liveSupport.every(file => file.content === (snapshot.supportAfter.find(item => item.path === file.path)?.content ?? null));
423
+ if (!supportMatches()) {
424
+ // Crash after the sources are archived but before the support files land: finish the
425
+ // idempotent support write from the prepared snapshot, then re-check.
426
+ if (!["support-pending", "sources-archived", "unit-written"].includes(journal.phase) || this.options.backend.restoreSupportFile === undefined)
427
+ return { ok: false, code: "unit-rollback-blocked", message: "support file live hash does not match the prepared support; manual recovery required" };
428
+ for (const file of snapshot.supportAfter)
429
+ await this.options.backend.restoreSupportFile(snapshot.target, file.path, file.content);
430
+ liveSupport = await Promise.all(snapshot.supportAfter.map(async (file) => ({ path: file.path, content: await this.options.backend.readSupportFile(snapshot.target, file.path) })));
431
+ if (!supportMatches())
432
+ return { ok: false, code: "unit-rollback-blocked", message: "support file could not be completed from the prepared snapshot" };
433
+ }
434
+ const evidence = {
435
+ schemaVersion: 1,
436
+ unitId,
437
+ runId: snapshot.runId,
438
+ planDigest: snapshot.planDigest,
439
+ state: "complete",
440
+ target: {
441
+ before: snapshot.targetBefore,
442
+ after: targetAfter,
443
+ supportDigest: createHash("sha256").update(JSON.stringify(snapshot.supportAfter)).digest("hex"),
444
+ created: !snapshot.targetExists,
445
+ },
446
+ sources: snapshot.sourceBefore.map((source) => ({
447
+ name: source.name,
448
+ before: source.body,
449
+ after: null,
450
+ archived: checkpoints.some((item) => item.source === source.name),
451
+ absorbedInto: snapshot.target,
452
+ })),
453
+ mutations: 0,
454
+ ...(snapshot.validationPlan === undefined ? {} : { validationPlan: snapshot.validationPlan }),
455
+ at: this.now(),
456
+ };
457
+ await writeUnitEvidence(this.options.io, this.options.home, evidence);
458
+ await writeUnitJournal(this.options.io, this.options.home, { ...journal, phase: "committed", checkpoints, updatedAt: this.now() });
459
+ return { ok: true, evidence };
460
+ }
461
+ async rollback(ref) {
462
+ const evidence = await readUnitEvidence(this.options.io, this.options.home, ref.unitId);
463
+ const receipt = await readUnitReceipt(this.options.io, this.options.home, ref.unitId);
464
+ if (evidence !== null || receipt !== null)
465
+ return { ok: false, code: "unit-committed", message: "committed unit cannot roll back" };
466
+ const snapshot = await readUnitSnapshot(this.options.io, this.options.home, ref.unitId);
467
+ if (snapshot === null)
468
+ return { ok: false, code: "unit-rollback-blocked", message: "snapshot missing" };
469
+ const journal = await readUnitJournal(this.options.io, this.options.home, ref.unitId);
470
+ let fenceLease;
471
+ let lock;
472
+ let claim;
473
+ try {
474
+ if (this.options.fence !== undefined) {
475
+ fenceLease = await this.options.fence.acquire();
476
+ lock = fenceLease.lock;
477
+ }
478
+ else {
479
+ const acquired = await acquireTreeWriteLock({ io: this.options.io, home: this.options.home, waitMs: this.options.waitMs ?? 5000 });
480
+ if (!acquired.ok)
481
+ return { ok: false, code: "unit-fence-failed", message: acquired.message ?? acquired.reason };
482
+ lock = acquired.lock;
483
+ }
484
+ this.options.onLock?.("tree");
485
+ if (!(await lock.assertHeld()))
486
+ return { ok: false, code: "unit-fence-failed", message: "tree fence lost" };
487
+ claim = await this.options.claim.acquire(snapshot.target, snapshot.runId);
488
+ this.options.onLock?.("claim");
489
+ const currentTarget = await this.options.backend.readSkill(snapshot.target);
490
+ if (currentTarget !== snapshot.targetBefore && !sameTarget(currentTarget, snapshot.targetAfter))
491
+ return {
492
+ ok: false,
493
+ code: "unit-rollback-blocked",
494
+ message: "target drifted since snapshot; manual recovery required",
495
+ };
496
+ for (const source of snapshot.sourceBefore) {
497
+ const current = await this.options.backend.readSkill(source.name);
498
+ if (current !== null && current !== source.body)
499
+ return { ok: false, code: "unit-rollback-blocked", message: "source drifted since snapshot: " + source.name };
500
+ }
501
+ if (!(await lock.assertHeld()))
502
+ return { ok: false, code: "unit-fence-failed", message: "tree fence lost before target restore" };
503
+ await this.options.backend.restoreTarget(snapshot.target, snapshot.targetBefore);
504
+ for (const file of snapshot.supportBefore) {
505
+ if (!(await lock.assertHeld()))
506
+ return { ok: false, code: "unit-fence-failed", message: "tree fence lost before support restore" };
507
+ await this.options.backend.restoreSupportFile?.(snapshot.target, file.path, file.content);
508
+ }
509
+ for (const source of snapshot.sourceBefore) {
510
+ if (!(await lock.assertHeld()))
511
+ return { ok: false, code: "unit-fence-failed", message: "tree fence lost before source restore" };
512
+ await this.options.backend.restoreSource(source.name, source.body);
513
+ }
514
+ if (journal !== null)
515
+ await writeUnitJournal(this.options.io, this.options.home, { ...journal, phase: "rolled-back", updatedAt: this.now() });
516
+ const evidenceRecord = {
517
+ schemaVersion: 1,
518
+ unitId: ref.unitId,
519
+ runId: snapshot.runId,
520
+ planDigest: snapshot.planDigest,
521
+ state: "target-only",
522
+ target: { before: snapshot.targetBefore, after: snapshot.targetBefore ?? "", supportDigest: "", created: false },
523
+ sources: snapshot.sourceBefore.map((source) => ({
524
+ name: source.name,
525
+ before: source.body,
526
+ after: null,
527
+ archived: false,
528
+ absorbedInto: snapshot.target,
529
+ })),
530
+ mutations: 0,
531
+ at: this.now(),
532
+ };
533
+ return { ok: true, evidence: evidenceRecord };
534
+ }
535
+ finally {
536
+ try {
537
+ await claim?.release();
538
+ }
539
+ finally {
540
+ if (fenceLease !== undefined)
541
+ await fenceLease.release();
542
+ else
543
+ await lock?.release();
544
+ }
545
+ }
546
+ }
547
+ async cancel(unitId, reason) {
548
+ const journal = await readUnitJournal(this.options.io, this.options.home, unitId);
549
+ if (journal === null)
550
+ return { ok: false, phase: "none" };
551
+ if (journal.phase === "prepared" && this.prepared.has(unitId)) {
552
+ this.prepared.delete(unitId);
553
+ return { ok: true, phase: "prepared" };
554
+ }
555
+ if (journal.phase === "target-pending" ||
556
+ journal.phase === "target-written" ||
557
+ journal.phase === "source-archiving" ||
558
+ journal.phase === "support-pending" ||
559
+ journal.phase === "sources-archived") {
560
+ const rolled = await this.rollback({ unitId, reason });
561
+ return { ok: rolled.ok, phase: journal.phase };
562
+ }
563
+ return { ok: false, phase: journal.phase };
564
+ }
565
+ }
566
+ //# sourceMappingURL=unit-protocol.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-merge",
3
3
  "description": "Skill auto-merge domain: screening, two-round authoring, queues and commit orchestration (community build)",
4
- "version": "0.40.1",
4
+ "version": "0.42.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -32,13 +32,12 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@deepseek-ai/schemastery": "^3.18.5",
35
- "@lmzhen/dsh-evolution-core": "^0.40.1",
36
- "@lmzhen/dsh-evolution-commands": "^0.40.1",
37
- "@lmzhen/dsh-evolution-merge-engine": "0.40.1"
35
+ "@lmzhen/dsh-evolution-core": "^0.42.0",
36
+ "@lmzhen/dsh-evolution-commands": "^0.42.0",
37
+ "@lmzhen/dsh-evolution-merge-engine": "0.42.0"
38
38
  },
39
39
  "peerDependencies": {
40
- "@deepseek-ai/cordis": "^4.0.5",
41
- "@deepseek-ai/dsh-llm": "^0.2.2-rc.0"
40
+ "@deepseek-ai/cordis": "^4.0.5"
42
41
  },
43
42
  "engines": {
44
43
  "dsh": ">=0.2.2-rc.0 <0.2.3-0"