@lmzhen/dsh-evolution-merge 0.30.2 → 0.30.3
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 +84 -29
- package/lib/types/adapters/library.js +8 -0
- package/lib/types/commit-execute.d.ts +4 -0
- package/lib/types/commit-execute.js +7 -6
- package/lib/types/commit-recovery.d.ts +1 -1
- package/lib/types/commit-recovery.js +29 -6
- package/lib/types/commit-snapshot.d.ts +22 -2
- package/lib/types/commit-snapshot.js +39 -4
- package/lib/types/coordinator.d.ts +2 -0
- package/lib/types/coordinator.js +2 -2
- package/lib/types/index.js +6 -3
- package/lib/types/runtime.js +1 -1
- package/lib/types/scan-run.js +3 -1
- package/lib/types/state.js +1 -1
- package/package.json +3 -3
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
1
|
+
import { join, resolve, sep } from "node:path";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { pairKeyOf, screen, selectCandidates, toEngineCandidate } from "@lmzhen/dsh-evolution-merge-engine";
|
|
4
|
-
import { DEFAULT_SKILL_LIMITS, authorityFrom, contentHash, createSubagentChannel, evolutionHome, evolutionIoAdapter, executeUnitUndo, isPresent, newRunRegistry, newSkillLibrary, parseFrontmatter, planUnitUndoAcross, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, scanContentThreats, supportRefs, transactIo, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core";
|
|
4
|
+
import { DEFAULT_SKILL_LIMITS, authorityFrom, contentHash, createSubagentChannel, evolutionHome, evolutionIoAdapter, executeUnitUndo, isPresent, newRunRegistry, newSkillLibrary, parseFrontmatter, planUnitUndoAcross, policyStageLimits, probeAbsent, probeList, probePresent, probeText, probeUnknown, scanContentThreats, skillsRoot, supportRefs, transactIo, validateFrontmatter, validateSupportPath } from "@lmzhen/dsh-evolution-core";
|
|
5
5
|
import { HOST_ROUTES, json, methodNotAllowed, registerHostRoutes } from "@lmzhen/dsh-evolution-core/http-routes";
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
//#region lib/types/adapters/select.js
|
|
@@ -356,7 +356,7 @@ async function upsertRow(io, home, now, row, expect = "any") {
|
|
|
356
356
|
current
|
|
357
357
|
}
|
|
358
358
|
};
|
|
359
|
-
if (current === void 0 && expect !== "any" && expect !== "absent") return {
|
|
359
|
+
if (current === void 0 && expect !== "any" && expect !== "absent" && expect !== "verify") return {
|
|
360
360
|
rows,
|
|
361
361
|
result: {
|
|
362
362
|
ok: false,
|
|
@@ -607,7 +607,16 @@ async function writeJournal(io, home, journal) {
|
|
|
607
607
|
function snapshotPath(home, runId) {
|
|
608
608
|
return join(home, "merge", "snapshots", runId.replace(/[^A-Za-z0-9_-]+/g, "_") + ".json");
|
|
609
609
|
}
|
|
610
|
-
async function writeCommitSnapshot(io, home, runId, plan, now) {
|
|
610
|
+
async function writeCommitSnapshot(io, home, runId, plan, now, port) {
|
|
611
|
+
if (!isSafeSkillName(plan.target) || plan.sources.some((name) => !isSafeSkillName(name))) throw new Error("unsafe skill name in commit plan: " + plan.target);
|
|
612
|
+
const readSupport = port?.readSupportFiles === void 0 ? void 0 : (name) => port.readSupportFiles?.(name) ?? Promise.resolve([]);
|
|
613
|
+
const targetSupport = readSupport === void 0 || !plan.targetExists ? [] : await readSupport(plan.target);
|
|
614
|
+
const sourceSupport = [];
|
|
615
|
+
if (readSupport !== void 0) for (const name of plan.sources) for (const file of await readSupport(name)) sourceSupport.push({
|
|
616
|
+
name,
|
|
617
|
+
path: file.path,
|
|
618
|
+
content: file.content
|
|
619
|
+
});
|
|
611
620
|
const snapshot = {
|
|
612
621
|
runId,
|
|
613
622
|
target: plan.target,
|
|
@@ -618,6 +627,9 @@ async function writeCommitSnapshot(io, home, runId, plan, now) {
|
|
|
618
627
|
name: item.name,
|
|
619
628
|
body: item.body
|
|
620
629
|
})),
|
|
630
|
+
...targetSupport.length === 0 ? {} : { targetSupport },
|
|
631
|
+
...plan.supportFiles.length === 0 ? {} : { targetSupportAfter: plan.supportFiles },
|
|
632
|
+
...sourceSupport.length === 0 ? {} : { sourceSupport },
|
|
621
633
|
at: new Date(now).toISOString()
|
|
622
634
|
};
|
|
623
635
|
await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
|
|
@@ -632,13 +644,27 @@ async function readCommitSnapshot(io, home, runId) {
|
|
|
632
644
|
return null;
|
|
633
645
|
}
|
|
634
646
|
}
|
|
635
|
-
function
|
|
636
|
-
return
|
|
647
|
+
function isSafeSkillName(name) {
|
|
648
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== "." && name !== "..";
|
|
649
|
+
}
|
|
650
|
+
function skillFilePath(root, name, path) {
|
|
651
|
+
if (!isSafeSkillName(name) || path.startsWith("/") || path.includes("..")) throw new Error("unsafe skill file: " + name + "/" + path);
|
|
652
|
+
const base = resolve(root, name);
|
|
653
|
+
const file = resolve(base, path);
|
|
654
|
+
if (!file.startsWith(base + sep)) throw new Error("skill file escapes skill: " + name + "/" + path);
|
|
655
|
+
return file;
|
|
656
|
+
}
|
|
657
|
+
function skillBodyPath(root, name) {
|
|
658
|
+
if (!isSafeSkillName(name)) throw new Error("unsafe skill name: " + name);
|
|
659
|
+
const base = resolve(root);
|
|
660
|
+
const path = resolve(base, name, "SKILL.md");
|
|
661
|
+
if (!path.startsWith(base + sep)) throw new Error("skill path escapes root: " + name);
|
|
662
|
+
return path;
|
|
637
663
|
}
|
|
638
664
|
//#endregion
|
|
639
665
|
//#region lib/types/commit-recovery.js
|
|
640
666
|
/** Startup replay for non-terminal commit journals. */
|
|
641
|
-
async function recoverCommitJournals(io, home, now) {
|
|
667
|
+
async function recoverCommitJournals(io, home, now, skillRoot, restoreSource) {
|
|
642
668
|
const dir = join(home, "merge", "journals");
|
|
643
669
|
if (!await io.exists(dir)) return [];
|
|
644
670
|
const files = (await io.list(dir)).filter((name) => name.endsWith(".json"));
|
|
@@ -660,11 +686,23 @@ async function recoverCommitJournals(io, home, now) {
|
|
|
660
686
|
action = "snapshot-missing";
|
|
661
687
|
ok = false;
|
|
662
688
|
} else try {
|
|
663
|
-
if (recovery === "rollback-sources")
|
|
689
|
+
if (recovery === "rollback-sources") {
|
|
690
|
+
let restoredArchived = restoreSource !== void 0;
|
|
691
|
+
if (restoreSource !== void 0) {
|
|
692
|
+
for (const source of snapshot.sourceBodies) if (!await restoreSource(source.name)) restoredArchived = false;
|
|
693
|
+
}
|
|
694
|
+
if (!restoredArchived) for (const source of snapshot.sourceBodies) await io.writeText(skillBodyPath(skillRoot, source.name), source.body);
|
|
695
|
+
}
|
|
664
696
|
if (snapshot.create && snapshot.targetBody === null) try {
|
|
665
|
-
await io.remove(skillBodyPath(
|
|
697
|
+
await io.remove(skillBodyPath(skillRoot, snapshot.target));
|
|
666
698
|
} catch {}
|
|
667
|
-
else if (snapshot.targetBody !== null) await io.writeText(skillBodyPath(
|
|
699
|
+
else if (snapshot.targetBody !== null) await io.writeText(skillBodyPath(skillRoot, snapshot.target), snapshot.targetBody);
|
|
700
|
+
const oldPaths = new Set((snapshot.targetSupport ?? []).map((file) => file.path));
|
|
701
|
+
for (const file of snapshot.targetSupport ?? []) await io.writeText(skillFilePath(skillRoot, snapshot.target, file.path), file.content);
|
|
702
|
+
for (const file of snapshot.targetSupportAfter ?? []) if (!oldPaths.has(file.path)) try {
|
|
703
|
+
await io.remove(skillFilePath(skillRoot, snapshot.target, file.path));
|
|
704
|
+
} catch {}
|
|
705
|
+
for (const file of snapshot.sourceSupport ?? []) await io.writeText(skillFilePath(skillRoot, file.name, file.path), file.content);
|
|
668
706
|
action = recovery === "rollback-sources" ? "restored-sources-and-target" : "restored-target";
|
|
669
707
|
} catch (error) {
|
|
670
708
|
action = "restore-failed: " + (error instanceof Error ? error.message : String(error));
|
|
@@ -866,11 +904,15 @@ function transition(current, event) {
|
|
|
866
904
|
return event.approval === "on" ? next(current, event.at, "pending", {
|
|
867
905
|
...event.decision === void 0 ? {} : { decision: event.decision },
|
|
868
906
|
...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
|
|
869
|
-
...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt }
|
|
907
|
+
...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
|
|
908
|
+
...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
|
|
909
|
+
...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
|
|
870
910
|
}) : next(current, event.at, "approved", {
|
|
871
911
|
...event.decision === void 0 ? {} : { decision: event.decision },
|
|
872
912
|
...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
|
|
873
|
-
...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt }
|
|
913
|
+
...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
|
|
914
|
+
...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
|
|
915
|
+
...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
|
|
874
916
|
});
|
|
875
917
|
case "round1-fail": return current.state === "verify" ? next(current, event.at, "verify", {
|
|
876
918
|
failure: event.failure,
|
|
@@ -1678,7 +1720,8 @@ async function runScan(options) {
|
|
|
1678
1720
|
});
|
|
1679
1721
|
if (!created.ok || created.row === null) throw new Error(created.ok ? "candidate row missing" : created.message);
|
|
1680
1722
|
row = created.row;
|
|
1681
|
-
await upsertRow(options.io, options.home, options.now, row);
|
|
1723
|
+
const placed = await upsertRow(options.io, options.home, options.now, row, "verify");
|
|
1724
|
+
if (!placed.ok) throw new Error("pair already in " + placed.current.state);
|
|
1682
1725
|
} catch (error) {
|
|
1683
1726
|
totals.failed += 1;
|
|
1684
1727
|
pairs.push({
|
|
@@ -3049,7 +3092,7 @@ async function commitStagedRun(io, home, runId, port, options) {
|
|
|
3049
3092
|
message: assembled.message
|
|
3050
3093
|
};
|
|
3051
3094
|
try {
|
|
3052
|
-
await writeCommitSnapshot(io, home, runId, assembled.plan, options.now());
|
|
3095
|
+
await writeCommitSnapshot(io, home, runId, assembled.plan, options.now(), port);
|
|
3053
3096
|
} catch (error) {
|
|
3054
3097
|
return {
|
|
3055
3098
|
ok: false,
|
|
@@ -3073,6 +3116,7 @@ async function commitStagedRun(io, home, runId, port, options) {
|
|
|
3073
3116
|
try {
|
|
3074
3117
|
await writeJournal(io, home, {
|
|
3075
3118
|
...journal,
|
|
3119
|
+
phase: "failed",
|
|
3076
3120
|
updatedAt: new Date(options.now()).toISOString()
|
|
3077
3121
|
});
|
|
3078
3122
|
} catch {}
|
|
@@ -3082,6 +3126,14 @@ async function commitStagedRun(io, home, runId, port, options) {
|
|
|
3082
3126
|
plan: assembled.plan
|
|
3083
3127
|
};
|
|
3084
3128
|
}
|
|
3129
|
+
const committed = {
|
|
3130
|
+
...journal,
|
|
3131
|
+
phase: "committed",
|
|
3132
|
+
updatedAt: new Date(options.now()).toISOString()
|
|
3133
|
+
};
|
|
3134
|
+
try {
|
|
3135
|
+
await writeJournal(io, home, committed);
|
|
3136
|
+
} catch {}
|
|
3085
3137
|
if (result.unitId === void 0) return {
|
|
3086
3138
|
ok: true,
|
|
3087
3139
|
plan: assembled.plan,
|
|
@@ -3101,14 +3153,6 @@ async function commitStagedRun(io, home, runId, port, options) {
|
|
|
3101
3153
|
};
|
|
3102
3154
|
try {
|
|
3103
3155
|
await writeCommitReceipt(io, home, receipt);
|
|
3104
|
-
try {
|
|
3105
|
-
await writeJournal(io, home, {
|
|
3106
|
-
...journal,
|
|
3107
|
-
unitId: result.unitId,
|
|
3108
|
-
phase: "committed",
|
|
3109
|
-
updatedAt: new Date(options.now()).toISOString()
|
|
3110
|
-
});
|
|
3111
|
-
} catch {}
|
|
3112
3156
|
return {
|
|
3113
3157
|
ok: true,
|
|
3114
3158
|
plan: assembled.plan,
|
|
@@ -3504,7 +3548,16 @@ function skillLibraryPort(skills) {
|
|
|
3504
3548
|
const names = await archivedNames.call(skills);
|
|
3505
3549
|
return names === null ? true : names.includes(name);
|
|
3506
3550
|
},
|
|
3507
|
-
commitMerge: (plan) => skills.commitMerge(plan)
|
|
3551
|
+
commitMerge: (plan) => skills.commitMerge(plan),
|
|
3552
|
+
async readSupportFiles(name) {
|
|
3553
|
+
if (typeof skills.listSupportFiles !== "function") return [];
|
|
3554
|
+
const probe = await skills.listSupportFiles(name);
|
|
3555
|
+
if (probe.kind !== "present") return [];
|
|
3556
|
+
return await Promise.all(probe.value.map(async (path) => ({
|
|
3557
|
+
path,
|
|
3558
|
+
content: await skills.readSupportFile(name, path) ?? ""
|
|
3559
|
+
})));
|
|
3560
|
+
}
|
|
3508
3561
|
};
|
|
3509
3562
|
}
|
|
3510
3563
|
//#endregion
|
|
@@ -3624,7 +3677,7 @@ function createMergeRuntime(deps) {
|
|
|
3624
3677
|
const managed = new Set(cards.filter((card) => card.managed === true).map((card) => card.name));
|
|
3625
3678
|
const loadedState = await loadMergeState(deps.io, deps.home);
|
|
3626
3679
|
const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
|
|
3627
|
-
const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || !canRetry(row)).map((row) => row.pairKey));
|
|
3680
|
+
const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || row.queue === "fuse" || !canRetry(row)).map((row) => row.pairKey));
|
|
3628
3681
|
const conflictNotes = loadedState.state.rows.filter((row) => row.state === "pending" || row.state === "approved" || row.state === "fuse" || row.state === "skipped").map((row) => "conflict:" + row.state + ":" + row.pairKey + ":" + row.id);
|
|
3629
3682
|
const result = await runScanCommand({
|
|
3630
3683
|
...common,
|
|
@@ -4404,11 +4457,6 @@ function apply(ctx, rawConfig = {}) {
|
|
|
4404
4457
|
loadMergeState(io, home).then((loaded) => cleanupExpiredStaging(io, home, Date.now(), new Set(loaded.state.rows.map((row) => row.runId).filter((id) => id !== void 0)))).catch((error) => {
|
|
4405
4458
|
ctx.logger.warn("merge staging cleanup failed: " + (error instanceof Error ? error.message : String(error)));
|
|
4406
4459
|
});
|
|
4407
|
-
recoverCommitJournals(io, home, Date.now()).then((reports) => {
|
|
4408
|
-
if (reports.length > 0) ctx.logger.info("merge commit recovery: " + reports.map((report) => report.runId + "=" + report.action).join(", "));
|
|
4409
|
-
}).catch((error) => {
|
|
4410
|
-
ctx.logger.warn("merge commit recovery failed: " + (error instanceof Error ? error.message : String(error)));
|
|
4411
|
-
});
|
|
4412
4460
|
const runs = newRunRegistry({
|
|
4413
4461
|
io,
|
|
4414
4462
|
home,
|
|
@@ -4434,6 +4482,13 @@ function apply(ctx, rawConfig = {}) {
|
|
|
4434
4482
|
...policyStageLimits(policySnapshot?.get?.())
|
|
4435
4483
|
}
|
|
4436
4484
|
});
|
|
4485
|
+
recoverCommitJournals(io, home, Date.now(), skillsRoot(), async (name) => {
|
|
4486
|
+
return (await skills.restoreFromArchive(name)).ok;
|
|
4487
|
+
}).then((reports) => {
|
|
4488
|
+
if (reports.length > 0) ctx.logger.info("merge commit recovery: " + reports.map((report) => report.runId + "=" + report.action).join(", "));
|
|
4489
|
+
}).catch((error) => {
|
|
4490
|
+
ctx.logger.warn("merge commit recovery failed: " + (error instanceof Error ? error.message : String(error)));
|
|
4491
|
+
});
|
|
4437
4492
|
const channel = subagentChannelAdapter(createSubagentChannel(ctx));
|
|
4438
4493
|
const stage = new StageToolRegistry();
|
|
4439
4494
|
const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
|
|
@@ -9,6 +9,14 @@ export function skillLibraryPort(skills) {
|
|
|
9
9
|
return names === null ? true : names.includes(name);
|
|
10
10
|
},
|
|
11
11
|
commitMerge: plan => skills.commitMerge(plan),
|
|
12
|
+
async readSupportFiles(name) {
|
|
13
|
+
if (typeof skills.listSupportFiles !== 'function')
|
|
14
|
+
return [];
|
|
15
|
+
const probe = await skills.listSupportFiles(name);
|
|
16
|
+
if (probe.kind !== 'present')
|
|
17
|
+
return [];
|
|
18
|
+
return await Promise.all(probe.value.map(async (path) => ({ path, content: await skills.readSupportFile(name, path) ?? '' })));
|
|
19
|
+
},
|
|
12
20
|
};
|
|
13
21
|
}
|
|
14
22
|
//# sourceMappingURL=library.js.map
|
|
@@ -8,6 +8,10 @@ import { type CommitAssemblyPort } from './commit-assembly.ts';
|
|
|
8
8
|
import { type CommitReceipt } from './commit-receipt.ts';
|
|
9
9
|
export interface CommitPort extends CommitAssemblyPort {
|
|
10
10
|
commitMerge(plan: CommitMergePlan): Promise<CommitMergeResult>;
|
|
11
|
+
readSupportFiles?(name: string): Promise<readonly {
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly content: string;
|
|
14
|
+
}[]>;
|
|
11
15
|
}
|
|
12
16
|
export interface CommitStagedOptions {
|
|
13
17
|
readonly origin: CommitMergePlan['origin'];
|
|
@@ -8,7 +8,7 @@ export async function commitStagedRun(io, home, runId, port, options) {
|
|
|
8
8
|
if (!assembled.ok)
|
|
9
9
|
return { ok: false, message: assembled.message };
|
|
10
10
|
try {
|
|
11
|
-
await writeCommitSnapshot(io, home, runId, assembled.plan, options.now());
|
|
11
|
+
await writeCommitSnapshot(io, home, runId, assembled.plan, options.now(), port);
|
|
12
12
|
}
|
|
13
13
|
catch (error) {
|
|
14
14
|
return { ok: false, message: 'commit snapshot failed; refusing to write: ' + (error instanceof Error ? error.message : String(error)), plan: assembled.plan };
|
|
@@ -21,11 +21,16 @@ export async function commitStagedRun(io, home, runId, port, options) {
|
|
|
21
21
|
const result = await port.commitMerge(assembled.plan);
|
|
22
22
|
if (!result.ok) {
|
|
23
23
|
try {
|
|
24
|
-
await writeJournal(io, home, { ...journal, updatedAt: new Date(options.now()).toISOString() });
|
|
24
|
+
await writeJournal(io, home, { ...journal, phase: 'failed', updatedAt: new Date(options.now()).toISOString() });
|
|
25
25
|
}
|
|
26
26
|
catch { }
|
|
27
27
|
return { ok: false, message: result.message, plan: assembled.plan };
|
|
28
28
|
}
|
|
29
|
+
const committed = { ...journal, phase: 'committed', updatedAt: new Date(options.now()).toISOString() };
|
|
30
|
+
try {
|
|
31
|
+
await writeJournal(io, home, committed);
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
29
34
|
if (result.unitId === undefined)
|
|
30
35
|
return { ok: true, plan: assembled.plan, result, warning: 'commit landed without a unitId; receipt not written' };
|
|
31
36
|
const receipt = {
|
|
@@ -41,10 +46,6 @@ export async function commitStagedRun(io, home, runId, port, options) {
|
|
|
41
46
|
};
|
|
42
47
|
try {
|
|
43
48
|
await writeCommitReceipt(io, home, receipt);
|
|
44
|
-
try {
|
|
45
|
-
await writeJournal(io, home, { ...journal, unitId: result.unitId, phase: 'committed', updatedAt: new Date(options.now()).toISOString() });
|
|
46
|
-
}
|
|
47
|
-
catch { }
|
|
48
49
|
return { ok: true, plan: assembled.plan, result, receipt };
|
|
49
50
|
}
|
|
50
51
|
catch (error) {
|
|
@@ -6,5 +6,5 @@ export interface CommitRecoveryReport {
|
|
|
6
6
|
readonly action: string;
|
|
7
7
|
readonly ok: boolean;
|
|
8
8
|
}
|
|
9
|
-
export declare function recoverCommitJournals(io: EvolutionIoLike, home: string, now: number): Promise<readonly CommitRecoveryReport[]>;
|
|
9
|
+
export declare function recoverCommitJournals(io: EvolutionIoLike, home: string, now: number, skillRoot: string, restoreSource?: (name: string) => Promise<boolean>): Promise<readonly CommitRecoveryReport[]>;
|
|
10
10
|
//# sourceMappingURL=commit-recovery.d.ts.map
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { recoveryFor } from "./domain/commit-journal.js";
|
|
4
4
|
import { writeJournal } from "./domain/commit-journal-store.js";
|
|
5
|
-
import { readCommitSnapshot, skillBodyPath } from "./commit-snapshot.js";
|
|
6
|
-
export async function recoverCommitJournals(io, home, now) {
|
|
5
|
+
import { readCommitSnapshot, skillBodyPath, skillFilePath } from "./commit-snapshot.js";
|
|
6
|
+
export async function recoverCommitJournals(io, home, now, skillRoot, restoreSource) {
|
|
7
7
|
const dir = join(home, 'merge', 'journals');
|
|
8
8
|
if (!(await io.exists(dir)))
|
|
9
9
|
return [];
|
|
@@ -33,18 +33,41 @@ export async function recoverCommitJournals(io, home, now) {
|
|
|
33
33
|
else {
|
|
34
34
|
try {
|
|
35
35
|
if (recovery === 'rollback-sources') {
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
let restoredArchived = restoreSource !== undefined;
|
|
37
|
+
if (restoreSource !== undefined) {
|
|
38
|
+
for (const source of snapshot.sourceBodies) {
|
|
39
|
+
if (!(await restoreSource(source.name)))
|
|
40
|
+
restoredArchived = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!restoredArchived) {
|
|
44
|
+
for (const source of snapshot.sourceBodies)
|
|
45
|
+
await io.writeText(skillBodyPath(skillRoot, source.name), source.body);
|
|
46
|
+
}
|
|
38
47
|
}
|
|
39
48
|
if (snapshot.create && snapshot.targetBody === null) {
|
|
40
49
|
try {
|
|
41
|
-
await io.remove(skillBodyPath(
|
|
50
|
+
await io.remove(skillBodyPath(skillRoot, snapshot.target));
|
|
42
51
|
}
|
|
43
52
|
catch { /* already gone */ }
|
|
44
53
|
}
|
|
45
54
|
else if (snapshot.targetBody !== null) {
|
|
46
|
-
await io.writeText(skillBodyPath(
|
|
55
|
+
await io.writeText(skillBodyPath(skillRoot, snapshot.target), snapshot.targetBody);
|
|
56
|
+
}
|
|
57
|
+
const oldPaths = new Set((snapshot.targetSupport ?? []).map(file => file.path));
|
|
58
|
+
for (const file of snapshot.targetSupport ?? []) {
|
|
59
|
+
await io.writeText(skillFilePath(skillRoot, snapshot.target, file.path), file.content);
|
|
60
|
+
}
|
|
61
|
+
for (const file of snapshot.targetSupportAfter ?? []) {
|
|
62
|
+
if (!oldPaths.has(file.path)) {
|
|
63
|
+
try {
|
|
64
|
+
await io.remove(skillFilePath(skillRoot, snapshot.target, file.path));
|
|
65
|
+
}
|
|
66
|
+
catch { /* already gone */ }
|
|
67
|
+
}
|
|
47
68
|
}
|
|
69
|
+
for (const file of snapshot.sourceSupport ?? [])
|
|
70
|
+
await io.writeText(skillFilePath(skillRoot, file.name, file.path), file.content);
|
|
48
71
|
action = recovery === 'rollback-sources' ? 'restored-sources-and-target' : 'restored-target';
|
|
49
72
|
}
|
|
50
73
|
catch (error) {
|
|
@@ -9,10 +9,30 @@ export interface CommitSnapshot {
|
|
|
9
9
|
readonly name: string;
|
|
10
10
|
readonly body: string;
|
|
11
11
|
}[];
|
|
12
|
+
readonly targetSupport?: readonly {
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly content: string;
|
|
15
|
+
}[];
|
|
16
|
+
readonly targetSupportAfter?: readonly {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly content: string;
|
|
19
|
+
}[];
|
|
20
|
+
readonly sourceSupport?: readonly {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly content: string;
|
|
24
|
+
}[];
|
|
12
25
|
readonly at: string;
|
|
13
26
|
}
|
|
14
27
|
export declare function snapshotPath(home: string, runId: string): string;
|
|
15
|
-
export declare function writeCommitSnapshot(io: EvolutionIoLike, home: string, runId: string, plan: CommitMergePlan, now: number
|
|
28
|
+
export declare function writeCommitSnapshot(io: EvolutionIoLike, home: string, runId: string, plan: CommitMergePlan, now: number, port?: {
|
|
29
|
+
readSupportFiles?(name: string): Promise<readonly {
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly content: string;
|
|
32
|
+
}[]>;
|
|
33
|
+
}): Promise<CommitSnapshot>;
|
|
16
34
|
export declare function readCommitSnapshot(io: EvolutionIoLike, home: string, runId: string): Promise<CommitSnapshot | null>;
|
|
17
|
-
export declare function
|
|
35
|
+
export declare function isSafeSkillName(name: string): boolean;
|
|
36
|
+
export declare function skillFilePath(root: string, name: string, path: string): string;
|
|
37
|
+
export declare function skillBodyPath(root: string, name: string): string;
|
|
18
38
|
//# sourceMappingURL=commit-snapshot.d.ts.map
|
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
/** Pre-commit snapshot: enough to restore the target and archived sources after a crash. */
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
3
|
export function snapshotPath(home, runId) {
|
|
4
4
|
return join(home, 'merge', 'snapshots', runId.replace(/[^A-Za-z0-9_-]+/g, '_') + '.json');
|
|
5
5
|
}
|
|
6
|
-
export async function writeCommitSnapshot(io, home, runId, plan, now) {
|
|
6
|
+
export async function writeCommitSnapshot(io, home, runId, plan, now, port) {
|
|
7
|
+
if (!isSafeSkillName(plan.target) || plan.sources.some(name => !isSafeSkillName(name))) {
|
|
8
|
+
throw new Error('unsafe skill name in commit plan: ' + plan.target);
|
|
9
|
+
}
|
|
10
|
+
const readSupport = port?.readSupportFiles === undefined
|
|
11
|
+
? undefined
|
|
12
|
+
: (name) => port.readSupportFiles?.(name) ?? Promise.resolve([]);
|
|
13
|
+
const targetSupport = readSupport === undefined || !plan.targetExists ? [] : await readSupport(plan.target);
|
|
14
|
+
const sourceSupport = [];
|
|
15
|
+
if (readSupport !== undefined) {
|
|
16
|
+
for (const name of plan.sources) {
|
|
17
|
+
for (const file of await readSupport(name))
|
|
18
|
+
sourceSupport.push({ name, path: file.path, content: file.content });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
const snapshot = {
|
|
8
22
|
runId, target: plan.target, create: !plan.targetExists, sources: plan.sources,
|
|
9
23
|
targetBody: plan.expectedTargetBody,
|
|
10
24
|
sourceBodies: plan.expectedSourceBodies.map(item => ({ name: item.name, body: item.body })),
|
|
25
|
+
...(targetSupport.length === 0 ? {} : { targetSupport }),
|
|
26
|
+
...(plan.supportFiles.length === 0 ? {} : { targetSupportAfter: plan.supportFiles }),
|
|
27
|
+
...(sourceSupport.length === 0 ? {} : { sourceSupport }),
|
|
11
28
|
at: new Date(now).toISOString(),
|
|
12
29
|
};
|
|
13
30
|
await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
|
|
@@ -24,7 +41,25 @@ export async function readCommitSnapshot(io, home, runId) {
|
|
|
24
41
|
return null;
|
|
25
42
|
}
|
|
26
43
|
}
|
|
27
|
-
export function
|
|
28
|
-
return
|
|
44
|
+
export function isSafeSkillName(name) {
|
|
45
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== '.' && name !== '..';
|
|
46
|
+
}
|
|
47
|
+
export function skillFilePath(root, name, path) {
|
|
48
|
+
if (!isSafeSkillName(name) || path.startsWith('/') || path.includes('..'))
|
|
49
|
+
throw new Error('unsafe skill file: ' + name + '/' + path);
|
|
50
|
+
const base = resolve(root, name);
|
|
51
|
+
const file = resolve(base, path);
|
|
52
|
+
if (!file.startsWith(base + sep))
|
|
53
|
+
throw new Error('skill file escapes skill: ' + name + '/' + path);
|
|
54
|
+
return file;
|
|
55
|
+
}
|
|
56
|
+
export function skillBodyPath(root, name) {
|
|
57
|
+
if (!isSafeSkillName(name))
|
|
58
|
+
throw new Error('unsafe skill name: ' + name);
|
|
59
|
+
const base = resolve(root);
|
|
60
|
+
const path = resolve(base, name, 'SKILL.md');
|
|
61
|
+
if (!path.startsWith(base + sep))
|
|
62
|
+
throw new Error('skill path escapes root: ' + name);
|
|
63
|
+
return path;
|
|
29
64
|
}
|
|
30
65
|
//# sourceMappingURL=commit-snapshot.js.map
|
|
@@ -20,6 +20,8 @@ export type MergeEvent = {
|
|
|
20
20
|
readonly decision?: MergeDecision;
|
|
21
21
|
readonly stagingRef?: string;
|
|
22
22
|
readonly stagingExpiresAt?: number;
|
|
23
|
+
readonly sourceRevision?: string;
|
|
24
|
+
readonly targetRevision?: string;
|
|
23
25
|
readonly at: string;
|
|
24
26
|
} | {
|
|
25
27
|
readonly type: 'round1-fail';
|
package/lib/types/coordinator.js
CHANGED
|
@@ -98,8 +98,8 @@ export function transition(current, event) {
|
|
|
98
98
|
if (current.state !== 'verify')
|
|
99
99
|
return invalid(event, current.state);
|
|
100
100
|
return event.approval === 'on'
|
|
101
|
-
? next(current, event.at, 'pending', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }) })
|
|
102
|
-
: next(current, event.at, 'approved', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }) });
|
|
101
|
+
? next(current, event.at, 'pending', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }), ...(event.sourceRevision === undefined ? {} : { sourceRevision: event.sourceRevision }), ...(event.targetRevision === undefined ? {} : { targetRevision: event.targetRevision }) })
|
|
102
|
+
: next(current, event.at, 'approved', { ...(event.decision === undefined ? {} : { decision: event.decision }), ...(event.stagingRef === undefined ? {} : { stagingRef: event.stagingRef }), ...(event.stagingExpiresAt === undefined ? {} : { stagingExpiresAt: event.stagingExpiresAt }), ...(event.sourceRevision === undefined ? {} : { sourceRevision: event.sourceRevision }), ...(event.targetRevision === undefined ? {} : { targetRevision: event.targetRevision }) });
|
|
103
103
|
case 'round1-fail':
|
|
104
104
|
return current.state === 'verify'
|
|
105
105
|
? next(current, event.at, 'verify', { failure: event.failure, attempts: (current.attempts ?? 0) + 1 })
|
package/lib/types/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { screen as engineScreen } from '@lmzhen/dsh-evolution-merge-engine';
|
|
|
4
4
|
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
|
-
import { authorityFrom, createSubagentChannel, DEFAULT_SKILL_LIMITS, evolutionHome, evolutionIoAdapter, newRunRegistry, newSkillLibrary, policyStageLimits } from '@lmzhen/dsh-evolution-core';
|
|
7
|
+
import { authorityFrom, createSubagentChannel, DEFAULT_SKILL_LIMITS, evolutionHome, evolutionIoAdapter, newRunRegistry, newSkillLibrary, policyStageLimits, skillsRoot } from '@lmzhen/dsh-evolution-core';
|
|
8
8
|
import { registerHostRoutes } from '@lmzhen/dsh-evolution-core/http-routes';
|
|
9
9
|
import { loadMergeState, upsertRow } from "./state.js";
|
|
10
10
|
import { cleanupExpiredStaging } from "./staging.js";
|
|
@@ -122,8 +122,6 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
122
122
|
const io = evolutionIoAdapter(() => registry.provider());
|
|
123
123
|
const home = evolutionHome();
|
|
124
124
|
void loadMergeState(io, home).then(loaded => cleanupExpiredStaging(io, home, Date.now(), new Set(loaded.state.rows.map(row => row.runId).filter((id) => id !== undefined)))).catch((error) => { ctx.logger.warn('merge staging cleanup failed: ' + (error instanceof Error ? error.message : String(error))); });
|
|
125
|
-
void recoverCommitJournals(io, home, Date.now()).then((reports) => { if (reports.length > 0)
|
|
126
|
-
ctx.logger.info('merge commit recovery: ' + reports.map(report => report.runId + '=' + report.action).join(', ')); }).catch((error) => { ctx.logger.warn('merge commit recovery failed: ' + (error instanceof Error ? error.message : String(error))); });
|
|
127
125
|
const runs = newRunRegistry({ io, home, warn: (message) => { ctx.logger.warn(message); } });
|
|
128
126
|
void runs.load();
|
|
129
127
|
ctx.effect(() => {
|
|
@@ -140,6 +138,11 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
140
138
|
io, ctx, authority: authorityFrom({ origin: 'subagent' }),
|
|
141
139
|
limits: { ...DEFAULT_SKILL_LIMITS, ...policyStageLimits(policySnapshot?.get?.()) },
|
|
142
140
|
});
|
|
141
|
+
void recoverCommitJournals(io, home, Date.now(), skillsRoot(), async (name) => {
|
|
142
|
+
const result = await skills.restoreFromArchive(name);
|
|
143
|
+
return result.ok;
|
|
144
|
+
}).then((reports) => { if (reports.length > 0)
|
|
145
|
+
ctx.logger.info('merge commit recovery: ' + reports.map(report => report.runId + '=' + report.action).join(', ')); }).catch((error) => { ctx.logger.warn('merge commit recovery failed: ' + (error instanceof Error ? error.message : String(error))); });
|
|
143
146
|
const channel = subagentChannelAdapter(createSubagentChannel(ctx));
|
|
144
147
|
const stage = new StageToolRegistry();
|
|
145
148
|
const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
|
package/lib/types/runtime.js
CHANGED
|
@@ -95,7 +95,7 @@ export function createMergeRuntime(deps) {
|
|
|
95
95
|
const managed = new Set(cards.filter(card => card.managed === true).map(card => card.name));
|
|
96
96
|
const loadedState = await loadMergeState(deps.io, deps.home);
|
|
97
97
|
const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
|
|
98
|
-
const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || !canRetry(row)).map(row => row.pairKey));
|
|
98
|
+
const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse' || !canRetry(row)).map(row => row.pairKey));
|
|
99
99
|
const conflictNotes = loadedState.state.rows
|
|
100
100
|
.filter(row => row.state === 'pending' || row.state === 'approved' || row.state === 'fuse' || row.state === 'skipped')
|
|
101
101
|
.map(row => 'conflict:' + row.state + ':' + row.pairKey + ':' + row.id);
|
package/lib/types/scan-run.js
CHANGED
|
@@ -65,7 +65,9 @@ export async function runScan(options) {
|
|
|
65
65
|
if (!created.ok || created.row === null)
|
|
66
66
|
throw new Error(created.ok ? 'candidate row missing' : created.message);
|
|
67
67
|
row = created.row;
|
|
68
|
-
await upsertRow(options.io, options.home, options.now, row);
|
|
68
|
+
const placed = await upsertRow(options.io, options.home, options.now, row, 'verify');
|
|
69
|
+
if (!placed.ok)
|
|
70
|
+
throw new Error('pair already in ' + placed.current.state);
|
|
69
71
|
}
|
|
70
72
|
catch (error) {
|
|
71
73
|
totals.failed += 1;
|
package/lib/types/state.js
CHANGED
|
@@ -62,7 +62,7 @@ export async function upsertRow(io, home, now, row, expect = 'any') {
|
|
|
62
62
|
if (current !== undefined && expect !== 'any' && (expect === 'absent' || queueOf(current.state) !== expect)) {
|
|
63
63
|
return { rows, result: { ok: false, current } };
|
|
64
64
|
}
|
|
65
|
-
if (current === undefined && expect !== 'any' && expect !== 'absent') {
|
|
65
|
+
if (current === undefined && expect !== 'any' && expect !== 'absent' && expect !== 'verify') {
|
|
66
66
|
return { rows, result: { ok: false, current: row } };
|
|
67
67
|
}
|
|
68
68
|
const normalized = { ...row, queue: queueOf(row.state) };
|
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.30.
|
|
4
|
+
"version": "0.30.3",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@deepseek-ai/schemastery": "^3.18.5",
|
|
30
|
-
"@lmzhen/dsh-evolution-core": "^0.30.
|
|
31
|
-
"@lmzhen/dsh-evolution-merge-engine": "^0.30.
|
|
30
|
+
"@lmzhen/dsh-evolution-core": "^0.30.3",
|
|
31
|
+
"@lmzhen/dsh-evolution-merge-engine": "^0.30.3"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"@deepseek-ai/cordis": "^4.0.5"
|