dsh-config-manager 0.1.20 → 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 (54) hide show
  1. package/lib/client.d.ts +312 -46
  2. package/lib/client.js +1172 -205
  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 +292 -19
  8. package/lib/index.js.map +1 -1
  9. package/lib/sync/autosync-config.d.ts +34 -0
  10. package/lib/sync/autosync-config.js +116 -0
  11. package/lib/sync/autosync-config.js.map +1 -0
  12. package/lib/sync/autosync-scheduler.d.ts +95 -0
  13. package/lib/sync/autosync-scheduler.js +379 -0
  14. package/lib/sync/autosync-scheduler.js.map +1 -0
  15. package/lib/sync/sync-engine.d.ts +59 -4
  16. package/lib/sync/sync-engine.js +138 -12
  17. package/lib/sync/sync-engine.js.map +1 -1
  18. package/lib/sync/sync-history.d.ts +39 -0
  19. package/lib/sync/sync-history.js +88 -0
  20. package/lib/sync/sync-history.js.map +1 -0
  21. package/lib/sync/sync-session.d.ts +39 -0
  22. package/lib/sync/sync-session.js +54 -0
  23. package/lib/sync/sync-session.js.map +1 -0
  24. package/lib/ui/i18n.d.ts +63 -0
  25. package/lib/ui/i18n.js +130 -1
  26. package/lib/ui/i18n.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/client/config-manager.module.css +18 -0
  29. package/src/client/sync/SyncConfirmView.tsx +301 -0
  30. package/src/client/sync/SyncHistoryView.tsx +107 -25
  31. package/src/client/sync/SyncSettingsView.tsx +226 -83
  32. package/src/client/sync/history-model.test.ts +82 -0
  33. package/src/client/sync/history-model.ts +89 -2
  34. package/src/client/sync/sync-api.test.ts +155 -0
  35. package/src/client/sync/sync-api.ts +208 -16
  36. package/src/client/sync/sync-locales.ts +162 -1
  37. package/src/client/sync/sync-view-v2.test.ts +131 -0
  38. package/src/client/sync/sync-view.ts +151 -4
  39. package/src/core/run-registry.ts +7 -3
  40. package/src/index.ts +314 -22
  41. package/src/sync/autosync-config.test.ts +93 -0
  42. package/src/sync/autosync-config.ts +137 -0
  43. package/src/sync/autosync-scheduler.test.ts +191 -0
  44. package/src/sync/autosync-scheduler.ts +443 -0
  45. package/src/sync/sync-engine.test.ts +107 -7
  46. package/src/sync/sync-engine.ts +176 -14
  47. package/src/sync/sync-history.test.ts +85 -0
  48. package/src/sync/sync-history.ts +126 -0
  49. package/src/sync/sync-session.test.ts +137 -0
  50. package/src/sync/sync-session.ts +76 -0
  51. package/src/ui/i18n.ts +132 -3
  52. package/src/client/sync/SyncPullPreviewView.test.ts +0 -54
  53. package/src/client/sync/SyncPullPreviewView.tsx +0 -165
  54. package/src/client/sync/pull-preview-model.ts +0 -44
@@ -414,14 +414,11 @@ test('applyMergePlan: 失败路径 → 整体回滚 + enqueueItems + ApplyReport
414
414
  assert.equal(report.rolledBack, true);
415
415
  assert.equal(report.applied.length, 0);
416
416
  assert.notEqual(report.restoreId, '', 'restoreId 应透传以便排查');
417
- assert.ok(report.review.length > 0, 'auto 项应入 review 队列');
418
- assert.equal(report.review[0]!.sectionId, 'settings');
419
- // sync-review-queue.json 应被写入
417
+ assert.deepEqual(report.review, [], '不再写 review-queue(§7.4)');
418
+ // sync-review-queue.json 不应被写入
420
419
  const rqPath = path.join(tmp, 'sync-review-queue.json');
421
- assert.ok(await fs.stat(rqPath).then(() => true).catch(() => false), 'review-queue.json 已写盘');
422
- const rq = JSON.parse(await fs.readFile(rqPath, 'utf8')) as { items: Array<{ sectionId: string }> };
423
- assert.equal(rq.items.length, 1);
424
- assert.equal(rq.items[0]!.sectionId, 'settings');
420
+ const rqExists = await fs.stat(rqPath).then(() => true).catch(() => false);
421
+ assert.equal(rqExists, false, 'review-queue.json 不应再被写入');
425
422
  // recordBaseline 不应在失败路径调用:sync-state.lastSnapshotId 应仍为空
426
423
  const state = await loadSyncState(tmp);
427
424
  assert.equal(state.lastSnapshotId, '', '失败时不应 recordBaseline');
@@ -586,3 +583,106 @@ test('merge: 不写本地配置、不执行导入,返回 MergePlan', async ()
586
583
  await fs.rm(tmp, { recursive: true, force: true });
587
584
  }
588
585
  });
586
+
587
+ // ─── P3:applyItems(一键同步逐项执行)测试 ──────────────────────────────
588
+
589
+ import type { ImportPlan } from '../core/types.ts';
590
+
591
+ function makeImportPlan(seed: string): ImportPlan {
592
+ return {
593
+ items: [{
594
+ id: `settings:general-${seed}`,
595
+ kind: 'Update',
596
+ adapter: 'settings',
597
+ description: `Update settings.general (${seed})`,
598
+ severity: 'info',
599
+ target: { adapter: 'settings', ref: 'general' },
600
+ }],
601
+ globalStrategy: 'merge',
602
+ pathMappings: [],
603
+ missingSecrets: [],
604
+ needsRestart: false,
605
+ estimatedActions: { settings: 1 } as unknown as Record<SectionId, number>,
606
+ };
607
+ }
608
+
609
+ test('applyItems: 成功路径 → 执行子计划 + recordBaseline', async () => {
610
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-applyitems-ok-'));
611
+ try {
612
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
613
+ seedSource(ctx);
614
+ const transport = new MemSyncTransport();
615
+ const mock = new MockImporter();
616
+ mock.ok = true;
617
+ const engine = makeEngineWithMockImporter({
618
+ ctx, transport, stateDir: tmp, mockImporter: mock,
619
+ });
620
+
621
+ // 需要真实 ZIP 路径(applyItems 用 executeImportPlan 的 zipPath)
622
+ // 用 mock importer 时 zipPath 可以被 mock 忽略
623
+ const zipPath = path.join(tmp, 'session.zip');
624
+ await fs.writeFile(zipPath, 'mock-zip-content');
625
+
626
+ const report = await engine.applyItems(zipPath, makeImportPlan('ok'));
627
+ assert.equal(report.ok, true);
628
+ assert.deepEqual(report.applied, ['settings']);
629
+ assert.notEqual(report.restoreId, '', 'restoreId 应非空');
630
+ assert.equal(report.rolledBack, false);
631
+ assert.equal(mock.executeCalls, 1, 'Importer.executeImportPlan 应被调用一次');
632
+ // recordBaseline 应被调用(lastSnapshotId 非空)
633
+ const state = await loadSyncState(tmp);
634
+ assert.notEqual(state.lastSnapshotId, '', 'applyItems 成功后 lastSnapshotId 非空');
635
+ } finally {
636
+ await fs.rm(tmp, { recursive: true, force: true });
637
+ }
638
+ });
639
+
640
+ test('applyItems: 失败路径 → 整体回滚 + ok:false', async () => {
641
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-applyitems-fail-'));
642
+ try {
643
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
644
+ seedSource(ctx);
645
+ const transport = new MemSyncTransport();
646
+ const mock = new MockImporter();
647
+ mock.ok = false;
648
+ const engine = makeEngineWithMockImporter({
649
+ ctx, transport, stateDir: tmp, mockImporter: mock,
650
+ });
651
+
652
+ const zipPath = path.join(tmp, 'session.zip');
653
+ await fs.writeFile(zipPath, 'mock-zip-content');
654
+
655
+ const report = await engine.applyItems(zipPath, makeImportPlan('fail'));
656
+ assert.equal(report.ok, false);
657
+ assert.equal(report.rolledBack, true);
658
+ assert.notEqual(report.restoreId, '', 'restoreId 应透传');
659
+ // 不再写 review-queue
660
+ const rqPath = path.join(tmp, 'sync-review-queue.json');
661
+ const rqExists = await fs.stat(rqPath).then(() => true).catch(() => false);
662
+ assert.equal(rqExists, false, '失败路径不应写 review-queue');
663
+ } finally {
664
+ await fs.rm(tmp, { recursive: true, force: true });
665
+ }
666
+ });
667
+
668
+ test('applyItems: 空子计划 → 直接返回 ok:true(不调 Importer)', async () => {
669
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-applyitems-empty-'));
670
+ try {
671
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
672
+ seedSource(ctx);
673
+ const transport = new MemSyncTransport();
674
+ const mock = new MockImporter();
675
+ const engine = makeEngineWithMockImporter({
676
+ ctx, transport, stateDir: tmp, mockImporter: mock,
677
+ });
678
+ const emptyPlan: ImportPlan = {
679
+ items: [], globalStrategy: 'merge', pathMappings: [], missingSecrets: [], needsRestart: false, estimatedActions: {} as unknown as Record<SectionId, number>,
680
+ };
681
+ const report = await engine.applyItems(path.join(tmp, 'none.zip'), emptyPlan);
682
+ assert.equal(report.ok, true);
683
+ assert.deepEqual(report.applied, []);
684
+ assert.equal(mock.executeCalls, 0, '空子计划不应触发 Importer');
685
+ } finally {
686
+ await fs.rm(tmp, { recursive: true, force: true });
687
+ }
688
+ });
@@ -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,19 +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
43
  import { DEFAULT_ANCESTOR_KEEP, loadAncestor, pruneAncestors, writeAncestor } from './ancestor.ts';
44
44
  import type { MergePlan, MergeSectionResult } from './merge.ts';
45
45
  import { merge as mergeSections } from './merge.ts';
46
- import { classifyMergePlan } from './risk.ts';
47
46
  import type { SyncApplyPlan } from './risk.ts';
48
47
  import { createSnapshot } from '../core/backup.ts';
49
48
  import { rollback } from '../core/rollback.ts';
50
49
  import { FileSnapshotStore } from '../core/backup.ts';
51
50
  import type { Snapshot, SnapshotStore } from '../core/types.ts';
52
- import { enqueueItems as enqueueReviewItems } from './review-queue.ts';
51
+ import type { PlanItemProgress } from '../core/analyzer.ts';
53
52
 
54
53
  export interface SyncEngineOptions {
55
54
  ctx: HostContext;
@@ -117,6 +116,17 @@ export interface SyncPullReport {
117
116
  message?: string;
118
117
  }
119
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
+
120
130
  /** 自动应用执行器(P2b)报告 */
121
131
  export interface ApplyReport {
122
132
  ok: boolean;
@@ -132,6 +142,24 @@ export interface ApplyReport {
132
142
  warnings: string[];
133
143
  }
134
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
+
135
163
  /** 同步通道结构性排除的敏感分区(即使 portable 判定有误也双保险拒绝;
136
164
  * 注:'credentials' 不是合法 SectionId——凭据状态分区为 credentialsStatus) */
137
165
  const FORBIDDEN_SECTIONS: readonly SectionId[] = ['credentialsStatus', 'secrets'];
@@ -296,6 +324,41 @@ export class SyncEngine {
296
324
  }
297
325
  }
298
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
+
299
362
  async merge(opts: { snapshotId?: string } = {}): Promise<MergePlan> {
300
363
  const metas = await this.transport.list();
301
364
  if (metas.length === 0) {
@@ -381,9 +444,9 @@ export class SyncEngine {
381
444
 
382
445
  /**
383
446
  * 应用自动应用计划:写本地(走 Importer.executeImportPlan 标准路径;应用前调 backup.createSnapshot 兜底);
384
- * 任一 auto 项失败 → 整体 rollback + 把全部 auto 项移到 review 队列(写回 sync-review-queue.json);
385
- * 成功后 recordBaseline 更新祖先基线。
447
+ * 任一 auto 项失败 → 整体 rollback;成功后 recordBaseline 更新祖先基线。
386
448
  * 返回 ApplyReport;不抛错到调用方(失败返回 ok:false + rolledBack:true)。
449
+ * 不再写 review-queue(§2.3/§7.4:待审语义改由同步历史 skipped 标记表达)。
387
450
  */
388
451
  async applyMergePlan(apply: SyncApplyPlan): Promise<ApplyReport> {
389
452
  if (!this.importer) {
@@ -453,19 +516,13 @@ export class SyncEngine {
453
516
  });
454
517
  if (!result.ok) {
455
518
  try { await rollback({ ctx: this.ctx, snapshot, store, adapters: this.adapters }); } catch { /* noop */ }
456
- // 4) 把全部 auto 项移到 review 队列
457
- const review = await enqueueReviewItems(this.stateDir, apply.autoApply.map((r) => ({
458
- sectionId: r.id,
459
- kind: 'section' as const,
460
- description: `自动应用失败:${r.id}(已回滚)`,
461
- merged: r.merged,
462
- })));
519
+ // 失败路径不再写 review-queue(§7.4):历史 skipped/failed 标记由上层(路由/调度器)写入。
463
520
  return {
464
521
  ok: false,
465
522
  applied: [],
466
523
  restoreId: snapshot.id,
467
524
  rolledBack: true,
468
- review: review.items,
525
+ review: [],
469
526
  warnings: result.warnings ?? [],
470
527
  };
471
528
  }
@@ -488,6 +545,111 @@ export class SyncEngine {
488
545
  }
489
546
  }
490
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
+
491
653
  /** 散文件快照 → 标准导出 ZIP(临时目录,用完即删):buildManifest + checksums + 平铺分区 */
492
654
  private async snapshotToZip(snapshot: SyncSnapshot, portableIds: Set<SectionId>): Promise<string> {
493
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
+ }