dsh-config-manager 0.1.19 → 0.1.21

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.
Files changed (82) hide show
  1. package/lib/client.d.ts +319 -2
  2. package/lib/client.js +1271 -109
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/run-registry.d.ts +2 -2
  5. package/lib/core/run-registry.js +5 -1
  6. package/lib/core/run-registry.js.map +1 -1
  7. package/lib/index.js +408 -2
  8. package/lib/index.js.map +1 -1
  9. package/lib/sync/ancestor.d.ts +17 -0
  10. package/lib/sync/ancestor.js +64 -0
  11. package/lib/sync/ancestor.js.map +1 -0
  12. package/lib/sync/autosync-config.d.ts +34 -0
  13. package/lib/sync/autosync-config.js +116 -0
  14. package/lib/sync/autosync-config.js.map +1 -0
  15. package/lib/sync/autosync-scheduler.d.ts +95 -0
  16. package/lib/sync/autosync-scheduler.js +379 -0
  17. package/lib/sync/autosync-scheduler.js.map +1 -0
  18. package/lib/sync/merge.d.ts +42 -0
  19. package/lib/sync/merge.js +325 -0
  20. package/lib/sync/merge.js.map +1 -0
  21. package/lib/sync/review-queue.d.ts +51 -0
  22. package/lib/sync/review-queue.js +119 -0
  23. package/lib/sync/review-queue.js.map +1 -0
  24. package/lib/sync/risk.d.ts +59 -0
  25. package/lib/sync/risk.js +76 -0
  26. package/lib/sync/risk.js.map +1 -0
  27. package/lib/sync/sync-config.d.ts +7 -2
  28. package/lib/sync/sync-config.js +24 -4
  29. package/lib/sync/sync-config.js.map +1 -1
  30. package/lib/sync/sync-engine.d.ts +88 -2
  31. package/lib/sync/sync-engine.js +333 -8
  32. package/lib/sync/sync-engine.js.map +1 -1
  33. package/lib/sync/sync-history.d.ts +39 -0
  34. package/lib/sync/sync-history.js +88 -0
  35. package/lib/sync/sync-history.js.map +1 -0
  36. package/lib/sync/sync-session.d.ts +39 -0
  37. package/lib/sync/sync-session.js +54 -0
  38. package/lib/sync/sync-session.js.map +1 -0
  39. package/lib/sync/sync-state.d.ts +7 -2
  40. package/lib/sync/sync-state.js +9 -5
  41. package/lib/sync/sync-state.js.map +1 -1
  42. package/lib/ui/i18n.d.ts +63 -0
  43. package/lib/ui/i18n.js +130 -1
  44. package/lib/ui/i18n.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/client/config-manager.module.css +18 -0
  47. package/src/client/sync/SyncConfirmView.tsx +301 -0
  48. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  49. package/src/client/sync/SyncHistoryView.tsx +155 -0
  50. package/src/client/sync/SyncSettingsView.tsx +241 -12
  51. package/src/client/sync/history-model.test.ts +82 -0
  52. package/src/client/sync/history-model.ts +112 -0
  53. package/src/client/sync/sync-api.test.ts +155 -0
  54. package/src/client/sync/sync-api.ts +219 -0
  55. package/src/client/sync/sync-locales.ts +162 -1
  56. package/src/client/sync/sync-view-v2.test.ts +131 -0
  57. package/src/client/sync/sync-view.ts +151 -4
  58. package/src/core/run-registry.ts +7 -3
  59. package/src/index.ts +420 -3
  60. package/src/sync/ancestor.test.ts +152 -0
  61. package/src/sync/ancestor.ts +81 -0
  62. package/src/sync/autosync-config.test.ts +93 -0
  63. package/src/sync/autosync-config.ts +137 -0
  64. package/src/sync/autosync-scheduler.test.ts +191 -0
  65. package/src/sync/autosync-scheduler.ts +443 -0
  66. package/src/sync/merge.test.ts +204 -0
  67. package/src/sync/merge.ts +360 -0
  68. package/src/sync/review-queue.test.ts +120 -0
  69. package/src/sync/review-queue.ts +167 -0
  70. package/src/sync/risk.test.ts +131 -0
  71. package/src/sync/risk.ts +122 -0
  72. package/src/sync/sync-config.test.ts +106 -0
  73. package/src/sync/sync-config.ts +23 -4
  74. package/src/sync/sync-engine.test.ts +392 -3
  75. package/src/sync/sync-engine.ts +383 -10
  76. package/src/sync/sync-history.test.ts +85 -0
  77. package/src/sync/sync-history.ts +126 -0
  78. package/src/sync/sync-session.test.ts +137 -0
  79. package/src/sync/sync-session.ts +76 -0
  80. package/src/sync/sync-state.test.ts +48 -2
  81. package/src/sync/sync-state.ts +11 -5
  82. package/src/ui/i18n.ts +132 -3
@@ -23,7 +23,7 @@ import { defaultSecretScanner } from '../core/exporter.ts';
23
23
  import { Importer } from '../core/importer.ts';
24
24
  import type {
25
25
  ConfigAdapter, ExportSection, GlobalConflictStrategy, HostContext,
26
- PlanItem, PlanItemKind, SecretScanner,
26
+ ImportAnalysis, ImportPlan, ImportResult, PlanItem, PlanItemKind, SecretScanner,
27
27
  } from '../core/types.ts';
28
28
  import { isFileSection, SECTION_FILE_PREFIXES, SECTION_JSON_PATHS } from '../schema/config.ts';
29
29
  import { buildManifest, CHECKSUMS_FILE, MANIFEST_FILE } from '../schema/manifest.ts';
@@ -37,9 +37,18 @@ import { createSnapshotFs, joinFs } from './fs.ts';
37
37
  import type { SnapshotFs } from './fs.ts';
38
38
  import { writeSnapshotToDir } from './layout.ts';
39
39
  import { hashSection, loadSyncState, saveSyncState } from './sync-state.ts';
40
- import type { SyncSnapshot, SyncTransport } from './transport.ts';
40
+ import type { SyncSnapshot, SyncSnapshotMeta, SyncTransport } from './transport.ts';
41
41
  import { msgOf, zhMsg } from '../core/messages.ts';
42
42
  import type { MsgFunc } from '../core/messages.ts';
43
+ import { DEFAULT_ANCESTOR_KEEP, loadAncestor, pruneAncestors, writeAncestor } from './ancestor.ts';
44
+ import type { MergePlan, MergeSectionResult } from './merge.ts';
45
+ import { merge as mergeSections } from './merge.ts';
46
+ import type { SyncApplyPlan } from './risk.ts';
47
+ import { createSnapshot } from '../core/backup.ts';
48
+ import { rollback } from '../core/rollback.ts';
49
+ import { FileSnapshotStore } from '../core/backup.ts';
50
+ import type { Snapshot, SnapshotStore } from '../core/types.ts';
51
+ import type { PlanItemProgress } from '../core/analyzer.ts';
43
52
 
44
53
  export interface SyncEngineOptions {
45
54
  ctx: HostContext;
@@ -107,6 +116,50 @@ export interface SyncPullReport {
107
116
  message?: string;
108
117
  }
109
118
 
119
+ /** 一键同步预览结果(preview() 返回;临时 ZIP 由调用方持有并负责清理)。 */
120
+ export interface SyncPreviewResult {
121
+ ok: boolean;
122
+ /** 临时标准 ZIP 路径(apply-items 复用 executeImportPlan 需要;调用方清理) */
123
+ zipPath: string;
124
+ plan: ImportPlan | null;
125
+ analysis: ImportAnalysis | null;
126
+ snapshotId: string;
127
+ message?: string;
128
+ }
129
+
130
+ /** 自动应用执行器(P2b)报告 */
131
+ export interface ApplyReport {
132
+ ok: boolean;
133
+ /** 实际写入本地的分区 id 列表 */
134
+ applied: string[];
135
+ /** 应用前快照 id(UI 可借此一键回滚);失败时仍透传以便排查 */
136
+ restoreId: string;
137
+ /** 是否触发了整体回滚 */
138
+ rolledBack: boolean;
139
+ /** 失败时移到 review 队列的项(ReviewQueueItem 形态供 UI 直接渲染) */
140
+ review: import('./review-queue.ts').ReviewQueueItem[];
141
+ /** 来自 Importer.ImportResult 的 warnings;UI 可用于红条提示 */
142
+ warnings: string[];
143
+ }
144
+
145
+ /** applyItems 报告(§3.4 ApplyItemsResponse 的服务端形态) */
146
+ export interface ApplyItemsReport {
147
+ ok: boolean;
148
+ /** 实际写入的分区 id 列表(去重) */
149
+ applied: string[];
150
+ /** 未采纳的 itemId 列表 */
151
+ skipped?: string[];
152
+ /** 应用前快照 id(UI 一键回滚用;失败时仍透传以便排查) */
153
+ restoreId: string;
154
+ /** 任一失败是否整体回滚 */
155
+ rolledBack: boolean;
156
+ warnings: string[];
157
+ failed: { itemId: string; message?: string }[];
158
+ /** 透传 executeImportPlan 结果 */
159
+ result: ImportResult | null;
160
+ needsRestart?: boolean;
161
+ }
162
+
110
163
  /** 同步通道结构性排除的敏感分区(即使 portable 判定有误也双保险拒绝;
111
164
  * 注:'credentials' 不是合法 SectionId——凭据状态分区为 credentialsStatus) */
112
165
  const FORBIDDEN_SECTIONS: readonly SectionId[] = ['credentialsStatus', 'secrets'];
@@ -216,14 +269,8 @@ export class SyncEngine {
216
269
  }
217
270
  // ② 上传远端(传输通道负责散文件落盘 + 提交推送)
218
271
  await this.transport.upload(snapshot);
219
- // ③ 更新 sync-state:每分区 hash + updatedAt;lastSyncAttransport 绑定
220
- const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
221
- for (const [sid, data] of Object.entries(sections)) {
222
- state.sections[sid as SectionId] = { hash: hashSection(data as SectionData), updatedAt: nowIso };
223
- }
224
- state.lastSyncAt = nowIso;
225
- state.transport = { type: this.transport.type, ref: this.transportRef };
226
- await saveSyncState(this.stateDir, state, this.fsx);
272
+ // ③ 记录祖先基线:写 sync-state(lastSnapshotId + 每分区 hash/updatedAt + lastSyncAt + transport)+ 裁剪
273
+ await this.recordBaseline(id, snapshot.sections, nowIso);
227
274
 
228
275
  return { ok: true, snapshotId: id, sections: Object.keys(sections) as SectionId[], warnings };
229
276
  }
@@ -277,6 +324,332 @@ export class SyncEngine {
277
324
  }
278
325
  }
279
326
 
327
+ /** 列出远端已有快照(按 createdAt 升序)—— 供「选择历史快照」下拉。 */
328
+ async listSnapshots(): Promise<SyncSnapshotMeta[]> {
329
+ return this.transport.list();
330
+ }
331
+
332
+ /**
333
+ * 一键同步预览:拉取远端(最新或指定历史快照)→ 转临时 ZIP → Importer 分析出计划。
334
+ * 与 pull 的区别:临时 ZIP **不清理**(由调用方 / 会话持有,供 apply-items 复用),
335
+ * 并返回完整 plan/analysis/snapshotId 供会话登记。
336
+ * 调用方负责在会话消费或取消后清理 zipPath 所在目录。
337
+ */
338
+ async preview(opts: SyncPullOptions = {}): Promise<SyncPreviewResult> {
339
+ if (!this.importer) {
340
+ throw new Error(this.msg('sync.missingImporter'));
341
+ }
342
+ const metas = await this.transport.list();
343
+ if (metas.length === 0) {
344
+ return { ok: false, zipPath: '', plan: null, analysis: null, snapshotId: '', message: this.msg('sync.remoteEmpty') };
345
+ }
346
+ const targetId = opts.snapshotId ?? metas[metas.length - 1]!.id;
347
+ const snapshot = await this.transport.download(targetId);
348
+ if (snapshot.manifest.containsSecrets) {
349
+ throw new Error(this.msg('sync.remoteContainsSecrets', { id: targetId }));
350
+ }
351
+ const portableIds = new Set(this.portableAdapters().map((a) => a.id));
352
+ const zipPath = await this.snapshotToZip(snapshot, portableIds);
353
+ const analysis = await this.importer.analyzeImport(zipPath);
354
+ const plan = await this.importer.createImportPlan(zipPath, {
355
+ strategy: opts.strategy ?? 'merge',
356
+ resolutions: {},
357
+ pathMappings: [],
358
+ });
359
+ return { ok: analysis.valid, zipPath, plan, analysis, snapshotId: targetId, message: undefined };
360
+ }
361
+
362
+ async merge(opts: { snapshotId?: string } = {}): Promise<MergePlan> {
363
+ const metas = await this.transport.list();
364
+ if (metas.length === 0) {
365
+ return { sections: [] };
366
+ }
367
+ const targetId = opts.snapshotId ?? metas[metas.length - 1]!.id;
368
+ const remote = await this.transport.download(targetId);
369
+ if (remote.manifest.containsSecrets) {
370
+ throw new Error(`远端快照 ${targetId} 声明 containsSecrets=true,拒绝合并(同步通道永不携带秘密)`);
371
+ }
372
+ // 共同祖先:从 sync-state.lastSnapshotId 读本地副本;空 = 首次/无祖先
373
+ const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
374
+ let ancestor: SyncSnapshot | undefined;
375
+ if (state.lastSnapshotId !== '' && this.localSnapshotsDir !== undefined) {
376
+ ancestor = await loadAncestor(this.localSnapshotsDir, state.lastSnapshotId, this.fsx);
377
+ }
378
+ // 本地当前:现场 export(含 s​e​c​r​e​t 剥离),与 push 同口径
379
+ const localSections: Partial<Record<SectionId, SectionData>> = {};
380
+ for (const adapter of this.portableAdapters()) {
381
+ let section: ExportSection;
382
+ try {
383
+ section = await adapter.export(this.ctx, { includeSecrets: false });
384
+ } catch {
385
+ continue;
386
+ }
387
+ const data = isFileSection(adapter.id) ? section.data : this.scanner.scanAndRedact(section.data).sanitized;
388
+ localSections[adapter.id] = data as SectionData;
389
+ }
390
+ const portableIds = new Set(this.portableAdapters().map((a) => a.id));
391
+ const remotePortable: Partial<Record<SectionId, SectionData>> = {};
392
+ for (const [id, data] of Object.entries(remote.sections)) {
393
+ if (portableIds.has(id as SectionId)) remotePortable[id as SectionId] = data as SectionData;
394
+ }
395
+ const ancestorPortable: Partial<Record<SectionId, SectionData>> = {};
396
+ if (ancestor) {
397
+ for (const [id, data] of Object.entries(ancestor.sections)) {
398
+ if (portableIds.has(id as SectionId)) ancestorPortable[id as SectionId] = data as SectionData;
399
+ }
400
+ }
401
+ return mergeSections(localSections, remotePortable, ancestorPortable);
402
+ }
403
+
404
+ /**
405
+ * 记录祖先基线:写本地祖先副本 + 更新 sync-state(lastSnapshotId、每分区 hash/updatedAt、lastSyncAt、transport)+ 裁剪到 keep。
406
+ * 通常由 push() 在上传成功后调用,也可被上层(合并 apply 完成后)显式调用以更新基线到合并后的快照。
407
+ */
408
+ async recordBaseline(
409
+ snapshotId: string,
410
+ sections: SyncSnapshot['sections'],
411
+ nowIso?: string,
412
+ ): Promise<void> {
413
+ const ts = nowIso ?? this.now().toISOString();
414
+ // 1) 写本地祖先副本(如有 localSnapshotsDir)
415
+ if (this.localSnapshotsDir !== undefined) {
416
+ const snapshot: SyncSnapshot = {
417
+ id: snapshotId,
418
+ createdAt: ts,
419
+ manifest: {
420
+ schemaVersion: CURRENT_SCHEMA_VERSION,
421
+ dshVersion: this.ctx.dshVersion,
422
+ platform: this.ctx.platform as Platform,
423
+ sectionIds: Object.keys(sections) as SectionId[],
424
+ containsSecrets: false,
425
+ },
426
+ sections,
427
+ };
428
+ await writeAncestor(this.localSnapshotsDir, snapshot, this.fsx);
429
+ }
430
+ // 2) 更新 sync-state:lastSnapshotId + 每分区 hash/updatedAt + lastSyncAt + transport
431
+ const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
432
+ state.lastSnapshotId = snapshotId;
433
+ state.lastSyncAt = ts;
434
+ state.transport = { type: this.transport.type, ref: this.transportRef };
435
+ for (const [sid, data] of Object.entries(sections)) {
436
+ state.sections[sid as SectionId] = { hash: hashSection(data as SectionData), updatedAt: ts };
437
+ }
438
+ await saveSyncState(this.stateDir, state, this.fsx);
439
+ // 3) 裁剪祖先副本(最近 N 个)
440
+ if (this.localSnapshotsDir !== undefined) {
441
+ await pruneAncestors(this.localSnapshotsDir, DEFAULT_ANCESTOR_KEEP, this.fsx);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * 应用自动应用计划:写本地(走 Importer.executeImportPlan 标准路径;应用前调 backup.createSnapshot 兜底);
447
+ * 任一 auto 项失败 → 整体 rollback;成功后 recordBaseline 更新祖先基线。
448
+ * 返回 ApplyReport;不抛错到调用方(失败返回 ok:false + rolledBack:true)。
449
+ * 不再写 review-queue(§2.3/§7.4:待审语义改由同步历史 skipped 标记表达)。
450
+ */
451
+ async applyMergePlan(apply: SyncApplyPlan): Promise<ApplyReport> {
452
+ if (!this.importer) {
453
+ throw new Error('applyMergePlan: SyncEngine 缺少 importer(需在 options 中注​入)');
454
+ }
455
+ const appliedIds = apply.autoApply.map((r) => r.id);
456
+ // 0) 空 autoApply:无物可应用,直接短路(不构造 ZIP、不调 Importer)
457
+ if (appliedIds.length === 0) {
458
+ return { ok: true, applied: [], restoreId: '', rolledBack: false, review: [], warnings: [] };
459
+ }
460
+ // 1) 构造临时 ZIP(仅含 autoApply 项的 merged payload)+ 分析 + 计划
461
+ const portableIds = new Set(this.portableAdapters().map((a) => a.id));
462
+ const tempSnapshot: SyncSnapshot = {
463
+ id: this.snapshotIdFn(),
464
+ createdAt: this.now().toISOString(),
465
+ manifest: {
466
+ schemaVersion: CURRENT_SCHEMA_VERSION,
467
+ dshVersion: this.ctx.dshVersion,
468
+ platform: this.ctx.platform as Platform,
469
+ sectionIds: apply.autoApply.map((r) => r.id) as SectionId[],
470
+ containsSecrets: false,
471
+ },
472
+ sections: apply.autoApply.reduce<Record<string, SectionData>>((acc, r) => {
473
+ if (r.merged !== undefined) acc[r.id] = r.merged as SectionData;
474
+ return acc;
475
+ }, {}),
476
+ };
477
+ const zipPath = await this.snapshotToZip(tempSnapshot, portableIds);
478
+ try {
479
+ const analysis = await this.importer.analyzeImport(zipPath);
480
+ const plan = await this.importer.createImportPlan(zipPath, {
481
+ strategy: 'replace', // auto 路径:冲突已在外部分流;这里 replace = 接受导入值
482
+ resolutions: {},
483
+ pathMappings: [],
484
+ });
485
+ if (!plan.items.length) {
486
+ // 没有可执行项 → 视为 ok 但空 applied
487
+ return { ok: true, applied: [], restoreId: '', rolledBack: false, review: [], warnings: [] };
488
+ }
489
+ // 2) 兜底:先建快照(拿到 restoreId 给 UI 一键回滚用)
490
+ const store: SnapshotStore = new FileSnapshotStore({ dir: this.stateDir });
491
+ let snapshot: Snapshot | undefined;
492
+ try {
493
+ snapshot = await createSnapshot({
494
+ ctx: this.ctx,
495
+ plan,
496
+ sourceZip: zipPath,
497
+ store,
498
+ adapters: this.adapters,
499
+ });
500
+ } catch (backupErr) {
501
+ return {
502
+ ok: false,
503
+ applied: [],
504
+ restoreId: '',
505
+ rolledBack: false,
506
+ review: [],
507
+ warnings: [`应用前快照失败:${backupErr instanceof Error ? backupErr.message : String(backupErr)}`],
508
+ };
509
+ }
510
+ // 3) 真正执行:Importer.executeImportPlan(rollbackOnError=true → 任一失败整体回滚)
511
+ const result = await this.importer.executeImportPlan(zipPath, plan, {
512
+ confirm: true,
513
+ rollbackOnError: true,
514
+ secretInputs: undefined,
515
+ decryptedCredentials: undefined,
516
+ });
517
+ if (!result.ok) {
518
+ try { await rollback({ ctx: this.ctx, snapshot, store, adapters: this.adapters }); } catch { /* noop */ }
519
+ // 失败路径不再写 review-queue(§7.4):历史 skipped/failed 标记由上层(路由/调度器)写入。
520
+ return {
521
+ ok: false,
522
+ applied: [],
523
+ restoreId: snapshot.id,
524
+ rolledBack: true,
525
+ review: [],
526
+ warnings: result.warnings ?? [],
527
+ };
528
+ }
529
+ // 5) 全成功 → recordBaseline(更新祖先基线指向合并后的快照)
530
+ const mergedSections: SyncSnapshot['sections'] = apply.autoApply.reduce<Record<string, SectionData>>((acc, r) => {
531
+ if (r.merged !== undefined) acc[r.id] = r.merged as SectionData;
532
+ return acc;
533
+ }, {});
534
+ await this.recordBaseline(tempSnapshot.id, mergedSections);
535
+ return {
536
+ ok: true,
537
+ applied: appliedIds,
538
+ restoreId: snapshot.id,
539
+ rolledBack: false,
540
+ review: [],
541
+ warnings: result.warnings ?? [],
542
+ };
543
+ } finally {
544
+ try { await fs.rm(path.dirname(zipPath), { recursive: true, force: true }); } catch { /* noop */ }
545
+ }
546
+ }
547
+
548
+ /**
549
+ * applyItems:按用户对差异项的逐项决策执行导入(§3.4/§5.3)。
550
+ *
551
+ * 与 applyMergePlan 的区别:applyItems 接收「会话级临时 ZIP + 子计划」,
552
+ * 直接执行(backup.createSnapshot 兜底 → importer.executeImportPlan →
553
+ * 成功 recordBaseline / 失败 rollback),不构造中间 SyncApplyPlan。
554
+ *
555
+ * @param zipPath 会话级临时标准 ZIP(由 sync/sync 生成,包含采纳项的 merged payload)
556
+ * @param subPlan 子计划(仅含采纳项的 ImportPlan;globalStrategy/pathMappings/needsRestart 沿用会话 plan)
557
+ * @param opts 执行选项(onItem 进度回调)
558
+ * @returns ApplyItemsReport(ok/applied/restoreId/rolledBack/warnings/result)
559
+ */
560
+ async applyItems(
561
+ zipPath: string,
562
+ subPlan: ImportPlan,
563
+ opts: { onItem?: (info: PlanItemProgress) => void } = {},
564
+ ): Promise<ApplyItemsReport> {
565
+ if (!this.importer) {
566
+ throw new Error('applyItems: SyncEngine 缺少 importer(需在 options 中注​入)');
567
+ }
568
+ if (subPlan.items.length === 0) {
569
+ return { ok: true, applied: [], restoreId: '', rolledBack: false, warnings: [], failed: [], result: null };
570
+ }
571
+
572
+ // 兜底快照(拿到 restoreId 给 UI 一键回滚用)
573
+ const store: SnapshotStore = new FileSnapshotStore({ dir: this.stateDir });
574
+ let snapshot: Snapshot | undefined;
575
+ try {
576
+ snapshot = await createSnapshot({
577
+ ctx: this.ctx,
578
+ plan: subPlan,
579
+ sourceZip: zipPath,
580
+ store,
581
+ adapters: this.adapters,
582
+ });
583
+ } catch (backupErr) {
584
+ return {
585
+ ok: false,
586
+ applied: [],
587
+ restoreId: '',
588
+ rolledBack: false,
589
+ warnings: [`应用前快照失败:${backupErr instanceof Error ? backupErr.message : String(backupErr)}`],
590
+ failed: subPlan.items.map((i) => ({ itemId: i.id })),
591
+ result: null,
592
+ };
593
+ }
594
+
595
+ // 真正执行:Importer.executeImportPlan(confirm:true + rollbackOnError:true → 任一失败整体回滚)
596
+ const result = await this.importer.executeImportPlan(zipPath, subPlan, {
597
+ confirm: true,
598
+ rollbackOnError: true,
599
+ secretInputs: undefined,
600
+ decryptedCredentials: undefined,
601
+ onItem: opts.onItem,
602
+ });
603
+
604
+ if (!result.ok) {
605
+ // executeImportPlan 已内部回滚(rollbackOnError);这里再显式 rollback 兜底(幂等)
606
+ try { await rollback({ ctx: this.ctx, snapshot, store, adapters: this.adapters }); } catch { /* noop */ }
607
+ return {
608
+ ok: false,
609
+ applied: [],
610
+ restoreId: snapshot.id,
611
+ rolledBack: true,
612
+ warnings: result.warnings ?? [],
613
+ failed: result.executed.filter((e) => e.status === 'failed').map((e) => ({ itemId: e.itemId, message: e.message })),
614
+ result,
615
+ };
616
+ }
617
+
618
+ // 成功:recordBaseline 更新祖先基线(合并后的快照)
619
+ const appliedIds = [...new Set(subPlan.items.map((i) => i.adapter))];
620
+ const mergedSections: SyncSnapshot['sections'] = {};
621
+ // 从 subPlan 中按 adapter 收集写入的分区数据(无法直接从 plan item 获取 merged data,
622
+ // 但这里不需要精确的 merged 数据——recordBaseline 只需要 sectionIds 与数据来源;
623
+ // 用现有应用后的 adapter export 作为快照数据更准确)
624
+ // 注意:recordBaseline 需要各分区内容 hash,因此导出当前各分区最新状态。
625
+ for (const adapter of this.portableAdapters()) {
626
+ if (!appliedIds.includes(adapter.id)) continue;
627
+ try {
628
+ const section = await adapter.export(this.ctx, { includeSecrets: false });
629
+ const data = isFileSection(adapter.id)
630
+ ? section.data
631
+ : this.scanner.scanAndRedact(section.data).sanitized;
632
+ mergedSections[adapter.id] = data as SectionData;
633
+ } catch {
634
+ // 单个分区导出失败不拖垮 recordBaseline(已应用的分区数据从 subPlan 兜底)
635
+ }
636
+ }
637
+ const snapshotId = this.snapshotIdFn();
638
+ await this.recordBaseline(snapshotId, mergedSections);
639
+
640
+ return {
641
+ ok: true,
642
+ applied: appliedIds,
643
+ skipped: subPlan.items.filter((i) => i.kind === 'Skip').map((i) => i.id),
644
+ restoreId: snapshot.id,
645
+ rolledBack: false,
646
+ warnings: result.warnings ?? [],
647
+ failed: [],
648
+ needsRestart: result.needsRestart,
649
+ result,
650
+ };
651
+ }
652
+
280
653
  /** 散文件快照 → 标准导出 ZIP(临时目录,用完即删):buildManifest + checksums + 平铺分区 */
281
654
  private async snapshotToZip(snapshot: SyncSnapshot, portableIds: Set<SectionId>): Promise<string> {
282
655
  const entries: ZipWriteEntry[] = [];
@@ -0,0 +1,85 @@
1
+ /**
2
+ * sync-history 测试:自动同步执行记录读写(sync-history.json)、append 升序、
3
+ * 读不存在 → 空、损坏拒绝、裁剪 N 条。
4
+ */
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import fs from 'node:fs/promises';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ import {
12
+ readSyncHistory, appendAutosyncEntry, SYNC_HISTORY_FILE,
13
+ AUTOSYNC_HISTORY_KEEP,
14
+ } from './sync-history.ts';
15
+ import type { AutosyncHistoryEntry } from './sync-history.ts';
16
+ import type { SectionId } from '../schema/types.ts';
17
+
18
+ function makeEntry(seed: number, status: AutosyncHistoryEntry['status']): AutosyncHistoryEntry {
19
+ return {
20
+ direction: 'both',
21
+ status,
22
+ appliedSections: (seed % 2 === 0 ? ['settings'] : []) as SectionId[],
23
+ createdAt: new Date(2026, 7, 16, 12, seed).toISOString(),
24
+ failureCountAtRun: 0,
25
+ };
26
+ }
27
+
28
+ test('appendAutosyncEntry:追加记录 → readSyncHistory 读回升序列表', async () => {
29
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-history-append-'));
30
+ try {
31
+ await appendAutosyncEntry(dir, makeEntry(0, 'success'));
32
+ await appendAutosyncEntry(dir, makeEntry(1, 'skipped'));
33
+ const hist = await readSyncHistory(dir);
34
+ assert.equal(hist.schemaVersion, 1);
35
+ assert.equal(hist.autosyncEntries.length, 2);
36
+ assert.equal(hist.autosyncEntries[0]!.status, 'success');
37
+ assert.equal(hist.autosyncEntries[1]!.status, 'skipped');
38
+ // updatedAt 非空
39
+ assert.ok(hist.updatedAt !== '', 'updatedAt 已写入');
40
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
41
+ });
42
+
43
+ test('readSyncHistory:文件不存在 → 返回空列表(schemaVersion=1)', async () => {
44
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-history-missing-'));
45
+ try {
46
+ const hist = await readSyncHistory(dir);
47
+ assert.equal(hist.schemaVersion, 1);
48
+ assert.deepEqual(hist.autosyncEntries, []);
49
+ assert.equal(hist.updatedAt, '');
50
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
51
+ });
52
+
53
+ test('readSyncHistory:损坏 JSON → 抛错', async () => {
54
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-history-corrupt-'));
55
+ try {
56
+ await fs.writeFile(path.join(dir, SYNC_HISTORY_FILE), '{not-json', 'utf8');
57
+ await assert.rejects(() => readSyncHistory(dir), /损坏/);
58
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
59
+ });
60
+
61
+ test('appendAutosyncEntry:裁剪到 AUTOSYNC_HISTORY_KEEP 条', async () => {
62
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-history-prune-'));
63
+ try {
64
+ // 预置 250 条
65
+ for (let i = 0; i < AUTOSYNC_HISTORY_KEEP + 50; i++) {
66
+ await appendAutosyncEntry(dir, makeEntry(i, 'success'));
67
+ }
68
+ const hist = await readSyncHistory(dir);
69
+ assert.ok(hist.autosyncEntries.length <= AUTOSYNC_HISTORY_KEEP, `裁剪后不超过 ${AUTOSYNC_HISTORY_KEEP} 条`);
70
+ assert.equal(hist.autosyncEntries.length, AUTOSYNC_HISTORY_KEEP);
71
+ // 保留的是最新的(末尾的 createdAt 最大)
72
+ const first = hist.autosyncEntries[0]!;
73
+ const last = hist.autosyncEntries[hist.autosyncEntries.length - 1]!;
74
+ assert.ok(first.createdAt <= last.createdAt, '升序排列,最新的在末尾');
75
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
76
+ });
77
+
78
+ test('appendAutosyncEntry:原子写(不残留 .tmp 文件)', async () => {
79
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-history-atomic-'));
80
+ try {
81
+ await appendAutosyncEntry(dir, makeEntry(0, 'failed'));
82
+ const files = await fs.readdir(dir);
83
+ assert.ok(!files.some((f) => f.includes('.tmp')), '不应残留 .tmp 临时文件');
84
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
85
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * m-autosync:自动同步执行记录持久化(sync-history.json)。
3
+ *
4
+ * 与 sync-config/autosync-config 并列独立文件。schemaVersion:1,
5
+ * 结构 { schemaVersion, autosyncEntries: AutosyncHistoryEntry[], updatedAt }。
6
+ *
7
+ * - append 追加(升序)并裁剪保留最近 AUTOSYNC_HISTORY_KEEP 条;
8
+ * - 原子写(临时文件 + rename);
9
+ * - 损坏 JSON 严格拒绝(不静默降级到空列表)。
10
+ */
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+
15
+ import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
16
+ import type { SectionId } from '../schema/types.ts';
17
+
18
+ export const SYNC_HISTORY_FILE = 'sync-history.json';
19
+ export const SYNC_HISTORY_SCHEMA_VERSION = 1;
20
+ /** 自动同步历史保留上限 */
21
+ export const AUTOSYNC_HISTORY_KEEP = 200;
22
+
23
+ /** 自动同步执行记录(§3.7 AutosyncHistoryEntry) */
24
+ export interface AutosyncHistoryEntry {
25
+ /** 执行方向 */
26
+ direction: 'pull' | 'push' | 'both';
27
+ status: 'success' | 'skipped' | 'failed' | 'partial';
28
+ /** 跳过原因(冲突项 / 缺失依赖 / Install / 错误 / 无远端 / 网络) */
29
+ skipReason?: string;
30
+ /** 被跳过的冲突分区 id(冲突跳过时列出) */
31
+ conflictedSections?: SectionId[];
32
+ /** 本次自动合并实际写入的分区 */
33
+ appliedSections?: SectionId[];
34
+ /** 本次 push 产生的快照 id(direction 含 push 时) */
35
+ pushedSnapshotId?: string;
36
+ /** 本次 pull 来源快照 id */
37
+ pulledSnapshotId?: string;
38
+ /** failed 时的错误摘要(脱敏) */
39
+ error?: string;
40
+ /** 连续失败 3 次通知时间(ISO-8601 UTC) */
41
+ notifiedAt?: string;
42
+ /** 本次触发时的连续失败计数 */
43
+ failureCountAtRun: number;
44
+ /** 记录创建时间(ISO-8601 UTC) */
45
+ createdAt: string;
46
+ }
47
+
48
+ /** sync-history.json 文件结构 */
49
+ export interface SyncHistoryFile {
50
+ schemaVersion: number;
51
+ autosyncEntries: AutosyncHistoryEntry[];
52
+ updatedAt: string;
53
+ }
54
+
55
+ /** 读取同步历史;文件不存在 → 空列表(schemaVersion=1)。损坏 JSON 抛错。 */
56
+ export async function readSyncHistory(dir: string): Promise<SyncHistoryFile> {
57
+ const file = path.join(dir, SYNC_HISTORY_FILE);
58
+ let raw: string;
59
+ try {
60
+ raw = await fs.readFile(file, 'utf8');
61
+ } catch {
62
+ return { schemaVersion: SYNC_HISTORY_SCHEMA_VERSION, autosyncEntries: [], updatedAt: '' };
63
+ }
64
+ let parsed: unknown;
65
+ try {
66
+ parsed = parseJsonSafe(raw);
67
+ } catch {
68
+ throw new Error(`${SYNC_HISTORY_FILE} 损坏:JSON 解析失败`);
69
+ }
70
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
71
+ throw new Error(`${SYNC_HISTORY_FILE} 损坏:必须是对象`);
72
+ }
73
+ const obj = parsed as Record<string, unknown>;
74
+ if (obj['schemaVersion'] !== SYNC_HISTORY_SCHEMA_VERSION) {
75
+ throw new Error(`${SYNC_HISTORY_FILE} 损坏:schemaVersion 必须是 ${SYNC_HISTORY_SCHEMA_VERSION}`);
76
+ }
77
+ if (!Array.isArray(obj['autosyncEntries'])) {
78
+ throw new Error(`${SYNC_HISTORY_FILE} 损坏:autosyncEntries 必须是数组`);
79
+ }
80
+ const entries: AutosyncHistoryEntry[] = [];
81
+ for (const it of obj['autosyncEntries']) {
82
+ if (it === null || typeof it !== 'object' || Array.isArray(it)) {
83
+ throw new Error(`${SYNC_HISTORY_FILE} 损坏:每个 entry 必须是对象`);
84
+ }
85
+ const e = it as Record<string, unknown>;
86
+ if (typeof e['direction'] !== 'string' || typeof e['status'] !== 'string' || typeof e['createdAt'] !== 'string') {
87
+ throw new Error(`${SYNC_HISTORY_FILE} 损坏:entry 必须含字符串 direction/status/createdAt`);
88
+ }
89
+ entries.push(it as unknown as AutosyncHistoryEntry);
90
+ }
91
+ return {
92
+ schemaVersion: SYNC_HISTORY_SCHEMA_VERSION,
93
+ autosyncEntries: entries,
94
+ updatedAt: typeof obj['updatedAt'] === 'string' ? obj['updatedAt'] : '',
95
+ };
96
+ }
97
+
98
+ /** 追加一条自动同步执行记录(升序),裁剪到 AUTOSYNC_HISTORY_KEEP 条,原子写。 */
99
+ export async function appendAutosyncEntry(
100
+ dir: string,
101
+ entry: AutosyncHistoryEntry,
102
+ now: () => Date = () => new Date(),
103
+ ): Promise<void> {
104
+ const current = await readSyncHistory(dir);
105
+ current.autosyncEntries.push(entry);
106
+ // 裁剪:保留最近 AUTOSYNC_HISTORY_KEEP 条(升序 → 去掉最前的超限条)
107
+ if (current.autosyncEntries.length > AUTOSYNC_HISTORY_KEEP) {
108
+ current.autosyncEntries = current.autosyncEntries.slice(current.autosyncEntries.length - AUTOSYNC_HISTORY_KEEP);
109
+ }
110
+ current.updatedAt = now().toISOString();
111
+
112
+ await fs.mkdir(dir, { recursive: true });
113
+ const target = path.join(dir, SYNC_HISTORY_FILE);
114
+ const tmp = path.join(
115
+ dir,
116
+ `.${SYNC_HISTORY_FILE}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
117
+ );
118
+ const data = stringifyJsonSafe(current, { space: 2 });
119
+ try {
120
+ await fs.writeFile(tmp, data, 'utf8');
121
+ await fs.rename(tmp, target);
122
+ } catch (err) {
123
+ try { await fs.rm(tmp, { force: true }); } catch { /* ignore */ }
124
+ throw err;
125
+ }
126
+ }