@xiaoyuyu6420/dsh-backup 0.13.1 → 0.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +8 -1
- package/README.md +10 -1
- package/lib/client.js +2 -2
- package/lib/index.js +532 -145
- package/package.json +8 -8
- package/rescue/rescue.mjs +48 -16
package/lib/index.js
CHANGED
|
@@ -47,6 +47,7 @@ import { mkdir, open, copyFile, chmod, readdir, readFile, rename, rm, stat as fs
|
|
|
47
47
|
import { createRequire } from 'node:module';
|
|
48
48
|
import { hostname } from 'node:os';
|
|
49
49
|
import { basename, dirname, join } from 'node:path';
|
|
50
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
50
51
|
import { fileURLToPath } from 'node:url';
|
|
51
52
|
import * as zlib from 'node:zlib';
|
|
52
53
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
@@ -59,8 +60,18 @@ export const inject = ['subprocess', 'commands', 'timer', 'tools'];
|
|
|
59
60
|
|
|
60
61
|
/** Windows 无 POSIX chmod 语义(用户目录 ACL 默认私有)。 */
|
|
61
62
|
const IS_WIN = process.platform === 'win32';
|
|
62
|
-
/** sha256
|
|
63
|
+
/** 深度校验(会话日志整读入内存)的体积上限;sha256 流式回退不适用此限。 */
|
|
63
64
|
const HASH_MAX_BYTES = 256 * 1024 * 1024;
|
|
65
|
+
/** 流式 sha256 的分块大小(4MB ≈ 每块几毫秒 CPU,块间让出事件循环)。 */
|
|
66
|
+
const HASH_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
67
|
+
/**
|
|
68
|
+
* 启动宽限期:插件装配后头 45 秒不启动任何重活(全量 tar/gzip + 哈希要几分钟)。
|
|
69
|
+
* 宿主自己的 boot 预算按 30s 计(#103:DSH Desktop 的 HostRpc 30s 超时),
|
|
70
|
+
* 启动期补跑全量备份会抢 CPU/IO 把宿主 boot 拖超时,进而被判失败强杀。
|
|
71
|
+
*/
|
|
72
|
+
const AUTO_BOOT_GRACE_MS = 45_000;
|
|
73
|
+
/** 自动备份失败后同会话内的重试间隔(只重试一次,避免失败热循环烧 CPU)。 */
|
|
74
|
+
const AUTO_RETRY_MS = 30 * 60_000;
|
|
64
75
|
/** GitHub 单个文件 100MB 上限,留 10MB 余量。 */
|
|
65
76
|
const MAX_GITHUB_BYTES = 90 * 1024 * 1024;
|
|
66
77
|
/** 同步工作树目录名(位于备份目录下)。 */
|
|
@@ -77,6 +88,8 @@ const DOWNLOAD_PREFIX = '/backup-download';
|
|
|
77
88
|
const SENSITIVE_DEFAULTS = ['.credentials.yaml', '.env', 'qq-bridge/config.json'];
|
|
78
89
|
/** 备份目录下保存明文敏感文件的子目录(随备份刷新为最新一份的副本)。 */
|
|
79
90
|
const VAULT_DIR = 'vault';
|
|
91
|
+
/** vault 下存放「凭据旧布局存底」的子目录:跨升级的长效安全网,备份刷新时不得删除。 */
|
|
92
|
+
const PRESERVED_DIR = 'preserved';
|
|
80
93
|
|
|
81
94
|
/**
|
|
82
95
|
* 分类型备份的内容类型表(相对 ~/.dsh 根的 POSIX 路径)。
|
|
@@ -94,7 +107,6 @@ const BACKUP_TYPES = Object.freeze({
|
|
|
94
107
|
settings: { label: '设置', paths: ['settings.yaml'] },
|
|
95
108
|
profiles: { label: '插件 profiles', paths: ['profiles'] },
|
|
96
109
|
});
|
|
97
|
-
const BACKUP_TYPE_KEYS = Object.freeze(Object.keys(BACKUP_TYPES));
|
|
98
110
|
/** 分类型归档前缀(与全量 dsh- 区分:独立轮换、不进全量列表、旧版插件不可见不误删)。 */
|
|
99
111
|
const TYPED_PREFIX = 'dsh-t-';
|
|
100
112
|
|
|
@@ -297,17 +309,36 @@ const DEFAULTS = Object.freeze({
|
|
|
297
309
|
/**
|
|
298
310
|
* 解析 settings 命名空间的 schema,同时定义默认值。
|
|
299
311
|
* Base 层:pluginConfig(cordis.patch.yml,插件级或 profile 级)。
|
|
300
|
-
* User 层:Settings
|
|
312
|
+
* User 层:Settings 面板运行时编辑(0.1.6 及更早持久化到 settings.yaml;
|
|
313
|
+
* 0.1.7 起由宿主写入 profile 覆盖层)。
|
|
314
|
+
*
|
|
315
|
+
* **两代模型共用这一份**:
|
|
316
|
+
* - ≤0.1.6-alpha.1:`ctx.settings.register(NS, SCHEMA)` 自注册命名空间;
|
|
317
|
+
* - ≥0.1.7(含 0.2.0):`register` 被移除,宿主改从「条目的 Config schema」
|
|
318
|
+
* 派生表单,且只认**标了 volatile 的字段**(宿主 dsh-settings 的
|
|
319
|
+
* `volatileForm` 仅当 `meta.volatile` 存在才纳入表单)。故此处导出 Config
|
|
320
|
+
* 并逐字段标注 volatile。
|
|
321
|
+
*
|
|
322
|
+
* `volatileField` 做运行时探测:老载体(@deepseek-ai/schemastery < 3.18.4)
|
|
323
|
+
* 没有 `.volatile()`,探测失败就原样返回——老宿主走 register 路径,不需要它。
|
|
324
|
+
* 这样同一份代码在 0.1.1 → 0.2.0 全列车都能装配,不会因新 API 缺失而崩在装载期。
|
|
301
325
|
*/
|
|
326
|
+
const volatileField = (schema) => (typeof schema.volatile === 'function' ? schema.volatile() : schema);
|
|
327
|
+
|
|
302
328
|
const SCHEMA = z.object({
|
|
303
|
-
destination: z.string().default(DEFAULTS.destination),
|
|
304
|
-
keep: z.number().min(0).default(0),
|
|
305
|
-
exclude: z.array(z.string()).default(DEFAULTS.exclude),
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
329
|
+
destination: volatileField(z.string().default(DEFAULTS.destination)),
|
|
330
|
+
keep: volatileField(z.number().min(0).default(0)),
|
|
331
|
+
exclude: volatileField(z.array(z.string()).default(DEFAULTS.exclude)),
|
|
332
|
+
// 'none' 是 v0.6 时代写法(代码里映射为 'off'):schema 收下它,别让老配置
|
|
333
|
+
// 在新宿主的条目校验里被拒。
|
|
334
|
+
redact: volatileField(z.union([z.array(z.string()), 'off', 'none']).default(DEFAULTS.redact)),
|
|
335
|
+
githubRepo: volatileField(z.string().default(DEFAULTS.githubRepo)),
|
|
336
|
+
updateCheck: volatileField(z.boolean().default(DEFAULTS.updateCheck)),
|
|
309
337
|
});
|
|
310
338
|
|
|
339
|
+
/** 宿主装载条目时读取的 Config schema(0.1.7+ 的设置表单来源)。 */
|
|
340
|
+
export const Config = SCHEMA;
|
|
341
|
+
|
|
311
342
|
const SETTINGS_FIELDS = ['destination', 'keep', 'exclude', 'redact', 'githubRepo', 'updateCheck'];
|
|
312
343
|
|
|
313
344
|
/** 校验并归一化单个字段到 `out`;非法值忽略。 */
|
|
@@ -488,7 +519,7 @@ class BackupPanelService extends TypertRemoteService {
|
|
|
488
519
|
return this.ops.doctorRepair(selector, signal);
|
|
489
520
|
}
|
|
490
521
|
|
|
491
|
-
/**
|
|
522
|
+
/** 迁移预检:只读扫描全部会话日志(v0 起各代,按文件名代判定),预测升级后哪些会话打不开。 */
|
|
492
523
|
migrateCheck(signal) {
|
|
493
524
|
return this.ops.migrateCheck(signal);
|
|
494
525
|
}
|
|
@@ -515,7 +546,13 @@ export function apply(ctx, pluginConfig) {
|
|
|
515
546
|
let settingsService;
|
|
516
547
|
ctx.inject(['settings'], (sctx) => {
|
|
517
548
|
settingsService = sctx.settings;
|
|
518
|
-
|
|
549
|
+
// 0.1.7 起宿主移除了 settings.register(新模型改由「profile 条目自带的
|
|
550
|
+
// Config schema」派生表单);旧宿主(≤0.1.6-alpha.1)仍走自注册命名空间。
|
|
551
|
+
// 两级都留:有 register 就注册,没有就跳过——请求侧按「命名空间缺失」显式
|
|
552
|
+
// 降级,而不是在装配期抛错(旧实现在新宿主上是静默 TypeError)。
|
|
553
|
+
if (typeof settingsService.register === 'function') {
|
|
554
|
+
settingsService.register(NS, SCHEMA, { base: resolveBase(pluginConfig) });
|
|
555
|
+
}
|
|
519
556
|
// detach 清理:服务中途 dispose 时置空,resolvedConfig() 自动降级到 pluginConfig
|
|
520
557
|
return () => { settingsService = undefined; };
|
|
521
558
|
});
|
|
@@ -574,10 +611,16 @@ export function apply(ctx, pluginConfig) {
|
|
|
574
611
|
|
|
575
612
|
// ---------- 工具函数 ----------
|
|
576
613
|
/**
|
|
577
|
-
* 读取 subprocess
|
|
578
|
-
* maxBytes(8192)时内存窗口只保留尾部(readFrom 返回 lossy: true),
|
|
579
|
-
* 完整输出 spill 到 spillPath
|
|
580
|
-
*
|
|
614
|
+
* 读取 subprocess 收集的输出流(诊断/小输出用)。dsh-subprocess 的收集契约:
|
|
615
|
+
* 输出超过 maxBytes(8192)时内存窗口只保留尾部(readFrom 返回 lossy: true),
|
|
616
|
+
* 完整输出 spill 到 spillPath。
|
|
617
|
+
*
|
|
618
|
+
* ⚠️ **不要用它读体积不可控、且结果参与安全判定的输出**(#119):spill 自身还有
|
|
619
|
+
* 上限,宿主对超过上限的流会**丢弃 spill 文件**(`discardSpill()`),此时
|
|
620
|
+
* `readFrom` 只给内存尾窗、`lossy: true` 且**没有 spillPath**——旧实现把这段
|
|
621
|
+
* 截断文本原样返回,`tar -tvzf` 校验清单因此把"断在文件名中间的碎片"当成不安全
|
|
622
|
+
* 条目,整包被拒绝恢复(备份成功、verify 报完整、面板正常列出,只在恢复时炸)。
|
|
623
|
+
* 这类输出请走 {@link streamListingLines}(原始流逐行消费,无上限)。
|
|
581
624
|
*/
|
|
582
625
|
async function collectOutput(col) {
|
|
583
626
|
const read = col?.readFrom(0);
|
|
@@ -592,6 +635,65 @@ export function apply(ctx, pluginConfig) {
|
|
|
592
635
|
return read.text ?? '';
|
|
593
636
|
}
|
|
594
637
|
|
|
638
|
+
/**
|
|
639
|
+
* 逐行消费一条命令的 stdout(`stdio: 'pipe'` 原始流形态),把每一行交给
|
|
640
|
+
* `onLine`;`onLine` 返回 false 时立即终止子进程并提前返回("发现违规就不必读完")。
|
|
641
|
+
*
|
|
642
|
+
* 为什么不用有界收集(#119):见 {@link collectOutput} 的 ⚠️。这里内存只保留
|
|
643
|
+
* "尚未收尾的一行",清单多大都不截断,也不会把碎片当成一行;stderr 仍走有界
|
|
644
|
+
* 收集(只作诊断,几 KB 足够)。
|
|
645
|
+
*/
|
|
646
|
+
async function streamListingLines(argv, cwd, signal, onLine) {
|
|
647
|
+
const proc = ctx.subprocess.spawn({
|
|
648
|
+
argv,
|
|
649
|
+
cwd,
|
|
650
|
+
graceMs: 5000,
|
|
651
|
+
signal,
|
|
652
|
+
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 8192, spill: { maxBytes: 1 << 20 } } },
|
|
653
|
+
});
|
|
654
|
+
const stream = proc.stdout;
|
|
655
|
+
if (!stream) throw new Error('宿主未提供原始输出流(subprocess stdout:"pipe" 不可用)——请升级宿主后重试');
|
|
656
|
+
// 单行长度的护栏:tar -tv 的一行正常不过几百字节;给足余量后仍设上限,
|
|
657
|
+
// 避免被构造出的超长名字把 carry 撑成内存炸弹。
|
|
658
|
+
const MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
659
|
+
const decoder = new StringDecoder('utf8');
|
|
660
|
+
let bytes = 0;
|
|
661
|
+
let lines = 0;
|
|
662
|
+
let carry = '';
|
|
663
|
+
let stopped = false;
|
|
664
|
+
for await (const chunk of stream) {
|
|
665
|
+
bytes += chunk.length;
|
|
666
|
+
carry += decoder.write(chunk);
|
|
667
|
+
let idx;
|
|
668
|
+
while ((idx = carry.indexOf('\n')) >= 0) {
|
|
669
|
+
const line = carry.slice(0, idx);
|
|
670
|
+
carry = carry.slice(idx + 1);
|
|
671
|
+
lines += 1;
|
|
672
|
+
if (onLine(line) === false) { stopped = true; break; }
|
|
673
|
+
}
|
|
674
|
+
if (stopped) break;
|
|
675
|
+
if (carry.length > MAX_LINE_BYTES) throw new Error(`输出单行超过 ${MAX_LINE_BYTES} 字节上限——拒绝继续解析`);
|
|
676
|
+
}
|
|
677
|
+
if (stopped) {
|
|
678
|
+
proc.terminate();
|
|
679
|
+
} else {
|
|
680
|
+
carry += decoder.end();
|
|
681
|
+
if (carry.length) { lines += 1; onLine(carry); }
|
|
682
|
+
}
|
|
683
|
+
let err = '';
|
|
684
|
+
try {
|
|
685
|
+
err = await collectOutput(proc.collected?.stderr);
|
|
686
|
+
} catch { /* 诊断文本尽力而为 */ }
|
|
687
|
+
const outcome = await proc.done;
|
|
688
|
+
// 取消优先于一切结果分类(与 spawnRun 同款取舍)
|
|
689
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
690
|
+
if (!stopped) {
|
|
691
|
+
if (outcome.signal != null) throw new Error(`命令被终止(signal=${outcome.signal})`);
|
|
692
|
+
if (outcome.exitCode !== 0) throw new Error(`命令失败 exit=${outcome.exitCode}: ${err}`);
|
|
693
|
+
}
|
|
694
|
+
return { bytes, lines, stopped };
|
|
695
|
+
}
|
|
696
|
+
|
|
595
697
|
async function spawnRun(argv, cwd, signal) {
|
|
596
698
|
const proc = ctx.subprocess.spawn({
|
|
597
699
|
argv,
|
|
@@ -678,6 +780,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
678
780
|
// ('D:' + '/' + name 仍是合法路径,renameBeside 等不受影响)。
|
|
679
781
|
const parentTar = (p) => (p.endsWith('/') ? p : `${p}/`);
|
|
680
782
|
|
|
783
|
+
/** 仓库地址:CLI 首次备份的 star 提示用(面板侧另有常驻入口,见 src/client.js)。 */
|
|
784
|
+
const STAR_URL = 'https://github.com/xiaoyuyu6420/dsh-backup';
|
|
785
|
+
|
|
681
786
|
/** semver 比较(常见形态,含 prerelease):a > b 才返回 true;解析失败保守降为 false。 */
|
|
682
787
|
function semverGt(a, b) {
|
|
683
788
|
const parse = (s) => {
|
|
@@ -723,17 +828,33 @@ export function apply(ctx, pluginConfig) {
|
|
|
723
828
|
}
|
|
724
829
|
}
|
|
725
830
|
|
|
831
|
+
/** 读回 profile 内实际解析到的本插件版本(pnpm 把直接依赖链接在 profile 的
|
|
832
|
+
* node_modules 下);路径异常或读不到时返回 null,调用方按「无法核对」处理。 */
|
|
833
|
+
async function installedVersion(profile) {
|
|
834
|
+
try {
|
|
835
|
+
const meta = JSON.parse(await readFile(join(paths().dshHome, 'profiles', profile, 'node_modules', SELF_META.name, 'package.json'), 'utf8'));
|
|
836
|
+
return typeof meta?.version === 'string' && meta.version ? meta.version : null;
|
|
837
|
+
} catch {
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
726
842
|
/**
|
|
727
843
|
* 一键更新(B):先拍升级前快照(未过期的退路),再走官方 `dsh plugin
|
|
728
|
-
* --profile <p> update <pkg>`(pnpm 语义——profile 是 pnpm
|
|
729
|
-
*
|
|
730
|
-
*
|
|
844
|
+
* --profile <p> update --latest <pkg>`(pnpm 语义——profile 是 pnpm 项目,
|
|
845
|
+
* 插件绝不自己覆写依赖文件,否则 lockfile 失配)。完成后提示重启生效:宿主
|
|
846
|
+
* 在启动时结算 Loader 树,Node 半不可在运行中热替换。
|
|
847
|
+
*
|
|
848
|
+
* `--latest` 不可省:pnpm update <pkg> 只在 profile 声明的版本范围内升级
|
|
849
|
+
* (安装时写入的是 ^x.y.z),跨 minor 会停在旧 minor 上——而升级按钮承诺的
|
|
850
|
+
* 目标通常正是新 minor,少了它就是「点了更新、版本没动」。命令跑完再读回
|
|
851
|
+
* profile 内的真实版本核对,不拿 registry 的 latest 冒充结果。
|
|
731
852
|
*/
|
|
732
853
|
async function runUpdate(signal, profileArg) {
|
|
733
854
|
const profile = profileArg || 'web';
|
|
734
855
|
const chk = await checkUpdate(signal);
|
|
735
856
|
if (chk.error) {
|
|
736
|
-
return { ok: false, updated: false, summary: `检查更新失败:${chk.error}\n可手动执行: dsh plugin --profile ${profile} update ${SELF_META.name}` };
|
|
857
|
+
return { ok: false, updated: false, summary: `检查更新失败:${chk.error}\n可手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}` };
|
|
737
858
|
}
|
|
738
859
|
if (!chk.update) {
|
|
739
860
|
return { ok: true, updated: false, current: chk.current, latest: chk.latest, summary: `已是最新版本 ${chk.current}` };
|
|
@@ -749,12 +870,12 @@ export function apply(ctx, pluginConfig) {
|
|
|
749
870
|
// detectHostTrain 同款定位——`node <入口> plugin ...` 与运行中的宿主同源)
|
|
750
871
|
let argv = null;
|
|
751
872
|
try {
|
|
752
|
-
argv = [await ctx.subprocess.resolveExecutable('dsh'), 'plugin', '--profile', profile, 'update', SELF_META.name];
|
|
873
|
+
argv = [await ctx.subprocess.resolveExecutable('dsh'), 'plugin', '--profile', profile, 'update', '--latest', SELF_META.name];
|
|
753
874
|
} catch {
|
|
754
875
|
try {
|
|
755
|
-
argv = [process.execPath, realpathSync(process.argv[1]), 'plugin', '--profile', profile, 'update', SELF_META.name];
|
|
876
|
+
argv = [process.execPath, realpathSync(process.argv[1]), 'plugin', '--profile', profile, 'update', '--latest', SELF_META.name];
|
|
756
877
|
} catch {
|
|
757
|
-
return { ok: false, updated: false, summary: `找不到 dsh 命令,请手动执行: dsh plugin --profile ${profile} update ${SELF_META.name}${snap ? `\n更新前快照已留: ${snap}` : ''}` };
|
|
878
|
+
return { ok: false, updated: false, summary: `找不到 dsh 命令,请手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}${snap ? `\n更新前快照已留: ${snap}` : ''}` };
|
|
758
879
|
}
|
|
759
880
|
}
|
|
760
881
|
try {
|
|
@@ -763,12 +884,26 @@ export function apply(ctx, pluginConfig) {
|
|
|
763
884
|
} catch (err) {
|
|
764
885
|
return { ok: false, updated: false, summary: `更新失败:${friendlyReason(err)}${snap ? `\n已有升级前快照 ${snap},可 /backup restore ${snap} 回滚。` : ''}` };
|
|
765
886
|
}
|
|
887
|
+
// 命令跑完 ≠ 版本已变:profile 里锁着精确版本、pnpm 配置忽略 --latest 等情况
|
|
888
|
+
// 都会让结果停在旧版,读回真实版本再回执(否则用户重启后仍是旧版,无从解释)。
|
|
889
|
+
const after = await installedVersion(profile);
|
|
890
|
+
if (after && after !== chk.latest) {
|
|
891
|
+
return {
|
|
892
|
+
ok: false,
|
|
893
|
+
updated: false,
|
|
894
|
+
current: after,
|
|
895
|
+
latest: chk.latest,
|
|
896
|
+
summary: `更新命令已执行,但 profile 内解析到的仍是 ${after}(目标 ${chk.latest})。`
|
|
897
|
+
+ `\n请手动执行: dsh plugin --profile ${profile} update --latest ${SELF_META.name}`
|
|
898
|
+
+ (snap ? `\n更新前已留升级前快照 ${snap}。` : ''),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
766
901
|
return {
|
|
767
902
|
ok: true,
|
|
768
903
|
updated: true,
|
|
769
|
-
current: chk.latest,
|
|
904
|
+
current: after || chk.latest,
|
|
770
905
|
latest: chk.latest,
|
|
771
|
-
summary: `已更新到 ${chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web 使新版本生效。`,
|
|
906
|
+
summary: `已更新到 ${after || chk.latest}。${snap ? `\n更新前已留升级前快照 ${snap}(想回退:/backup restore ${snap})。` : '\n更新前快照未生成(不影响本次更新)。'}\n请重启 dsh web 使新版本生效。`,
|
|
772
907
|
};
|
|
773
908
|
}
|
|
774
909
|
|
|
@@ -816,6 +951,18 @@ export function apply(ctx, pluginConfig) {
|
|
|
816
951
|
return out;
|
|
817
952
|
}
|
|
818
953
|
|
|
954
|
+
/**
|
|
955
|
+
* vault 串行锁:同一进程内「备份刷新镜像」与「凭据存底」(启动钩子 / migrate
|
|
956
|
+
* 预检)都写同一个 vault 目录。旧实现是"整目录 rm -rf 再重写",两路并发时
|
|
957
|
+
* 互相踩——Linux 上实测 ENOTEMPTY 直接让整个备份失败(CI 稳定复现)。
|
|
958
|
+
*/
|
|
959
|
+
let vaultChain = Promise.resolve();
|
|
960
|
+
function withVaultLock(task) {
|
|
961
|
+
const next = vaultChain.then(task, task);
|
|
962
|
+
vaultChain = next.then(() => {}, () => {});
|
|
963
|
+
return next;
|
|
964
|
+
}
|
|
965
|
+
|
|
819
966
|
/**
|
|
820
967
|
* 刷新本机 vault:把 ~/.dsh 中现存的敏感文件按相对路径镜像到
|
|
821
968
|
* `<备份目录>/vault/`(先清空旧镜像,vault 永远对应最新一次备份)。
|
|
@@ -827,29 +974,41 @@ export function apply(ctx, pluginConfig) {
|
|
|
827
974
|
const vaultDir = `${root}/${VAULT_DIR}`;
|
|
828
975
|
if (!rels.length) return [];
|
|
829
976
|
if (signal?.aborted) throw new Error('操作已取消');
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
if (err && err.code === 'ENOENT') return false;
|
|
836
|
-
throw err;
|
|
837
|
-
});
|
|
838
|
-
if (!ok) continue;
|
|
839
|
-
const dst = `${vaultDir}/${rel}`;
|
|
840
|
-
await mkdir(dirname(dst), { recursive: true });
|
|
841
|
-
await copyFile(src, dst);
|
|
842
|
-
stored.push(rel);
|
|
843
|
-
}
|
|
844
|
-
if (stored.length && !IS_WIN) {
|
|
845
|
-
const chmod = await ctx.subprocess.resolveExecutable('chmod');
|
|
977
|
+
return withVaultLock(async () => {
|
|
978
|
+
// 只清 vault 里的**镜像条目**:`preserved/`(凭据存底)绝不能被下一次备份
|
|
979
|
+
// 抹掉——它正是为"升级后想回退"留的后路。镜像文件随后会被 copyFile 覆盖成
|
|
980
|
+
// 最新内容,所以清理失败(另一实例正在写)一律容忍,不值得让备份失败。
|
|
981
|
+
let names = [];
|
|
846
982
|
try {
|
|
847
|
-
await
|
|
848
|
-
} catch {
|
|
849
|
-
|
|
983
|
+
names = await readdir(vaultDir);
|
|
984
|
+
} catch { /* vault 尚不存在:下面逐文件 mkdir 会建 */ }
|
|
985
|
+
for (const n of names) {
|
|
986
|
+
if (n === PRESERVED_DIR) continue;
|
|
987
|
+
await rm(`${vaultDir}/${n}`, { recursive: true, force: true }).catch(() => {});
|
|
850
988
|
}
|
|
851
|
-
|
|
852
|
-
|
|
989
|
+
const stored = [];
|
|
990
|
+
for (const rel of rels) {
|
|
991
|
+
const src = `${dshHome}/${rel}`;
|
|
992
|
+
const ok = await fsStat(src).then(() => true, (err) => {
|
|
993
|
+
if (err && err.code === 'ENOENT') return false;
|
|
994
|
+
throw err;
|
|
995
|
+
});
|
|
996
|
+
if (!ok) continue;
|
|
997
|
+
const dst = `${vaultDir}/${rel}`;
|
|
998
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
999
|
+
await copyFile(src, dst);
|
|
1000
|
+
stored.push(rel);
|
|
1001
|
+
}
|
|
1002
|
+
if (stored.length && !IS_WIN) {
|
|
1003
|
+
const chmod = await ctx.subprocess.resolveExecutable('chmod');
|
|
1004
|
+
try {
|
|
1005
|
+
await spawnRun([chmod, '-R', 'go-rwx', vaultDir], home, signal);
|
|
1006
|
+
} catch {
|
|
1007
|
+
// chmod 失败不阻断备份:vault 已在用户私有目录下,权限收紧是纵深防御
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return stored;
|
|
1011
|
+
});
|
|
853
1012
|
}
|
|
854
1013
|
|
|
855
1014
|
function stampNow() {
|
|
@@ -997,7 +1156,11 @@ export function apply(ctx, pluginConfig) {
|
|
|
997
1156
|
}
|
|
998
1157
|
|
|
999
1158
|
// 校验和:POSIX 优先 sha256sum(macOS 回退 shasum)流式计算;
|
|
1000
|
-
// 都不可用时(Windows
|
|
1159
|
+
// 都不可用时(Windows 典型)回退 node:crypto 流式哈希。
|
|
1160
|
+
// #103:旧实现是 readFile 整包进内存 + 同步 update,且 >256MB 直接抛错——
|
|
1161
|
+
// Windows 上 3GB 级归档因此**必然失败**,备份写完 tar 却永远收不了尾,
|
|
1162
|
+
// 锚点不前进 → 每次启动重跑一次全量 tar → 宿主 boot 窗口被拖死。
|
|
1163
|
+
// 改为 createReadStream 分块喂哈希:不整包入内存、每块之间让出事件循环、无体积上限。
|
|
1001
1164
|
async function sha256File(absPath, home, signal) {
|
|
1002
1165
|
for (const candidate of [['sha256sum', []], ['shasum', ['-a', '256']]]) {
|
|
1003
1166
|
try {
|
|
@@ -1010,12 +1173,18 @@ export function apply(ctx, pluginConfig) {
|
|
|
1010
1173
|
// 尝试下一个
|
|
1011
1174
|
}
|
|
1012
1175
|
}
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1176
|
+
const hash = createHash('sha256');
|
|
1177
|
+
const stream = createReadStream(absPath, { highWaterMark: HASH_CHUNK_BYTES });
|
|
1178
|
+
try {
|
|
1179
|
+
for await (const chunk of stream) {
|
|
1180
|
+
if (signal?.aborted) throw new Error('操作已取消');
|
|
1181
|
+
hash.update(chunk);
|
|
1182
|
+
}
|
|
1183
|
+
} catch (err) {
|
|
1184
|
+
stream.destroy();
|
|
1185
|
+
throw err;
|
|
1016
1186
|
}
|
|
1017
|
-
|
|
1018
|
-
return createHash('sha256').update(bytes).digest('hex');
|
|
1187
|
+
return hash.digest('hex');
|
|
1019
1188
|
}
|
|
1020
1189
|
|
|
1021
1190
|
async function existsStat(p) {
|
|
@@ -1638,7 +1807,29 @@ export function apply(ctx, pluginConfig) {
|
|
|
1638
1807
|
}
|
|
1639
1808
|
}
|
|
1640
1809
|
if (nameIdx < 0 || nameIdx >= f.length) return null;
|
|
1641
|
-
|
|
1810
|
+
const rest = f.slice(nameIdx).join(' ');
|
|
1811
|
+
// tar -tv 的链接形态:硬链接 `<名> link to <目标>`、符号链接 `<名> -> <目标>`。
|
|
1812
|
+
// 必须把目标摘出来单独校验:旧实现把它并进 name,于是硬链接条目的"名字"
|
|
1813
|
+
// 天然含空格与目标路径,必然被判非法而整体拒绝(#112),错误信息里也把
|
|
1814
|
+
// 目标一起印出来。
|
|
1815
|
+
let name = rest;
|
|
1816
|
+
let link = null;
|
|
1817
|
+
const hardAt = rest.indexOf(' link to ');
|
|
1818
|
+
if (hardAt >= 0) {
|
|
1819
|
+
name = rest.slice(0, hardAt);
|
|
1820
|
+
link = rest.slice(hardAt + ' link to '.length);
|
|
1821
|
+
} else {
|
|
1822
|
+
const symAt = rest.lastIndexOf(' -> ');
|
|
1823
|
+
if (symAt >= 0) {
|
|
1824
|
+
name = rest.slice(0, symAt);
|
|
1825
|
+
link = rest.slice(symAt + 4);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
return {
|
|
1829
|
+
type: f[0][0],
|
|
1830
|
+
name: name.replace(/\/$/, ''),
|
|
1831
|
+
link: link === null ? null : link.replace(/\/$/, ''),
|
|
1832
|
+
};
|
|
1642
1833
|
}
|
|
1643
1834
|
|
|
1644
1835
|
/**
|
|
@@ -1699,33 +1890,40 @@ export function apply(ctx, pluginConfig) {
|
|
|
1699
1890
|
const { root, dshHome } = paths();
|
|
1700
1891
|
const base = dshHome.split('/').pop();
|
|
1701
1892
|
const tar = await ctx.subprocess.resolveExecutable('tar');
|
|
1702
|
-
|
|
1703
|
-
//
|
|
1704
|
-
// 条目必须是普通文件/目录、相对路径且位于备份根目录之下;任一违规
|
|
1705
|
-
// 即整体拒绝,恢复绝不触碰备份根之外的文件。
|
|
1893
|
+
// 清单流式读取(#119):不用有界收集——大归档的清单超过 spill 上限时会被截断,
|
|
1894
|
+
// 碎片会被误判成"不安全条目"而拒绝整包(详见 streamListingLines 注释)。
|
|
1706
1895
|
const entries = [];
|
|
1707
1896
|
const bad = [];
|
|
1708
|
-
|
|
1897
|
+
// 路径检查(反斜杠归一化后再判):`.dsh/..\..\escape.txt` 与 `/` 分隔的
|
|
1898
|
+
// `..` 段同样拒绝(Windows 风格路径变体)。
|
|
1899
|
+
const insideBase = (p) => Boolean(p)
|
|
1900
|
+
&& !p.startsWith('/') && !/^[A-Za-z]:/.test(p)
|
|
1901
|
+
&& !p.split('/').includes('..')
|
|
1902
|
+
&& (p === base || p.startsWith(`${base}/`));
|
|
1903
|
+
const onLine = (line) => {
|
|
1709
1904
|
const s = line.trim();
|
|
1710
|
-
if (!s)
|
|
1905
|
+
if (!s) return true;
|
|
1711
1906
|
const parsed = parseTarEntry(s);
|
|
1712
|
-
// 反斜杠归一化后再做全部路径检查:`.dsh/..\..\escape.txt` 与
|
|
1713
|
-
// `/` 分隔的 `..` 段同样拒绝(Windows 风格路径变体)
|
|
1714
1907
|
const name = parsed ? parsed.name.replace(/\\/g, '/').replace(/\/$/, '') : null;
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1908
|
+
const link = parsed && parsed.link ? parsed.link.replace(/\\/g, '/').replace(/\/$/, '') : null;
|
|
1909
|
+
// 允许的类型:普通文件 '-'、目录 'd',以及**目标也在备份根内**的硬链接 'h'。
|
|
1910
|
+
// 硬链接是 DSH 附件库(attachments/v1:files/ 与 file-objects/ 同一 inode)的
|
|
1911
|
+
// 正常形态,tar 会把第二个路径写成 'h' 条目——旧实现只放行 -/d,于是**带附件的
|
|
1912
|
+
// 归档一律被判"不安全条目"整体拒绝**,完整恢复/dry-run/分类型合并/doctor 修复/
|
|
1913
|
+
// 救援台全链路失效(#112)。硬链接不能跨文件系统、也不能指向目录,目标同为
|
|
1914
|
+
// 归档内相对路径时解压不会逃出备份根;符号链接(l)、设备等仍一律拒绝。
|
|
1915
|
+
const plainOk = Boolean(parsed) && Boolean(name) && (parsed.type === '-' || parsed.type === 'd') && insideBase(name);
|
|
1916
|
+
const hardOk = Boolean(parsed) && parsed.type === 'h' && insideBase(name) && insideBase(link);
|
|
1917
|
+
if (!plainOk && !hardOk) {
|
|
1722
1918
|
bad.push(parsed ? name : s);
|
|
1723
1919
|
} else {
|
|
1724
1920
|
entries.push(name);
|
|
1725
1921
|
}
|
|
1726
|
-
|
|
1922
|
+
return bad.length === 0; // 发现违规立即停读并终止 tar,不必读完
|
|
1923
|
+
};
|
|
1924
|
+
const listed = await streamListingLines([tar, '-tvzf', pickedName], root, signal, onLine);
|
|
1727
1925
|
if (bad.length) {
|
|
1728
|
-
throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')}
|
|
1926
|
+
throw new Error(`归档包含不安全条目(拒绝恢复):${bad.slice(0, 3).join(', ')}(已读清单 ${listed.lines} 行 / ${listed.bytes} 字节),恢复已中止`);
|
|
1729
1927
|
}
|
|
1730
1928
|
return entries;
|
|
1731
1929
|
}
|
|
@@ -1843,17 +2041,23 @@ export function apply(ctx, pluginConfig) {
|
|
|
1843
2041
|
if (mergedProfiles.length) {
|
|
1844
2042
|
try {
|
|
1845
2043
|
const pnpm = await ctx.subprocess.resolveExecutable('pnpm');
|
|
2044
|
+
// 单个 profile 装失败记入 failed 并继续(与全量恢复路径同语义);
|
|
2045
|
+
// pnpm 本身缺失才落到外层 note。
|
|
1846
2046
|
for (const p of mergedProfiles) {
|
|
1847
2047
|
try {
|
|
1848
2048
|
await spawnRun([pnpm, 'install', '--frozen-lockfile'], `${dshHome}/profiles/${p}`, signal);
|
|
1849
2049
|
deps.installed.push(p);
|
|
1850
2050
|
} catch {
|
|
1851
|
-
|
|
1852
|
-
|
|
2051
|
+
try {
|
|
2052
|
+
await spawnRun([pnpm, 'install'], `${dshHome}/profiles/${p}`, signal);
|
|
2053
|
+
deps.installed.push(p);
|
|
2054
|
+
} catch {
|
|
2055
|
+
deps.failed.push(p);
|
|
2056
|
+
}
|
|
1853
2057
|
}
|
|
1854
2058
|
}
|
|
1855
2059
|
} catch {
|
|
1856
|
-
deps.note = 'pnpm
|
|
2060
|
+
deps.note = '未找到 pnpm,请在各 profile 目录手动执行 pnpm install';
|
|
1857
2061
|
}
|
|
1858
2062
|
}
|
|
1859
2063
|
}
|
|
@@ -2049,7 +2253,17 @@ export function apply(ctx, pluginConfig) {
|
|
|
2049
2253
|
// (魔数/帧头/块序列/校验和),再逐帧解出逻辑行。检测目标正是社区高频的
|
|
2050
2254
|
// "corrupt session log"灾难:并发写 seq 撞号、坏帧、尾部截断、单帧重写。
|
|
2051
2255
|
const ZSTD_MAGIC = 0xfd2fb528;
|
|
2052
|
-
|
|
2256
|
+
// 会话日志文件名 = 宿主 sessionFormatLogFilename 的形态:session[.v<N>].jsonl[.zstd]
|
|
2257
|
+
// (N≥1 可多位;无 .vN 后缀即第 0 代)。**代数是文件名的一部分、会随格式换代前进**,
|
|
2258
|
+
// 写死具体名字会在换代当天整体失明(#117:v4 机器上 doctor/migrate 一份都看不见),
|
|
2259
|
+
// 故按形态匹配,让新代文件至少进入校验/预检流程(代的判定另见迁移预检)。
|
|
2260
|
+
const SESSION_LOG_RE = /^session(?:\.v([1-9][0-9]*))?\.jsonl(?:\.zstd)?$/;
|
|
2261
|
+
const isSessionLogName = (name) => SESSION_LOG_RE.test(name);
|
|
2262
|
+
/** 从会话日志文件名取格式代;非会话日志名返回 null(无 .vN 后缀即 v0)。 */
|
|
2263
|
+
function sessionLogVersion(name) {
|
|
2264
|
+
const m = SESSION_LOG_RE.exec(name);
|
|
2265
|
+
return m ? Number(m[1] ?? 0) : null;
|
|
2266
|
+
}
|
|
2053
2267
|
const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
|
|
2054
2268
|
const SCAN_MAX_FILES = 4000;
|
|
2055
2269
|
const SCAN_MAX_DEPTH = 8;
|
|
@@ -2194,7 +2408,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2194
2408
|
}
|
|
2195
2409
|
|
|
2196
2410
|
/** 有界递归收集会话日志文件(相对 ~/.dsh 路径);跳过依赖/系统目录。 */
|
|
2197
|
-
async function collectSessionLogs(dir, relPrefix, depth, out,
|
|
2411
|
+
async function collectSessionLogs(dir, relPrefix, depth, out, isLogName = isSessionLogName) {
|
|
2198
2412
|
if (depth > SCAN_MAX_DEPTH || out.length >= SCAN_MAX_FILES) return;
|
|
2199
2413
|
let dirents;
|
|
2200
2414
|
try {
|
|
@@ -2207,8 +2421,8 @@ export function apply(ctx, pluginConfig) {
|
|
|
2207
2421
|
const rel = relPrefix ? `${relPrefix}/${d.name}` : d.name;
|
|
2208
2422
|
if (d.isDirectory()) {
|
|
2209
2423
|
if (SCAN_SKIP_DIRS.has(d.name)) continue;
|
|
2210
|
-
await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out,
|
|
2211
|
-
} else if (
|
|
2424
|
+
await collectSessionLogs(`${dir}/${d.name}`, rel, depth + 1, out, isLogName);
|
|
2425
|
+
} else if (isLogName(d.name)) {
|
|
2212
2426
|
out.push({ abs: `${dir}/${d.name}`, rel });
|
|
2213
2427
|
}
|
|
2214
2428
|
}
|
|
@@ -2292,16 +2506,13 @@ export function apply(ctx, pluginConfig) {
|
|
|
2292
2506
|
// (0.1.5-rc.2 校准,与 0.11.2 对齐 isHeaderLine 同一套路),覆盖"解析即判"
|
|
2293
2507
|
// 的规则;需要重放迁移过程的规则(chunk 折叠溯源、引用重映射、worker 复核)
|
|
2294
2508
|
// 不在覆盖范围,结果里如实标注。三层判定:fail=会话将打不开;migratable=
|
|
2295
|
-
//
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
{ name: 'session.v3.jsonl', version: 3 },
|
|
2303
|
-
];
|
|
2304
|
-
const MIGRATE_LOG_SET = new Set(MIGRATE_LOG_NAMES.flatMap((g) => [g.name, `${g.name}.zstd`]));
|
|
2509
|
+
// 旧代但静态规则全过(首次写开会发布当前代新文件);ok=已是当前代。
|
|
2510
|
+
//
|
|
2511
|
+
// **校准代(#117)**:宿主会随格式换代推进文件名与 header 的 version(0.1.7 起
|
|
2512
|
+
// 为 v4——迁移包只到 `v3-to-v4`、无 `v4-to-v5`)。此常数是本插件**规则**覆盖到的
|
|
2513
|
+
// 最高代,换代时须同步更新;比它更新的日志只提示、不判"打不开"(见
|
|
2514
|
+
// newer-generation)——"宿主读不读得了"取决于宿主版本,不是我们这张注定滞后的表。
|
|
2515
|
+
const MIGRATE_CALIBRATED_VERSION = 4;
|
|
2305
2516
|
// 冻结事件清单(RELEASED_V0_EVENT_TYPES / RELEASED_V2_EVENT_TYPES,宿主
|
|
2306
2517
|
// 按契约不增长旧代清单);migration 对清单外类型连 ignorable 也拒(#6355)。
|
|
2307
2518
|
const MIGRATE_V0_EVENT_TYPES = new Set(['agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided', 'approval/policy', 'assistant/chunk', 'assistant/message', 'command/done', 'command/run', 'compaction/end', 'compaction/prune', 'compaction/start', 'compaction/summary', 'feedback/record', 'goal/change', 'hook/invoked', 'hook/result', 'llm/retry', 'llm/retry-started', 'model/selection', 'permission/preset', 'plan/mode', 'request/context', 'request/header', 'sandbox/mode', 'schedule/change', 'session-log-deepseek/delivery-accepted', 'session/end-seed', 'session/title', 'session/title-llm-request', 'step/end', 'step/start', 'subagent/descriptor', 'subagent/model-selection-policy', 'team/member', 'team/message/delivered', 'team/message/queued', 'team/task', 'todo/write', 'tool-workflow/agent-end', 'tool-workflow/agent-start', 'tool-workflow/run-end', 'tool-workflow/run-start', 'tool/call', 'tool/code-dispatch', 'tool/code-dispatch-start', 'tool/result', 'turn/end', 'turn/start', 'user/message', 'web/deepseek-search-llm-request']);
|
|
@@ -2309,14 +2520,26 @@ export function apply(ctx, pluginConfig) {
|
|
|
2309
2520
|
/** v0 侧退役类型(pre-react-loop 预发布形态;宿主点名拒绝)。 */
|
|
2310
2521
|
const MIGRATE_V0_RETIRED_TYPES = new Set(['request/header-delta', 'mode/set']);
|
|
2311
2522
|
/** 消息来源 kind 白名单(v3 侧 15 类;未知 kind 在 v2→v3 边被拒——#6355 主症状)。 */
|
|
2312
|
-
|
|
2523
|
+
/**
|
|
2524
|
+
* 「消息来源 kind」的**已知**集合(#113)。注意语义:宿主的 `source.kind` 是
|
|
2525
|
+
* **可合并扩展的联合类型**——每个生产者在自己的模块里声明自己的 kind,没有
|
|
2526
|
+
* 统一的兜底值,所以任何硬编码清单都**不可能完整**。这里的集合因此只当"已知
|
|
2527
|
+
* 名单"用:命中的静默通过,没命中的记一条**提示**(不再判为"打不开",见
|
|
2528
|
+
* 下方 unknown-source-kind)——真正会拒载的是旧代**事件类型**的冻结清单。
|
|
2529
|
+
*
|
|
2530
|
+
* 内容来自宿主 `@deepseek-ai/dsh-session-format-v3-to-v4` 自带的
|
|
2531
|
+
* `RENAMED_PRODUCERS` 取值与 `RELEASED_SAME_NAME_PRODUCERS` 名单
|
|
2532
|
+
* (2026-09-29 从 0.2.0-rc.1 实装包逐行核对),外加 `producerKind()` 的兜底
|
|
2533
|
+
* 形态 `plugin:<插件名>`(用前缀匹配,见 MIGRATE_KIND_KNOWN)。
|
|
2534
|
+
*/
|
|
2535
|
+
const MIGRATE_SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook', 'agent-message', 'compact-checkpoint', 'ptc-mode', 'compact-basic', 'runtime-context', 'system-prompt', 'model-selection', 'plan-mode', 'time-context', 'tmux-context', 'user-approval', 'repeat-tool-reminder', 'tool-cordis', 'cordis-host-runner', 'tool-goal', 'tool-jobs', 'hooks-codex', 'hooks-claude-code', 'schedule', 'dsh-session-title-llm', 'fallback', 'provider']);
|
|
2313
2536
|
const MIGRATE_MAX_FINDINGS = 3;
|
|
2537
|
+
/** kind 是否在已知名单内:精确命中,或宿主兜底形态 `plugin:<插件名>`(可任意长尾,故用前缀判定)。 */
|
|
2538
|
+
const MIGRATE_KIND_KNOWN = (kind) => MIGRATE_SOURCE_KINDS.has(kind) || kind.startsWith('plugin:');
|
|
2314
2539
|
|
|
2315
|
-
/**
|
|
2540
|
+
/** 非代文件名(未来扩展)返回 null;代的判定与 doctor 共用 SESSION_LOG_RE。 */
|
|
2316
2541
|
function migrateFilenameVersion(fileName) {
|
|
2317
|
-
|
|
2318
|
-
const g = MIGRATE_LOG_NAMES.find((x) => x.name === base);
|
|
2319
|
-
return g ? g.version : null;
|
|
2542
|
+
return sessionLogVersion(fileName);
|
|
2320
2543
|
}
|
|
2321
2544
|
|
|
2322
2545
|
/**
|
|
@@ -2334,18 +2557,26 @@ export function apply(ctx, pluginConfig) {
|
|
|
2334
2557
|
if (!header || typeof header !== 'object' || typeof header.version !== 'number') return null;
|
|
2335
2558
|
const sourceVersion = header.version;
|
|
2336
2559
|
const findings = [];
|
|
2560
|
+
const warnings = [];
|
|
2337
2561
|
let total = 0;
|
|
2338
2562
|
const fail = (rule, detail) => {
|
|
2339
2563
|
total += 1;
|
|
2340
2564
|
if (findings.length < MIGRATE_MAX_FINDINGS) findings.push({ rule, detail });
|
|
2341
2565
|
};
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2566
|
+
// 提示(不判"打不开"):用于**开放集合**上的命中失败——最典型的是
|
|
2567
|
+
// source.kind(可合并扩展的联合类型,硬编码清单天生补不全,#113)。
|
|
2568
|
+
const warn = (rule, detail) => {
|
|
2569
|
+
if (warnings.length < MIGRATE_MAX_FINDINGS) warnings.push({ rule, detail });
|
|
2570
|
+
};
|
|
2571
|
+
if (sourceVersion > MIGRATE_CALIBRATED_VERSION) {
|
|
2572
|
+
// 更新的格式代:本插件的规则表对它是盲区,但"能不能读"由宿主决定(同代/更新可读、
|
|
2573
|
+
// 降级运行被拒)——按开放集合纪律只提示、不替宿主下"打不开"的判决(#117/#113)。
|
|
2574
|
+
warn('newer-generation', `header version=${sourceVersion} 高于本插件校准到的 v${MIGRATE_CALIBRATED_VERSION}——日志由更新的格式代写出。能否打开取决于宿主:宿主同代或更新即可读,**降级运行会被拒**;若你的宿主已是这一代,说明本插件校准滞后,欢迎反馈。仅提示,不判为打不开`);
|
|
2575
|
+
return { sourceVersion, findings, warnings, total };
|
|
2345
2576
|
}
|
|
2346
2577
|
if (fileVersion !== null && fileVersion !== sourceVersion) {
|
|
2347
2578
|
fail('name-version-mismatch', `文件名标 v${fileVersion},header 标 v${sourceVersion}——宿主按不一致直接拒载`);
|
|
2348
|
-
return { sourceVersion, findings, total };
|
|
2579
|
+
return { sourceVersion, findings, warnings, total };
|
|
2349
2580
|
}
|
|
2350
2581
|
// 事件行静态规则。seq 游标语义与 validateSessionLines 一致:v0 有 packed 行
|
|
2351
2582
|
//(一条行多个 chunk),v1 起一行一事件。
|
|
@@ -2383,14 +2614,22 @@ export function apply(ctx, pluginConfig) {
|
|
|
2383
2614
|
if (sourceVersion === 2 && (type === 'tool/code-dispatch' || type === 'tool/code-dispatch-start')) fail('ptc-reserved-v2', `第 ${i + 1} 行 ${type} 是 v3 保留 PTC 标签,v2→v3 拒绝(ignorable 也拒)`);
|
|
2384
2615
|
if ((sourceVersion === 2 || sourceVersion === 3) && type === 'request/header' && rec.data?.header?.system !== undefined) fail('retired-header-system', `第 ${i + 1} 行 request/header 携带退役的 header.system——v2→v3 无法保留时序地提升它${sourceVersion === 3 ? ',当前代读取同样拒绝' : ''}`);
|
|
2385
2616
|
const kind = rec?.data?.source?.kind;
|
|
2386
|
-
|
|
2617
|
+
// kind 只作提示,不作判据(#113):宿主把它设计成可合并扩展的联合类型,
|
|
2618
|
+
// 每个生产者(含任意社区插件)都能加自己的 kind,且生产端对未知 kind 照读
|
|
2619
|
+
// ——旧实现把"不在白名单"直接判成"打不开",在一台真实机器上把 81/82 份
|
|
2620
|
+
// 完全健康的日志报成不可打开。
|
|
2621
|
+
if (typeof kind === 'string' && !MIGRATE_KIND_KNOWN(kind)) {
|
|
2622
|
+
warn('unknown-source-kind', `第 ${i + 1} 行消息来源 kind="${kind}" 不在已知名单(宿主为可合并扩展的联合类型,官方/社区插件都能新增)——仅提示,不判为打不开;真正会拒载的是旧代事件类型不在冻结清单内`);
|
|
2623
|
+
}
|
|
2387
2624
|
}
|
|
2388
|
-
return { sourceVersion, findings, total };
|
|
2625
|
+
return { sourceVersion, findings, warnings, total };
|
|
2389
2626
|
}
|
|
2390
2627
|
|
|
2391
2628
|
/**
|
|
2392
2629
|
* 单文件迁移预检:解码(复用 doctor 的帧走查)→ 判代 → 静态规则。
|
|
2393
|
-
* 返回 { verdict: 'fail'|'migratable'|'ok'|'skipped', version, findings, total, reason }
|
|
2630
|
+
* 返回 { verdict: 'fail'|'migratable'|'ok'|'skipped', version, findings, warnings, total, reason }——
|
|
2631
|
+
* findings 是"判为打不开"的硬伤,warnings 是开放集合上的命中失败(如未知
|
|
2632
|
+
* source.kind),只提示不改变 verdict(#113)。
|
|
2394
2633
|
*/
|
|
2395
2634
|
async function migrateAnalyzeFile(f) {
|
|
2396
2635
|
const fileVersion = migrateFilenameVersion(f.abs.split('/').pop());
|
|
@@ -2405,8 +2644,8 @@ export function apply(ctx, pluginConfig) {
|
|
|
2405
2644
|
if (!lines.length) return { verdict: 'fail', version: fileVersion, findings: [{ rule: 'empty', detail: '解出的逻辑行为空' }], total: 1 };
|
|
2406
2645
|
const r = migrateAnalyzeLines(lines, fileVersion);
|
|
2407
2646
|
if (!r) return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: 'header 行不可解析——按损坏处理(doctor 体检覆盖),迁移预检跳过' };
|
|
2408
|
-
if (r.total > 0) return { verdict: 'fail', version: r.sourceVersion, findings: r.findings, total: r.total };
|
|
2409
|
-
return { verdict: r.sourceVersion <
|
|
2647
|
+
if (r.total > 0) return { verdict: 'fail', version: r.sourceVersion, findings: r.findings, warnings: r.warnings, total: r.total };
|
|
2648
|
+
return { verdict: r.sourceVersion < MIGRATE_CALIBRATED_VERSION ? 'migratable' : 'ok', version: r.sourceVersion, findings: [], warnings: r.warnings, total: 0 };
|
|
2410
2649
|
} catch (err) {
|
|
2411
2650
|
return { verdict: 'skipped', version: fileVersion, findings: [], total: 0, reason: `解码失败(${String(err && err.message ? err.message : err)})——doctor 体检会按损坏处理` };
|
|
2412
2651
|
}
|
|
@@ -2428,14 +2667,17 @@ export function apply(ctx, pluginConfig) {
|
|
|
2428
2667
|
async function preserveFlatCredentials() {
|
|
2429
2668
|
const { dshHome, root } = paths();
|
|
2430
2669
|
if (!(await credentialsIsFlatLayout(dshHome))) return null;
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2670
|
+
// 与备份侧的 vault 刷新共用串行锁:并发写入同一 vault 目录互相踩(ENOTEMPTY)
|
|
2671
|
+
return withVaultLock(async () => {
|
|
2672
|
+
const dir = `${root}/${VAULT_DIR}/${PRESERVED_DIR}`;
|
|
2673
|
+
await mkdir(dir, { recursive: true });
|
|
2674
|
+
const dest = `${dir}/credentials-flat-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.yaml`;
|
|
2675
|
+
await copyFile(`${dshHome}/.credentials.yaml`, dest);
|
|
2676
|
+
// 轮换:只留最近 3 份(按文件名时间戳排序即时间序)
|
|
2677
|
+
const old = (await readdir(dir)).filter((n) => n.startsWith('credentials-flat-')).sort();
|
|
2678
|
+
for (const n of old.slice(0, Math.max(old.length - 3, 0))) await rm(`${dir}/${n}`, { force: true });
|
|
2679
|
+
return dest;
|
|
2680
|
+
});
|
|
2439
2681
|
}
|
|
2440
2682
|
|
|
2441
2683
|
/** 环境级探测:硬链接支持(v3 发布用 fs.link,exFAT 等会 ENOTSUP)+ 凭据布局。 */
|
|
@@ -2469,7 +2711,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2469
2711
|
const { dshHome, root } = paths();
|
|
2470
2712
|
if (signal?.aborted) throw new Error('操作已取消');
|
|
2471
2713
|
const found = [];
|
|
2472
|
-
await collectSessionLogs(dshHome, '', 0, found
|
|
2714
|
+
await collectSessionLogs(dshHome, '', 0, found);
|
|
2473
2715
|
const sessions = [];
|
|
2474
2716
|
for (const f of found) {
|
|
2475
2717
|
if (signal?.aborted) throw new Error('操作已取消');
|
|
@@ -2479,8 +2721,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
2479
2721
|
const fails = sessions.filter((s) => s.verdict === 'fail');
|
|
2480
2722
|
const migratable = sessions.filter((s) => s.verdict === 'migratable');
|
|
2481
2723
|
const skipped = sessions.filter((s) => s.verdict === 'skipped');
|
|
2724
|
+
const warned = sessions.filter((s) => (s.warnings?.length ?? 0) > 0);
|
|
2482
2725
|
const lines = [];
|
|
2483
|
-
lines.push(`扫描 ${sessions.length}
|
|
2726
|
+
lines.push(`扫描 ${sessions.length} 个会话日志(v0 起各代,按文件名代判定):${fails.length} 个升级后将打不开、${migratable.length} 个旧代待迁移(静态规则全过)、${skipped.length} 个未能判定${warned.length ? `、${warned.length} 个带提示(不影响打开)` : ''}`);
|
|
2484
2727
|
if (fails.length) {
|
|
2485
2728
|
lines.push('');
|
|
2486
2729
|
for (const s of fails.slice(0, 15)) {
|
|
@@ -2492,24 +2735,33 @@ export function apply(ctx, pluginConfig) {
|
|
|
2492
2735
|
lines.push('');
|
|
2493
2736
|
lines.push('升级前建议:先拍一份升级前快照(/backup),坏的会话可从更早归档定点修复(/backup doctor --repair)。');
|
|
2494
2737
|
}
|
|
2738
|
+
if (warned.length) {
|
|
2739
|
+
lines.push('');
|
|
2740
|
+
lines.push(`ℹ️ ${warned.length} 个日志带提示(**不计入"打不开"**,逐条看原因):`);
|
|
2741
|
+
for (const s of warned.slice(0, 5)) {
|
|
2742
|
+
for (const w of s.warnings) lines.push(` · ${s.rel}:${w.detail}`);
|
|
2743
|
+
}
|
|
2744
|
+
if (warned.length > 5) lines.push(` …另有 ${warned.length - 5} 个文件`);
|
|
2745
|
+
}
|
|
2495
2746
|
if (!env.hardlink) {
|
|
2496
2747
|
lines.push('');
|
|
2497
|
-
lines.push(`⚠️ 文件系统不支持硬链接(${env.hardlinkNote}
|
|
2748
|
+
lines.push(`⚠️ 文件系统不支持硬链接(${env.hardlinkNote})——宿主发布新代文件会失败(官方 #6358 场景):旧会话读得了、写开就报错,且升级无法继续。建议把 ~/.dsh 挪回 APFS/ext4 等本地文件系统。`);
|
|
2498
2749
|
}
|
|
2499
2750
|
if (env.credentialsFlat) {
|
|
2500
2751
|
lines.push('');
|
|
2501
2752
|
lines.push(`⚠️ .credentials.yaml 还是旧扁平布局——宿主下次读档会把它原子替换成 version:1(不可逆,旧宿主将读不回)。${env.credentialsPreserved ? `已自动存底:${env.credentialsPreserved.replace(`${root}/`, '')}` : '自动存底失败(vault 不可写?),建议立即手动 /backup 一次。'}`);
|
|
2502
2753
|
}
|
|
2503
2754
|
lines.push('');
|
|
2504
|
-
lines.push(`覆盖说明:以上为静态规则判定(宿主 ${detectHostTrain() || '未知列车'}
|
|
2755
|
+
lines.push(`覆盖说明:以上为静态规则判定(宿主 ${detectHostTrain() || '未知列车'} 校准;格式代校准到 v${MIGRATE_CALIBRATED_VERSION})。需要重放迁移过程的检查(chunk 折叠溯源、引用重映射、发布前 worker 复核)不在本版覆盖内——静态全过仍可能在新代发布时被宿主拒载。`);
|
|
2505
2756
|
const head = fails.length || !env.hardlink || env.credentialsFlat ? '⚠️' : '✅';
|
|
2506
2757
|
return {
|
|
2507
2758
|
scanned: sessions.length,
|
|
2508
|
-
sessions: sessions.map((s) => ({ rel: s.rel, verdict: s.verdict, version: s.version, total: s.total, findings: s.findings, ...(s.reason ? { reason: s.reason } : {}) })),
|
|
2759
|
+
sessions: sessions.map((s) => ({ rel: s.rel, verdict: s.verdict, version: s.version, total: s.total, findings: s.findings, ...(s.warnings?.length ? { warnings: s.warnings } : {}), ...(s.reason ? { reason: s.reason } : {}) })),
|
|
2509
2760
|
env,
|
|
2510
2761
|
failCount: fails.length,
|
|
2511
2762
|
migratableCount: migratable.length,
|
|
2512
2763
|
skippedCount: skipped.length,
|
|
2764
|
+
warningCount: warned.length,
|
|
2513
2765
|
summary: `${head} ${lines.join('\n')}`,
|
|
2514
2766
|
};
|
|
2515
2767
|
}
|
|
@@ -2598,6 +2850,30 @@ export function apply(ctx, pluginConfig) {
|
|
|
2598
2850
|
// 上次见到的宿主列车(detectHostTrain 代理值):null=尚未记录(首启动只记
|
|
2599
2851
|
// 录不拍快照),非 null 且变化 → 升/降级 → 自动拍 dsh-pre-upgrade- 快照。
|
|
2600
2852
|
let lastTrain = null;
|
|
2853
|
+
// 增长:CLI 回执的 star 提示只出现一次(持久化在 auto.json;旧版读未知字段安全忽略)
|
|
2854
|
+
let starHinted = false;
|
|
2855
|
+
// #103 循环防护三件套:
|
|
2856
|
+
// - lastAutoAttemptAt:每次自动备份**起跑即记**(不等到成功)。写盘后即使进程
|
|
2857
|
+
// 被强杀(重活干到一半被杀正是本 issue 的形态),下次启动也知道"刚试过",
|
|
2858
|
+
// 不会以 delay≤0 立刻重跑;旧版读未知字段安全忽略。
|
|
2859
|
+
// - newestArchiveAt:备份目录里最新归档的 mtime,调度锚点的最终兜底——磁盘上
|
|
2860
|
+
// 躺着新归档就说明不必马上再打一份,避免锚点丢失导致的重跑。
|
|
2861
|
+
// - lastAutoError:上次失败原因(成功即清),让"备份一直在失败"这件事在
|
|
2862
|
+
// /backup 与面板上可见,而不是只躺在没人看的宿主日志里。
|
|
2863
|
+
let lastAutoAttemptAt = null;
|
|
2864
|
+
let newestArchiveAt = 0;
|
|
2865
|
+
let lastAutoError = null;
|
|
2866
|
+
// 连续失败次数(内存态):失败重试按 30min × 2^(n-1) 退避、封顶一个周期,
|
|
2867
|
+
// 避免确定性失败(磁盘满/权限)变成每 30 分钟烧一次 CPU 的热循环。
|
|
2868
|
+
let autoFailStreak = 0;
|
|
2869
|
+
// 无人值守的重活串行队列:开机补跑与列车快照可能同时到点,两份 GB 级 tar
|
|
2870
|
+
// 并行会把机器彻底压死。手动备份/恢复快照是用户在等结果的显式操作,不入队。
|
|
2871
|
+
let heavyChain = Promise.resolve();
|
|
2872
|
+
function enqueueHeavy(task) {
|
|
2873
|
+
const next = heavyChain.then(task, task);
|
|
2874
|
+
heavyChain = next.then(() => {}, () => {});
|
|
2875
|
+
return next;
|
|
2876
|
+
}
|
|
2601
2877
|
|
|
2602
2878
|
/**
|
|
2603
2879
|
* 等待 settings 用户层加载完成(describe 出现 revision)。启动钩子若在
|
|
@@ -2640,7 +2916,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2640
2916
|
// (先到者 rename 走,后到者 ENOENT),UUID 跨实例唯一
|
|
2641
2917
|
const tmp = `${target}.${randomUUID()}.tmp`;
|
|
2642
2918
|
try {
|
|
2643
|
-
await writeOwned(tmp, `${JSON.stringify({ hours: autoHours, lastAutoAt, github: githubState, lastTrain })}\n`);
|
|
2919
|
+
await writeOwned(tmp, `${JSON.stringify({ hours: autoHours, lastAutoAt, github: githubState, lastTrain, starHinted, lastAutoAttemptAt, lastAutoError })}\n`);
|
|
2644
2920
|
await rename(tmp, target);
|
|
2645
2921
|
} catch (err) {
|
|
2646
2922
|
await rm(tmp, { force: true }).catch(() => {});
|
|
@@ -2648,6 +2924,32 @@ export function apply(ctx, pluginConfig) {
|
|
|
2648
2924
|
}
|
|
2649
2925
|
}
|
|
2650
2926
|
|
|
2927
|
+
/**
|
|
2928
|
+
* 磁盘上最新全量归档的 mtime(#103 兜底锚点):锚点丢失/不前进时,只要备份
|
|
2929
|
+
* 目录里躺着新归档就不该马上再打一份——这是"失败→重跑→再失败"死循环的
|
|
2930
|
+
* 第二道断路(第一道是 lastAutoAttemptAt)。口径与 listBackups 一致:
|
|
2931
|
+
* 只看用户可见的全量归档,不含内部快照与分类型归档。
|
|
2932
|
+
*/
|
|
2933
|
+
async function refreshNewestArchiveAt() {
|
|
2934
|
+
const { root } = paths();
|
|
2935
|
+
let dirents;
|
|
2936
|
+
try {
|
|
2937
|
+
dirents = await readdir(root, { withFileTypes: true });
|
|
2938
|
+
} catch {
|
|
2939
|
+
return; // 备份目录还没有/不可读:锚点保持 0
|
|
2940
|
+
}
|
|
2941
|
+
let newest = 0;
|
|
2942
|
+
for (const d of dirents) {
|
|
2943
|
+
if (!d.name.startsWith('dsh-') || !d.name.endsWith('.tar.gz')) continue;
|
|
2944
|
+
if (SNAPSHOT_PREFIXES.some((p) => d.name.startsWith(p)) || d.name.startsWith(TYPED_PREFIX)) continue;
|
|
2945
|
+
try {
|
|
2946
|
+
const st = await fsStat(`${root}/${d.name}`);
|
|
2947
|
+
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
|
2948
|
+
} catch { /* 与轮换/删除竞态:忽略 */ }
|
|
2949
|
+
}
|
|
2950
|
+
newestArchiveAt = newest;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2651
2953
|
async function loadAutoState() {
|
|
2652
2954
|
try {
|
|
2653
2955
|
const { root } = paths();
|
|
@@ -2665,7 +2967,16 @@ export function apply(ctx, pluginConfig) {
|
|
|
2665
2967
|
// 故一律视为非法 → null,从当前时间起算 catch-up。
|
|
2666
2968
|
const lastTs = typeof parsed?.lastAutoAt === 'string' ? Date.parse(parsed.lastAutoAt) : NaN;
|
|
2667
2969
|
lastAutoAt = Number.isNaN(lastTs) || lastTs > Date.now() ? null : parsed.lastAutoAt;
|
|
2970
|
+
const attTs = typeof parsed?.lastAutoAttemptAt === 'string' ? Date.parse(parsed.lastAutoAttemptAt) : NaN;
|
|
2971
|
+
lastAutoAttemptAt = Number.isNaN(attTs) || attTs > Date.now() ? null : parsed.lastAutoAttemptAt;
|
|
2668
2972
|
lastTrain = typeof parsed?.lastTrain === 'string' && parsed.lastTrain ? parsed.lastTrain : null;
|
|
2973
|
+
starHinted = parsed?.starHinted === true;
|
|
2974
|
+
const e = parsed?.lastAutoError;
|
|
2975
|
+
lastAutoError = e && typeof e === 'object' && typeof e.message === 'string'
|
|
2976
|
+
? { at: typeof e.at === 'string' ? e.at : '', message: e.message }
|
|
2977
|
+
: null;
|
|
2978
|
+
// 旧版写的 auto.json 没有这些字段 → 全部按缺失处理,行为与旧版一致
|
|
2979
|
+
await refreshNewestArchiveAt();
|
|
2669
2980
|
return Number.isFinite(h) && h >= 1 && h <= 720 ? Math.floor(h) : 0;
|
|
2670
2981
|
} catch (err) {
|
|
2671
2982
|
// ENOENT = 首次使用还没有 auto.json,是正常初始状态,静默按未配置处理;
|
|
@@ -2673,16 +2984,20 @@ export function apply(ctx, pluginConfig) {
|
|
|
2673
2984
|
if (err && err.code !== 'ENOENT') {
|
|
2674
2985
|
console.warn(`[dsh-backup] 自动备份计划文件读取失败,定时备份已暂停(不影响已有备份):执行 /backup auto <小时数> 可重新开启(${String(err && err.message ? err.message : err)})`);
|
|
2675
2986
|
}
|
|
2987
|
+
await refreshNewestArchiveAt();
|
|
2676
2988
|
return 0;
|
|
2677
2989
|
}
|
|
2678
2990
|
}
|
|
2679
2991
|
|
|
2680
|
-
/**
|
|
2992
|
+
/** 调度锚点(毫秒):上次成功 / 磁盘最新归档,取最晚者;皆无则 0。 */
|
|
2993
|
+
function autoAnchorAt() {
|
|
2994
|
+
return Math.max(Number(new Date(lastAutoAt)) || 0, newestArchiveAt || 0);
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
/** 下次自动备份触发时间(毫秒):锚点 + 周期;无锚点从当前时间起算。 */
|
|
2681
2998
|
function nextAutoAt() {
|
|
2682
2999
|
const cycle = autoHours * 3600 * 1000;
|
|
2683
|
-
|
|
2684
|
-
const anchor = (Number(new Date(lastAutoAt)) || 0) + cycle;
|
|
2685
|
-
return Math.max(anchor, Date.now());
|
|
3000
|
+
return Math.max(autoAnchorAt() + cycle, Date.now());
|
|
2686
3001
|
}
|
|
2687
3002
|
|
|
2688
3003
|
/** 本地时间显示:YYYY-MM-DD HH:mm——不依赖运行时 locale(裸跑环境会落 en-US)。 */
|
|
@@ -2692,43 +3007,82 @@ export function apply(ctx, pluginConfig) {
|
|
|
2692
3007
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
2693
3008
|
}
|
|
2694
3009
|
|
|
3010
|
+
/** 失败提示(若有):让"备份一直在失败"在 /backup 与面板上可见,而不是只躺在宿主日志里。 */
|
|
3011
|
+
function autoErrorNote() {
|
|
3012
|
+
if (!lastAutoError) return '';
|
|
3013
|
+
const when = lastAutoError.at ? formatWhen(Date.parse(lastAutoError.at)) : '';
|
|
3014
|
+
const msg = lastAutoError.message.length > 200 ? `${lastAutoError.message.slice(0, 200)}…` : lastAutoError.message;
|
|
3015
|
+
return `\n⚠️ 上次自动备份未完成${when ? `(${when})` : ''}:${msg}`;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
2695
3018
|
function autoSummary() {
|
|
2696
|
-
if (!autoDispose) return
|
|
2697
|
-
// 绝对节奏:下次 =
|
|
2698
|
-
return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次 ${formatWhen(nextAutoAt())}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}`;
|
|
3019
|
+
if (!autoDispose) return `自动备份未开启(/backup auto <N小时> 开启)${autoErrorNote()}`;
|
|
3020
|
+
// 绝对节奏:下次 = 锚点 + 周期;错过(重启间隔超周期)则按现在显示,调度会在启动宽限后补跑。
|
|
3021
|
+
return `自动备份已开启:每 ${autoHours} 小时一次(已持久化,重启续跑),下次 ${formatWhen(nextAutoAt())}${lastAuto ? `;上次自动备份: ${lastAuto}` : ''}${autoErrorNote()}`;
|
|
2699
3022
|
}
|
|
2700
3023
|
|
|
2701
|
-
/**
|
|
2702
|
-
|
|
3024
|
+
/**
|
|
3025
|
+
* 链式 timeout 调度:每次触发后按锚点推算下一次,重启后节奏不重置。
|
|
3026
|
+
* #103 两条断路,缺一不可:
|
|
3027
|
+
* 1. boot 宽限——启动路径的首次触发绝不落在宿主 boot 窗口内(delay≤0 的补跑
|
|
3028
|
+
* 正是把 GB 级 tar 塞进 boot 窗口、拖垮 HostRpc 的元凶);
|
|
3029
|
+
* 2. 尝试冷却——上次**起跑**(不要求成功)之后 30 分钟(失败按 2^n 退避)内
|
|
3030
|
+
* 不再起跑。锚点落盘,所以"干到一半被强杀 → 下次启动又立刻重跑"的循环断开。
|
|
3031
|
+
*/
|
|
3032
|
+
function scheduleAuto(opts = {}) {
|
|
2703
3033
|
// 关闭竞态保护:auto off 时 in-flight 备份完成后的续链会以 autoHours=0
|
|
2704
3034
|
// 算出 delay=0,形成无限备份循环——关闭后绝不续链。
|
|
2705
3035
|
if (autoHours <= 0) return;
|
|
2706
|
-
const
|
|
3036
|
+
const cycle = autoHours * 3600 * 1000;
|
|
3037
|
+
let delay = nextAutoAt() - Date.now();
|
|
3038
|
+
const attemptAt = Number(new Date(lastAutoAttemptAt)) || 0;
|
|
3039
|
+
const cooldown = autoFailStreak > 0
|
|
3040
|
+
? Math.min(AUTO_RETRY_MS * 2 ** (autoFailStreak - 1), cycle)
|
|
3041
|
+
: AUTO_RETRY_MS;
|
|
3042
|
+
if (attemptAt) delay = Math.max(delay, attemptAt + cooldown - Date.now());
|
|
3043
|
+
if (opts.boot) delay = Math.max(delay, AUTO_BOOT_GRACE_MS);
|
|
2707
3044
|
autoDispose = ctx.timeout(async () => {
|
|
2708
|
-
|
|
3045
|
+
// 无人值守重活串行:与列车快照等其它无人值守任务排队,不并行抢机器
|
|
3046
|
+
await enqueueHeavy(runAutoBackup);
|
|
2709
3047
|
scheduleAuto();
|
|
2710
3048
|
}, delay);
|
|
2711
3049
|
}
|
|
2712
3050
|
|
|
2713
3051
|
async function runAutoBackup() {
|
|
3052
|
+
// 起跑留痕(先写盘再干活):进程被强杀也不丢"刚试过"这个事实,
|
|
3053
|
+
// 下次启动据此冷却,不会在 boot 窗口里重跑(#103)。
|
|
3054
|
+
lastAutoAttemptAt = new Date().toISOString();
|
|
3055
|
+
await saveAutoState().catch(() => { /* 记录失败不阻断备份本身 */ });
|
|
2714
3056
|
try {
|
|
2715
3057
|
const r = await doBackup(autoKeep());
|
|
2716
3058
|
lastAuto = r.path.split('/').pop();
|
|
2717
3059
|
lastAutoAt = new Date().toISOString();
|
|
3060
|
+
lastAutoError = null;
|
|
3061
|
+
autoFailStreak = 0;
|
|
3062
|
+
newestArchiveAt = Date.now();
|
|
2718
3063
|
await saveAutoState();
|
|
2719
3064
|
console.log(`[dsh-backup] 自动备份完成: ${r.path} (sha ${r.sha.slice(0, 12)}…)`);
|
|
2720
3065
|
} catch (err) {
|
|
2721
|
-
//
|
|
2722
|
-
//
|
|
2723
|
-
|
|
2724
|
-
|
|
3066
|
+
// 失败不再"只推进内存锚点"(旧实现不落盘 → 每次启动 delay≤0 重跑,
|
|
3067
|
+
// 3GB 归档必然失败时就是无限重跑;#103)。改记失败原因 + 退避重试,
|
|
3068
|
+
// 由 lastAutoAttemptAt(已落盘)负责跨重启冷却。
|
|
3069
|
+
autoFailStreak += 1;
|
|
3070
|
+
const message = String(err && err.message ? err.message : err);
|
|
3071
|
+
lastAutoError = { at: new Date().toISOString(), message };
|
|
3072
|
+
await saveAutoState().catch(() => { /* 失败路径写盘也可能失败,不影响内存退避 */ });
|
|
3073
|
+
console.error(`[dsh-backup] 自动备份失败(第 ${autoFailStreak} 次,${Math.round(Math.min(AUTO_RETRY_MS * 2 ** (autoFailStreak - 1), autoHours * 3600 * 1000) / 60000)} 分钟后重试): ${message}`);
|
|
2725
3074
|
}
|
|
2726
3075
|
}
|
|
2727
3076
|
|
|
2728
3077
|
async function setAuto(h) {
|
|
2729
3078
|
if (autoDispose) { autoDispose(); autoDispose = null; }
|
|
2730
3079
|
autoHours = h;
|
|
3080
|
+
// 开启/重设 = 用户要一次基线备份:清掉旧锚点与失败退避,让下次触发落在"现在"
|
|
2731
3081
|
lastAutoAt = null;
|
|
3082
|
+
lastAutoAttemptAt = null;
|
|
3083
|
+
newestArchiveAt = 0;
|
|
3084
|
+
autoFailStreak = 0;
|
|
3085
|
+
lastAutoError = null;
|
|
2732
3086
|
if (h > 0) scheduleAuto();
|
|
2733
3087
|
await saveAutoState();
|
|
2734
3088
|
}
|
|
@@ -2918,9 +3272,17 @@ export function apply(ctx, pluginConfig) {
|
|
|
2918
3272
|
const typesMatch = input.match(/--(?:types|only)\s+([\w,]+)/);
|
|
2919
3273
|
const types = typesMatch ? [...new Set(typesMatch[1].split(',').map((s) => s.trim()).filter(Boolean))] : undefined;
|
|
2920
3274
|
const r = await doBackup(keep, invocation.signal, types ? { types } : {});
|
|
3275
|
+
// 增长(每周增长仪表盘配套):首次手动备份成功后给一次 star 提示,此后永不再现;
|
|
3276
|
+
// 自动备份的日志不加(噪音),面板有常驻入口。
|
|
3277
|
+
let starNote = '';
|
|
3278
|
+
if (!starHinted) {
|
|
3279
|
+
starNote = `\n\n💡 觉得好用的话,给个 Star 是对我们最大的支持: ${STAR_URL}`;
|
|
3280
|
+
starHinted = true;
|
|
3281
|
+
await saveAutoState().catch(() => { /* 记录失败不影响回执内容 */ });
|
|
3282
|
+
}
|
|
2921
3283
|
return {
|
|
2922
3284
|
kind: 'success',
|
|
2923
|
-
text: `✅ 备份完成${types ? `(类型: ${types.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\n 文件: ${r.path}\n 校验和: ${r.sha.slice(0, 16)}…\n 轮换: 删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)${quarantineNote(r)}\n ${autoSummary()}`,
|
|
3285
|
+
text: `✅ 备份完成${types ? `(类型: ${types.join(', ')}${r.hasCredentials ? ',含明文凭据·不进 GitHub 同步' : ''})` : ''}\n 文件: ${r.path}\n 校验和: ${r.sha.slice(0, 16)}…\n 轮换: 删除 ${r.stale} 份旧备份(保留 ${r.keep} 份)${quarantineNote(r)}\n ${autoSummary()}${starNote}`,
|
|
2924
3286
|
};
|
|
2925
3287
|
} catch (err) {
|
|
2926
3288
|
return { kind: 'error', text: `备份失败: ${friendlyReason(err)}` };
|
|
@@ -2931,7 +3293,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
2931
3293
|
// ---------- backup_dsh 模型工具 ----------
|
|
2932
3294
|
ctx.tools.register(defineTool({
|
|
2933
3295
|
name: 'backup_dsh',
|
|
2934
|
-
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 归档定点修复;mode=migrate
|
|
3296
|
+
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 归档定点修复;mode=migrate 迁移预检——静态扫描全部代会话日志(v0 起各代),预测宿主升级后哪些会话会打不开、挂在哪条规则,并探测文件系统硬链接支持与凭据文件旧布局',
|
|
2935
3297
|
parameters: {
|
|
2936
3298
|
mode: { type: 'string', required: true, enum: ['backup', 'list', 'verify', 'restore', 'auto', 'doctor', 'migrate'], description: 'backup=执行备份,list=列出备份,verify=校验完整性,restore=恢复,auto=定时备份,doctor=会话日志体检,migrate=升级迁移预检' },
|
|
2937
3299
|
keep: { type: 'number', description: '保留的备份份数(mode=backup,默认 7)' },
|
|
@@ -2996,8 +3358,11 @@ export function apply(ctx, pluginConfig) {
|
|
|
2996
3358
|
failCount: r.failCount,
|
|
2997
3359
|
migratableCount: r.migratableCount,
|
|
2998
3360
|
skippedCount: r.skippedCount,
|
|
3361
|
+
warningCount: r.warningCount ?? 0,
|
|
2999
3362
|
summary: r.summary,
|
|
3000
|
-
|
|
3363
|
+
// fail 项照旧全带;带提示(不判打不开)的条目也带上 warnings——否则
|
|
3364
|
+
// UI 只能看到"没问题",用户无从知道有哪些开放集合上的命中失败(#113)。
|
|
3365
|
+
sessions: r.sessions.filter((s) => s.verdict === 'fail' || s.warnings?.length).map((s) => ({ path: s.rel, version: s.version, findings: s.findings, ...(s.warnings?.length ? { warnings: s.warnings } : {}) })),
|
|
3001
3366
|
env: r.env,
|
|
3002
3367
|
};
|
|
3003
3368
|
}
|
|
@@ -3306,8 +3671,22 @@ export function apply(ctx, pluginConfig) {
|
|
|
3306
3671
|
sendJson(res, 503, { error: 'settings-unavailable' });
|
|
3307
3672
|
return;
|
|
3308
3673
|
}
|
|
3674
|
+
// 0.1.7 起宿主换了 settings 模型:插件的 register(ns, schema) 被移除,
|
|
3675
|
+
// 描述符改由「profile 条目自带的 Config schema」派生——我们自注册的
|
|
3676
|
+
// 命名空间因此不在 describe() 里(0.1.6-alpha.1 及更早仍是旧模型)。
|
|
3677
|
+
// 旧实现在这里直接抛 TypeError(读 undefined.value),面板只看到一句
|
|
3678
|
+
// 原始报错、编辑还静默失败。改为显式降级:如实说明并指向配置出路。
|
|
3679
|
+
const desc = describeOwn(settingsService, NS);
|
|
3680
|
+
if (!desc) {
|
|
3681
|
+
sendJson(res, 200, {
|
|
3682
|
+
unavailable: true,
|
|
3683
|
+
reason: 'settings-namespace-missing',
|
|
3684
|
+
note: '此宿主版本的设置面板由宿主自带的配置表单接管(0.1.7 起插件自注册的设置命名空间不再生效);本插件的选项请写在 profile 的 cordis.yml / cordis.patch.yml 的插件条目 config 下。',
|
|
3685
|
+
});
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
3309
3688
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
3310
|
-
sendJson(res, 200, describeResponse(
|
|
3689
|
+
sendJson(res, 200, describeResponse(desc));
|
|
3311
3690
|
return;
|
|
3312
3691
|
}
|
|
3313
3692
|
if (req.method !== 'POST') {
|
|
@@ -3340,7 +3719,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
3340
3719
|
}
|
|
3341
3720
|
throw err;
|
|
3342
3721
|
}
|
|
3343
|
-
sendJson(res, 200, describeResponse(describeOwn(settingsService, NS)));
|
|
3722
|
+
sendJson(res, 200, describeResponse(describeOwn(settingsService, NS) ?? desc));
|
|
3344
3723
|
} catch (err) {
|
|
3345
3724
|
sendJson(res, 400, { error: String(err && err.message ? err.message : err) });
|
|
3346
3725
|
}
|
|
@@ -3348,7 +3727,8 @@ export function apply(ctx, pluginConfig) {
|
|
|
3348
3727
|
}), 'dsh-backup: settings route');
|
|
3349
3728
|
});
|
|
3350
3729
|
|
|
3351
|
-
//
|
|
3730
|
+
// 启动时恢复持久化的定时备份计划(不阻塞插件装配);错过则在**启动宽限期之后**
|
|
3731
|
+
// 补跑——绝不在宿主 boot 窗口内启动 GB 级 tar(#103)。
|
|
3352
3732
|
// 同处做升级前快照:宿主列车变化(升/降级)先拍一份 dsh-pre-upgrade- 安全网
|
|
3353
3733
|
// 再继续——命中社区"想试新版怕搞坏"的最大恐惧。首见列车只记录不拍。
|
|
3354
3734
|
void (async () => {
|
|
@@ -3357,7 +3737,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
3357
3737
|
const h = await loadAutoState();
|
|
3358
3738
|
if (h > 0 && !autoDispose) {
|
|
3359
3739
|
autoHours = h;
|
|
3360
|
-
scheduleAuto();
|
|
3740
|
+
scheduleAuto({ boot: true });
|
|
3361
3741
|
}
|
|
3362
3742
|
// 凭据哨兵:宿主首次读档会把扁平布局的 .credentials.yaml 原子替换成
|
|
3363
3743
|
// version:1(不可逆——旧宿主读不回 versioned,官方 #6358)。启动即检测,
|
|
@@ -3379,14 +3759,21 @@ export function apply(ctx, pluginConfig) {
|
|
|
3379
3759
|
}
|
|
3380
3760
|
if (lastTrain === train) return;
|
|
3381
3761
|
const oldTrain = lastTrain;
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3762
|
+
// 列车快照同样是 GB 级重活:与自动补跑一样推迟到 boot 窗口之外,并走同一条
|
|
3763
|
+
// 无人值守队列(两者同时到点时串行而非并行)。失败时不更新 lastTrain,
|
|
3764
|
+
// 下次启动重试——语义与旧版一致。
|
|
3765
|
+
ctx.timeout(() => {
|
|
3766
|
+
void enqueueHeavy(async () => {
|
|
3767
|
+
try {
|
|
3768
|
+
const snap = await doBackup(undefined, undefined, { namePrefix: 'dsh-pre-upgrade-' });
|
|
3769
|
+
await prunePrefixedSnapshots('dsh-pre-upgrade-', 2, snap.name);
|
|
3770
|
+
lastTrain = train;
|
|
3771
|
+
await saveAutoState();
|
|
3772
|
+
console.log(`[dsh-backup] 宿主列车变化(${oldTrain} → ${train}),已自动拍升级前快照: ${snap.name}${snap.quarantined?.length ? `(${snap.quarantined.length} 个损坏会话文件未入档)` : ''}`);
|
|
3773
|
+
} catch (err) {
|
|
3774
|
+
console.warn(`[dsh-backup] 升级前快照失败(不影响运行,下次启动会重试): ${String(err && err.message ? err.message : err)}`);
|
|
3775
|
+
}
|
|
3776
|
+
});
|
|
3777
|
+
}, AUTO_BOOT_GRACE_MS);
|
|
3391
3778
|
})();
|
|
3392
3779
|
}
|