@lmzhen/dsh-evolution-merge 0.30.3 → 0.30.5
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 +163 -75
- package/lib/types/adapters/library.js +9 -2
- package/lib/types/commands.js +1 -1
- package/lib/types/commit-recovery.d.ts +1 -1
- package/lib/types/commit-recovery.js +25 -2
- package/lib/types/commit-snapshot.js +1 -1
- package/lib/types/domain/commit-journal.d.ts +1 -1
- package/lib/types/domain/commit-journal.js +1 -0
- package/lib/types/index.js +5 -1
- package/lib/types/revision.d.ts +3 -1
- package/lib/types/revision.js +11 -2
- package/lib/types/runtime.js +19 -2
- package/lib/types/scan-ports.js +4 -1
- package/package.json +3 -3
package/lib/index.js
CHANGED
|
@@ -588,6 +588,7 @@ function recoveryFor(journal) {
|
|
|
588
588
|
case "committed":
|
|
589
589
|
case "rolled-back":
|
|
590
590
|
case "failed": return "none";
|
|
591
|
+
default: return "refuse";
|
|
591
592
|
}
|
|
592
593
|
}
|
|
593
594
|
//#endregion
|
|
@@ -648,7 +649,7 @@ function isSafeSkillName(name) {
|
|
|
648
649
|
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== "." && name !== "..";
|
|
649
650
|
}
|
|
650
651
|
function skillFilePath(root, name, path) {
|
|
651
|
-
if (!isSafeSkillName(name) || path.startsWith("/") || path.includes("..")) throw new Error("unsafe skill file: " + name + "/" + path);
|
|
652
|
+
if (!isSafeSkillName(name) || path.startsWith("/") || path.split("/").includes("..")) throw new Error("unsafe skill file: " + name + "/" + path);
|
|
652
653
|
const base = resolve(root, name);
|
|
653
654
|
const file = resolve(base, path);
|
|
654
655
|
if (!file.startsWith(base + sep)) throw new Error("skill file escapes skill: " + name + "/" + path);
|
|
@@ -662,68 +663,6 @@ function skillBodyPath(root, name) {
|
|
|
662
663
|
return path;
|
|
663
664
|
}
|
|
664
665
|
//#endregion
|
|
665
|
-
//#region lib/types/commit-recovery.js
|
|
666
|
-
/** Startup replay for non-terminal commit journals. */
|
|
667
|
-
async function recoverCommitJournals(io, home, now, skillRoot, restoreSource) {
|
|
668
|
-
const dir = join(home, "merge", "journals");
|
|
669
|
-
if (!await io.exists(dir)) return [];
|
|
670
|
-
const files = (await io.list(dir)).filter((name) => name.endsWith(".json"));
|
|
671
|
-
const reports = [];
|
|
672
|
-
for (const file of files) {
|
|
673
|
-
let journal;
|
|
674
|
-
try {
|
|
675
|
-
journal = JSON.parse(await io.readText(join(dir, file)) ?? "");
|
|
676
|
-
} catch {
|
|
677
|
-
continue;
|
|
678
|
-
}
|
|
679
|
-
const recovery = recoveryFor(journal);
|
|
680
|
-
if (recovery === "none") continue;
|
|
681
|
-
const snapshot = await readCommitSnapshot(io, home, journal.runId);
|
|
682
|
-
let action = "report-only";
|
|
683
|
-
let ok = true;
|
|
684
|
-
if (recovery === "retry-commit") action = "no-host-write";
|
|
685
|
-
else if (snapshot === null) {
|
|
686
|
-
action = "snapshot-missing";
|
|
687
|
-
ok = false;
|
|
688
|
-
} else try {
|
|
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
|
-
}
|
|
696
|
-
if (snapshot.create && snapshot.targetBody === null) try {
|
|
697
|
-
await io.remove(skillBodyPath(skillRoot, snapshot.target));
|
|
698
|
-
} catch {}
|
|
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);
|
|
706
|
-
action = recovery === "rollback-sources" ? "restored-sources-and-target" : "restored-target";
|
|
707
|
-
} catch (error) {
|
|
708
|
-
action = "restore-failed: " + (error instanceof Error ? error.message : String(error));
|
|
709
|
-
ok = false;
|
|
710
|
-
}
|
|
711
|
-
await writeJournal(io, home, {
|
|
712
|
-
...journal,
|
|
713
|
-
phase: ok ? "rolled-back" : "failed",
|
|
714
|
-
updatedAt: new Date(now).toISOString()
|
|
715
|
-
});
|
|
716
|
-
reports.push({
|
|
717
|
-
runId: journal.runId,
|
|
718
|
-
phase: journal.phase,
|
|
719
|
-
action,
|
|
720
|
-
ok
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
await io.writeText(join(home, "merge", "recovery.json"), JSON.stringify(reports, null, 2));
|
|
724
|
-
return reports;
|
|
725
|
-
}
|
|
726
|
-
//#endregion
|
|
727
666
|
//#region lib/types/commit-receipt.js
|
|
728
667
|
/**
|
|
729
668
|
* Commit receipt: <run>/commit.json, written immediately after a successful commit.
|
|
@@ -799,6 +738,113 @@ async function readCommitReceipt(io, home, runId, target) {
|
|
|
799
738
|
return null;
|
|
800
739
|
}
|
|
801
740
|
//#endregion
|
|
741
|
+
//#region lib/types/commit-recovery.js
|
|
742
|
+
/** Startup replay for non-terminal commit journals. */
|
|
743
|
+
async function recoverCommitJournals(io, home, now, skillRoot, restoreSource, isActive) {
|
|
744
|
+
const dir = join(home, "merge", "journals");
|
|
745
|
+
if (!await io.exists(dir)) return [];
|
|
746
|
+
const files = (await io.list(dir)).filter((name) => name.endsWith(".json"));
|
|
747
|
+
const reports = [];
|
|
748
|
+
for (const file of files) {
|
|
749
|
+
let journal;
|
|
750
|
+
try {
|
|
751
|
+
journal = JSON.parse(await io.readText(join(dir, file)) ?? "");
|
|
752
|
+
} catch {
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (isActive?.(journal.runId) === true) {
|
|
756
|
+
reports.push({
|
|
757
|
+
runId: journal.runId,
|
|
758
|
+
phase: journal.phase,
|
|
759
|
+
action: "skipped-active-run",
|
|
760
|
+
ok: true
|
|
761
|
+
});
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const recovery = recoveryFor(journal);
|
|
765
|
+
if (recovery === "none") continue;
|
|
766
|
+
if (recovery === "refuse") {
|
|
767
|
+
reports.push({
|
|
768
|
+
runId: journal.runId,
|
|
769
|
+
phase: journal.phase,
|
|
770
|
+
action: "unknown-phase-refused",
|
|
771
|
+
ok: false
|
|
772
|
+
});
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
let receipt = null;
|
|
776
|
+
try {
|
|
777
|
+
receipt = await readCommitReceipt(io, home, journal.runId, journal.target);
|
|
778
|
+
} catch {
|
|
779
|
+
reports.push({
|
|
780
|
+
runId: journal.runId,
|
|
781
|
+
phase: journal.phase,
|
|
782
|
+
action: "receipt-read-failed",
|
|
783
|
+
ok: false
|
|
784
|
+
});
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (receipt !== null) {
|
|
788
|
+
await writeJournal(io, home, {
|
|
789
|
+
...journal,
|
|
790
|
+
phase: "committed",
|
|
791
|
+
unitId: receipt.unitId,
|
|
792
|
+
updatedAt: new Date(now).toISOString()
|
|
793
|
+
});
|
|
794
|
+
reports.push({
|
|
795
|
+
runId: journal.runId,
|
|
796
|
+
phase: journal.phase,
|
|
797
|
+
action: "receipt-committed",
|
|
798
|
+
ok: true
|
|
799
|
+
});
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const snapshot = await readCommitSnapshot(io, home, journal.runId);
|
|
803
|
+
let action = "report-only";
|
|
804
|
+
let ok = true;
|
|
805
|
+
if (recovery === "retry-commit") action = "no-host-write";
|
|
806
|
+
else if (snapshot === null) {
|
|
807
|
+
action = "snapshot-missing";
|
|
808
|
+
ok = false;
|
|
809
|
+
} else try {
|
|
810
|
+
if (recovery === "rollback-sources") {
|
|
811
|
+
let restoredArchived = restoreSource !== void 0;
|
|
812
|
+
if (restoreSource !== void 0) {
|
|
813
|
+
for (const source of snapshot.sourceBodies) if (!await restoreSource(source.name)) restoredArchived = false;
|
|
814
|
+
}
|
|
815
|
+
if (!restoredArchived) for (const source of snapshot.sourceBodies) await io.writeText(skillBodyPath(skillRoot, source.name), source.body);
|
|
816
|
+
}
|
|
817
|
+
if (snapshot.create && snapshot.targetBody === null) try {
|
|
818
|
+
await io.remove(skillBodyPath(skillRoot, snapshot.target));
|
|
819
|
+
} catch {}
|
|
820
|
+
else if (snapshot.targetBody !== null) await io.writeText(skillBodyPath(skillRoot, snapshot.target), snapshot.targetBody);
|
|
821
|
+
const oldPaths = new Set((snapshot.targetSupport ?? []).map((file) => file.path));
|
|
822
|
+
for (const file of snapshot.targetSupport ?? []) await io.writeText(skillFilePath(skillRoot, snapshot.target, file.path), file.content);
|
|
823
|
+
for (const file of snapshot.targetSupportAfter ?? []) if (!oldPaths.has(file.path)) try {
|
|
824
|
+
await io.remove(skillFilePath(skillRoot, snapshot.target, file.path));
|
|
825
|
+
} catch {}
|
|
826
|
+
for (const file of snapshot.sourceSupport ?? []) await io.writeText(skillFilePath(skillRoot, file.name, file.path), file.content);
|
|
827
|
+
action = recovery === "rollback-sources" ? "restored-sources-and-target" : "restored-target";
|
|
828
|
+
} catch (error) {
|
|
829
|
+
action = "restore-failed: " + (error instanceof Error ? error.message : String(error));
|
|
830
|
+
ok = false;
|
|
831
|
+
}
|
|
832
|
+
if (ok) await writeJournal(io, home, {
|
|
833
|
+
...journal,
|
|
834
|
+
phase: "rolled-back",
|
|
835
|
+
updatedAt: new Date(now).toISOString()
|
|
836
|
+
});
|
|
837
|
+
reports.push({
|
|
838
|
+
runId: journal.runId,
|
|
839
|
+
phase: journal.phase,
|
|
840
|
+
action,
|
|
841
|
+
ok
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
await io.writeText(join(home, "merge", "recovery.json"), JSON.stringify(reports, null, 2));
|
|
845
|
+
return reports;
|
|
846
|
+
}
|
|
847
|
+
//#endregion
|
|
802
848
|
//#region lib/types/coordinator.js
|
|
803
849
|
/**
|
|
804
850
|
* Pure merge transition table. No io, no run shell, no subagents.
|
|
@@ -1238,7 +1284,7 @@ function makeMergeRoutes(deps, _audit) {
|
|
|
1238
1284
|
}
|
|
1239
1285
|
//#endregion
|
|
1240
1286
|
//#region lib/types/commands.js
|
|
1241
|
-
const USAGE = "usage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id
|
|
1287
|
+
const USAGE = "usage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>\ndefaults: mergeApproval=false, mergeSkippedTtlDays=0, mergeRunTimeoutMinutes=0, mergePairTimeoutMinutes=15, mergeModelsRoot=\"\"";
|
|
1242
1288
|
function flags(args, allowed) {
|
|
1243
1289
|
const values = /* @__PURE__ */ new Map();
|
|
1244
1290
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -1471,7 +1517,7 @@ function parseMergeCommand(argv) {
|
|
|
1471
1517
|
default: return {
|
|
1472
1518
|
ok: false,
|
|
1473
1519
|
code: "usage",
|
|
1474
|
-
message: "unknown merge command: " + verb + "\nusage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id
|
|
1520
|
+
message: "unknown merge command: " + verb + "\nusage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>\ndefaults: mergeApproval=false, mergeSkippedTtlDays=0, mergeRunTimeoutMinutes=0, mergePairTimeoutMinutes=15, mergeModelsRoot=\"\""
|
|
1475
1521
|
};
|
|
1476
1522
|
}
|
|
1477
1523
|
}
|
|
@@ -3240,14 +3286,23 @@ async function runAuthoring(options) {
|
|
|
3240
3286
|
}
|
|
3241
3287
|
//#endregion
|
|
3242
3288
|
//#region lib/types/revision.js
|
|
3243
|
-
|
|
3289
|
+
function fingerprint(card) {
|
|
3290
|
+
const files = [...card.files ?? []].sort((left, right) => left.path.localeCompare(right.path));
|
|
3291
|
+
return card.name + "\0" + card.body + "\0" + files.map((file) => file.path + "\0" + file.content).join("\0");
|
|
3292
|
+
}
|
|
3293
|
+
/** Stable source revision for a pair: names sorted, body+support hashed in that order. */
|
|
3244
3294
|
function sourceRevisionOf(cards, a, b) {
|
|
3245
3295
|
const byName = new Map(cards.map((card) => [card.name, card]));
|
|
3246
3296
|
const names = [a, b].sort();
|
|
3247
3297
|
const first = byName.get(names[0] ?? "");
|
|
3248
3298
|
const second = byName.get(names[1] ?? "");
|
|
3249
3299
|
if (first === void 0 || second === void 0) return void 0;
|
|
3250
|
-
return contentHash(first
|
|
3300
|
+
return contentHash(fingerprint(first) + "\0" + fingerprint(second));
|
|
3301
|
+
}
|
|
3302
|
+
/** Revision of the merge target; undefined when the target is not a live card. */
|
|
3303
|
+
function targetRevisionOf(cards, target) {
|
|
3304
|
+
const card = cards.find((item) => item.name === target);
|
|
3305
|
+
return card === void 0 ? void 0 : contentHash(fingerprint(card));
|
|
3251
3306
|
}
|
|
3252
3307
|
//#endregion
|
|
3253
3308
|
//#region lib/types/scan-ports.js
|
|
@@ -3308,11 +3363,14 @@ function createChannelScanPorts(options) {
|
|
|
3308
3363
|
});
|
|
3309
3364
|
}
|
|
3310
3365
|
const sourceRevision = result.outcome.kind === "merge" ? sourceRevisionOf(options.cards, candidate.a, candidate.b) : void 0;
|
|
3366
|
+
const target = result.outcome.kind === "merge" ? result.outcome.decision?.target : void 0;
|
|
3367
|
+
const targetRevision = target === void 0 ? void 0 : targetRevisionOf(options.cards, target);
|
|
3311
3368
|
return result.outcome.kind === "merge" ? {
|
|
3312
3369
|
...result.outcome,
|
|
3313
3370
|
stagingRef: stageKey,
|
|
3314
3371
|
stagingExpiresAt: options.now() + 7 * 864e5,
|
|
3315
|
-
...sourceRevision === void 0 ? {} : { sourceRevision }
|
|
3372
|
+
...sourceRevision === void 0 ? {} : { sourceRevision },
|
|
3373
|
+
...targetRevision === void 0 ? {} : { targetRevision }
|
|
3316
3374
|
} : result.outcome;
|
|
3317
3375
|
},
|
|
3318
3376
|
async round2(row, decision, signal) {
|
|
@@ -3552,11 +3610,16 @@ function skillLibraryPort(skills) {
|
|
|
3552
3610
|
async readSupportFiles(name) {
|
|
3553
3611
|
if (typeof skills.listSupportFiles !== "function") return [];
|
|
3554
3612
|
const probe = await skills.listSupportFiles(name);
|
|
3555
|
-
if (probe.kind
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
content
|
|
3559
|
-
|
|
3613
|
+
if (probe.kind === "unknown") throw new Error("support file list unknown: " + name);
|
|
3614
|
+
if (probe.kind === "absent") return [];
|
|
3615
|
+
return await Promise.all(probe.value.map(async (path) => {
|
|
3616
|
+
const content = await skills.readSupportFile(name, path);
|
|
3617
|
+
if (content === null) throw new Error("support file disappeared: " + name + "/" + path);
|
|
3618
|
+
return {
|
|
3619
|
+
path,
|
|
3620
|
+
content
|
|
3621
|
+
};
|
|
3622
|
+
}));
|
|
3560
3623
|
}
|
|
3561
3624
|
};
|
|
3562
3625
|
}
|
|
@@ -3602,6 +3665,10 @@ function createMergeRuntime(deps) {
|
|
|
3602
3665
|
const detached = deps.detached !== false;
|
|
3603
3666
|
if (command.verb === "pair") {
|
|
3604
3667
|
const signal = new AbortController().signal;
|
|
3668
|
+
if (command.a === command.b) return {
|
|
3669
|
+
ok: false,
|
|
3670
|
+
text: "merge pair 需要两个不同技能源: " + command.a
|
|
3671
|
+
};
|
|
3605
3672
|
const cards = await deps.cards(signal);
|
|
3606
3673
|
const cardA = cards.find((card) => card.name === command.a);
|
|
3607
3674
|
const cardB = cards.find((card) => card.name === command.b);
|
|
@@ -3609,11 +3676,16 @@ function createMergeRuntime(deps) {
|
|
|
3609
3676
|
ok: false,
|
|
3610
3677
|
text: "merge pair 仅支持 family-managed 技能: " + command.a + " / " + command.b
|
|
3611
3678
|
};
|
|
3679
|
+
if (cardA.protectedBy != null || cardB.protectedBy != null || cardA.protectionUnknown === true || cardB.protectionUnknown === true) return {
|
|
3680
|
+
ok: false,
|
|
3681
|
+
text: "merge pair 不接受受保护/保护状态未知技能: " + command.a + " / " + command.b
|
|
3682
|
+
};
|
|
3612
3683
|
const pair = [{
|
|
3613
3684
|
a: command.a,
|
|
3614
3685
|
b: command.b
|
|
3615
3686
|
}];
|
|
3616
3687
|
const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.pairKey === pairKey(command.a, command.b));
|
|
3688
|
+
if (existing?.state === "skipped") await dropRow(deps.io, deps.home, deps.now, existing.id);
|
|
3617
3689
|
if (existing?.state === "pending") return {
|
|
3618
3690
|
ok: false,
|
|
3619
3691
|
text: "该 pair 已在待批准队列,先 approve/reject/discard: " + existing.id
|
|
@@ -3674,7 +3746,7 @@ function createMergeRuntime(deps) {
|
|
|
3674
3746
|
if (command.verb === "scan") {
|
|
3675
3747
|
const signal = new AbortController().signal;
|
|
3676
3748
|
const cards = await deps.cards(signal);
|
|
3677
|
-
const managed = new Set(cards.filter((card) => card.managed === true).map((card) => card.name));
|
|
3749
|
+
const managed = new Set(cards.filter((card) => card.managed === true && card.protectedBy == null).map((card) => card.name));
|
|
3678
3750
|
const loadedState = await loadMergeState(deps.io, deps.home);
|
|
3679
3751
|
const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
|
|
3680
3752
|
const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || row.queue === "fuse" || !canRetry(row)).map((row) => row.pairKey));
|
|
@@ -3960,6 +4032,18 @@ function createMergeRuntime(deps) {
|
|
|
3960
4032
|
text: "source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard"
|
|
3961
4033
|
};
|
|
3962
4034
|
}
|
|
4035
|
+
const currentTargetRevision = targetRevisionOf(cards, candidate.decision.target);
|
|
4036
|
+
if (candidate.targetRevision !== void 0 && currentTargetRevision !== void 0 && currentTargetRevision !== candidate.targetRevision) {
|
|
4037
|
+
const taken = transition(candidate, {
|
|
4038
|
+
type: "target-taken",
|
|
4039
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
4040
|
+
});
|
|
4041
|
+
if (taken.ok && taken.row !== null) await upsertRow(deps.io, deps.home, deps.now, taken.row);
|
|
4042
|
+
return {
|
|
4043
|
+
ok: false,
|
|
4044
|
+
text: "target-taken:目标 " + candidate.decision.target + " 在决策后变化,拒绝照跑;可重新 merge pair 或 discard"
|
|
4045
|
+
};
|
|
4046
|
+
}
|
|
3963
4047
|
if (candidate.decision.mode === "merge-into-existing" && !cards.some((card) => card.name === candidate.decision?.target)) {
|
|
3964
4048
|
const expired = transition(candidate, {
|
|
3965
4049
|
type: "target-taken",
|
|
@@ -4482,8 +4566,12 @@ function apply(ctx, rawConfig = {}) {
|
|
|
4482
4566
|
...policyStageLimits(policySnapshot?.get?.())
|
|
4483
4567
|
}
|
|
4484
4568
|
});
|
|
4485
|
-
|
|
4569
|
+
const mergeSkillsRoot = skills.root ?? skillsRoot();
|
|
4570
|
+
recoverCommitJournals(io, home, Date.now(), mergeSkillsRoot, async (name) => {
|
|
4486
4571
|
return (await skills.restoreFromArchive(name)).ok;
|
|
4572
|
+
}, (runId) => {
|
|
4573
|
+
const run = runs.find(runId);
|
|
4574
|
+
return run !== void 0 && run.state === "running";
|
|
4487
4575
|
}).then((reports) => {
|
|
4488
4576
|
if (reports.length > 0) ctx.logger.info("merge commit recovery: " + reports.map((report) => report.runId + "=" + report.action).join(", "));
|
|
4489
4577
|
}).catch((error) => {
|
|
@@ -13,9 +13,16 @@ export function skillLibraryPort(skills) {
|
|
|
13
13
|
if (typeof skills.listSupportFiles !== 'function')
|
|
14
14
|
return [];
|
|
15
15
|
const probe = await skills.listSupportFiles(name);
|
|
16
|
-
if (probe.kind
|
|
16
|
+
if (probe.kind === 'unknown')
|
|
17
|
+
throw new Error('support file list unknown: ' + name);
|
|
18
|
+
if (probe.kind === 'absent')
|
|
17
19
|
return [];
|
|
18
|
-
return await Promise.all(probe.value.map(async (path) =>
|
|
20
|
+
return await Promise.all(probe.value.map(async (path) => {
|
|
21
|
+
const content = await skills.readSupportFile(name, path);
|
|
22
|
+
if (content === null)
|
|
23
|
+
throw new Error('support file disappeared: ' + name + '/' + path);
|
|
24
|
+
return { path, content };
|
|
25
|
+
}));
|
|
19
26
|
},
|
|
20
27
|
};
|
|
21
28
|
}
|
package/lib/types/commands.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const USAGE = 'usage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id
|
|
1
|
+
const USAGE = 'usage: /evolution merge scan | pair [--force] <a> <b> | status [id] | report [id] | cancel [id] | runs | doctor | pending [--expired] | candidates [--retry] | approve <id> | reject <id> | resume [id] [--retry] [--clear <id>] | skipped [--restore <id>] | discard <id> | undo <target> --run <id>\ndefaults: mergeApproval=false, mergeSkippedTtlDays=0, mergeRunTimeoutMinutes=0, mergePairTimeoutMinutes=15, mergeModelsRoot=""';
|
|
2
2
|
function flags(args, allowed) {
|
|
3
3
|
const values = new Map();
|
|
4
4
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -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, skillRoot: string, restoreSource?: (name: string) => Promise<boolean
|
|
9
|
+
export declare function recoverCommitJournals(io: EvolutionIoLike, home: string, now: number, skillRoot: string, restoreSource?: (name: string) => Promise<boolean>, isActive?: (runId: string) => boolean): Promise<readonly CommitRecoveryReport[]>;
|
|
10
10
|
//# sourceMappingURL=commit-recovery.d.ts.map
|
|
@@ -3,7 +3,8 @@ 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
5
|
import { readCommitSnapshot, skillBodyPath, skillFilePath } from "./commit-snapshot.js";
|
|
6
|
-
|
|
6
|
+
import { readCommitReceipt } from "./commit-receipt.js";
|
|
7
|
+
export async function recoverCommitJournals(io, home, now, skillRoot, restoreSource, isActive) {
|
|
7
8
|
const dir = join(home, 'merge', 'journals');
|
|
8
9
|
if (!(await io.exists(dir)))
|
|
9
10
|
return [];
|
|
@@ -17,9 +18,30 @@ export async function recoverCommitJournals(io, home, now, skillRoot, restoreSou
|
|
|
17
18
|
catch {
|
|
18
19
|
continue;
|
|
19
20
|
}
|
|
21
|
+
if (isActive?.(journal.runId) === true) {
|
|
22
|
+
reports.push({ runId: journal.runId, phase: journal.phase, action: 'skipped-active-run', ok: true });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
20
25
|
const recovery = recoveryFor(journal);
|
|
21
26
|
if (recovery === 'none')
|
|
22
27
|
continue;
|
|
28
|
+
if (recovery === 'refuse') {
|
|
29
|
+
reports.push({ runId: journal.runId, phase: journal.phase, action: 'unknown-phase-refused', ok: false });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
let receipt = null;
|
|
33
|
+
try {
|
|
34
|
+
receipt = await readCommitReceipt(io, home, journal.runId, journal.target);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
reports.push({ runId: journal.runId, phase: journal.phase, action: 'receipt-read-failed', ok: false });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (receipt !== null) {
|
|
41
|
+
await writeJournal(io, home, { ...journal, phase: 'committed', unitId: receipt.unitId, updatedAt: new Date(now).toISOString() });
|
|
42
|
+
reports.push({ runId: journal.runId, phase: journal.phase, action: 'receipt-committed', ok: true });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
23
45
|
const snapshot = await readCommitSnapshot(io, home, journal.runId);
|
|
24
46
|
let action = 'report-only';
|
|
25
47
|
let ok = true;
|
|
@@ -75,7 +97,8 @@ export async function recoverCommitJournals(io, home, now, skillRoot, restoreSou
|
|
|
75
97
|
ok = false;
|
|
76
98
|
}
|
|
77
99
|
}
|
|
78
|
-
|
|
100
|
+
if (ok)
|
|
101
|
+
await writeJournal(io, home, { ...journal, phase: 'rolled-back', updatedAt: new Date(now).toISOString() });
|
|
79
102
|
reports.push({ runId: journal.runId, phase: journal.phase, action, ok });
|
|
80
103
|
}
|
|
81
104
|
await io.writeText(join(home, 'merge', 'recovery.json'), JSON.stringify(reports, null, 2));
|
|
@@ -45,7 +45,7 @@ export function isSafeSkillName(name) {
|
|
|
45
45
|
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== '.' && name !== '..';
|
|
46
46
|
}
|
|
47
47
|
export function skillFilePath(root, name, path) {
|
|
48
|
-
if (!isSafeSkillName(name) || path.startsWith('/') || path.includes('..'))
|
|
48
|
+
if (!isSafeSkillName(name) || path.startsWith('/') || path.split('/').includes('..'))
|
|
49
49
|
throw new Error('unsafe skill file: ' + name + '/' + path);
|
|
50
50
|
const base = resolve(root, name);
|
|
51
51
|
const file = resolve(base, path);
|
|
@@ -10,7 +10,7 @@ export interface CommitJournal {
|
|
|
10
10
|
readonly updatedAt: string;
|
|
11
11
|
}
|
|
12
12
|
export declare function advanceJournal(journal: CommitJournal, phase: JournalPhase, at: string): CommitJournal;
|
|
13
|
-
export type JournalRecovery = 'none' | 'retry-commit' | 'rollback-target' | 'rollback-sources';
|
|
13
|
+
export type JournalRecovery = 'none' | 'retry-commit' | 'rollback-target' | 'rollback-sources' | 'refuse';
|
|
14
14
|
/** What a restart may do with the last durable phase. */
|
|
15
15
|
export declare function recoveryFor(journal: CommitJournal): JournalRecovery;
|
|
16
16
|
//# sourceMappingURL=commit-journal.d.ts.map
|
package/lib/types/index.js
CHANGED
|
@@ -138,9 +138,13 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
138
138
|
io, ctx, authority: authorityFrom({ origin: 'subagent' }),
|
|
139
139
|
limits: { ...DEFAULT_SKILL_LIMITS, ...policyStageLimits(policySnapshot?.get?.()) },
|
|
140
140
|
});
|
|
141
|
-
|
|
141
|
+
const mergeSkillsRoot = skills.root ?? skillsRoot();
|
|
142
|
+
void recoverCommitJournals(io, home, Date.now(), mergeSkillsRoot, async (name) => {
|
|
142
143
|
const result = await skills.restoreFromArchive(name);
|
|
143
144
|
return result.ok;
|
|
145
|
+
}, (runId) => {
|
|
146
|
+
const run = runs.find(runId);
|
|
147
|
+
return run !== undefined && run.state === 'running';
|
|
144
148
|
}).then((reports) => { if (reports.length > 0)
|
|
145
149
|
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))); });
|
|
146
150
|
const channel = subagentChannelAdapter(createSubagentChannel(ctx));
|
package/lib/types/revision.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { SkillCard } from './skill-card.ts';
|
|
2
|
-
/** Stable source revision for a pair: names sorted, body hashed in that order. */
|
|
2
|
+
/** Stable source revision for a pair: names sorted, body+support hashed in that order. */
|
|
3
3
|
export declare function sourceRevisionOf(cards: readonly SkillCard[], a: string, b: string): string | undefined;
|
|
4
|
+
/** Revision of the merge target; undefined when the target is not a live card. */
|
|
5
|
+
export declare function targetRevisionOf(cards: readonly SkillCard[], target: string): string | undefined;
|
|
4
6
|
//# sourceMappingURL=revision.d.ts.map
|
package/lib/types/revision.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { contentHash } from '@lmzhen/dsh-evolution-core';
|
|
2
|
-
|
|
2
|
+
function fingerprint(card) {
|
|
3
|
+
const files = [...(card.files ?? [])].sort((left, right) => left.path.localeCompare(right.path));
|
|
4
|
+
return card.name + '\u0000' + card.body + '\u0000' + files.map(file => file.path + '\u0000' + file.content).join('\u0000');
|
|
5
|
+
}
|
|
6
|
+
/** Stable source revision for a pair: names sorted, body+support hashed in that order. */
|
|
3
7
|
export function sourceRevisionOf(cards, a, b) {
|
|
4
8
|
const byName = new Map(cards.map(card => [card.name, card]));
|
|
5
9
|
const names = [a, b].sort();
|
|
@@ -7,6 +11,11 @@ export function sourceRevisionOf(cards, a, b) {
|
|
|
7
11
|
const second = byName.get(names[1] ?? '');
|
|
8
12
|
if (first === undefined || second === undefined)
|
|
9
13
|
return undefined;
|
|
10
|
-
return contentHash(first
|
|
14
|
+
return contentHash(fingerprint(first) + '\u0000' + fingerprint(second));
|
|
15
|
+
}
|
|
16
|
+
/** Revision of the merge target; undefined when the target is not a live card. */
|
|
17
|
+
export function targetRevisionOf(cards, target) {
|
|
18
|
+
const card = cards.find(item => item.name === target);
|
|
19
|
+
return card === undefined ? undefined : contentHash(fingerprint(card));
|
|
11
20
|
}
|
|
12
21
|
//# sourceMappingURL=revision.js.map
|
package/lib/types/runtime.js
CHANGED
|
@@ -22,7 +22,7 @@ import { createChannelScanPorts } from "./scan-ports.js";
|
|
|
22
22
|
import { createNewChildRound2 } from "./new-child-authoring.js";
|
|
23
23
|
import { skillLibraryPort } from "./adapters/library.js";
|
|
24
24
|
import { fitToolText } from "./presentation-budget.js";
|
|
25
|
-
import { sourceRevisionOf } from "./revision.js";
|
|
25
|
+
import { sourceRevisionOf, targetRevisionOf } from "./revision.js";
|
|
26
26
|
function forcedDecisionFor(a, b) {
|
|
27
27
|
const sorted = [a, b].sort();
|
|
28
28
|
const target = ('merge-' + sorted.join('-')).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
|
|
@@ -47,14 +47,23 @@ export function createMergeRuntime(deps) {
|
|
|
47
47
|
const detached = deps.detached !== false;
|
|
48
48
|
if (command.verb === 'pair') {
|
|
49
49
|
const signal = new AbortController().signal;
|
|
50
|
+
if (command.a === command.b)
|
|
51
|
+
return { ok: false, text: 'merge pair 需要两个不同技能源: ' + command.a };
|
|
50
52
|
const cards = await deps.cards(signal);
|
|
51
53
|
const cardA = cards.find(card => card.name === command.a);
|
|
52
54
|
const cardB = cards.find(card => card.name === command.b);
|
|
53
55
|
if (cardA === undefined || cardB === undefined || cardA.managed !== true || cardB.managed !== true) {
|
|
54
56
|
return { ok: false, text: 'merge pair 仅支持 family-managed 技能: ' + command.a + ' / ' + command.b };
|
|
55
57
|
}
|
|
58
|
+
if (cardA.protectedBy != null || cardB.protectedBy != null
|
|
59
|
+
|| cardA.protectionUnknown === true || cardB.protectionUnknown === true) {
|
|
60
|
+
return { ok: false, text: 'merge pair 不接受受保护/保护状态未知技能: ' + command.a + ' / ' + command.b };
|
|
61
|
+
}
|
|
56
62
|
const pair = [{ a: command.a, b: command.b }];
|
|
57
63
|
const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
|
|
64
|
+
if (existing?.state === 'skipped') {
|
|
65
|
+
await dropRow(deps.io, deps.home, deps.now, existing.id);
|
|
66
|
+
}
|
|
58
67
|
if (existing?.state === 'pending') {
|
|
59
68
|
return { ok: false, text: '该 pair 已在待批准队列,先 approve/reject/discard: ' + existing.id };
|
|
60
69
|
}
|
|
@@ -92,7 +101,7 @@ export function createMergeRuntime(deps) {
|
|
|
92
101
|
if (command.verb === 'scan') {
|
|
93
102
|
const signal = new AbortController().signal;
|
|
94
103
|
const cards = await deps.cards(signal);
|
|
95
|
-
const managed = new Set(cards.filter(card => card.managed === true).map(card => card.name));
|
|
104
|
+
const managed = new Set(cards.filter(card => card.managed === true && card.protectedBy == null).map(card => card.name));
|
|
96
105
|
const loadedState = await loadMergeState(deps.io, deps.home);
|
|
97
106
|
const skipped = skippedPairKeys(loadedState.state.rows, deps.params.mergeSkippedTtlDays, deps.now());
|
|
98
107
|
const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse' || !canRetry(row)).map(row => row.pairKey));
|
|
@@ -270,6 +279,14 @@ export function createMergeRuntime(deps) {
|
|
|
270
279
|
await upsertRow(deps.io, deps.home, deps.now, changed.row);
|
|
271
280
|
return { ok: false, text: 'source-changed:源技能内容在决策后变化,拒绝照跑;出口:重新 merge pair,或 merge skipped --discard' };
|
|
272
281
|
}
|
|
282
|
+
const currentTargetRevision = targetRevisionOf(cards, candidate.decision.target);
|
|
283
|
+
if (candidate.targetRevision !== undefined && currentTargetRevision !== undefined
|
|
284
|
+
&& currentTargetRevision !== candidate.targetRevision) {
|
|
285
|
+
const taken = transition(candidate, { type: 'target-taken', at: new Date().toISOString() });
|
|
286
|
+
if (taken.ok && taken.row !== null)
|
|
287
|
+
await upsertRow(deps.io, deps.home, deps.now, taken.row);
|
|
288
|
+
return { ok: false, text: 'target-taken:目标 ' + candidate.decision.target + ' 在决策后变化,拒绝照跑;可重新 merge pair 或 discard' };
|
|
289
|
+
}
|
|
273
290
|
if (candidate.decision.mode === 'merge-into-existing' && !cards.some(card => card.name === candidate.decision?.target)) {
|
|
274
291
|
const expired = transition(candidate, { type: 'target-taken', at: new Date().toISOString() });
|
|
275
292
|
if (expired.ok && expired.row !== null)
|
package/lib/types/scan-ports.js
CHANGED
|
@@ -2,7 +2,7 @@ import { pairKey } from "./contracts.js";
|
|
|
2
2
|
import { runRound1OverChannel } from "./channel-port.js";
|
|
3
3
|
import { runAuthoring } from "./authoring-flow.js";
|
|
4
4
|
import { dropStaging, ensureStaging, stageKeyOf, stagingRoot } from "./staging.js";
|
|
5
|
-
import { sourceRevisionOf } from "./revision.js";
|
|
5
|
+
import { sourceRevisionOf, targetRevisionOf } from "./revision.js";
|
|
6
6
|
export function createChannelScanPorts(options) {
|
|
7
7
|
const sessions = new Map();
|
|
8
8
|
return {
|
|
@@ -44,10 +44,13 @@ export function createChannelScanPorts(options) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
const sourceRevision = result.outcome.kind === 'merge' ? sourceRevisionOf(options.cards, candidate.a, candidate.b) : undefined;
|
|
47
|
+
const target = result.outcome.kind === 'merge' ? result.outcome.decision?.target : undefined;
|
|
48
|
+
const targetRevision = target === undefined ? undefined : targetRevisionOf(options.cards, target);
|
|
47
49
|
return result.outcome.kind === 'merge'
|
|
48
50
|
? {
|
|
49
51
|
...result.outcome, stagingRef: stageKey, stagingExpiresAt: options.now() + 7 * 86_400_000,
|
|
50
52
|
...(sourceRevision === undefined ? {} : { sourceRevision }),
|
|
53
|
+
...(targetRevision === undefined ? {} : { targetRevision }),
|
|
51
54
|
}
|
|
52
55
|
: result.outcome;
|
|
53
56
|
},
|
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.5",
|
|
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.5",
|
|
31
|
+
"@lmzhen/dsh-evolution-merge-engine": "^0.30.5"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"@deepseek-ai/cordis": "^4.0.5"
|