@xiaoyuyu6420/dsh-backup 0.8.0 → 0.9.1

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
@@ -15,12 +15,18 @@
15
15
  * - `/backup github status|sync|pull [--restore <前缀|latest>]|repo <地址|off>`
16
16
  * GitHub 同步状态 / 立即推送 / 拉取到本地 / 设置仓库
17
17
  * - `/backup delete|rm <前缀|latest>` 删除备份(归档 + 校验边车)
18
+ * - `/backup doctor [--repair <前缀|latest>]` 会话日志体检:按帧走查 zstd 拼接
19
+ * 日志 + seq 连续性校验,检出损坏文件;
20
+ * --repair 从指定归档定点恢复损坏的会话
18
21
  * - `/backup --keep N` 覆盖本次保留份数
19
- * - `backup_dsh` 模型工具:mode=backup|list|verify|restore|auto(restore 支持 syncDeps)
22
+ * - `backup_dsh` 模型工具:mode=backup|list|verify|restore|auto|doctor(restore 支持 syncDeps)
20
23
  * - cordis.yml `config`:destination / keep / exclude / redact / githubRepo(见 README)
21
24
  * - Settings「备份」标签页(Web):状态、立即备份、校验、恢复、下载、
22
25
  * 自动备份开关与 GitHub 同步(推送 + 拉取),经 `backupPanel` Typert Remote 命名空间访问
23
26
  * - Web 下载路由:GET /backup-download/<归档名>(仅 loopback,附件形式)
27
+ * - 救援通道(进程外):每次备份把 rescue.mjs + 双击启动器(点我恢复.command/.bat/.sh)
28
+ * + RESCUE.txt 写进备份目录——DSH 起不来时双击/`node rescue.mjs` 打开零依赖
29
+ * 救援网页(列表/校验/恢复/doctor),Node 活着就能自救
24
30
  *
25
31
  * 安全说明(v0.7.0 起):凭据文件(.credentials.yaml / .env / qq-bridge/config.json,
26
32
  * config.redact 可增删)默认脱敏——不进归档、不进 GitHub 同步,明文只存备份目录下
@@ -35,11 +41,14 @@
35
41
  * 模型面的沙箱 surface(workspace-write 会拒绝 Desktop),不适用于宿主
36
42
  * 插件的自有存储。
37
43
  */
38
- import { createHash } from 'node:crypto';
39
- import { createReadStream } from 'node:fs';
44
+ import { createHash, randomUUID } from 'node:crypto';
45
+ import { createReadStream, readFileSync, realpathSync } from 'node:fs';
40
46
  import { mkdir, open, copyFile, readdir, readFile, rename, rm, stat as fsStat, unlink, writeFile } from 'node:fs/promises';
47
+ import { createRequire } from 'node:module';
41
48
  import { hostname } from 'node:os';
42
- import { dirname, join } from 'node:path';
49
+ import { basename, dirname, join } from 'node:path';
50
+ import { fileURLToPath } from 'node:url';
51
+ import * as zlib from 'node:zlib';
43
52
  import { defineTool } from '@deepseek-ai/dsh-tools';
44
53
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
45
54
  import { settingsNamespace, SettingsConflictError } from '@deepseek-ai/dsh-settings';
@@ -69,6 +78,141 @@ const SENSITIVE_DEFAULTS = ['.credentials.yaml', '.env', 'qq-bridge/config.json'
69
78
  /** 备份目录下保存明文敏感文件的子目录(随备份刷新为最新一份的副本)。 */
70
79
  const VAULT_DIR = 'vault';
71
80
 
81
+ /** 随备份落盘的救援说明:DSH 起不来时,用户打开备份目录第一眼能看到的东西。 */
82
+ const RESCUE_TXT = `DSH 出问题时的自救通道(不依赖 DSH 能否启动)
83
+ ====================================================
84
+
85
+ 最简单:双击「点我恢复」文件(macOS .command / Windows .bat / Linux .sh),
86
+ 浏览器会自动打开救援页面,点按钮即可校验/恢复/修复。
87
+
88
+ 没有双击文件或想用终端:
89
+ node rescue.mjs 打开救援网页(同上)
90
+ node rescue.mjs list 列出备份
91
+ node rescue.mjs verify all 校验完整性
92
+ node rescue.mjs restore latest 预览恢复;加 --yes 执行
93
+ node rescue.mjs doctor 会话日志体检
94
+ node rescue.mjs doctor --repair 修复损坏的会话日志
95
+
96
+ 恢复是整体替换:现有 .dsh 会先自动快照并挪到一旁(.dsh.pre-restore-*),
97
+ 凭据从本目录 vault/ 自动补回。rescue.mjs / 启动器 / 本说明由 dsh-backup 每次备份时更新。
98
+ `;
99
+
100
+ /** 双击启动器:起救援服务并自动开浏览器。按当前平台生成,避免目录里躺三个系统的文件。 */
101
+ const RESCUE_LAUNCHERS = Object.freeze({
102
+ darwin: ['点我恢复.command', '#!/bin/sh\ncd "$(dirname "$0")" || exit 1\nexec node rescue.mjs serve --open\n'],
103
+ win32: ['点我恢复.bat', '@echo off\r\nchcp 65001 >nul\r\ncd /d "%~dp0"\r\nnode rescue.mjs serve --open\r\npause\r\n'],
104
+ linux: ['点我恢复.sh', '#!/bin/sh\ncd "$(dirname "$0")" || exit 1\nexec node rescue.mjs serve --open\n'],
105
+ });
106
+
107
+ /**
108
+ * 从归档名解析自然日(dsh-YYYYMMDD-HHMMSSmmm);解析不出返回 null。
109
+ * 纯函数、模块级:轮换分层与测试共用。
110
+ */
111
+ function archiveDay(name) {
112
+ const m = /^dsh-(\d{4})(\d{2})(\d{2})-\d{6}\d{3}\.tar\.gz$/.exec(name);
113
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
114
+ }
115
+
116
+ /** ISO 周号(周一为首日,YYYY-Www):跨日/跨周分层保留的桶键。 */
117
+ function isoWeekOf(day) {
118
+ const dt = new Date(`${day}T00:00:00Z`);
119
+ const dayNum = (dt.getUTCDay() + 6) % 7;
120
+ dt.setUTCDate(dt.getUTCDate() - dayNum + 3);
121
+ const firstThursday = new Date(Date.UTC(dt.getUTCFullYear(), 0, 4));
122
+ const fday = (firstThursday.getUTCDay() + 6) % 7;
123
+ firstThursday.setUTCDate(firstThursday.getUTCDate() - fday + 3);
124
+ return `${dt.getUTCFullYear()}-W${String(1 + Math.round((dt - firstThursday) / 604800000)).padStart(2, '0')}`;
125
+ }
126
+
127
+ /**
128
+ * 分级轮换的保护集:tailDesc(keep 窗口外、新→旧)里,每个自然日的首份受
129
+ * 保护(最多 7 个不同日);某日已见或日额用尽时,落至周级——该 ISO 周首份
130
+ * 受保护(最多 4 个不同周;日级保护同时登记其所在周,避免同周重复占额)。
131
+ * 今日的档内冗余不保护(keep 窗口已覆盖今日)。返回受保护的归档名集合。
132
+ */
133
+ function tierProtectedSet(tailDesc, newestName) {
134
+ const protectedNames = new Set();
135
+ const daySeen = new Set();
136
+ const weekSeen = new Set();
137
+ const today = newestName ? archiveDay(newestName) : null;
138
+ let daysLeft = 7;
139
+ let weeksLeft = 4;
140
+ for (const b of tailDesc) {
141
+ const d = archiveDay(b.name);
142
+ if (!d || d === today) continue;
143
+ const wk = isoWeekOf(d);
144
+ if (!daySeen.has(d) && daysLeft > 0) {
145
+ daySeen.add(d);
146
+ weekSeen.add(wk);
147
+ daysLeft -= 1;
148
+ protectedNames.add(b.name);
149
+ } else if (!weekSeen.has(wk) && weeksLeft > 0) {
150
+ weekSeen.add(wk);
151
+ weeksLeft -= 1;
152
+ protectedNames.add(b.name);
153
+ }
154
+ }
155
+ return protectedNames;
156
+ }
157
+
158
+ /**
159
+ * 探测正在运行的宿主版本(升级前快照的触发依据)。
160
+ * 关键约束:必须测"正在跑的宿主",而不是 profile 里 hoisted 的 peer 包——
161
+ * 用户经全局 CLI / `npx @deepseek-ai/dsh web` 升级宿主时,profile 的 peer
162
+ * 包纹丝不动,peer 代理会永远 stale、快照永不触发。
163
+ * 优先级:
164
+ * 1. env DSH_VERSION(宿主未来若提供则最准)
165
+ * 2. process.argv[1] 真实路径向上扫:宿主 bin 位于
166
+ * <...>/node_modules/@deepseek-ai/dsh/ 下(覆盖全局/npm/npx 缓存/
167
+ * 本地目录安装),或自身就在包目录内
168
+ * 3. createRequire 解析 @deepseek-ai/dsh/package.json(解析链恰含宿主时命中)
169
+ * 4. peer @dsh-settings 版本代理(最后兜底:测的是 profile 列车,仅覆盖
170
+ * "重装插件/刷新 hoisted 包带动列车"的场景)
171
+ * 全部失败返回 null(快照特性静默停用,绝不影响装配)。
172
+ */
173
+ function detectHostTrain() {
174
+ const fromEnv = process.env.DSH_VERSION;
175
+ if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim();
176
+ try {
177
+ if (process.argv[1]) {
178
+ let dir = dirname(realpathSync(process.argv[1]));
179
+ for (let i = 0; i < 8 && dir !== dirname(dir); i += 1) {
180
+ const candidates = [join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')];
181
+ if (basename(dir) === 'dsh' && basename(dirname(dir)) === '@deepseek-ai') {
182
+ candidates.push(join(dir, 'package.json'));
183
+ }
184
+ for (const c of candidates) {
185
+ try {
186
+ const v = JSON.parse(readFileSync(c, 'utf8')).version;
187
+ if (typeof v === 'string' && v) return v;
188
+ } catch { /* 尝试下一个候选 */ }
189
+ }
190
+ dir = dirname(dir);
191
+ }
192
+ }
193
+ } catch { /* argv 异常走兜底 */ }
194
+ try {
195
+ const v = JSON.parse(readFileSync(createRequire(import.meta.url).resolve('@deepseek-ai/dsh/package.json'), 'utf8')).version;
196
+ if (typeof v === 'string' && v) return v;
197
+ } catch { /* 宿主不在本插件的解析链上 */ }
198
+ const candidates = [];
199
+ try {
200
+ candidates.push(createRequire(import.meta.url).resolve('@deepseek-ai/dsh-settings/package.json'));
201
+ } catch { /* exports 未暴露 package.json */ }
202
+ try {
203
+ for (const rel of ['../node_modules/@deepseek-ai/dsh-settings/package.json', '../../node_modules/@deepseek-ai/dsh-settings/package.json', '../../../node_modules/@deepseek-ai/dsh-settings/package.json']) {
204
+ candidates.push(fileURLToPath(new URL(rel, import.meta.url)));
205
+ }
206
+ } catch { /* URL 解析异常 */ }
207
+ for (const p of candidates) {
208
+ try {
209
+ const v = JSON.parse(readFileSync(p, 'utf8')).version;
210
+ if (typeof v === 'string' && v) return v;
211
+ } catch { /* 尝试下一个候选 */ }
212
+ }
213
+ return null;
214
+ }
215
+
72
216
  /** Settings 命名空间:经 `ctx.settings` 提供(持久化到 $DSH_HOME/settings.yaml)。 */
73
217
  const NS = settingsNamespace('dsh-backup');
74
218
 
@@ -194,6 +338,8 @@ const PANEL_INVOCATIONS = Object.freeze([
194
338
  panelDescriptor('githubPull', [], true),
195
339
  panelDescriptor('removeEntry', ['selector'], true),
196
340
  panelDescriptor('setGithubRepo', ['repo']),
341
+ panelDescriptor('doctorScan', [], true),
342
+ panelDescriptor('doctorRepair', ['selector'], true),
197
343
  ]);
198
344
 
199
345
  /**
@@ -257,6 +403,16 @@ class BackupPanelService extends TypertRemoteService {
257
403
  setGithubRepo(repo) {
258
404
  return this.ops.setGithubRepo(repo);
259
405
  }
406
+
407
+ /** 会话日志体检(只读扫描,不写任何文件)。 */
408
+ doctorScan(signal) {
409
+ return this.ops.doctorScan(signal);
410
+ }
411
+
412
+ /** 从指定归档定点修复损坏的会话日志(缺省最新一份)。 */
413
+ doctorRepair(selector, signal) {
414
+ return this.ops.doctorRepair(selector, signal);
415
+ }
260
416
  }
261
417
 
262
418
  export function apply(ctx, pluginConfig) {
@@ -528,7 +684,8 @@ export function apply(ctx, pluginConfig) {
528
684
  * 这一份。恢复是低频操作,快照又被 listBackups 排除、用户不可见,故每
529
685
  * 次恢复后清掉前一次的快照防隐藏累积(aside 目录仍由用户自行管理)。
530
686
  */
531
- async function prunePreRestoreSnapshots(keepName, signal) {
687
+ /** 清理内部快照(pre-restore / pre-upgrade 共用):只保留最近 keepN 份。 */
688
+ async function prunePrefixedSnapshots(prefix, keepN, keepName, signal) {
532
689
  const { root } = paths();
533
690
  let dirents;
534
691
  try {
@@ -536,11 +693,20 @@ export function apply(ctx, pluginConfig) {
536
693
  } catch {
537
694
  return;
538
695
  }
539
- const keepSet = new Set(sidecarsFor(keepName));
540
- const stale = dirents
541
- .filter((d) => d.name.startsWith('dsh-pre-restore-') && (d.name.endsWith('.tar.gz') || d.name.endsWith('.sha256') || d.name.endsWith('.meta.json') || d.name.endsWith('.redacted.json')) && !keepSet.has(d.name))
542
- .map((d) => d.name);
543
- if (stale.length) await removeFiles(stale, root, signal);
696
+ const names = dirents
697
+ .filter((d) => d.name.startsWith(prefix) && (d.name.endsWith('.tar.gz') || d.name.endsWith('.sha256') || d.name.endsWith('.meta.json') || d.name.endsWith('.redacted.json')))
698
+ .map((d) => d.name)
699
+ .sort()
700
+ .reverse();
701
+ // 归档名排序即时间序;边车跟宿主同名排序紧邻其后,按"每份归档一组"数份数
702
+ const archiveCount = names.filter((n) => n.endsWith('.tar.gz'));
703
+ const staleArchives = archiveCount.slice(keepN).filter((n) => n !== keepName);
704
+ const staleSet = new Set(staleArchives.flatMap((n) => sidecarsFor(n)));
705
+ if (staleSet.size) await removeFiles([...staleSet], root, signal);
706
+ }
707
+
708
+ async function prunePreRestoreSnapshots(keepName, signal) {
709
+ await prunePrefixedSnapshots('dsh-pre-restore-', 1, keepName, signal);
544
710
  }
545
711
 
546
712
  /** 归档伴生边车集:轮换、删除与 GitHub 同步对同一归档统一处理的文件列表。 */
@@ -548,6 +714,9 @@ export function apply(ctx, pluginConfig) {
548
714
  return [name, `${name}.sha256`, `${name}.meta.json`, `${name}.redacted.json`];
549
715
  }
550
716
 
717
+ /** 内部快照前缀(恢复前 / 升级前):不进用户列表、不参与轮换与 keep 配额。 */
718
+ const SNAPSHOT_PREFIXES = Object.freeze(['dsh-pre-restore-', 'dsh-pre-upgrade-']);
719
+
551
720
  async function listBackups() {
552
721
  const { root } = paths();
553
722
  let dirents;
@@ -559,10 +728,10 @@ export function apply(ctx, pluginConfig) {
559
728
  }
560
729
  const backups = [];
561
730
  for (const d of dirents) {
562
- // 恢复前自动快照(dsh-pre-restore-*)是内部安全网,不进用户列表、
563
- // 不参与轮换、不被 pickArchive 的 latest/前缀误选——经 listBackups
564
- // 过滤即可同时收口这三处入口。
565
- if (!d.name.startsWith('dsh-') || d.name.startsWith('dsh-pre-restore-') || !d.name.endsWith('.tar.gz')) continue;
731
+ // 内部快照(dsh-pre-restore-* / dsh-pre-upgrade-*)不进用户列表、
732
+ // 不参与轮换、不被 pickArchive 的 latest 误选;显式前缀仍可选中
733
+ // (见 pickArchive 的快照回退分支)。
734
+ if (!d.name.startsWith('dsh-') || SNAPSHOT_PREFIXES.some((p) => d.name.startsWith(p)) || !d.name.endsWith('.tar.gz')) continue;
566
735
  let size;
567
736
  try {
568
737
  size = (await fsStat(`${root}/${d.name}`)).size;
@@ -575,6 +744,22 @@ export function apply(ctx, pluginConfig) {
575
744
  return backups.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
576
745
  }
577
746
 
747
+ /** 列出内部快照归档名(新→旧),prefix 传 'dsh-pre-' 可同时取两类。 */
748
+ async function listSnapshotNames(prefix = 'dsh-pre-') {
749
+ const { root } = paths();
750
+ let dirents;
751
+ try {
752
+ dirents = await readdir(root, { withFileTypes: true });
753
+ } catch {
754
+ return [];
755
+ }
756
+ return dirents
757
+ .filter((d) => d.name.endsWith('.tar.gz') && SNAPSHOT_PREFIXES.some((p) => d.name.startsWith(p)) && d.name.startsWith(prefix))
758
+ .map((d) => d.name)
759
+ .sort()
760
+ .reverse();
761
+ }
762
+
578
763
  async function writeOwned(p, content) {
579
764
  await mkdir(dirname(p), { recursive: true });
580
765
  await writeFile(p, content, 'utf8');
@@ -614,6 +799,17 @@ export function apply(ctx, pluginConfig) {
614
799
  const base = dshHome.split('/').pop();
615
800
  const parent = dshHome.slice(0, -(base.length + 1)) || '/';
616
801
 
802
+ // 备份前体检联动:损坏的会话日志**不入档**——坏字节一旦进了归档,轮换会在
803
+ // 不知情的情况下删掉最后一份好副本。检出项记入 meta.quarantined,恢复侧
804
+ // 如实提示。体检失败不阻断备份(fail-open)。
805
+ let quarantined = [];
806
+ if (!opts?.skipScan) {
807
+ try {
808
+ quarantined = (await runDoctorScan(signal)).corrupt.map((c) => c.rel);
809
+ } catch { /* 扫描异常按无隔离处理 */ }
810
+ }
811
+ const quarantineFlags = quarantined.map((rel) => `--exclude=${base}/${rel}`);
812
+
617
813
  // tar 以备份目录为 cwd、用纯文件名传 -f:Windows 上 GNU tar(msys)会把
618
814
  // 含盘符冒号的绝对路径当远程归档("Cannot connect to C"),bsdtar 则两者皆可。
619
815
  // 敏感文件先从归档排除(redactExcludeFlags),其明文副本进本机 vault。
@@ -621,7 +817,7 @@ export function apply(ctx, pluginConfig) {
621
817
  // 仅存的凭据副本清掉——只列清单不拷贝,vault 保持上一次常规备份的状态。
622
818
  const redacted = opts?.skipVault ? await existingSensitive(dshHome) : await refreshVault(dshHome, home, signal);
623
819
  const tar = await ctx.subprocess.resolveExecutable('tar');
624
- await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...redactExcludeFlags(base), ...extraExcludes(), '-czf', name, '-C', parent, base], root, signal);
820
+ await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...quarantineFlags, ...redactExcludeFlags(base), ...extraExcludes(), '-czf', name, '-C', parent, base], root, signal);
625
821
 
626
822
  const shaText = await sha256File(out, home, signal);
627
823
  await writeOwned(`${out}.sha256`, `${shaText} ${out}\n`);
@@ -629,8 +825,26 @@ export function apply(ctx, pluginConfig) {
629
825
  if (sensitivePaths().length) {
630
826
  await writeOwned(`${out}.redacted.json`, `${JSON.stringify({ files: redacted })}\n`);
631
827
  }
632
- // 机器元数据边车:跨机恢复的预检依据(home 不一致 → 绝对路径提示)。
633
- await writeOwned(`${out}.meta.json`, `${JSON.stringify({ host: hostname(), home, dshHome, createdAt: new Date().toISOString(), redacted: redacted.length })}\n`);
828
+ // 机器元数据边车:跨机恢复的预检依据(home 不一致 → 绝对路径提示);
829
+ // quarantined 记录体检隔离、未入档的损坏会话文件。
830
+ await writeOwned(`${out}.meta.json`, `${JSON.stringify({ host: hostname(), home, dshHome, createdAt: new Date().toISOString(), redacted: redacted.length, ...(quarantined.length ? { quarantined } : {}) })}\n`);
831
+
832
+ // 救援通道:每次备份把进程外恢复工具落进备份目录——宿主起不来时
833
+ // `node rescue.mjs`(或双击启动器)仍可恢复(DSH 依赖 Node,宿主死了
834
+ // Node 一定活着)。复制失败不阻断备份:rescue 缺席只影响灾时体验。
835
+ try {
836
+ await copyFile(new URL('../rescue/rescue.mjs', import.meta.url), `${root}/rescue.mjs`);
837
+ await writeOwned(`${root}/RESCUE.txt`, RESCUE_TXT);
838
+ const launcher = RESCUE_LAUNCHERS[process.platform];
839
+ if (launcher) {
840
+ const [name, content] = launcher;
841
+ await writeOwned(`${root}/${name}`, content);
842
+ if (!IS_WIN) {
843
+ const chmod = await ctx.subprocess.resolveExecutable('chmod');
844
+ await spawnRun([chmod, '755', `${root}/${name}`], home, signal);
845
+ }
846
+ }
847
+ } catch { /* 老安装缺文件等场景静默跳过 */ }
634
848
 
635
849
  // 安全:备份含明文凭据(.credentials.yaml / qq-bridge/config.json),收紧为仅本人可读写。
636
850
  // Windows 无 chmod,用户目录 ACL 默认私有。
@@ -639,9 +853,13 @@ export function apply(ctx, pluginConfig) {
639
853
  await spawnRun([chmod, '600', out, `${out}.sha256`], home, signal);
640
854
  }
641
855
 
642
- // 轮换:只保留最近 keepN
856
+ // 分级轮换:keep 窗口之外的尾巴不再一刀切全删——保留最近 7 个不同自然日
857
+ // 的每日首份、再往前 4 个不同 ISO 周的每周首份(今日的档内冗余仍按 keep
858
+ // 裁剪)。严格只比旧的"全删"多保留,绝不多删;非标准命名(解析不出日期)
859
+ // 保持旧行为不保护。
643
860
  const all = await listBackups();
644
- const stale = all.slice(keepN).map((b) => b.name);
861
+ const tail = all.slice(keepN);
862
+ const stale = tail.filter((b) => !tierProtectedSet(tail, all[0]?.name).has(b.name)).map((b) => b.name);
645
863
  if (stale.length) {
646
864
  await removeFiles(stale.flatMap((n) => sidecarsFor(n)), root, signal);
647
865
  }
@@ -664,7 +882,14 @@ export function apply(ctx, pluginConfig) {
664
882
  await saveAutoState();
665
883
  }
666
884
 
667
- return { path: out, name, sha: shaText, total: all.length, stale: stale.length, keep: keepN, sync };
885
+ return { path: out, name, sha: shaText, total: all.length, stale: stale.length, keep: keepN, quarantined, sync };
886
+ }
887
+
888
+ /** 备份回执的隔离提示行:体检发现坏会话文件未入档时如实告知 + 指路修复。 */
889
+ function quarantineNote(r) {
890
+ return Array.isArray(r?.quarantined) && r.quarantined.length
891
+ ? `\n⚠️ ${r.quarantined.length} 个损坏会话文件未入档(防轮换毁掉好副本);/backup doctor --repair 修复后可正常备份`
892
+ : '';
668
893
  }
669
894
 
670
895
  // ---------- GitHub 同步 ----------
@@ -934,7 +1159,17 @@ export function apply(ctx, pluginConfig) {
934
1159
  const exact = all.filter((b) => b.name === selector);
935
1160
  const hits = exact.length ? exact : all.filter((b) => b.name.startsWith(selector));
936
1161
  if (hits.length === 1) return hits[0];
937
- if (!hits.length) throw new Error(`没有匹配 "${selector}" 的备份,/backup list 查看`);
1162
+ if (!hits.length) {
1163
+ // 显式前缀可命中内部快照(升级前/恢复前)——latest 永不落入此分支
1164
+ const snaps = await listSnapshotNames('dsh-pre-');
1165
+ const sExact = snaps.filter((n) => n === selector);
1166
+ const sHits = sExact.length ? sExact : snaps.filter((n) => n.startsWith(selector));
1167
+ if (sHits.length === 1) return { name: sHits[0], size: undefined };
1168
+ if (sHits.length > 1) {
1169
+ throw new Error(`"${selector}" 匹配多份快照,请加长前缀:\n${sHits.slice(0, 5).map((n) => ` ${n}`).join('\n')}`);
1170
+ }
1171
+ throw new Error(`没有匹配 "${selector}" 的备份,/backup list 查看`);
1172
+ }
938
1173
  throw new Error(`"${selector}" 匹配多份备份,请加长前缀:\n${hits.slice(0, 5).map((b) => ` ${b.name}`).join('\n')}`);
939
1174
  }
940
1175
 
@@ -1023,26 +1258,28 @@ export function apply(ctx, pluginConfig) {
1023
1258
  if (Array.isArray(preflight.redactedFiles)) {
1024
1259
  lines.push(`🔐 该归档已脱敏:${preflight.redactedFiles.length} 个凭据文件不随归档走,恢复时从本机 vault 还原(跨机恢复需重填)`);
1025
1260
  }
1261
+ if (preflight.legacyUnaccountedCredentials) {
1262
+ lines.push('⚠️ 归档未携带脱敏清单(v0.6.x 时代格式)且不含凭据文件——恢复后凭据需从本机 vault 或原机器补齐');
1263
+ }
1264
+ if (Array.isArray(preflight.quarantinedFiles) && preflight.quarantinedFiles.length) {
1265
+ lines.push(`⚠️ 备份时体检隔离了 ${preflight.quarantinedFiles.length} 个损坏会话文件(未入档)——这些会话恢复后缺失,可用 /backup doctor --repair 从更早归档修复`);
1266
+ }
1026
1267
  if (profileDirs.length) {
1027
1268
  lines.push(`📦 归档含 ${profileDirs.length} 个 profile 的依赖声明(node_modules 不随归档),恢复后用 --sync-deps 或手动 pnpm install 重装插件`);
1028
1269
  }
1029
1270
  return lines;
1030
1271
  }
1031
1272
 
1032
- async function restoreArchive(selector, dryRun, signal, opts = {}) {
1033
- const { home, dshHome, root } = paths();
1034
- const picked = await pickArchive(selector);
1035
- const archive = `${root}/${picked.name}`;
1036
-
1037
- // 恢复前强制校验:损坏的归档绝不覆盖现有数据。
1038
- const v = await verifyOne(picked.name, home, signal);
1039
- if (!v.ok) throw new Error(`校验未通过(${v.note}),恢复已中止`);
1040
-
1273
+ /**
1274
+ * 列出归档全部条目并做安全校验(restoreArchive doctorRepair 共用):
1275
+ * cwd 为备份目录、-f 用纯文件名(规避 Windows GNU tar 盘符冒号问题),
1276
+ * 逐行解析 -tvzf 输出并拒绝备份根之外的任何条目——含不安全条目直接抛错。
1277
+ */
1278
+ async function safeArchiveEntries(pickedName, signal) {
1279
+ const { root, dshHome } = paths();
1041
1280
  const base = dshHome.split('/').pop();
1042
- const parent = dshHome.slice(0, -(base.length + 1)) || '/';
1043
1281
  const tar = await ctx.subprocess.resolveExecutable('tar');
1044
- // doBackup:cwd 为备份目录,-f 用纯文件名,规避 Windows GNU tar 的盘符冒号问题。
1045
- const listed = await spawnRun([tar, '-tvzf', picked.name], root, signal);
1282
+ const listed = await spawnRun([tar, '-tvzf', pickedName], root, signal);
1046
1283
  // tar 路径穿越防护:逐行解析 -tvzf 输出(双布局,见 parseTarEntry),
1047
1284
  // 条目必须是普通文件/目录、相对路径且位于备份根目录之下;任一违规
1048
1285
  // 即整体拒绝,恢复绝不触碰备份根之外的文件。
@@ -1070,6 +1307,21 @@ export function apply(ctx, pluginConfig) {
1070
1307
  if (bad.length) {
1071
1308
  throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')},恢复已中止`);
1072
1309
  }
1310
+ return entries;
1311
+ }
1312
+
1313
+ async function restoreArchive(selector, dryRun, signal, opts = {}) {
1314
+ const { home, dshHome, root } = paths();
1315
+ const picked = await pickArchive(selector);
1316
+ const archive = `${root}/${picked.name}`;
1317
+
1318
+ // 恢复前强制校验:损坏的归档绝不覆盖现有数据。
1319
+ const v = await verifyOne(picked.name, home, signal);
1320
+ if (!v.ok) throw new Error(`校验未通过(${v.note}),恢复已中止`);
1321
+
1322
+ const base = dshHome.split('/').pop();
1323
+ const parent = dshHome.slice(0, -(base.length + 1)) || '/';
1324
+ const entries = await safeArchiveEntries(picked.name, signal);
1073
1325
 
1074
1326
  // 预检(dry-run 与真实恢复共用):跨机 home 对比 + 脱敏清单 + profile 依赖。
1075
1327
  const { meta, redactedFiles } = await readArchiveMeta(picked.name);
@@ -1078,14 +1330,34 @@ export function apply(ctx, pluginConfig) {
1078
1330
  sourceHost: meta?.host ?? null,
1079
1331
  sourceHome: meta?.home ?? null,
1080
1332
  redactedFiles,
1333
+ quarantinedFiles: Array.isArray(meta?.quarantined) ? meta.quarantined : null,
1334
+ // 老归档无 .redacted.json:无法声明凭据保障。若归档里也没有任何敏感
1335
+ // 路径条目,恢复出的数据将缺凭据——如实提示,优于静默缺文件。
1336
+ legacyUnaccountedCredentials: !Array.isArray(redactedFiles)
1337
+ && sensitivePaths().length > 0
1338
+ && !entries.some((e) => sensitivePaths().some((rel) => e.endsWith(`/${rel}`))),
1081
1339
  };
1082
1340
  const profileDirs = [...new Set(entries
1083
1341
  .filter((n) => n.startsWith(`${base}/profiles/`) && n.endsWith('/package.json'))
1084
1342
  .map((n) => n.slice(`${base}/profiles/`.length).split('/')[0]))];
1085
1343
 
1344
+ // 现有数据探测:~/.dsh 已缺失(数据全失/新机首恢复)时没有可快照的
1345
+ // 内容,跳过快照与挪旁直接解压——先快照后判存在的旧序会让 tar 对不
1346
+ // 存在的 .dsh 报错,恢复在最需要的场景反而失败。ENOENT 视为首次恢
1347
+ // 复;其他 stat 失败(如 EACCES)中止恢复,不在不明状态下动现有数
1348
+ // 据。预览(dry-run)也据此切换「挪旁+快照」与「直接写入」两种口径。
1349
+ const targetExists = await fsStat(dshHome).then(
1350
+ () => true,
1351
+ (err) => {
1352
+ if (err && err.code === 'ENOENT') return false;
1353
+ throw err;
1354
+ },
1355
+ );
1356
+
1086
1357
  if (dryRun) {
1087
1358
  return {
1088
1359
  archive, files: entries.length, sample: entries.slice(0, 12), aside: null, snapshotPath: null, dryRun: true,
1360
+ targetExists,
1089
1361
  preflight: preflightLines(preflight, profileDirs),
1090
1362
  };
1091
1363
  }
@@ -1093,30 +1365,39 @@ export function apply(ctx, pluginConfig) {
1093
1365
  // 恢复前自动快照当前数据,并把当前数据移到旁边(而非合并覆盖)。
1094
1366
  // 快照用 dsh-pre-restore- 前缀与用户常规备份区分,经 listBackups 过滤
1095
1367
  // 后不进列表/轮换/latest 选择;每次恢复清掉上一次的快照防累积。
1096
- // 现有数据存在性先行:~/.dsh 已缺失(数据全失/新机首恢复)时没有可
1097
- // 快照的内容,跳过快照直接解压——先快照后判存在的旧序会让 tar 对
1098
- // 不存在的 .dsh 报错,恢复在最需要的场景反而失败。
1099
- let current = false;
1100
- try {
1101
- await fsStat(dshHome);
1102
- current = true;
1103
- } catch (err) {
1104
- // dshHome 不存在(ENOENT)视为首次恢复、无现有数据可移开;其他 stat
1105
- // 失败(如 EACCES)中止恢复,不跳过 aside 直接覆盖现有数据
1106
- if (!err || err.code !== 'ENOENT') throw err;
1107
- current = false;
1108
- }
1109
- const snapshot = current
1368
+ const snapshot = targetExists
1110
1369
  ? await doBackup(undefined, signal, { namePrefix: 'dsh-pre-restore-', skipVault: true })
1111
1370
  : null;
1112
1371
  if (snapshot) await prunePreRestoreSnapshots(snapshot.name, signal);
1372
+ const asideName = `${base}.pre-restore-${stampNow()}`;
1113
1373
  let aside = null;
1114
- if (current) {
1115
- const asideName = `${base}.pre-restore-${stampNow()}`;
1374
+ if (targetExists) {
1116
1375
  await renameBeside(parent, base, asideName, signal);
1117
1376
  aside = `${parent}/${asideName}`;
1118
1377
  }
1119
- await spawnRun([tar, '-xzf', picked.name, '-C', parent], root, signal);
1378
+ try {
1379
+ const tar = await ctx.subprocess.resolveExecutable('tar');
1380
+ await spawnRun([tar, '-xzf', picked.name, '-C', parent], root, signal);
1381
+ } catch (err) {
1382
+ // 挪旁之后解压失败会让 .dsh 处于空位——这是恢复流程里用户最容易慌
1383
+ // 的时刻。尽力自动回滚:清掉半截解压产物、把原数据改回原名;连回滚
1384
+ // 都失败时,错误消息必须带上数据位置和手工还原方法,绝不抛裸异常。
1385
+ const reason = err && err.message ? err.message : String(err);
1386
+ let rolledBack = false;
1387
+ try {
1388
+ await rm(dshHome, { recursive: true, force: true });
1389
+ if (targetExists) await renameBeside(parent, asideName, base, signal);
1390
+ rolledBack = true;
1391
+ } catch {
1392
+ // 回滚失败:落入下方的人工指引分支,把位置交代清楚
1393
+ }
1394
+ if (rolledBack) {
1395
+ throw new Error(`解压归档失败(${reason})。已自动还原到恢复前状态,数据未丢失;排查磁盘空间/权限后可重试`);
1396
+ }
1397
+ throw new Error(aside
1398
+ ? `解压归档失败(${reason}),且自动还原未成功。原数据完好保存在 ${aside},把它改名回 "${dshHome}" 即可回到恢复前状态`
1399
+ : `解压归档失败(${reason}),且清理未成功。半截解压产物在 ${dshHome},删除后即可重试`);
1400
+ }
1120
1401
 
1121
1402
  // vault 还原:脱敏归档恢复出的 ~/.dsh 不含凭据文件,从本机 vault 拷回。
1122
1403
  // vault 为空(跨机首恢复)时逐项记入 missing,由调用方提示重填——凭据
@@ -1177,28 +1458,368 @@ export function apply(ctx, pluginConfig) {
1177
1458
  /** 恢复结果的人类可读汇总(命令、模型工具与面板共用)。 */
1178
1459
  function summarizeRestore(r) {
1179
1460
  if (r.dryRun) {
1180
- const pre = r.preflight?.length ? `\n${r.preflight.map((l) => ` ${l}`).join('\n')}` : '';
1181
- return `📦 恢复预览(未写入)\n 归档: ${r.archive}\n 条目: ${r.files} 项${pre}\n${r.sample.map((s) => ` ${s}`).join('\n')}`;
1182
- }
1183
- const lines = [' 恢复完成', ` 来源: ${r.archive}(${r.files} 项)`];
1184
- if (r.snapshotPath) lines.push(` 恢复前快照: ${r.snapshotPath}`);
1185
- if (r.aside) lines.push(` 旧数据已移至: ${r.aside}`);
1186
- for (const p of r.preflight ?? []) lines.push(` ${p}`);
1187
- if (r.vaultRestored?.length) lines.push(`🔐 本机 vault 已还原 ${r.vaultRestored.length} 个凭据文件`);
1188
- if (r.vaultMissing?.length) lines.push(`⚠️ 以下凭据文件不在本机 vault,需重填: ${r.vaultMissing.join(', ')}`);
1189
- if (r.deps?.installed?.length) lines.push(`📦 已重装 profile 依赖: ${r.deps.installed.join(', ')}`);
1190
- if (r.deps?.failed?.length) lines.push(`⚠️ 依赖重装失败: ${r.deps.failed.join(', ')}`);
1191
- if (r.deps?.note) lines.push(`⚠️ ${r.deps.note}`);
1192
- lines.push(' 请重启 dsh 使恢复的会话与配置生效。');
1461
+ // 预览要回答两件事:确认后会发生什么(按目标是否已有数据切换口径)、
1462
+ // 凭据/依赖的 preflight 提醒。此时还没动任何文件,标题必须说清。
1463
+ const moves = r.targetExists === false
1464
+ ? [' · 目标位置还没有 .dsh(首次恢复):直接写入,不做挪动和快照']
1465
+ : [
1466
+ ' · 当前的 .dsh 整个挪到一旁,改名为 .dsh.pre-restore-<时间>,不会和新数据混在一起',
1467
+ ' · 挪之前自动做一份快照备份;快照只保留最近一次',
1468
+ ' · 然后把这份归档的内容原样写回 .dsh',
1469
+ ];
1470
+ const pre = ['', ...moves, ...(r.preflight ?? []).map((l) => ` ${l}`)].join('\n');
1471
+ const shown = r.sample.length;
1472
+ const tail = r.files > shown ? `\n … 共 ${r.files} 项` : '';
1473
+ return `📦 恢复预览 —— 只是预览,还没动你磁盘上的任何文件\n 归档: ${r.archive}\n 将写入: ${r.files} 项${pre}\n\n 将写入的文件:\n${r.sample.map((s) => ` ${s}`).join('\n')}${tail}`;
1474
+ }
1475
+ // 回执按「发生了什么 / 数据在哪 / 接下来做什么」组织;preflight 里只有
1476
+ // 跨机路径提醒在恢复后仍然成立(🔐📦 两行会被下面的实际结果取代)。
1477
+ const lines = [
1478
+ '✅ 恢复完成',
1479
+ ` 已替换: .dsh 全部内容 ← 来自 ${r.archive.split('/').pop()}(${r.files} 项)`,
1480
+ ];
1481
+ if (r.aside) lines.push(` 旧数据没丢: 在 ${r.aside}(确认一切正常后可自行删除)`);
1482
+ if (r.snapshotPath) lines.push(` 保险快照: ${r.snapshotPath.split('/').pop()}(在备份目录内,只保留最近一次)`);
1483
+ for (const p of r.preflight ?? []) {
1484
+ if (p.startsWith('⚠️')) lines.push(` ${p}`);
1485
+ }
1486
+ if (r.vaultRestored?.length) lines.push(` 凭据: 已从本机 vault 补回 ${r.vaultRestored.length} 个`);
1487
+ if (r.vaultMissing?.length) lines.push(` ⚠️ 凭据缺失需手动重填: ${r.vaultMissing.join(', ')}`);
1488
+ const installed = r.deps?.installed ?? [];
1489
+ const failed = r.deps?.failed ?? [];
1490
+ if (r.deps?.note) {
1491
+ lines.push(` 插件依赖: 还没装 —— ${r.deps.note};之后也可用 --sync-deps 补装`);
1492
+ } else if (failed.length) {
1493
+ lines.push(` ⚠️ 插件依赖安装失败: ${failed.join(', ')}(进对应 profile 目录手动 pnpm install)${installed.length ? `;已装: ${installed.join(', ')}` : ''}`);
1494
+ } else if (installed.length) {
1495
+ lines.push(` 插件依赖: 已装好 ${installed.join(', ')}`);
1496
+ } else if (r.profiles?.length) {
1497
+ lines.push(' 插件依赖: 还没装 —— 恢复时带 --sync-deps 重装,或进各 profile 目录手动 pnpm install');
1498
+ }
1499
+ lines.push(' ⏸️ 待办: 重启 dsh,会话和配置才会切换到恢复的内容');
1193
1500
  return lines.join('\n');
1194
1501
  }
1195
1502
 
1503
+ // ---------- 会话日志体检(doctor) ----------
1504
+ //
1505
+ // 布局与格式依据宿主内置的 @deepseek-ai/dsh-session-persistence-jsonl:
1506
+ // <会话根>/--<项目目录>--/<编码后 id>/session.jsonl.zstd(compression:'none' 时为 .jsonl)
1507
+ // zstd 文件是独立帧的拼接:首帧是带校验和的 SessionHeader 行,其后每个追加批一帧。
1508
+ // Node 内置 zstd 解码器不跨帧续读,因此先按 RFC 8878 走帧字节边界
1509
+ // (魔数/帧头/块序列/校验和),再逐帧解出逻辑行。检测目标正是社区高频的
1510
+ // "corrupt session log"灾难:并发写 seq 撞号、坏帧、尾部截断。
1511
+ const ZSTD_MAGIC = 0xfd2fb528;
1512
+ const SESSION_LOG_NAMES = new Set(['session.jsonl.zstd', 'session.jsonl']);
1513
+ const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
1514
+ const SCAN_MAX_FILES = 4000;
1515
+ const SCAN_MAX_DEPTH = 8;
1516
+ // Node 内置 zstd API 要 v22.15 / v23.8 才进 node:zlib;更老运行时没有这个
1517
+ // 导出。必须用命名空间导入 + 运行时探测——具名导入是静态校验的,在缺导出
1518
+ // 的运行时上会让整个插件模块加载失败,比少一个体检功能严重得多。
1519
+ const HAS_NODE_ZSTD = typeof zlib.zstdDecompressSync === 'function';
1520
+
1521
+ /** 截断超长内容用于缺陷描述。 */
1522
+ function snippet(text, max = 60) {
1523
+ return text.length > max ? `${text.slice(0, max)}…` : text;
1524
+ }
1525
+
1526
+ /**
1527
+ * 按 RFC 8878 结构走查拼接帧的字节边界(不解压):返回 [start,end] 区间数组;
1528
+ * 结构不合法(截断/未知魔数/保留位/越界块)抛错。skippable 帧一并支持。
1529
+ */
1530
+ function walkZstdFrames(buf) {
1531
+ const frames = [];
1532
+ let off = 0;
1533
+ while (off < buf.length) {
1534
+ if (buf.length - off < 4) throw new Error(`尾部截断(余 ${buf.length - off} 字节不成帧)`);
1535
+ const magic = buf.readUInt32LE(off);
1536
+ if (magic === ZSTD_MAGIC) {
1537
+ let pos = off + 4;
1538
+ if (pos >= buf.length) throw new Error('帧头描述符缺失');
1539
+ const desc = buf[pos++];
1540
+ if (desc & 0x08) throw new Error('帧头保留位置位');
1541
+ const singleSegment = Boolean(desc & 0x20);
1542
+ const checksum = Boolean(desc & 0x04);
1543
+ const dictSize = [0, 1, 2, 4][desc & 0x03];
1544
+ const fcsSize = singleSegment ? [1, 2, 4, 8][desc >> 6] : [0, 2, 4, 8][desc >> 6];
1545
+ if (!singleSegment) pos += 1; // Window_Descriptor
1546
+ pos += dictSize + fcsSize;
1547
+ if (pos > buf.length) throw new Error('帧头越界');
1548
+ let last = false;
1549
+ while (!last) {
1550
+ if (buf.length - pos < 3) throw new Error('块头截断');
1551
+ const bh = buf.readUIntLE(pos, 3);
1552
+ last = Boolean(bh & 0x01);
1553
+ const btype = (bh >> 1) & 0x03;
1554
+ const bsize = bh >> 3;
1555
+ if (btype === 3) throw new Error('保留块类型');
1556
+ // Raw/Compressed 块占 bsize 字节;RLE 块只占 1 字节
1557
+ pos += 3 + (btype === 1 ? 1 : bsize);
1558
+ if (pos > buf.length) throw new Error('块数据越界');
1559
+ }
1560
+ if (checksum) {
1561
+ if (buf.length - pos < 4) throw new Error('帧校验和截断');
1562
+ pos += 4;
1563
+ }
1564
+ frames.push([off, pos]);
1565
+ off = pos;
1566
+ } else if (magic >= 0x184d2a50 && magic <= 0x184d2a5f) {
1567
+ if (buf.length - off < 8) throw new Error('skippable 帧头截断');
1568
+ const size = buf.readUInt32LE(off + 4);
1569
+ const end = off + 8 + size;
1570
+ if (end > buf.length) throw new Error('skippable 帧数据越界');
1571
+ frames.push([off, end]);
1572
+ off = end;
1573
+ } else {
1574
+ throw new Error(`偏移 ${off} 处不是 zstd 帧魔数`);
1575
+ }
1576
+ }
1577
+ return frames;
1578
+ }
1579
+
1580
+ /** 解出 .zstd 会话日志的全部逻辑行;任一帧损坏即抛错(错误含偏移信息)。 */
1581
+ function decodeZstdLog(buf) {
1582
+ const parts = [];
1583
+ let frameNo = 0;
1584
+ for (const [s, e] of walkZstdFrames(buf)) {
1585
+ frameNo += 1;
1586
+ try {
1587
+ parts.push(zlib.zstdDecompressSync(buf.subarray(s, e)));
1588
+ } catch (err) {
1589
+ throw new Error(`第 ${frameNo} 帧解压失败(${e - s}B):${String(err && err.message ? err.message : err)}`);
1590
+ }
1591
+ }
1592
+ return Buffer.concat(parts).toString('utf8').split('\n').filter((l) => l.length > 0);
1593
+ }
1594
+
1595
+ /**
1596
+ * 校验逻辑行流:首行必须是 SessionHeader(type:"session"),其后每行是一条存储
1597
+ * 记录——裸事件要求 seq 精确连续(events[i].seq === i),packed chunk 行按成员数
1598
+ * 推进游标(语义对齐 dsh-session 的 buildRow/expandRow:成员 k 的 seq = seq0+k,
1599
+ * 成员数 = texts/args 长度)。返回缺陷描述;无缺陷返回 null。
1600
+ */
1601
+ function validateSessionLines(lines) {
1602
+ let header;
1603
+ try {
1604
+ header = JSON.parse(lines[0]);
1605
+ } catch {
1606
+ return `首行不是 JSON(${snippet(String(lines[0]))})`;
1607
+ }
1608
+ if (!header || typeof header !== 'object' || header.type !== 'session' || typeof header.id !== 'string') {
1609
+ return '首行不是有效的 SessionHeader(缺 type:"session" 或 id)';
1610
+ }
1611
+ let nextSeq = 0;
1612
+ for (let i = 1; i < lines.length; i++) {
1613
+ let rec;
1614
+ try {
1615
+ rec = JSON.parse(lines[i]);
1616
+ } catch {
1617
+ return `第 ${i + 1} 行不是合法 JSON(${snippet(String(lines[i]))})`;
1618
+ }
1619
+ if (!rec || typeof rec !== 'object') return `第 ${i + 1} 行记录不是对象`;
1620
+ if (rec.type === 'text-chunks' || rec.type === 'reasoning-chunks' || rec.type === 'tool-call-chunks') {
1621
+ const data = rec.data ?? {};
1622
+ const members = Array.isArray(data.texts) ? data.texts.length : Array.isArray(data.args) ? data.args.length : 0;
1623
+ if (!Number.isSafeInteger(rec.seq0) || members <= 0) return `第 ${i + 1} 行 packed 行缺少 seq0 或成员列表`;
1624
+ if (rec.seq0 !== nextSeq) return `第 ${i + 1} 行 packed 行 seq0=${rec.seq0} 与游标 ${nextSeq} 不接续`;
1625
+ nextSeq += members;
1626
+ } else if (typeof rec.seq === 'number') {
1627
+ if (rec.seq !== nextSeq) return `第 ${i + 1} 行 seq=${rec.seq} 撞号/跳号(期望 ${nextSeq})`;
1628
+ nextSeq = rec.seq + 1;
1629
+ }
1630
+ // 无 seq 且非 packed 的行(未来格式扩展):放行,交由宿主自身加载判定
1631
+ }
1632
+ return null;
1633
+ }
1634
+
1635
+ /** 有界递归收集会话日志文件(相对 ~/.dsh 路径);跳过依赖/系统目录。 */
1636
+ async function collectSessionLogs(dir, relPrefix, depth, out) {
1637
+ if (depth > SCAN_MAX_DEPTH || out.length >= SCAN_MAX_FILES) return;
1638
+ let dirents;
1639
+ try {
1640
+ dirents = await readdir(dir, { withFileTypes: true });
1641
+ } catch {
1642
+ return; // 不可读目录静默跳过(权限/竞态不构成体检故障)
1643
+ }
1644
+ for (const d of dirents) {
1645
+ if (out.length >= SCAN_MAX_FILES) return;
1646
+ const rel = relPrefix ? `${relPrefix}/${d.name}` : d.name;
1647
+ if (d.isDirectory()) {
1648
+ if (SCAN_SKIP_DIRS.has(d.name)) continue;
1649
+ await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out);
1650
+ } else if (SESSION_LOG_NAMES.has(d.name)) {
1651
+ out.push({ abs: `${dir}/${d.name}`, rel });
1652
+ }
1653
+ }
1654
+ }
1655
+
1656
+ /**
1657
+ * 单个会话日志体检:非空 → 可解码(zstd 帧走查+逐帧解压,或原样文本)→
1658
+ * SessionHeader + 存储记录流校验。返回 { state:'ok' } 或
1659
+ * { state:'bad', reason }(缺陷描述)或 { state:'skipped', reason }
1660
+ * (无法深度校验:运行时缺 zstd 支持,或文件超过读入上限——不算损坏)。
1661
+ */
1662
+ async function validateSessionFile(f) {
1663
+ try {
1664
+ const info = await fsStat(f.abs);
1665
+ if (info.size === 0) return { state: 'bad', reason: '空文件(0 字节)' };
1666
+ if (f.abs.endsWith('.zstd') && !HAS_NODE_ZSTD) {
1667
+ return { state: 'skipped', reason: `运行时 Node(${process.version})无内置 zstd 解码,跳过深度校验(需 ≥22.15/23.8)` };
1668
+ }
1669
+ if (info.size > HASH_MAX_BYTES) {
1670
+ return { state: 'skipped', reason: `文件 ${Math.floor(info.size / 1048576)}MB 超过深度校验上限(${Math.floor(HASH_MAX_BYTES / 1048576)}MB),不整读入内存` };
1671
+ }
1672
+ let lines;
1673
+ if (f.abs.endsWith('.zstd')) {
1674
+ lines = decodeZstdLog(await readFile(f.abs));
1675
+ } else {
1676
+ lines = (await readFile(f.abs, 'utf8')).split('\n').filter((l) => l.length > 0);
1677
+ }
1678
+ if (!lines.length) return { state: 'bad', reason: '解出的逻辑行为空' };
1679
+ const defect = validateSessionLines(lines);
1680
+ return defect === null ? { state: 'ok' } : { state: 'bad', reason: defect };
1681
+ } catch (err) {
1682
+ return { state: 'bad', reason: String(err && err.message ? err.message : err) };
1683
+ }
1684
+ }
1685
+
1686
+ function summarizeDoctorScan(r) {
1687
+ if (!r.files.length) return '未找到任何会话日志——可能还没有产生过会话,或布局与预期不同。';
1688
+ const healthy = r.scanned - r.corruptCount - (r.skippedCount ?? 0);
1689
+ let head = `扫描 ${r.scanned} 个会话日志:${healthy} 健康、${r.corruptCount} 损坏`;
1690
+ if (r.skippedCount) head += `、${r.skippedCount} 个未能深度校验(运行时缺 zstd 支持或文件过大)`;
1691
+ if (!r.corruptCount) return `✅ ${head}`;
1692
+ const shown = r.corrupt.slice(0, 15);
1693
+ const more = r.corrupt.length > shown.length ? `\n …另有 ${r.corrupt.length - shown.length} 个损坏文件` : '';
1694
+ const detail = shown.map((c) => ` ❌ ${c.rel}\n ${c.reason}`).join('\n');
1695
+ return `${head}\n${detail}${more}\n\n可从备份定点修复:/backup doctor --repair [前缀|latest](修复前会把损坏文件留档为 *.corrupt-*)`;
1696
+ }
1697
+
1698
+ /** 只读扫描 ~/.dsh 下全部会话日志;不写任何文件。 */
1699
+ async function runDoctorScan(signal) {
1700
+ const { dshHome } = paths();
1701
+ if (signal?.aborted) throw new Error('操作已取消');
1702
+ const found = [];
1703
+ await collectSessionLogs(dshHome, '', 0, found);
1704
+ const files = [];
1705
+ for (const f of found) {
1706
+ if (signal?.aborted) throw new Error('操作已取消');
1707
+ const verdict = await validateSessionFile(f);
1708
+ files.push({ rel: f.rel, ok: verdict.state === 'ok', skipped: verdict.state === 'skipped', reason: verdict.reason });
1709
+ }
1710
+ const corrupt = files.filter((x) => !x.ok && !x.skipped);
1711
+ const skippedCount = files.filter((x) => x.skipped).length;
1712
+ return { scanned: files.length, files, corrupt, corruptCount: corrupt.length, skippedCount };
1713
+ }
1714
+
1715
+ /**
1716
+ * 定点修复:扫描出损坏的会话日志后,从指定归档(缺省最新一份,须通过
1717
+ * sha256 校验)里提取同名条目覆盖。只动损坏的文件本身——不做整库 aside
1718
+ * 切换;每个被覆盖的损坏文件先就地留档为 `<原名>.corrupt-<时间戳>`。
1719
+ * 归档里没有对应条目的文件列入 unrecoverable(提示改用全量恢复)。
1720
+ */
1721
+ async function runDoctorRepair(selector, signal) {
1722
+ const { home, dshHome, root } = paths();
1723
+ const scan = await runDoctorScan(signal);
1724
+ if (!scan.corruptCount) return { repaired: [], unrecoverable: [], stillBad: [], scanned: scan.scanned, summary: summarizeDoctorScan(scan) };
1725
+
1726
+ const picked = await pickArchive(selector || 'latest');
1727
+ const v = await verifyOne(picked.name, home, signal);
1728
+ if (!v.ok) throw new Error(`归档校验未通过(${v.note}),定点修复已中止`);
1729
+ const base = dshHome.split('/').pop();
1730
+ const parent = dshHome.slice(0, -(base.length + 1)) || '/';
1731
+ const entries = await safeArchiveEntries(picked.name, signal);
1732
+ const entrySet = new Set(entries);
1733
+
1734
+ const targets = [];
1735
+ const unrecoverable = [];
1736
+ for (const c of scan.corrupt) {
1737
+ const entry = `${base}/${c.rel}`;
1738
+ if (entrySet.has(entry)) targets.push({ ...c, entry });
1739
+ else unrecoverable.push(c.rel);
1740
+ }
1741
+
1742
+ // 覆盖前留档损坏现场:文件名即证据链(seq 撞号样本对排查宿主 bug 有价值)
1743
+ const stamp = stampNow();
1744
+ const kept = [];
1745
+ for (const t of targets) {
1746
+ const keep = `${dshHome}/${t.rel}.corrupt-${stamp}`;
1747
+ await mkdir(dirname(keep), { recursive: true });
1748
+ await copyFile(`${dshHome}/${t.rel}`, keep);
1749
+ kept.push(keep);
1750
+ }
1751
+
1752
+ // 提取可能半途失败、只替换了一部分目标——对齐恢复流程 #28 的回滚哲学:
1753
+ // 用留档副本尽力还原每个目标的修复前状态;连还原都失败时把留档路径
1754
+ // 交代清楚,绝不静默停在最糟的中间态。
1755
+ try {
1756
+ const tar = await ctx.subprocess.resolveExecutable('tar');
1757
+ await spawnRun([tar, '-xzf', picked.name, '-C', parent, ...targets.map((t) => t.entry)], root, signal);
1758
+ } catch (err) {
1759
+ const reason = err && err.message ? err.message : String(err);
1760
+ const restored = new Set();
1761
+ for (let i = 0; i < targets.length; i++) {
1762
+ try {
1763
+ await copyFile(kept[i], `${dshHome}/${targets[i].rel}`);
1764
+ restored.add(targets[i].rel);
1765
+ } catch { /* 单个还原失败:落入下方如实报告 */ }
1766
+ }
1767
+ if (restored.size === targets.length) {
1768
+ throw new Error(`定点修复失败(${reason})。已从留档还原全部损坏现场,数据保持修复前状态;排查磁盘空间/权限后可重试`);
1769
+ }
1770
+ const missing = targets.filter((t) => !restored.has(t.rel)).map((t) => t.rel);
1771
+ throw new Error(`定点修复失败(${reason}),且部分现场未自动还原: ${missing.join(', ')}。对应的 *.corrupt-${stamp} 留档仍在原目录旁,手工拷回原位即可`);
1772
+ }
1773
+
1774
+ // 修完复检:从归档解出的副本必须健康,否则如实报告(skipped 不算失败——
1775
+ // 缺 zstd 支持的运行时根本检不出 zstd 文件损坏,能进 targets 的都是可校验的)
1776
+ const stillBad = [];
1777
+ for (const t of targets) {
1778
+ const verdict = await validateSessionFile({ abs: `${dshHome}/${t.rel}`, rel: t.rel });
1779
+ if (verdict.state === 'bad') stillBad.push(`${t.rel}(${verdict.reason})`);
1780
+ }
1781
+
1782
+ const lines = [`🩹 定点修复完成(来源: ${picked.name})`];
1783
+ if (targets.length) lines.push(` 已恢复 ${targets.length} 个文件${stillBad.length ? '' : ',复检全部健康'}`);
1784
+ for (const k of kept) lines.push(` 损坏现场已留档: ${k}`);
1785
+ if (unrecoverable.length) lines.push(` ⚠️ 归档中无对应副本、无法定点修复: ${unrecoverable.join(', ')}`);
1786
+ if (stillBad.length) lines.push(` ❌ 修复后复检仍异常: ${stillBad.join('; ')}`);
1787
+ lines.push(' 请重启 dsh 后确认会话历史加载正常。');
1788
+ return { repaired: targets.map((t) => t.rel), unrecoverable, stillBad, scanned: scan.scanned, summary: lines.join('\n') };
1789
+ }
1790
+
1196
1791
  // ---------- 自动备份与 GitHub 同步状态(落盘,重启续跑) ----------
1197
1792
  let autoDispose = null;
1198
1793
  let autoHours = 0;
1199
1794
  let lastAuto = null;
1200
1795
  let lastAutoAt = null;
1201
1796
  let githubState = { repo: null, lastPush: null, lastError: null };
1797
+ // 上次见到的宿主列车(detectHostTrain 代理值):null=尚未记录(首启动只记
1798
+ // 录不拍快照),非 null 且变化 → 升/降级 → 自动拍 dsh-pre-upgrade- 快照。
1799
+ let lastTrain = null;
1800
+
1801
+ /**
1802
+ * 等待 settings 用户层加载完成(describe 出现 revision)。启动钩子若在
1803
+ * settings 装配完成前运行,resolvedConfig 会拿到 base 默认值——升级快照
1804
+ * 就会落进默认目录而不是用户配置的目的地(e2e 实测的分裂写场景)。
1805
+ * 注意两段等待:先等 services 异步装配出 settingsService(ctx.inject 的
1806
+ * 回调在宿主组合期才触发),再等用户层装载出 revision。全程无 settings
1807
+ * 的 profile(headless 等)等满上限后按默认目的地继续——钩子是异步的,
1808
+ * 不阻塞装配。
1809
+ */
1810
+ async function waitForSettingsReady(capMs = 10000) {
1811
+ const t0 = Date.now();
1812
+ while (!settingsService && Date.now() - t0 < capMs) {
1813
+ await new Promise((r) => setTimeout(r, 100));
1814
+ }
1815
+ while (settingsService && Date.now() - t0 < capMs) {
1816
+ try {
1817
+ const desc = describeOwn(settingsService, NS);
1818
+ if (desc && typeof desc.revision === 'number') return;
1819
+ } catch { /* 未就绪,继续等 */ }
1820
+ await new Promise((r) => setTimeout(r, 200));
1821
+ }
1822
+ }
1202
1823
 
1203
1824
  /** 自动备份的保留份数:config.keep 支配(未配置时 <24h 3 份、否则 7 份)。 */
1204
1825
  function autoKeep() {
@@ -1207,9 +1828,23 @@ export function apply(ctx, pluginConfig) {
1207
1828
  return Number.isFinite(k) && k > 0 ? Math.floor(k) : (autoHours >= 24 ? 7 : 3);
1208
1829
  }
1209
1830
 
1831
+ // 启动钩子与备份链路可能并发保存 auto.json(多实例/多 profile 亦然)——
1832
+ // 并发 writeFile 同一路径会撕裂(实测产出 A+B 拼接体)。走"临时文件 +
1833
+ // 原子改名":读者要么见到旧完整文件、要么见到新完整文件,绝不撕裂;
1834
+ // 与宿主 settings 持久化的 writeFileAtomic 同一模式。
1210
1835
  async function saveAutoState() {
1211
1836
  const { root } = paths();
1212
- await writeOwned(`${root}/auto.json`, `${JSON.stringify({ hours: autoHours, lastAutoAt, github: githubState })}\n`);
1837
+ const target = `${root}/auto.json`;
1838
+ // 临时名用 UUID:时间戳+实例内序号在"多实例同毫秒启动"时仍会撞名
1839
+ // (先到者 rename 走,后到者 ENOENT),UUID 跨实例唯一
1840
+ const tmp = `${target}.${randomUUID()}.tmp`;
1841
+ try {
1842
+ await writeOwned(tmp, `${JSON.stringify({ hours: autoHours, lastAutoAt, github: githubState, lastTrain })}\n`);
1843
+ await rename(tmp, target);
1844
+ } catch (err) {
1845
+ await rm(tmp, { force: true }).catch(() => {});
1846
+ throw err;
1847
+ }
1213
1848
  }
1214
1849
 
1215
1850
  async function loadAutoState() {
@@ -1229,6 +1864,7 @@ export function apply(ctx, pluginConfig) {
1229
1864
  // 故一律视为非法 → null,从当前时间起算 catch-up。
1230
1865
  const lastTs = typeof parsed?.lastAutoAt === 'string' ? Date.parse(parsed.lastAutoAt) : NaN;
1231
1866
  lastAutoAt = Number.isNaN(lastTs) || lastTs > Date.now() ? null : parsed.lastAutoAt;
1867
+ lastTrain = typeof parsed?.lastTrain === 'string' && parsed.lastTrain ? parsed.lastTrain : null;
1232
1868
  return Number.isFinite(h) && h >= 1 && h <= 720 ? Math.floor(h) : 0;
1233
1869
  } catch (err) {
1234
1870
  console.warn(`[dsh-backup] auto.json 无法读取,自动备份计划已重置: ${String(err && err.message ? err.message : err)}`);
@@ -1289,7 +1925,7 @@ export function apply(ctx, pluginConfig) {
1289
1925
  // ---------- /backup 命令 ----------
1290
1926
  ctx.commands.register({
1291
1927
  name: 'backup',
1292
- description: '备份/恢复 DSH 数据;子命令: list | verify [前缀|all] | restore <前缀|latest> [--dry-run] [--sync-deps] | auto [N小时|off] | github status|sync|pull|repo | [--keep N]',
1928
+ description: '备份/恢复 DSH 数据;子命令: list | verify [前缀|all] | restore <前缀|latest> [--dry-run] [--sync-deps] | doctor [--repair <前缀|latest>] | auto [N小时|off] | github status|sync|pull|repo | [--keep N]',
1293
1929
  handler: async (invocation) => {
1294
1930
  const input = invocation.rawInput.trim();
1295
1931
  try {
@@ -1301,9 +1937,13 @@ export function apply(ctx, pluginConfig) {
1301
1937
  const all = await listBackups();
1302
1938
  const total = all.reduce((s, b) => s + (b.size || 0), 0);
1303
1939
  const lines = all.map((b) => ` ${b.name}${b.size !== undefined ? ` ${(b.size / 1048576).toFixed(1)}MB` : ''}`);
1940
+ // 内部快照分区展示:升级前(最多 2 份)/ 恢复前(最近 1 份),可按前缀选用
1941
+ const snaps = await listSnapshotNames('dsh-pre-');
1942
+ const snapLines = snaps.map((n) => ` ${n}${n.startsWith('dsh-pre-upgrade-') ? ' (升级前快照)' : ' (恢复前快照)'}`);
1943
+ const snapText = snapLines.length ? `\n\n内部快照(可 restore 前缀选用,不占保留配额):\n${snapLines.join('\n')}` : '';
1304
1944
  const text = all.length
1305
- ? `已有备份 (${all.length} 份,共 ${(total / 1048576).toFixed(1)}MB):\n${lines.join('\n')}\n\n${autoSummary()}`
1306
- : `暂无备份。输入 /backup 执行首次备份。\n\n${autoSummary()}`;
1945
+ ? `已有备份 (${all.length} 份,共 ${(total / 1048576).toFixed(1)}MB):\n${lines.join('\n')}${snapText}\n\n${autoSummary()}`
1946
+ : `暂无备份。输入 /backup 执行首次备份。${snapText}\n\n${autoSummary()}`;
1307
1947
  return { kind: 'success', text };
1308
1948
  }
1309
1949
 
@@ -1329,6 +1969,18 @@ export function apply(ctx, pluginConfig) {
1329
1969
  return { kind: 'success', text: summarizeRestore(r) };
1330
1970
  }
1331
1971
 
1972
+ if (head === 'doctor') {
1973
+ const repairIdx = parts.indexOf('--repair');
1974
+ if (repairIdx < 0) {
1975
+ const r = await runDoctorScan(invocation.signal);
1976
+ return { kind: r.corruptCount ? 'error' : 'success', text: summarizeDoctorScan(r) };
1977
+ }
1978
+ const selArg = parts[repairIdx + 1];
1979
+ const sel = selArg && !selArg.startsWith('--') ? selArg : 'latest';
1980
+ const r = await runDoctorRepair(sel, invocation.signal);
1981
+ return { kind: 'success', text: r.summary };
1982
+ }
1983
+
1332
1984
  if (head === 'github') {
1333
1985
  const arg = parts[1] || 'status';
1334
1986
  if (arg === 'repo') {
@@ -1415,7 +2067,7 @@ export function apply(ctx, pluginConfig) {
1415
2067
  const r = await doBackup(keep, invocation.signal);
1416
2068
  return {
1417
2069
  kind: 'success',
1418
- text: `✅ 备份完成\n 文件: ${r.path}\n 校验和: ${r.sha.slice(0, 16)}…\n 轮换: 删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)\n ${autoSummary()}`,
2070
+ text: `✅ 备份完成\n 文件: ${r.path}\n 校验和: ${r.sha.slice(0, 16)}…\n 轮换: 删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)${quarantineNote(r)}\n ${autoSummary()}`,
1419
2071
  };
1420
2072
  } catch (err) {
1421
2073
  return { kind: 'error', text: `备份失败: ${String(err && err.message ? err.message : err)}` };
@@ -1426,14 +2078,15 @@ export function apply(ctx, pluginConfig) {
1426
2078
  // ---------- backup_dsh 模型工具 ----------
1427
2079
  ctx.tools.register(defineTool({
1428
2080
  name: 'backup_dsh',
1429
- description: '备份、校验或恢复 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,dryRun 仅预览,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询)。',
2081
+ description: '备份、校验、恢复或体检 DSH 用户数据(~/.dsh 的会话、配置、技能)。凭据文件默认脱敏:不进归档、不进 GitHub 同步,明文只存本机 vault,恢复时自动还原。mode=backup 立即备份(keep 指定保留份数);mode=list 列出备份;mode=verify 校验完整性(selector=前缀或 all,缺省最新一份);mode=restore 恢复(selector=前缀或 latest,syncDeps 恢复后重装 profile 依赖;恢复前自动校验并快照当前数据,解压失败自动还原;真实恢复前必须先 dryRun=true 预览并向用户展示确认);mode=auto 设置定时备份(hours 间隔小时数,0=关闭,缺省查询);mode=doctor 会话日志体检——检测损坏的会话历史(seq 撞号/坏帧/截断),repair=true 时从 selector 归档定点修复',
1430
2082
  parameters: {
1431
- mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份' },
2083
+ mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto', 'doctor'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份,doctor=会话日志体检' },
1432
2084
  keep: { type: 'number', description: '保留的备份份数(mode=backup,默认 7)' },
1433
2085
  hours: { type: 'number', description: '定时备份间隔小时数(mode=auto;0=关闭;缺省=查询状态)' },
1434
- selector: { type: 'string', description: '备份选择器(mode=verify/restore):归档名前缀、latest 或 all' },
1435
- dryRun: { type: 'boolean', description: 'mode=restore 时仅预览恢复内容,不写入' },
2086
+ selector: { type: 'string', description: '备份选择器(mode=verify/restore/doctor):归档名前缀、latest 或 all' },
2087
+ dryRun: { type: 'boolean', description: 'mode=restore 时仅预览(不写入);真实恢复前必须先预览并向用户展示确认' },
1436
2088
  syncDeps: { type: 'boolean', description: 'mode=restore 时恢复后对各 profile 执行 pnpm install 重装插件依赖' },
2089
+ repair: { type: 'boolean', description: 'mode=doctor 时从 selector 归档定点修复损坏的会话日志(缺省仅扫描)' },
1437
2090
  },
1438
2091
  output: {
1439
2092
  schema: { type: 'object', additionalProperties: true },
@@ -1465,6 +2118,21 @@ export function apply(ctx, pluginConfig) {
1465
2118
  const r = await restoreArchive(selector || 'latest', Boolean(args && args.dryRun), signal);
1466
2119
  return { ok: true, path: r.archive, summary: summarizeRestore(r) };
1467
2120
  }
2121
+ if (mode === 'doctor') {
2122
+ if (args && args.repair) {
2123
+ const r = await runDoctorRepair(selector || 'latest', signal);
2124
+ return { ok: r.stillBad.length === 0 && r.unrecoverable.length === 0, summary: r.summary, repaired: r.repaired, unrecoverable: r.unrecoverable, stillBad: r.stillBad };
2125
+ }
2126
+ const r = await runDoctorScan(signal);
2127
+ return {
2128
+ ok: r.corruptCount === 0,
2129
+ scanned: r.scanned,
2130
+ corruptCount: r.corruptCount,
2131
+ skippedCount: r.skippedCount,
2132
+ summary: summarizeDoctorScan(r),
2133
+ corrupt: r.corrupt.map((c) => ({ path: c.rel, reason: c.reason })),
2134
+ };
2135
+ }
1468
2136
  if (mode === 'auto') {
1469
2137
  const h = args && args.hours !== undefined ? args.hours : null;
1470
2138
  if (h === null) return { ok: true, summary: autoSummary() };
@@ -1477,7 +2145,7 @@ export function apply(ctx, pluginConfig) {
1477
2145
  return { ok: true, summary: `自动备份已开启:每 ${h} 小时一次(已持久化)。\n${autoSummary()}` };
1478
2146
  }
1479
2147
  const r = await doBackup(args && args.keep ? args.keep : undefined, signal);
1480
- return { ok: true, path: r.path, sha: r.sha, summary: `备份完成: ${r.path}\nsha256: ${r.sha}\n轮换删除 ${r.stale} 份(保留 ${r.keep} 份)` };
2148
+ return { ok: true, path: r.path, sha: r.sha, summary: `备份完成: ${r.path}\nsha256: ${r.sha}\n轮换删除 ${r.stale} 份(保留 ${r.keep} 份)${quarantineNote(r)}` };
1481
2149
  } catch (err) {
1482
2150
  return { ok: false, summary: `操作失败: ${String(err && err.message ? err.message : err)}` };
1483
2151
  }
@@ -1502,7 +2170,7 @@ export function apply(ctx, pluginConfig) {
1502
2170
  const r = await doBackup(keep, signal);
1503
2171
  return {
1504
2172
  ok: true,
1505
- summary: `备份完成: ${r.path}\nsha256: ${r.sha}\n轮换删除 ${r.stale} 份(保留 ${r.keep} 份)`,
2173
+ summary: `备份完成: ${r.path}\nsha256: ${r.sha}\n轮换删除 ${r.stale} 份(保留 ${r.keep} 份)${quarantineNote(r)}`,
1506
2174
  path: r.path,
1507
2175
  sha: r.sha,
1508
2176
  stale: r.stale,
@@ -1528,7 +2196,7 @@ export function apply(ctx, pluginConfig) {
1528
2196
  try {
1529
2197
  const r = await restoreArchive(selector || 'latest', Boolean(dryRun), signal);
1530
2198
  if (r.dryRun) {
1531
- return { ok: true, dryRun: true, archive: r.archive, files: r.files, sample: r.sample, preflight: r.preflight ?? [], summary: summarizeRestore(r) };
2199
+ return { ok: true, dryRun: true, archive: r.archive, files: r.files, sample: r.sample, preflight: r.preflight ?? [], targetExists: r.targetExists, summary: summarizeRestore(r) };
1532
2200
  }
1533
2201
  return {
1534
2202
  ok: true,
@@ -1645,6 +2313,34 @@ export function apply(ctx, pluginConfig) {
1645
2313
  }
1646
2314
  return { ok: true, repo: clean, summary: `GitHub 同步仓库已设为: ${clean}` };
1647
2315
  },
2316
+ doctorScan: async (signal) => {
2317
+ try {
2318
+ const r = await runDoctorScan(signal);
2319
+ return {
2320
+ ok: r.corruptCount === 0,
2321
+ scanned: r.scanned,
2322
+ corruptCount: r.corruptCount,
2323
+ summary: summarizeDoctorScan(r),
2324
+ corrupt: r.corrupt.map((c) => ({ path: c.rel, reason: c.reason })),
2325
+ };
2326
+ } catch (err) {
2327
+ return { ok: false, summary: String(err && err.message ? err.message : err), corrupt: [] };
2328
+ }
2329
+ },
2330
+ doctorRepair: async (selector, signal) => {
2331
+ try {
2332
+ const r = await runDoctorRepair(typeof selector === 'string' && selector ? selector : undefined, signal);
2333
+ return {
2334
+ ok: r.stillBad.length === 0 && r.unrecoverable.length === 0,
2335
+ summary: r.summary,
2336
+ repaired: r.repaired,
2337
+ unrecoverable: r.unrecoverable,
2338
+ stillBad: r.stillBad,
2339
+ };
2340
+ } catch (err) {
2341
+ return { ok: false, summary: String(err && err.message ? err.message : err), repaired: [], unrecoverable: [], stillBad: [] };
2342
+ }
2343
+ },
1648
2344
  };
1649
2345
 
1650
2346
  // 仅在装配了 Typert registry 的 profile(Web)里挂载面板服务;其余 profile 安静跳过。
@@ -1730,11 +2426,34 @@ export function apply(ctx, pluginConfig) {
1730
2426
  });
1731
2427
 
1732
2428
  // 启动时恢复持久化的定时备份计划(不阻塞插件装配);错过则 delay=0 立即补跑。
2429
+ // 同处做升级前快照:宿主列车变化(升/降级)先拍一份 dsh-pre-upgrade- 安全网
2430
+ // 再继续——命中社区"想试新版怕搞坏"的最大恐惧。首见列车只记录不拍。
1733
2431
  void (async () => {
2432
+ await waitForSettingsReady();
1734
2433
  const h = await loadAutoState();
1735
2434
  if (h > 0 && !autoDispose) {
1736
2435
  autoHours = h;
1737
2436
  scheduleAuto();
1738
2437
  }
2438
+ const train = detectHostTrain();
2439
+ if (!train) return;
2440
+ if (lastTrain === null) {
2441
+ lastTrain = train;
2442
+ try {
2443
+ await saveAutoState();
2444
+ } catch { /* 记录失败不致命:下次启动重新记录 */ }
2445
+ return;
2446
+ }
2447
+ if (lastTrain === train) return;
2448
+ const oldTrain = lastTrain;
2449
+ try {
2450
+ const snap = await doBackup(undefined, undefined, { namePrefix: 'dsh-pre-upgrade-' });
2451
+ await prunePrefixedSnapshots('dsh-pre-upgrade-', 2, snap.name);
2452
+ lastTrain = train;
2453
+ await saveAutoState();
2454
+ console.log(`[dsh-backup] 宿主列车变化(${oldTrain} → ${train}),已自动拍升级前快照: ${snap.name}${snap.quarantined?.length ? `(${snap.quarantined.length} 个损坏会话文件未入档)` : ''}`);
2455
+ } catch (err) {
2456
+ console.warn(`[dsh-backup] 升级前快照失败(不影响运行,下次启动会重试): ${String(err && err.message ? err.message : err)}`);
2457
+ }
1739
2458
  })();
1740
2459
  }