@lmzhen/dsh-evolution-merge 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +93 -42
- package/lib/types/doctor.js +3 -3
- package/lib/types/domain/undo-journal.d.ts +8 -1
- package/lib/types/domain/undo-journal.js +29 -0
- package/lib/types/index.js +0 -2
- package/lib/types/undo-merge.d.ts +7 -1
- package/lib/types/undo-merge.js +20 -13
- package/lib/types/undo-operation.d.ts +15 -3
- package/lib/types/undo-operation.js +38 -11
- package/lib/types/undo-resume.d.ts +36 -0
- package/lib/types/undo-resume.js +18 -0
- package/lib/types/unit-protocol.js +13 -9
- package/package.json +4 -4
package/lib/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { dirname, isAbsolute, join } from "node:path";
|
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { pairKeyOf, screen, selectCandidates } from "@lmzhen/dsh-evolution-merge-engine";
|
|
4
4
|
import { createUserMessage, defineTool } from "@lmzhen/dsh-evolution-core/platform";
|
|
5
|
-
import { DEFAULT_SKILL_LIMITS,
|
|
5
|
+
import { DEFAULT_SKILL_LIMITS, HomeAuthority, LeaseStore, authorityFrom, authorityHolder, contentHash, createSubagentChannel, errorText, evolutionHome, evolutionIoAdapter, isPresent, isProcessAlive, leaseAsTreeLock, newRunRegistry, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, reportsSweepLockTarget, runsFile, scanContentThreats, supportRefs, sweepReports, transactIo } from "@lmzhen/dsh-evolution-core";
|
|
6
6
|
import { SkillLibrary, newSkillLibrary, parseFrontmatter, revisionAnchorOf, revisionFingerprint, revisionSupportDigest, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core/skills";
|
|
7
7
|
import { HOST_ROUTES, json, methodNotAllowed, registerHostRoutes } from "@lmzhen/dsh-evolution-core/http-routes";
|
|
8
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -3300,17 +3300,22 @@ var MergeUnitTransactions = class {
|
|
|
3300
3300
|
lock = fenceLease.lock;
|
|
3301
3301
|
this.options.onLock?.("tree");
|
|
3302
3302
|
} else {
|
|
3303
|
-
const
|
|
3304
|
-
io: this.options.io,
|
|
3303
|
+
const fallbackLease = await new HomeAuthority({
|
|
3305
3304
|
home: this.options.home,
|
|
3306
3305
|
waitMs: this.options.waitMs ?? 5e3
|
|
3307
|
-
});
|
|
3308
|
-
if (
|
|
3306
|
+
}).lease("tree").catch(() => void 0);
|
|
3307
|
+
if (fallbackLease === void 0) return {
|
|
3309
3308
|
ok: false,
|
|
3310
3309
|
code: "unit-fence-failed",
|
|
3311
|
-
message:
|
|
3310
|
+
message: "tree lock unavailable"
|
|
3311
|
+
};
|
|
3312
|
+
fenceLease = {
|
|
3313
|
+
lock: leaseAsTreeLock(fallbackLease),
|
|
3314
|
+
release: async () => {
|
|
3315
|
+
await fallbackLease.release();
|
|
3316
|
+
}
|
|
3312
3317
|
};
|
|
3313
|
-
lock =
|
|
3318
|
+
lock = fenceLease.lock;
|
|
3314
3319
|
this.options.onLock?.("tree");
|
|
3315
3320
|
}
|
|
3316
3321
|
if (!await lock.assertHeld()) return {
|
|
@@ -3691,17 +3696,22 @@ var MergeUnitTransactions = class {
|
|
|
3691
3696
|
fenceLease = await this.options.fence.acquire();
|
|
3692
3697
|
lock = fenceLease.lock;
|
|
3693
3698
|
} else {
|
|
3694
|
-
const
|
|
3695
|
-
io: this.options.io,
|
|
3699
|
+
const fallbackLease = await new HomeAuthority({
|
|
3696
3700
|
home: this.options.home,
|
|
3697
3701
|
waitMs: this.options.waitMs ?? 5e3
|
|
3698
|
-
});
|
|
3699
|
-
if (
|
|
3702
|
+
}).lease("tree").catch(() => void 0);
|
|
3703
|
+
if (fallbackLease === void 0) return {
|
|
3700
3704
|
ok: false,
|
|
3701
3705
|
code: "unit-fence-failed",
|
|
3702
|
-
message:
|
|
3706
|
+
message: "tree lock unavailable"
|
|
3703
3707
|
};
|
|
3704
|
-
|
|
3708
|
+
fenceLease = {
|
|
3709
|
+
lock: leaseAsTreeLock(fallbackLease),
|
|
3710
|
+
release: async () => {
|
|
3711
|
+
await fallbackLease.release();
|
|
3712
|
+
}
|
|
3713
|
+
};
|
|
3714
|
+
lock = fenceLease.lock;
|
|
3705
3715
|
}
|
|
3706
3716
|
this.options.onLock?.("tree");
|
|
3707
3717
|
if (!await lock.assertHeld()) return {
|
|
@@ -5025,12 +5035,12 @@ async function mergeDoctor(io, home, configuredRoot) {
|
|
|
5025
5035
|
status: quarantineCount < 0 ? "unknown" : quarantineCount === 0 ? "ok" : "warn",
|
|
5026
5036
|
detail: quarantineCount < 0 ? "quarantine 不可读" : quarantineCount + " 行待人工检查"
|
|
5027
5037
|
});
|
|
5028
|
-
const holder = await
|
|
5038
|
+
const holder = await authorityHolder(home).catch(() => null);
|
|
5029
5039
|
rows.push({
|
|
5030
5040
|
key: "tree-lock",
|
|
5031
5041
|
label: "tree lock",
|
|
5032
5042
|
status: holder == null ? "ok" : "warn",
|
|
5033
|
-
detail: holder == null ? "free" : "holder pid=" + String(holder.pid)
|
|
5043
|
+
detail: holder == null ? "free" : "holder pid=" + String(holder.pid)
|
|
5034
5044
|
});
|
|
5035
5045
|
const backupPath = v2BackupPath(home);
|
|
5036
5046
|
const backupRaw = await readTextOrNull(io, backupPath);
|
|
@@ -7860,20 +7870,23 @@ async function runResumeCommand(options) {
|
|
|
7860
7870
|
}
|
|
7861
7871
|
//#endregion
|
|
7862
7872
|
//#region lib/types/undo-merge.js
|
|
7863
|
-
async function undoMergeReceipt(receipt, port) {
|
|
7864
|
-
const
|
|
7865
|
-
if (
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7873
|
+
async function undoMergeReceipt(receipt, port, options = {}) {
|
|
7874
|
+
const members = [];
|
|
7875
|
+
if (options.skipTarget !== true) {
|
|
7876
|
+
const targetVersions = (await port.listVersions(receipt.target)).filter((entry) => entry.unitId === receipt.unitId);
|
|
7877
|
+
if (targetVersions.length === 0) return {
|
|
7878
|
+
ok: false,
|
|
7879
|
+
message: "unit " + receipt.unitId + " is not in the target history; refusing to guess",
|
|
7880
|
+
reverted: []
|
|
7881
|
+
};
|
|
7882
|
+
members.push({
|
|
7883
|
+
skill: receipt.target,
|
|
7884
|
+
entries: targetVersions,
|
|
7885
|
+
status: "live",
|
|
7886
|
+
...receipt.create ? { createdInUnit: true } : {}
|
|
7887
|
+
});
|
|
7888
|
+
}
|
|
7889
|
+
if (options.skipSources !== true) for (const source of receipt.sources) {
|
|
7877
7890
|
const live = await port.readSkill(source);
|
|
7878
7891
|
members.push({
|
|
7879
7892
|
skill: source,
|
|
@@ -7882,6 +7895,11 @@ async function undoMergeReceipt(receipt, port) {
|
|
|
7882
7895
|
...live === null ? { archivedPath: "archive" } : {}
|
|
7883
7896
|
});
|
|
7884
7897
|
}
|
|
7898
|
+
if (members.length === 0) return {
|
|
7899
|
+
ok: true,
|
|
7900
|
+
message: "nothing left to restore",
|
|
7901
|
+
reverted: []
|
|
7902
|
+
};
|
|
7885
7903
|
const plan = planUnitUndoAcross(members);
|
|
7886
7904
|
if (plan.refusals.length > 0) return {
|
|
7887
7905
|
ok: false,
|
|
@@ -7896,6 +7914,15 @@ async function undoMergeReceipt(receipt, port) {
|
|
|
7896
7914
|
function undoJournalPath(home, runId) {
|
|
7897
7915
|
return join(home, "merge", "undo-journal", encodeURIComponent(runId) + ".json");
|
|
7898
7916
|
}
|
|
7917
|
+
async function readUndoJournal(io, home, runId) {
|
|
7918
|
+
const raw = await readTextOrNull(io, undoJournalPath(home, runId));
|
|
7919
|
+
if (raw === null) return null;
|
|
7920
|
+
try {
|
|
7921
|
+
return JSON.parse(raw);
|
|
7922
|
+
} catch {
|
|
7923
|
+
return null;
|
|
7924
|
+
}
|
|
7925
|
+
}
|
|
7899
7926
|
async function writeUndoJournal(io, home, journal) {
|
|
7900
7927
|
await transactIo(io, undoJournalPath(home, journal.runId), () => JSON.stringify(journal, null, 2) + "\n");
|
|
7901
7928
|
}
|
|
@@ -7990,7 +8017,20 @@ async function settleRun(io, home, runId, pairKey, allPairs, at) {
|
|
|
7990
8017
|
updatedAt: new Date(at).toISOString()
|
|
7991
8018
|
});
|
|
7992
8019
|
}
|
|
8020
|
+
/**
|
|
8021
|
+
* Run one merge-undo pass.
|
|
8022
|
+
*
|
|
8023
|
+
* The fence, when the caller supplies one, covers the WHOLE pass: undo writes the skill tree, the
|
|
8024
|
+
* undo receipt, the pair index and run metadata, so a pass that lost exclusion halfway would
|
|
8025
|
+
* interleave with the commit it is undoing (W9-commit-protocol §3.2). A partially applied pair is
|
|
8026
|
+
* reported as `resumable` rather than refused: the journal records the phase, and
|
|
8027
|
+
* `resumeUndo` continues from it.
|
|
8028
|
+
*/
|
|
7993
8029
|
async function runUndoOperation(options) {
|
|
8030
|
+
const run = async () => await runUndoSteps(options);
|
|
8031
|
+
return options.fence === void 0 ? await run() : await options.fence.run(run);
|
|
8032
|
+
}
|
|
8033
|
+
async function runUndoSteps(options) {
|
|
7994
8034
|
const runReceipts = (await readCommitReceipts(options.io, options.home)).filter((receipt) => (receipt.parentRunId ?? receipt.runId) === options.runId);
|
|
7995
8035
|
const allPairKeys = runReceipts.map((receipt) => receipt.pairKey ?? receipt.unitId);
|
|
7996
8036
|
const allReceipts = runReceipts.filter((receipt) => options.target === void 0 || receipt.target === options.target);
|
|
@@ -8015,6 +8055,20 @@ async function runUndoOperation(options) {
|
|
|
8015
8055
|
const undone = [];
|
|
8016
8056
|
for (const receipt of sortReceipts(receipts)) {
|
|
8017
8057
|
const pairKey = receipt.pairKey ?? receipt.unitId;
|
|
8058
|
+
/** Record the durable phase before the step it names (W9-commit-protocol §3.1). */
|
|
8059
|
+
const markPhase = async (next) => {
|
|
8060
|
+
const current = await readUndoJournal(options.io, options.home, options.runId);
|
|
8061
|
+
if (current === null) return;
|
|
8062
|
+
await writeUndoJournal(options.io, options.home, {
|
|
8063
|
+
...current,
|
|
8064
|
+
phase: "running",
|
|
8065
|
+
pairs: current.pairs.map((pair) => pair.pairKey === pairKey ? {
|
|
8066
|
+
...pair,
|
|
8067
|
+
phase: next
|
|
8068
|
+
} : pair),
|
|
8069
|
+
updatedAt: new Date(options.now()).toISOString()
|
|
8070
|
+
});
|
|
8071
|
+
};
|
|
8018
8072
|
if (options.finalizeUnit !== void 0) {
|
|
8019
8073
|
const finalized = await options.finalizeUnit(receipt.unitId);
|
|
8020
8074
|
if (!finalized.ok) return {
|
|
@@ -8024,6 +8078,7 @@ async function runUndoOperation(options) {
|
|
|
8024
8078
|
};
|
|
8025
8079
|
}
|
|
8026
8080
|
if (await readUndoReceipt(options.io, options.home, options.runId, pairKey) !== null) {
|
|
8081
|
+
await markPhase("pair-index");
|
|
8027
8082
|
await options.onPhase?.("pair-index", pairKey);
|
|
8028
8083
|
await recordPair(options.io, options.home, {
|
|
8029
8084
|
pairKey,
|
|
@@ -8031,6 +8086,7 @@ async function runUndoOperation(options) {
|
|
|
8031
8086
|
result: "undone",
|
|
8032
8087
|
target: receipt.target
|
|
8033
8088
|
});
|
|
8089
|
+
await markPhase("run-meta");
|
|
8034
8090
|
await options.onPhase?.("run-meta", pairKey);
|
|
8035
8091
|
await settleRun(options.io, options.home, options.runId, pairKey, allPairKeys, options.now());
|
|
8036
8092
|
undone.push(pairKey);
|
|
@@ -8056,6 +8112,7 @@ async function runUndoOperation(options) {
|
|
|
8056
8112
|
restoredSourceHashes: Object.fromEntries(checked.evidence.sources.map((source) => [source.name, contentHash(source.before)])),
|
|
8057
8113
|
at: options.now()
|
|
8058
8114
|
});
|
|
8115
|
+
await markPhase("pair-index");
|
|
8059
8116
|
await options.onPhase?.("pair-index", pairKey);
|
|
8060
8117
|
await recordPair(options.io, options.home, {
|
|
8061
8118
|
pairKey,
|
|
@@ -8063,21 +8120,15 @@ async function runUndoOperation(options) {
|
|
|
8063
8120
|
result: "undone",
|
|
8064
8121
|
target: receipt.target
|
|
8065
8122
|
});
|
|
8123
|
+
await markPhase("run-meta");
|
|
8066
8124
|
await options.onPhase?.("run-meta", pairKey);
|
|
8067
8125
|
await settleRun(options.io, options.home, options.runId, pairKey, allPairKeys, options.now());
|
|
8068
8126
|
undone.push(pairKey);
|
|
8069
8127
|
continue;
|
|
8070
8128
|
}
|
|
8071
|
-
|
|
8072
|
-
|
|
8073
|
-
|
|
8074
|
-
if (targetStage === "skip" && sourceStage === "skip") {}
|
|
8075
|
-
}
|
|
8076
|
-
if (checked.targetDone !== checked.sourcesDone && (checked.targetDone || checked.sourcesDone)) return {
|
|
8077
|
-
ok: false,
|
|
8078
|
-
code: "undo-partial-state",
|
|
8079
|
-
message: "undo is partially applied; manual retry required",
|
|
8080
|
-
undone
|
|
8129
|
+
const skip = {
|
|
8130
|
+
...checked.targetDone ? { skipTarget: true } : {},
|
|
8131
|
+
...checked.sourcesDone ? { skipSources: true } : {}
|
|
8081
8132
|
};
|
|
8082
8133
|
const phase = (next) => ({
|
|
8083
8134
|
pairKey,
|
|
@@ -8092,11 +8143,12 @@ async function runUndoOperation(options) {
|
|
|
8092
8143
|
updatedAt: new Date(options.now()).toISOString()
|
|
8093
8144
|
});
|
|
8094
8145
|
await options.onPhase?.("restore-target", pairKey);
|
|
8095
|
-
const executed = await undoMergeReceipt(receipt, options.port);
|
|
8146
|
+
const executed = await undoMergeReceipt(receipt, options.port, skip);
|
|
8096
8147
|
if (!executed.ok) return {
|
|
8097
8148
|
ok: false,
|
|
8098
8149
|
message: executed.message,
|
|
8099
|
-
undone
|
|
8150
|
+
undone,
|
|
8151
|
+
resumable: true
|
|
8100
8152
|
};
|
|
8101
8153
|
await writeUndoJournal(options.io, options.home, {
|
|
8102
8154
|
version: 1,
|
|
@@ -11325,7 +11377,6 @@ function formatUnreadyError() {
|
|
|
11325
11377
|
}
|
|
11326
11378
|
const inject = [];
|
|
11327
11379
|
function apply(ctx, rawConfig = {}) {
|
|
11328
|
-
registerOwnerSettings(ctx, "evolution-merge", Config);
|
|
11329
11380
|
configureMergePresentation(ctx);
|
|
11330
11381
|
const startupParams = resolveMergeParams(rawConfig);
|
|
11331
11382
|
let mergeRun;
|
package/lib/types/doctor.js
CHANGED
|
@@ -7,7 +7,7 @@ import { readTextOrNull } from "./domain/read-probe.js";
|
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
8
|
import { stat } from 'node:fs/promises';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { authorityHolder, probeList, runsFile } from '@lmzhen/dsh-evolution-core';
|
|
11
11
|
import { readUnitJournal } from '@lmzhen/dsh-evolution-core/write';
|
|
12
12
|
import { migrationJournalPath, migrationReportPath, v2BackupPath } from "./domain/state-bootstrap.js";
|
|
13
13
|
import { readMergeSnapshot } from "./domain/merge-snapshot.js";
|
|
@@ -104,8 +104,8 @@ export async function mergeDoctor(io, home, configuredRoot) {
|
|
|
104
104
|
quarantineCount = -1;
|
|
105
105
|
}
|
|
106
106
|
rows.push({ key: 'quarantine', label: 'quarantine', status: quarantineCount < 0 ? 'unknown' : quarantineCount === 0 ? 'ok' : 'warn', detail: quarantineCount < 0 ? 'quarantine 不可读' : quarantineCount + ' 行待人工检查' });
|
|
107
|
-
const holder = await
|
|
108
|
-
rows.push({ key: 'tree-lock', label: 'tree lock', status: holder == null ? 'ok' : 'warn', detail: holder == null ? 'free' : 'holder pid=' + String(holder.pid)
|
|
107
|
+
const holder = await authorityHolder(home).catch(() => null);
|
|
108
|
+
rows.push({ key: 'tree-lock', label: 'tree lock', status: holder == null ? 'ok' : 'warn', detail: holder == null ? 'free' : 'holder pid=' + String(holder.pid) });
|
|
109
109
|
const backupPath = v2BackupPath(home);
|
|
110
110
|
const backupRaw = await readTextOrNull(io, backupPath);
|
|
111
111
|
const backupMode = await stat(backupPath).then(value => value.mode).catch(() => null);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
|
|
2
|
-
|
|
2
|
+
import type { JournalProtocol } from '@lmzhen/dsh-evolution-core';
|
|
3
|
+
export type UndoPairPhase = 'pending' | 'restore-target' | 'restore-source' | 'receipt' | 'pair-index' | 'run-meta' | 'done';
|
|
3
4
|
export interface UndoPairState {
|
|
4
5
|
readonly pairKey: string;
|
|
5
6
|
readonly unitId: string;
|
|
@@ -13,6 +14,12 @@ export interface UndoJournal {
|
|
|
13
14
|
readonly pairs: readonly UndoPairState[];
|
|
14
15
|
readonly updatedAt: string;
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* The undo journal's protocol (W9-commit-protocol §3.1): `pair-index` and `run-meta` are explicit
|
|
19
|
+
* phases, because "the receipt was written but the pair index was not" is exactly the state a
|
|
20
|
+
* restart has to recognise.
|
|
21
|
+
*/
|
|
22
|
+
export declare const UNDO_PAIR_PROTOCOL: JournalProtocol<UndoPairPhase>;
|
|
16
23
|
export declare function undoJournalPath(home: string, runId: string): string;
|
|
17
24
|
export declare function readUndoJournal(io: EvolutionIoLike, home: string, runId: string): Promise<UndoJournal | null>;
|
|
18
25
|
export declare function writeUndoJournal(io: EvolutionIoLike, home: string, journal: UndoJournal): Promise<void>;
|
|
@@ -2,6 +2,35 @@ import { readTextOrNull } from "./read-probe.js";
|
|
|
2
2
|
/** Per-run undo journal: one pair at a time, durable phase before each write. */
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { transactIo } from '@lmzhen/dsh-evolution-core';
|
|
5
|
+
/**
|
|
6
|
+
* The undo journal's protocol (W9-commit-protocol §3.1): `pair-index` and `run-meta` are explicit
|
|
7
|
+
* phases, because "the receipt was written but the pair index was not" is exactly the state a
|
|
8
|
+
* restart has to recognise.
|
|
9
|
+
*/
|
|
10
|
+
export const UNDO_PAIR_PROTOCOL = {
|
|
11
|
+
phases: ['pending', 'restore-target', 'restore-source', 'receipt', 'pair-index', 'run-meta', 'done'],
|
|
12
|
+
next: {
|
|
13
|
+
pending: ['restore-target', 'receipt', 'done'],
|
|
14
|
+
'restore-target': ['restore-source', 'receipt'],
|
|
15
|
+
'restore-source': ['receipt'],
|
|
16
|
+
receipt: ['pair-index'],
|
|
17
|
+
'pair-index': ['run-meta'],
|
|
18
|
+
'run-meta': ['done'],
|
|
19
|
+
done: [],
|
|
20
|
+
},
|
|
21
|
+
recoveryOf(phase) {
|
|
22
|
+
switch (phase) {
|
|
23
|
+
case 'done': return 'done';
|
|
24
|
+
case 'pending': return 'retry';
|
|
25
|
+
case 'restore-target':
|
|
26
|
+
case 'restore-source':
|
|
27
|
+
case 'receipt':
|
|
28
|
+
case 'pair-index':
|
|
29
|
+
case 'run-meta': return 'continue';
|
|
30
|
+
default: return 'refuse';
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
5
34
|
export function undoJournalPath(home, runId) { return join(home, 'merge', 'undo-journal', encodeURIComponent(runId) + '.json'); }
|
|
6
35
|
export async function readUndoJournal(io, home, runId) {
|
|
7
36
|
const raw = await readTextOrNull(io, undoJournalPath(home, runId));
|
package/lib/types/index.js
CHANGED
|
@@ -5,7 +5,6 @@ import { selectWithErrors } from "./adapters/select.js";
|
|
|
5
5
|
import { createEngineScreening } from "./adapters/engine-screen.js";
|
|
6
6
|
import { createEngineRunner } from "./adapters/model-runner.js";
|
|
7
7
|
import { authorityFrom, createSubagentChannel, evolutionHome, evolutionIoAdapter, newRunRegistry, reportsSweepLockTarget, sweepReports, transactIo } from '@lmzhen/dsh-evolution-core';
|
|
8
|
-
import { registerOwnerSettings } from '@lmzhen/dsh-evolution-core';
|
|
9
8
|
import { newSkillLibrary } from '@lmzhen/dsh-evolution-core/skills';
|
|
10
9
|
import { registerHostRoutes } from '@lmzhen/dsh-evolution-core/http-routes';
|
|
11
10
|
import { loadMergeState } from "./state.js";
|
|
@@ -137,7 +136,6 @@ function formatUnreadyError() {
|
|
|
137
136
|
}
|
|
138
137
|
export const inject = [];
|
|
139
138
|
export function apply(ctx, rawConfig = {}) {
|
|
140
|
-
registerOwnerSettings(ctx, 'evolution-merge', Config);
|
|
141
139
|
configureMergePresentation(ctx);
|
|
142
140
|
const startupParams = resolveMergeParams(rawConfig);
|
|
143
141
|
let mergeRun;
|
|
@@ -9,5 +9,11 @@ import type { CommitReceipt } from './commit-receipt.ts';
|
|
|
9
9
|
export interface MergeUndoPort extends UnitUndoSink {
|
|
10
10
|
listVersions(skill: string): Promise<readonly SkillVersion[]>;
|
|
11
11
|
}
|
|
12
|
-
export
|
|
12
|
+
export interface UndoMergeOptions {
|
|
13
|
+
/** The target already holds its pre-commit content (a resumed pass). */
|
|
14
|
+
readonly skipTarget?: boolean;
|
|
15
|
+
/** Every source already holds its pre-commit content (a resumed pass). */
|
|
16
|
+
readonly skipSources?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function undoMergeReceipt(receipt: CommitReceipt, port: MergeUndoPort, options?: UndoMergeOptions): Promise<UnitUndoExecution>;
|
|
13
19
|
//# sourceMappingURL=undo-merge.d.ts.map
|
package/lib/types/undo-merge.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import { executeUnitUndo, planUnitUndoAcross } from '@lmzhen/dsh-evolution-core/write';
|
|
2
|
-
export async function undoMergeReceipt(receipt, port) {
|
|
3
|
-
const
|
|
4
|
-
if (
|
|
5
|
-
|
|
2
|
+
export async function undoMergeReceipt(receipt, port, options = {}) {
|
|
3
|
+
const members = [];
|
|
4
|
+
if (options.skipTarget !== true) {
|
|
5
|
+
const targetVersions = (await port.listVersions(receipt.target)).filter(entry => entry.unitId === receipt.unitId);
|
|
6
|
+
if (targetVersions.length === 0) {
|
|
7
|
+
return { ok: false, message: 'unit ' + receipt.unitId + ' is not in the target history; refusing to guess', reverted: [] };
|
|
8
|
+
}
|
|
9
|
+
members.push({ skill: receipt.target, entries: targetVersions, status: 'live', ...(receipt.create ? { createdInUnit: true } : {}) });
|
|
6
10
|
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
if (options.skipSources !== true) {
|
|
12
|
+
for (const source of receipt.sources) {
|
|
13
|
+
const live = await port.readSkill(source);
|
|
14
|
+
members.push({
|
|
15
|
+
skill: source,
|
|
16
|
+
entries: (await port.listVersions(source)).filter(entry => entry.unitId === receipt.unitId),
|
|
17
|
+
status: live === null ? 'archived' : 'live',
|
|
18
|
+
...(live === null ? { archivedPath: 'archive' } : {}),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
16
21
|
}
|
|
22
|
+
if (members.length === 0)
|
|
23
|
+
return { ok: true, message: 'nothing left to restore', reverted: [] };
|
|
17
24
|
const plan = planUnitUndoAcross(members);
|
|
18
25
|
if (plan.refusals.length > 0) {
|
|
19
26
|
return { ok: false, message: 'merge undo preflight refused: ' + plan.refusals.join('; '), reverted: [] };
|
|
@@ -6,9 +6,10 @@ export interface UndoResult {
|
|
|
6
6
|
readonly ok: boolean;
|
|
7
7
|
readonly code?: UndoErrorCode;
|
|
8
8
|
readonly message: string;
|
|
9
|
-
readonly undone: readonly string[];
|
|
9
|
+
readonly undone: readonly string[]; /** True when a journal phase remains and `resumeUndo` can continue. */
|
|
10
|
+
readonly resumable?: boolean;
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
+
type UndoOperationOptions = {
|
|
12
13
|
readonly io: EvolutionIoLike;
|
|
13
14
|
readonly home: string;
|
|
14
15
|
readonly runId: string;
|
|
@@ -24,5 +25,16 @@ export declare function runUndoOperation(options: {
|
|
|
24
25
|
readonly fence?: {
|
|
25
26
|
run<T>(body: () => Promise<T>): Promise<T>;
|
|
26
27
|
};
|
|
27
|
-
}
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Run one merge-undo pass.
|
|
31
|
+
*
|
|
32
|
+
* The fence, when the caller supplies one, covers the WHOLE pass: undo writes the skill tree, the
|
|
33
|
+
* undo receipt, the pair index and run metadata, so a pass that lost exclusion halfway would
|
|
34
|
+
* interleave with the commit it is undoing (W9-commit-protocol §3.2). A partially applied pair is
|
|
35
|
+
* reported as `resumable` rather than refused: the journal records the phase, and
|
|
36
|
+
* `resumeUndo` continues from it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function runUndoOperation(options: UndoOperationOptions): Promise<UndoResult>;
|
|
39
|
+
export {};
|
|
28
40
|
//# sourceMappingURL=undo-operation.d.ts.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { contentHash } from '@lmzhen/dsh-evolution-core';
|
|
2
2
|
import { readCommitReceipts } from "./commit-receipt.js";
|
|
3
3
|
import { undoMergeReceipt } from "./undo-merge.js";
|
|
4
|
-
import { writeUndoJournal } from "./domain/undo-journal.js";
|
|
4
|
+
import { readUndoJournal, writeUndoJournal } from "./domain/undo-journal.js";
|
|
5
5
|
import { readUndoReceipt, writeUndoReceipt } from "./domain/undo-receipt.js";
|
|
6
6
|
import { recordPair } from "./domain/pair-index-store.js";
|
|
7
7
|
import { readRunMeta, writeRunMeta } from "./domain/run-coordinator.js";
|
|
@@ -71,7 +71,20 @@ async function settleRun(io, home, runId, pairKey, allPairs, at) {
|
|
|
71
71
|
createdAt: current?.createdAt ?? new Date(at).toISOString(), updatedAt: new Date(at).toISOString(),
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Run one merge-undo pass.
|
|
76
|
+
*
|
|
77
|
+
* The fence, when the caller supplies one, covers the WHOLE pass: undo writes the skill tree, the
|
|
78
|
+
* undo receipt, the pair index and run metadata, so a pass that lost exclusion halfway would
|
|
79
|
+
* interleave with the commit it is undoing (W9-commit-protocol §3.2). A partially applied pair is
|
|
80
|
+
* reported as `resumable` rather than refused: the journal records the phase, and
|
|
81
|
+
* `resumeUndo` continues from it.
|
|
82
|
+
*/
|
|
74
83
|
export async function runUndoOperation(options) {
|
|
84
|
+
const run = async () => await runUndoSteps(options);
|
|
85
|
+
return options.fence === undefined ? await run() : await options.fence.run(run);
|
|
86
|
+
}
|
|
87
|
+
async function runUndoSteps(options) {
|
|
75
88
|
const runReceipts = (await readCommitReceipts(options.io, options.home)).filter(receipt => (receipt.parentRunId ?? receipt.runId) === options.runId);
|
|
76
89
|
const allPairKeys = runReceipts.map(receipt => receipt.pairKey ?? receipt.unitId);
|
|
77
90
|
const allReceipts = runReceipts.filter(receipt => options.target === undefined || receipt.target === options.target);
|
|
@@ -83,6 +96,18 @@ export async function runUndoOperation(options) {
|
|
|
83
96
|
const undone = [];
|
|
84
97
|
for (const receipt of sortReceipts(receipts)) {
|
|
85
98
|
const pairKey = receipt.pairKey ?? receipt.unitId;
|
|
99
|
+
/** Record the durable phase before the step it names (W9-commit-protocol §3.1). */
|
|
100
|
+
const markPhase = async (next) => {
|
|
101
|
+
const current = await readUndoJournal(options.io, options.home, options.runId);
|
|
102
|
+
if (current === null)
|
|
103
|
+
return;
|
|
104
|
+
await writeUndoJournal(options.io, options.home, {
|
|
105
|
+
...current,
|
|
106
|
+
phase: 'running',
|
|
107
|
+
pairs: current.pairs.map(pair => (pair.pairKey === pairKey ? { ...pair, phase: next } : pair)),
|
|
108
|
+
updatedAt: new Date(options.now()).toISOString(),
|
|
109
|
+
});
|
|
110
|
+
};
|
|
86
111
|
if (options.finalizeUnit !== undefined) {
|
|
87
112
|
const finalized = await options.finalizeUnit(receipt.unitId);
|
|
88
113
|
if (!finalized.ok)
|
|
@@ -90,8 +115,10 @@ export async function runUndoOperation(options) {
|
|
|
90
115
|
}
|
|
91
116
|
const existing = await readUndoReceipt(options.io, options.home, options.runId, pairKey);
|
|
92
117
|
if (existing !== null) {
|
|
118
|
+
await markPhase('pair-index');
|
|
93
119
|
await options.onPhase?.('pair-index', pairKey);
|
|
94
120
|
await recordPair(options.io, options.home, { pairKey, runId: options.runId, result: 'undone', target: receipt.target });
|
|
121
|
+
await markPhase('run-meta');
|
|
95
122
|
await options.onPhase?.('run-meta', pairKey);
|
|
96
123
|
await settleRun(options.io, options.home, options.runId, pairKey, allPairKeys, options.now());
|
|
97
124
|
undone.push(pairKey);
|
|
@@ -103,27 +130,27 @@ export async function runUndoOperation(options) {
|
|
|
103
130
|
if (checked.done) {
|
|
104
131
|
await options.onPhase?.('receipt', pairKey);
|
|
105
132
|
await writeUndoReceipt(options.io, options.home, { version: 1, runId: options.runId, ...(receipt.parentRunId === undefined ? {} : { parentRunId: receipt.parentRunId }), pairKey, unitId: receipt.unitId, evidenceSha256: receipt.evidenceSha256 ?? unitEvidenceDigest(checked.evidence), target: receipt.target, sources: checked.evidence.sources.map(source => source.name), restoredTargetHash: receipt.create ? null : digest(checked.evidence.target.before), restoredSourceHashes: Object.fromEntries(checked.evidence.sources.map(source => [source.name, contentHash(source.before)])), at: options.now() });
|
|
133
|
+
await markPhase('pair-index');
|
|
106
134
|
await options.onPhase?.('pair-index', pairKey);
|
|
107
135
|
await recordPair(options.io, options.home, { pairKey, runId: options.runId, result: 'undone', target: receipt.target });
|
|
136
|
+
await markPhase('run-meta');
|
|
108
137
|
await options.onPhase?.('run-meta', pairKey);
|
|
109
138
|
await settleRun(options.io, options.home, options.runId, pairKey, allPairKeys, options.now());
|
|
110
139
|
undone.push(pairKey);
|
|
111
140
|
continue;
|
|
112
141
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return { ok: false, code: 'undo-partial-state', message: 'undo is partially applied; manual retry required', undone };
|
|
120
|
-
}
|
|
142
|
+
// A resumed pass continues from what the previous attempt already restored: the plan is built
|
|
143
|
+
// from the missing side only, so re-running the pass is idempotent instead of refused.
|
|
144
|
+
const skip = {
|
|
145
|
+
...(checked.targetDone ? { skipTarget: true } : {}),
|
|
146
|
+
...(checked.sourcesDone ? { skipSources: true } : {}),
|
|
147
|
+
};
|
|
121
148
|
const phase = (next) => ({ pairKey, unitId: receipt.unitId, phase: next });
|
|
122
149
|
await writeUndoJournal(options.io, options.home, { version: 1, runId: options.runId, phase: 'running', pairs: pairs.map(pair => pair.pairKey === pairKey ? phase('restore-target') : pair), updatedAt: new Date(options.now()).toISOString() });
|
|
123
150
|
await options.onPhase?.('restore-target', pairKey);
|
|
124
|
-
const executed = await undoMergeReceipt(receipt, options.port);
|
|
151
|
+
const executed = await undoMergeReceipt(receipt, options.port, skip);
|
|
125
152
|
if (!executed.ok)
|
|
126
|
-
return { ok: false, message: executed.message, undone };
|
|
153
|
+
return { ok: false, message: executed.message, undone, resumable: true };
|
|
127
154
|
await writeUndoJournal(options.io, options.home, { version: 1, runId: options.runId, phase: 'running', pairs: pairs.map(pair => pair.pairKey === pairKey ? phase('restore-source') : pair), updatedAt: new Date(options.now()).toISOString() });
|
|
128
155
|
await options.onPhase?.('restore-source', pairKey);
|
|
129
156
|
await writeUndoJournal(options.io, options.home, { version: 1, runId: options.runId, phase: 'running', pairs: pairs.map(pair => pair.pairKey === pairKey ? phase('receipt') : pair), updatedAt: new Date(options.now()).toISOString() });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume an interrupted merge undo from its journal (W9-commit-protocol §3.2).
|
|
3
|
+
*
|
|
4
|
+
* The pass itself is idempotent: a pair whose target or sources already hold their pre-commit
|
|
5
|
+
* content is planned from the missing side only. What a resume needs from the journal is therefore
|
|
6
|
+
* not step replay but a decision — is there anything left, and may it continue? That is what this
|
|
7
|
+
* entry point answers before delegating to the same pass.
|
|
8
|
+
* @module @lmzhen/dsh-evolution-merge/undo-resume
|
|
9
|
+
*/
|
|
10
|
+
import type { EvolutionIoLike } from '@lmzhen/dsh-evolution-core';
|
|
11
|
+
import { type UndoResult } from './undo-operation.ts';
|
|
12
|
+
import type { MergeUndoPort } from './undo-merge.ts';
|
|
13
|
+
export interface ResumeUndoOptions {
|
|
14
|
+
readonly io: EvolutionIoLike;
|
|
15
|
+
readonly home: string;
|
|
16
|
+
readonly runId: string;
|
|
17
|
+
readonly port: MergeUndoPort;
|
|
18
|
+
readonly now: () => number;
|
|
19
|
+
readonly pairKey?: string;
|
|
20
|
+
readonly target?: string;
|
|
21
|
+
readonly finalizeUnit?: (unitId: string) => Promise<{
|
|
22
|
+
readonly ok: boolean;
|
|
23
|
+
readonly message?: string;
|
|
24
|
+
}>;
|
|
25
|
+
readonly onPhase?: (phase: string, pairKey: string) => void | Promise<void>;
|
|
26
|
+
readonly fence?: {
|
|
27
|
+
run<T>(body: () => Promise<T>): Promise<T>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Continue an undo that stopped in a non-terminal phase.
|
|
32
|
+
* @param options - the same inputs the original pass took.
|
|
33
|
+
* @returns the pass result, or a refusal when there is nothing to resume.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resumeUndo(options: ResumeUndoOptions): Promise<UndoResult>;
|
|
36
|
+
//# sourceMappingURL=undo-resume.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readUndoJournal } from "./domain/undo-journal.js";
|
|
2
|
+
import { runUndoOperation } from "./undo-operation.js";
|
|
3
|
+
/**
|
|
4
|
+
* Continue an undo that stopped in a non-terminal phase.
|
|
5
|
+
* @param options - the same inputs the original pass took.
|
|
6
|
+
* @returns the pass result, or a refusal when there is nothing to resume.
|
|
7
|
+
*/
|
|
8
|
+
export async function resumeUndo(options) {
|
|
9
|
+
const journal = await readUndoJournal(options.io, options.home, options.runId);
|
|
10
|
+
if (journal === null) {
|
|
11
|
+
return { ok: false, code: 'undo-partial-state', message: 'no readable undo journal for run ' + options.runId + '; nothing to resume', undone: [] };
|
|
12
|
+
}
|
|
13
|
+
if (journal.pairs.every(pair => pair.phase === 'done')) {
|
|
14
|
+
return { ok: true, message: 'undo already settled: ' + options.runId, undone: [] };
|
|
15
|
+
}
|
|
16
|
+
return await runUndoOperation(options);
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=undo-resume.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** G2 opaque UnitTransaction. Core owns prepare/commit/verify/rollback lifecycle. */
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { HomeAuthority, leaseAsTreeLock } from '@lmzhen/dsh-evolution-core';
|
|
4
4
|
import { readUnitEvidence, readUnitReceipt, readUnitIndex, readUnitSnapshot, unitStorageKey, writeUnitEvidence, writeUnitReceipt, writeUnitSnapshot, } from '@lmzhen/dsh-evolution-core/write';
|
|
5
5
|
import { readUnitJournal, writeUnitJournal } from '@lmzhen/dsh-evolution-core/write';
|
|
6
6
|
function canonicalJson(value) {
|
|
@@ -163,10 +163,12 @@ export class MergeUnitTransactions {
|
|
|
163
163
|
this.options.onLock?.("tree");
|
|
164
164
|
}
|
|
165
165
|
else {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
const fallback = new HomeAuthority({ home: this.options.home, waitMs: this.options.waitMs ?? 5000 });
|
|
167
|
+
const fallbackLease = await fallback.lease("tree").catch(() => undefined);
|
|
168
|
+
if (fallbackLease === undefined)
|
|
169
|
+
return { ok: false, code: "unit-fence-failed", message: "tree lock unavailable" };
|
|
170
|
+
fenceLease = { lock: leaseAsTreeLock(fallbackLease), release: async () => { await fallbackLease.release(); } };
|
|
171
|
+
lock = fenceLease.lock;
|
|
170
172
|
this.options.onLock?.("tree");
|
|
171
173
|
}
|
|
172
174
|
if (!(await lock.assertHeld()))
|
|
@@ -476,10 +478,12 @@ export class MergeUnitTransactions {
|
|
|
476
478
|
lock = fenceLease.lock;
|
|
477
479
|
}
|
|
478
480
|
else {
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
481
|
+
const fallback = new HomeAuthority({ home: this.options.home, waitMs: this.options.waitMs ?? 5000 });
|
|
482
|
+
const fallbackLease = await fallback.lease("tree").catch(() => undefined);
|
|
483
|
+
if (fallbackLease === undefined)
|
|
484
|
+
return { ok: false, code: "unit-fence-failed", message: "tree lock unavailable" };
|
|
485
|
+
fenceLease = { lock: leaseAsTreeLock(fallbackLease), release: async () => { await fallbackLease.release(); } };
|
|
486
|
+
lock = fenceLease.lock;
|
|
483
487
|
}
|
|
484
488
|
this.options.onLock?.("tree");
|
|
485
489
|
if (!(await lock.assertHeld()))
|
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.
|
|
4
|
+
"version": "0.45.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@deepseek-ai/schemastery": "^3.18.5",
|
|
37
|
-
"@lmzhen/dsh-evolution-core": "^0.
|
|
38
|
-
"@lmzhen/dsh-evolution-commands": "^0.
|
|
39
|
-
"@lmzhen/dsh-evolution-merge-engine": "0.
|
|
37
|
+
"@lmzhen/dsh-evolution-core": "^0.45.0",
|
|
38
|
+
"@lmzhen/dsh-evolution-commands": "^0.45.0",
|
|
39
|
+
"@lmzhen/dsh-evolution-merge-engine": "0.45.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@deepseek-ai/cordis": "^4.0.5"
|