@lmzhen/dsh-evolution-merge 0.30.2 → 0.30.4

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 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,
@@ -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
@@ -607,7 +608,16 @@ async function writeJournal(io, home, journal) {
607
608
  function snapshotPath(home, runId) {
608
609
  return join(home, "merge", "snapshots", runId.replace(/[^A-Za-z0-9_-]+/g, "_") + ".json");
609
610
  }
610
- async function writeCommitSnapshot(io, home, runId, plan, now) {
611
+ async function writeCommitSnapshot(io, home, runId, plan, now, port) {
612
+ if (!isSafeSkillName(plan.target) || plan.sources.some((name) => !isSafeSkillName(name))) throw new Error("unsafe skill name in commit plan: " + plan.target);
613
+ const readSupport = port?.readSupportFiles === void 0 ? void 0 : (name) => port.readSupportFiles?.(name) ?? Promise.resolve([]);
614
+ const targetSupport = readSupport === void 0 || !plan.targetExists ? [] : await readSupport(plan.target);
615
+ const sourceSupport = [];
616
+ if (readSupport !== void 0) for (const name of plan.sources) for (const file of await readSupport(name)) sourceSupport.push({
617
+ name,
618
+ path: file.path,
619
+ content: file.content
620
+ });
611
621
  const snapshot = {
612
622
  runId,
613
623
  target: plan.target,
@@ -618,6 +628,9 @@ async function writeCommitSnapshot(io, home, runId, plan, now) {
618
628
  name: item.name,
619
629
  body: item.body
620
630
  })),
631
+ ...targetSupport.length === 0 ? {} : { targetSupport },
632
+ ...plan.supportFiles.length === 0 ? {} : { targetSupportAfter: plan.supportFiles },
633
+ ...sourceSupport.length === 0 ? {} : { sourceSupport },
621
634
  at: new Date(now).toISOString()
622
635
  };
623
636
  await io.writeText(snapshotPath(home, runId), JSON.stringify(snapshot, null, 2));
@@ -632,58 +645,22 @@ async function readCommitSnapshot(io, home, runId) {
632
645
  return null;
633
646
  }
634
647
  }
635
- function skillBodyPath(home, name) {
636
- return join(home, "skills", name, "SKILL.md");
637
- }
638
- //#endregion
639
- //#region lib/types/commit-recovery.js
640
- /** Startup replay for non-terminal commit journals. */
641
- async function recoverCommitJournals(io, home, now) {
642
- const dir = join(home, "merge", "journals");
643
- if (!await io.exists(dir)) return [];
644
- const files = (await io.list(dir)).filter((name) => name.endsWith(".json"));
645
- const reports = [];
646
- for (const file of files) {
647
- let journal;
648
- try {
649
- journal = JSON.parse(await io.readText(join(dir, file)) ?? "");
650
- } catch {
651
- continue;
652
- }
653
- const recovery = recoveryFor(journal);
654
- if (recovery === "none") continue;
655
- const snapshot = await readCommitSnapshot(io, home, journal.runId);
656
- let action = "report-only";
657
- let ok = true;
658
- if (recovery === "retry-commit") action = "no-host-write";
659
- else if (snapshot === null) {
660
- action = "snapshot-missing";
661
- ok = false;
662
- } else try {
663
- if (recovery === "rollback-sources") for (const source of snapshot.sourceBodies) await io.writeText(skillBodyPath(home, source.name), source.body);
664
- if (snapshot.create && snapshot.targetBody === null) try {
665
- await io.remove(skillBodyPath(home, snapshot.target));
666
- } catch {}
667
- else if (snapshot.targetBody !== null) await io.writeText(skillBodyPath(home, snapshot.target), snapshot.targetBody);
668
- action = recovery === "rollback-sources" ? "restored-sources-and-target" : "restored-target";
669
- } catch (error) {
670
- action = "restore-failed: " + (error instanceof Error ? error.message : String(error));
671
- ok = false;
672
- }
673
- await writeJournal(io, home, {
674
- ...journal,
675
- phase: ok ? "rolled-back" : "failed",
676
- updatedAt: new Date(now).toISOString()
677
- });
678
- reports.push({
679
- runId: journal.runId,
680
- phase: journal.phase,
681
- action,
682
- ok
683
- });
684
- }
685
- await io.writeText(join(home, "merge", "recovery.json"), JSON.stringify(reports, null, 2));
686
- return reports;
648
+ function isSafeSkillName(name) {
649
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) && name !== "." && name !== "..";
650
+ }
651
+ function skillFilePath(root, name, path) {
652
+ if (!isSafeSkillName(name) || path.startsWith("/") || path.split("/").includes("..")) throw new Error("unsafe skill file: " + name + "/" + path);
653
+ const base = resolve(root, name);
654
+ const file = resolve(base, path);
655
+ if (!file.startsWith(base + sep)) throw new Error("skill file escapes skill: " + name + "/" + path);
656
+ return file;
657
+ }
658
+ function skillBodyPath(root, name) {
659
+ if (!isSafeSkillName(name)) throw new Error("unsafe skill name: " + name);
660
+ const base = resolve(root);
661
+ const path = resolve(base, name, "SKILL.md");
662
+ if (!path.startsWith(base + sep)) throw new Error("skill path escapes root: " + name);
663
+ return path;
687
664
  }
688
665
  //#endregion
689
666
  //#region lib/types/commit-receipt.js
@@ -761,6 +738,113 @@ async function readCommitReceipt(io, home, runId, target) {
761
738
  return null;
762
739
  }
763
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
764
848
  //#region lib/types/coordinator.js
765
849
  /**
766
850
  * Pure merge transition table. No io, no run shell, no subagents.
@@ -866,11 +950,15 @@ function transition(current, event) {
866
950
  return event.approval === "on" ? next(current, event.at, "pending", {
867
951
  ...event.decision === void 0 ? {} : { decision: event.decision },
868
952
  ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
869
- ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt }
953
+ ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
954
+ ...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
955
+ ...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
870
956
  }) : next(current, event.at, "approved", {
871
957
  ...event.decision === void 0 ? {} : { decision: event.decision },
872
958
  ...event.stagingRef === void 0 ? {} : { stagingRef: event.stagingRef },
873
- ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt }
959
+ ...event.stagingExpiresAt === void 0 ? {} : { stagingExpiresAt: event.stagingExpiresAt },
960
+ ...event.sourceRevision === void 0 ? {} : { sourceRevision: event.sourceRevision },
961
+ ...event.targetRevision === void 0 ? {} : { targetRevision: event.targetRevision }
874
962
  });
875
963
  case "round1-fail": return current.state === "verify" ? next(current, event.at, "verify", {
876
964
  failure: event.failure,
@@ -1678,7 +1766,8 @@ async function runScan(options) {
1678
1766
  });
1679
1767
  if (!created.ok || created.row === null) throw new Error(created.ok ? "candidate row missing" : created.message);
1680
1768
  row = created.row;
1681
- await upsertRow(options.io, options.home, options.now, row);
1769
+ const placed = await upsertRow(options.io, options.home, options.now, row, "verify");
1770
+ if (!placed.ok) throw new Error("pair already in " + placed.current.state);
1682
1771
  } catch (error) {
1683
1772
  totals.failed += 1;
1684
1773
  pairs.push({
@@ -3049,7 +3138,7 @@ async function commitStagedRun(io, home, runId, port, options) {
3049
3138
  message: assembled.message
3050
3139
  };
3051
3140
  try {
3052
- await writeCommitSnapshot(io, home, runId, assembled.plan, options.now());
3141
+ await writeCommitSnapshot(io, home, runId, assembled.plan, options.now(), port);
3053
3142
  } catch (error) {
3054
3143
  return {
3055
3144
  ok: false,
@@ -3073,6 +3162,7 @@ async function commitStagedRun(io, home, runId, port, options) {
3073
3162
  try {
3074
3163
  await writeJournal(io, home, {
3075
3164
  ...journal,
3165
+ phase: "failed",
3076
3166
  updatedAt: new Date(options.now()).toISOString()
3077
3167
  });
3078
3168
  } catch {}
@@ -3082,6 +3172,14 @@ async function commitStagedRun(io, home, runId, port, options) {
3082
3172
  plan: assembled.plan
3083
3173
  };
3084
3174
  }
3175
+ const committed = {
3176
+ ...journal,
3177
+ phase: "committed",
3178
+ updatedAt: new Date(options.now()).toISOString()
3179
+ };
3180
+ try {
3181
+ await writeJournal(io, home, committed);
3182
+ } catch {}
3085
3183
  if (result.unitId === void 0) return {
3086
3184
  ok: true,
3087
3185
  plan: assembled.plan,
@@ -3101,14 +3199,6 @@ async function commitStagedRun(io, home, runId, port, options) {
3101
3199
  };
3102
3200
  try {
3103
3201
  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
3202
  return {
3113
3203
  ok: true,
3114
3204
  plan: assembled.plan,
@@ -3504,7 +3594,21 @@ function skillLibraryPort(skills) {
3504
3594
  const names = await archivedNames.call(skills);
3505
3595
  return names === null ? true : names.includes(name);
3506
3596
  },
3507
- commitMerge: (plan) => skills.commitMerge(plan)
3597
+ commitMerge: (plan) => skills.commitMerge(plan),
3598
+ async readSupportFiles(name) {
3599
+ if (typeof skills.listSupportFiles !== "function") return [];
3600
+ const probe = await skills.listSupportFiles(name);
3601
+ if (probe.kind === "unknown") throw new Error("support file list unknown: " + name);
3602
+ if (probe.kind === "absent") return [];
3603
+ return await Promise.all(probe.value.map(async (path) => {
3604
+ const content = await skills.readSupportFile(name, path);
3605
+ if (content === null) throw new Error("support file disappeared: " + name + "/" + path);
3606
+ return {
3607
+ path,
3608
+ content
3609
+ };
3610
+ }));
3611
+ }
3508
3612
  };
3509
3613
  }
3510
3614
  //#endregion
@@ -3561,6 +3665,7 @@ function createMergeRuntime(deps) {
3561
3665
  b: command.b
3562
3666
  }];
3563
3667
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find((row) => row.pairKey === pairKey(command.a, command.b));
3668
+ if (existing?.state === "skipped") await dropRow(deps.io, deps.home, deps.now, existing.id);
3564
3669
  if (existing?.state === "pending") return {
3565
3670
  ok: false,
3566
3671
  text: "该 pair 已在待批准队列,先 approve/reject/discard: " + existing.id
@@ -3624,7 +3729,7 @@ function createMergeRuntime(deps) {
3624
3729
  const managed = new Set(cards.filter((card) => card.managed === true).map((card) => card.name));
3625
3730
  const loadedState = await loadMergeState(deps.io, deps.home);
3626
3731
  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));
3732
+ const held = new Set(loadedState.state.rows.filter((row) => row.queue === "pending" || row.queue === "fuse" || !canRetry(row)).map((row) => row.pairKey));
3628
3733
  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
3734
  const result = await runScanCommand({
3630
3735
  ...common,
@@ -4404,11 +4509,6 @@ function apply(ctx, rawConfig = {}) {
4404
4509
  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
4510
  ctx.logger.warn("merge staging cleanup failed: " + (error instanceof Error ? error.message : String(error)));
4406
4511
  });
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
4512
  const runs = newRunRegistry({
4413
4513
  io,
4414
4514
  home,
@@ -4434,6 +4534,17 @@ function apply(ctx, rawConfig = {}) {
4434
4534
  ...policyStageLimits(policySnapshot?.get?.())
4435
4535
  }
4436
4536
  });
4537
+ const mergeSkillsRoot = skills.root ?? skillsRoot();
4538
+ recoverCommitJournals(io, home, Date.now(), mergeSkillsRoot, async (name) => {
4539
+ return (await skills.restoreFromArchive(name)).ok;
4540
+ }, (runId) => {
4541
+ const run = runs.find(runId);
4542
+ return run !== void 0 && run.state === "running";
4543
+ }).then((reports) => {
4544
+ if (reports.length > 0) ctx.logger.info("merge commit recovery: " + reports.map((report) => report.runId + "=" + report.action).join(", "));
4545
+ }).catch((error) => {
4546
+ ctx.logger.warn("merge commit recovery failed: " + (error instanceof Error ? error.message : String(error)));
4547
+ });
4437
4548
  const channel = subagentChannelAdapter(createSubagentChannel(ctx));
4438
4549
  const stage = new StageToolRegistry();
4439
4550
  const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
@@ -9,6 +9,21 @@ 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 === 'unknown')
17
+ throw new Error('support file list unknown: ' + name);
18
+ if (probe.kind === 'absent')
19
+ return [];
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
+ }));
26
+ },
12
27
  };
13
28
  }
14
29
  //# 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>, isActive?: (runId: string) => boolean): Promise<readonly CommitRecoveryReport[]>;
10
10
  //# sourceMappingURL=commit-recovery.d.ts.map
@@ -2,8 +2,9 @@
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
+ 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) {
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;
@@ -33,18 +55,41 @@ export async function recoverCommitJournals(io, home, now) {
33
55
  else {
34
56
  try {
35
57
  if (recovery === 'rollback-sources') {
36
- for (const source of snapshot.sourceBodies)
37
- await io.writeText(skillBodyPath(home, source.name), source.body);
58
+ let restoredArchived = restoreSource !== undefined;
59
+ if (restoreSource !== undefined) {
60
+ for (const source of snapshot.sourceBodies) {
61
+ if (!(await restoreSource(source.name)))
62
+ restoredArchived = false;
63
+ }
64
+ }
65
+ if (!restoredArchived) {
66
+ for (const source of snapshot.sourceBodies)
67
+ await io.writeText(skillBodyPath(skillRoot, source.name), source.body);
68
+ }
38
69
  }
39
70
  if (snapshot.create && snapshot.targetBody === null) {
40
71
  try {
41
- await io.remove(skillBodyPath(home, snapshot.target));
72
+ await io.remove(skillBodyPath(skillRoot, snapshot.target));
42
73
  }
43
74
  catch { /* already gone */ }
44
75
  }
45
76
  else if (snapshot.targetBody !== null) {
46
- await io.writeText(skillBodyPath(home, snapshot.target), snapshot.targetBody);
77
+ await io.writeText(skillBodyPath(skillRoot, snapshot.target), snapshot.targetBody);
78
+ }
79
+ const oldPaths = new Set((snapshot.targetSupport ?? []).map(file => file.path));
80
+ for (const file of snapshot.targetSupport ?? []) {
81
+ await io.writeText(skillFilePath(skillRoot, snapshot.target, file.path), file.content);
82
+ }
83
+ for (const file of snapshot.targetSupportAfter ?? []) {
84
+ if (!oldPaths.has(file.path)) {
85
+ try {
86
+ await io.remove(skillFilePath(skillRoot, snapshot.target, file.path));
87
+ }
88
+ catch { /* already gone */ }
89
+ }
47
90
  }
91
+ for (const file of snapshot.sourceSupport ?? [])
92
+ await io.writeText(skillFilePath(skillRoot, file.name, file.path), file.content);
48
93
  action = recovery === 'rollback-sources' ? 'restored-sources-and-target' : 'restored-target';
49
94
  }
50
95
  catch (error) {
@@ -52,7 +97,8 @@ export async function recoverCommitJournals(io, home, now) {
52
97
  ok = false;
53
98
  }
54
99
  }
55
- await writeJournal(io, home, { ...journal, phase: ok ? 'rolled-back' : 'failed', updatedAt: new Date(now).toISOString() });
100
+ if (ok)
101
+ await writeJournal(io, home, { ...journal, phase: 'rolled-back', updatedAt: new Date(now).toISOString() });
56
102
  reports.push({ runId: journal.runId, phase: journal.phase, action, ok });
57
103
  }
58
104
  await io.writeText(join(home, 'merge', 'recovery.json'), JSON.stringify(reports, null, 2));
@@ -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): Promise<CommitSnapshot>;
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 skillBodyPath(home: string, name: string): string;
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 skillBodyPath(home, name) {
28
- return join(home, 'skills', name, 'SKILL.md');
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.split('/').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';
@@ -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 })
@@ -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
@@ -20,6 +20,7 @@ export function recoveryFor(journal) {
20
20
  case 'committed':
21
21
  case 'rolled-back':
22
22
  case 'failed': return 'none';
23
+ default: return 'refuse';
23
24
  }
24
25
  }
25
26
  //# sourceMappingURL=commit-journal.js.map
@@ -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,15 @@ 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
+ const mergeSkillsRoot = skills.root ?? skillsRoot();
142
+ void recoverCommitJournals(io, home, Date.now(), mergeSkillsRoot, async (name) => {
143
+ const result = await skills.restoreFromArchive(name);
144
+ return result.ok;
145
+ }, (runId) => {
146
+ const run = runs.find(runId);
147
+ return run !== undefined && run.state === 'running';
148
+ }).then((reports) => { if (reports.length > 0)
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))); });
143
150
  const channel = subagentChannelAdapter(createSubagentChannel(ctx));
144
151
  const stage = new StageToolRegistry();
145
152
  const engineConfig = engineConfigFromEnv(params.mergeModelsRoot);
@@ -55,6 +55,9 @@ export function createMergeRuntime(deps) {
55
55
  }
56
56
  const pair = [{ a: command.a, b: command.b }];
57
57
  const existing = (await loadMergeState(deps.io, deps.home)).state.rows.find(row => row.pairKey === pairKey(command.a, command.b));
58
+ if (existing?.state === 'skipped') {
59
+ await dropRow(deps.io, deps.home, deps.now, existing.id);
60
+ }
58
61
  if (existing?.state === 'pending') {
59
62
  return { ok: false, text: '该 pair 已在待批准队列,先 approve/reject/discard: ' + existing.id };
60
63
  }
@@ -95,7 +98,7 @@ export function createMergeRuntime(deps) {
95
98
  const managed = new Set(cards.filter(card => card.managed === true).map(card => card.name));
96
99
  const loadedState = await loadMergeState(deps.io, deps.home);
97
100
  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));
101
+ const held = new Set(loadedState.state.rows.filter(row => row.queue === 'pending' || row.queue === 'fuse' || !canRetry(row)).map(row => row.pairKey));
99
102
  const conflictNotes = loadedState.state.rows
100
103
  .filter(row => row.state === 'pending' || row.state === 'approved' || row.state === 'fuse' || row.state === 'skipped')
101
104
  .map(row => 'conflict:' + row.state + ':' + row.pairKey + ':' + row.id);
@@ -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;
@@ -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.2",
4
+ "version": "0.30.4",
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.2",
31
- "@lmzhen/dsh-evolution-merge-engine": "^0.30.2"
30
+ "@lmzhen/dsh-evolution-core": "^0.30.4",
31
+ "@lmzhen/dsh-evolution-merge-engine": "^0.30.4"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@deepseek-ai/cordis": "^4.0.5"