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
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ import * as yaml from 'js-yaml'
58
58
 
59
59
  import { Exporter, FileSnapshotStore, Importer } from './core/index.ts'
60
60
  import { listSnapshots, planRestore, type RestorePlan, type RestoreReport } from './core/restore.ts'
61
+ import { rollback as performRollback } from './core/rollback.ts'
61
62
  import { RunRegistry, type RunState } from './core/run-registry.ts'
62
63
  import { makeMsg, msgOf, zhMsg } from './core/messages.ts'
63
64
  import type { MsgFunc } from './core/messages.ts'
@@ -67,7 +68,7 @@ import {
67
68
  } from './core/plugin-cli.ts'
68
69
  import type {
69
70
  ConfigAdapter, CredentialsFacade, FileSystemFacade, HostContext, ImportDecisions,
70
- ImportPlan, NamespaceInfo, PatchFileFacade, PluginInfo, PluginsFacade,
71
+ ImportPlan, NamespaceInfo, PatchFileFacade, PlanItemKind, PluginInfo, PluginsFacade,
71
72
  SettingsFacade, WorkspaceFacade,
72
73
  } from './core/types.ts'
73
74
  import { createAdapters, USER_PATCH_FILE } from './adapters/index.ts'
@@ -76,7 +77,12 @@ import { createHardenedZipParser } from './security/zip-security.ts'
76
77
  import { GitTransport } from './sync/git/git-transport.ts'
77
78
  import { DeviceFlowStore, GitHubAuthClient } from './sync/github-auth.ts'
78
79
  import { SyncEngine } from './sync/sync-engine.ts'
79
- import { loadSyncState } from './sync/sync-state.ts'
80
+ import { SyncSessionStore } from './sync/sync-session.ts'
81
+ import { AutoSyncScheduler } from './sync/autosync-scheduler.ts'
82
+ import { defaultAutosyncConfig, readAutosyncConfig, writeAutosyncConfig } from './sync/autosync-config.ts'
83
+ import type { AutosyncInterval, AutosyncRunStatus } from './sync/autosync-config.ts'
84
+ import { appendAutosyncEntry, readSyncHistory } from './sync/sync-history.ts'
85
+ import { loadSyncState, saveSyncState } from './sync/sync-state.ts'
80
86
  import { readSyncConfig, writeSyncConfig, validateRepoUrl } from './sync/sync-config.ts'
81
87
  import { MANIFEST_FILE, parseManifest } from './schema/manifest.ts'
82
88
  import { SECTION_IDS } from './schema/config.ts'
@@ -94,7 +100,7 @@ export const name = 'config-manager'
94
100
  export const inject = ['settings', 'credentials']
95
101
 
96
102
  /** Plugin version, kept in sync with package.json ("version"). */
97
- const PLUGIN_VERSION = '0.1.19'
103
+ const PLUGIN_VERSION = '0.1.20'
98
104
 
99
105
  /**
100
106
  * 内置 GitHub OAuth App 的 client_id(「使用 GitHub 登录」device flow 缺省值)。
@@ -148,6 +154,15 @@ const API = {
148
154
  syncGithubStart: '/api/dsh-config-manager/sync/github/start',
149
155
  syncGithubPoll: '/api/dsh-config-manager/sync/github/poll',
150
156
  syncGithubCancel: '/api/dsh-config-manager/sync/github/cancel',
157
+ // P2:同步历史 / 自动应用 / 一键回滚
158
+ syncHistory: '/api/dsh-config-manager/sync/history',
159
+ syncRollback: '/api/dsh-config-manager/sync/rollback',
160
+ // m-sync-v2:一键同步(差异确认会话)+ 自动同步 + 历史快照
161
+ syncSnapshotsList: '/api/dsh-config-manager/sync/snapshots-list',
162
+ syncSync: '/api/dsh-config-manager/sync/sync',
163
+ syncApplyItems: '/api/dsh-config-manager/sync/apply-items',
164
+ syncCancel: '/api/dsh-config-manager/sync/cancel',
165
+ syncAutosync: '/api/dsh-config-manager/sync/autosync',
151
166
  } as const
152
167
 
153
168
  /**
@@ -822,6 +837,88 @@ export function writeSyncRouteError(res: ServerResponse, error: unknown): void {
822
837
  writeJson(res, 500, { error: message })
823
838
  }
824
839
 
840
+ /** 需要人工决策的 PlanItemKind(一键同步 needsReview 判定 + 逐项确认标记) */
841
+ const REVIEW_KINDS: ReadonlySet<PlanItemKind> = new Set([
842
+ 'Conflict', 'MissingSecret', 'MissingDependency', 'Install', 'Error', 'PathMapping',
843
+ ])
844
+
845
+ /** 一键同步差异项(client 逐项确认的最小契约;与 sync-api.ts SyncConfirmItem 对齐) */
846
+ interface SyncConfirmItem {
847
+ itemId: string
848
+ adapter: SectionId
849
+ kind: PlanItemKind
850
+ description: string
851
+ severity: 'info' | 'warning' | 'error'
852
+ defaultAdopt: boolean
853
+ adopt: boolean
854
+ conflict?: { path: string; kind: 'key' | 'file' | 'section'; local?: unknown; remote?: unknown; ancestor?: unknown; diff?: string }
855
+ target?: { adapter: SectionId; ref: string }
856
+ }
857
+
858
+ /** 把 ImportPlan 投影为逐项可确认的差异项(默认采用 Create/Update/Install;人工项默认不采用)。 */
859
+ function planToConfirmItems(plan: ImportPlan): SyncConfirmItem[] {
860
+ return plan.items.map((item) => {
861
+ const manual = REVIEW_KINDS.has(item.kind)
862
+ let conflict: SyncConfirmItem['conflict']
863
+ if (item.kind === 'Conflict') {
864
+ const c = (item as { conflict?: { path?: string; kind?: string; local?: unknown; remote?: unknown; ancestor?: unknown } }).conflict
865
+ conflict = {
866
+ path: c?.path ?? '$',
867
+ kind: c?.kind === 'file' ? 'file' : c?.kind === 'section' ? 'section' : 'key',
868
+ ...(c?.local !== undefined ? { local: c.local } : {}),
869
+ ...(c?.remote !== undefined ? { remote: c.remote } : {}),
870
+ ...(c?.ancestor !== undefined ? { ancestor: c.ancestor } : {}),
871
+ }
872
+ }
873
+ return {
874
+ itemId: item.id,
875
+ adapter: item.adapter,
876
+ kind: item.kind,
877
+ description: item.description,
878
+ severity: item.severity,
879
+ defaultAdopt: !manual,
880
+ adopt: !manual,
881
+ ...(conflict !== undefined ? { conflict } : {}),
882
+ ...(item.target !== undefined ? { target: item.target } : {}),
883
+ }
884
+ })
885
+ }
886
+
887
+ /** autosync interval 类型守卫 */
888
+ function isAutosyncInterval(v: unknown): v is AutosyncInterval {
889
+ return v === '5m' || v === '15m' || v === '30m' || v === '60m' || v === '6h' || v === '12h' || v === '24h'
890
+ }
891
+
892
+ /** 自动同步状态响应(GET /sync/autosync 与 POST 回填;读盘计算 elapsedMs)。 */
893
+ async function buildAutosyncStatus(dir: string): Promise<AutosyncStatusResponse> {
894
+ const cfg = await readAutosyncConfig(dir)
895
+ const elapsedMs = cfg.lastRunAt === undefined || cfg.lastRunAt === ''
896
+ ? -1
897
+ : Math.max(0, Date.now() - Date.parse(cfg.lastRunAt))
898
+ return {
899
+ enabled: cfg.enabled,
900
+ interval: cfg.interval,
901
+ ...(cfg.lastRunAt !== undefined ? { lastRunAt: cfg.lastRunAt } : {}),
902
+ ...(cfg.lastRunStatus !== undefined ? { lastRunStatus: cfg.lastRunStatus } : {}),
903
+ ...(cfg.lastRunMessage !== undefined ? { lastRunMessage: cfg.lastRunMessage } : {}),
904
+ consecutiveFailures: cfg.consecutiveFailures,
905
+ elapsedMs,
906
+ ...(cfg.lastRunHistoryId !== undefined ? { lastRunHistoryId: cfg.lastRunHistoryId } : {}),
907
+ }
908
+ }
909
+
910
+ /** GET /sync/autosync 响应类型(与 sync-api.ts AutosyncStatusResponse 对齐) */
911
+ interface AutosyncStatusResponse {
912
+ enabled: boolean
913
+ interval: AutosyncInterval
914
+ lastRunAt?: string
915
+ lastRunStatus?: AutosyncRunStatus
916
+ lastRunMessage?: string
917
+ consecutiveFailures: number
918
+ elapsedMs: number
919
+ lastRunHistoryId?: string
920
+ }
921
+
825
922
  /* -------------------------------------------------- restore 路由(M4) */
826
923
 
827
924
  /** POST /restore 请求体校验(纯函数;snapshotId 拒绝路径分隔符防 join 越界)。 */
@@ -1043,6 +1140,21 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
1043
1140
  });
1044
1141
  }
1045
1142
 
1143
+ /** 一键同步差异确认会话存储(进程内存;/sync/sync 预览 → /sync/apply-items 逐项执行解耦) */
1144
+ const syncSessions = new SyncSessionStore()
1145
+
1146
+ /** 自动同步后台调度器(宿主进程生命周期,不依赖浏览器) */
1147
+ let scheduler: AutoSyncScheduler | undefined
1148
+ scheduler = new AutoSyncScheduler({
1149
+ syncDir,
1150
+ host,
1151
+ makeSyncEngine,
1152
+ msg,
1153
+ runs,
1154
+ })
1155
+ // 启动:读 autosync-config;若 enabled 启动定时器;无条件执行一次「启动触发下载合并」(受阈值约束)
1156
+ scheduler.start()
1157
+
1046
1158
  return [
1047
1159
  // ------------------------------------------------------------- status
1048
1160
  {
@@ -1401,6 +1513,7 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
1401
1513
  lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
1402
1514
  sectionCount: Object.keys(state.sections).length,
1403
1515
  transport: state.transport,
1516
+ autosync: await buildAutosyncStatus(syncDir),
1404
1517
  })
1405
1518
  } catch (error) {
1406
1519
  writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
@@ -1574,6 +1687,310 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
1574
1687
  writeJson(res, 200, { ok: true })
1575
1688
  },
1576
1689
  },
1690
+ // ------------------------------------------------------ sync/history
1691
+ // P2:列出本地祖先快照目录的 manifest.json(id/createdAt/sectionHashes),
1692
+ // 同时统计 review-queue 中关联到该 snapshotId 的项数。
1693
+ {
1694
+ kind: 'exact',
1695
+ path: API.syncHistory,
1696
+ handler: async (req, res) => {
1697
+ if (!guard(req, res, 'GET')) return
1698
+ try {
1699
+ const localDir = join(syncDir, 'snapshots')
1700
+ const entries = await fs.readdir(localDir).catch(() => [])
1701
+ const rows: Array<{ id: string; createdAt: string; sectionCount: number; reviewCount: number }> = []
1702
+ for (const name of entries) {
1703
+ const dir = join(localDir, name)
1704
+ const stat = await fs.stat(dir).catch(() => null)
1705
+ if (!stat?.isDirectory()) continue
1706
+ const manifestPath = join(dir, 'manifest.json')
1707
+ const raw = await fs.readFile(manifestPath, 'utf8').catch(() => null)
1708
+ if (raw === null) continue
1709
+ try {
1710
+ const m = JSON.parse(raw) as { id?: unknown; createdAt?: unknown; sectionHashes?: unknown }
1711
+ if (typeof m.id !== 'string' || typeof m.createdAt !== 'string') continue
1712
+ const sectionCount = m.sectionHashes && typeof m.sectionHashes === 'object'
1713
+ ? Object.keys(m.sectionHashes as Record<string, unknown>).length
1714
+ : 0
1715
+ rows.push({ id: m.id, createdAt: m.createdAt, sectionCount, reviewCount: 0 })
1716
+ } catch { /* skip malformed */ }
1717
+ }
1718
+ // 关联 review-queue 计数
1719
+ const rqPath = join(syncDir, 'sync-review-queue.json')
1720
+ const rqRaw = await fs.readFile(rqPath, 'utf8').catch(() => null)
1721
+ if (rqRaw !== null) {
1722
+ try {
1723
+ const rq = JSON.parse(rqRaw) as { items?: Array<{ snapshotId?: string }> }
1724
+ const byId = new Map<string, number>()
1725
+ for (const it of rq.items ?? []) {
1726
+ if (typeof it.snapshotId === 'string') {
1727
+ byId.set(it.snapshotId, (byId.get(it.snapshotId) ?? 0) + 1)
1728
+ }
1729
+ }
1730
+ for (const r of rows) {
1731
+ const c = byId.get(r.id)
1732
+ if (c !== undefined) r.reviewCount = c
1733
+ }
1734
+ } catch { /* skip */ }
1735
+ }
1736
+ rows.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
1737
+ // 合并自动同步执行记录(sync-history.json)
1738
+ const hist = await readSyncHistory(syncDir)
1739
+ const merged = [
1740
+ ...rows.map((r) => ({ ...r, kind: 'apply' as const })),
1741
+ ...hist.autosyncEntries.map((e) => ({
1742
+ id: e.createdAt,
1743
+ createdAt: e.createdAt,
1744
+ kind: 'autosync' as const,
1745
+ autosync: e,
1746
+ })),
1747
+ ].sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
1748
+ writeJson(res, 200, { entries: merged })
1749
+ } catch (error) {
1750
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
1751
+ }
1752
+ },
1753
+ },
1754
+ // ------------------------------------------------------ sync/snapshots-list
1755
+ // m-sync-v2:远端历史快照列表(供「选择历史快照」下拉)。
1756
+ {
1757
+ kind: 'exact',
1758
+ path: API.syncSnapshotsList,
1759
+ handler: async (req, res) => {
1760
+ if (!guard(req, res, 'POST')) return
1761
+ const body = await readJsonBody(req)
1762
+ if (body === undefined) {
1763
+ writeJson(res, 400, { error: 'invalid JSON body' })
1764
+ return
1765
+ }
1766
+ try {
1767
+ const { repoUrl, gitBin } = await prepareSync(body)
1768
+ const engine = makeSyncEngine(repoUrl, gitBin)
1769
+ const metas = await withTimeout(
1770
+ engine.listSnapshots(),
1771
+ ROUTE_TIMEOUT_MS,
1772
+ msg('host.syncPullTimeout'),
1773
+ )
1774
+ const snapshots = [...metas]
1775
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
1776
+ .map((m) => ({
1777
+ id: m.id,
1778
+ createdAt: m.createdAt,
1779
+ sectionCount: m.manifest.sectionIds.length,
1780
+ platform: m.manifest.platform,
1781
+ dshVersion: m.manifest.dshVersion,
1782
+ }))
1783
+ const state = await loadSyncState(syncDir)
1784
+ writeJson(res, 200, { ok: true, snapshots, currentSnapshotId: state.lastSnapshotId === '' ? undefined : state.lastSnapshotId })
1785
+ } catch (error) {
1786
+ writeSyncRouteError(res, error)
1787
+ }
1788
+ },
1789
+ },
1790
+ // ------------------------------------------------------ sync/sync
1791
+ // m-sync-v2:一键同步第一步 —— 拉取 → 差异确认会话(内存登记临时 ZIP + ImportPlan)。
1792
+ {
1793
+ kind: 'exact',
1794
+ path: API.syncSync,
1795
+ handler: async (req, res) => {
1796
+ if (!guard(req, res, 'POST')) return
1797
+ const body = await readJsonBody(req)
1798
+ if (body === undefined) {
1799
+ writeJson(res, 400, { error: 'invalid JSON body' })
1800
+ return
1801
+ }
1802
+ try {
1803
+ const { repoUrl, gitBin } = await prepareSync(body)
1804
+ const engine = makeSyncEngine(repoUrl, gitBin)
1805
+ const snapshotId = typeof body['snapshotId'] === 'string' && body['snapshotId'] !== '' ? body['snapshotId'] : undefined
1806
+ const preview = await withTimeout(
1807
+ engine.preview(snapshotId === undefined ? {} : { snapshotId }),
1808
+ ROUTE_TIMEOUT_MS,
1809
+ msg('host.syncPullTimeout'),
1810
+ )
1811
+ if (!preview.ok || preview.plan === null || preview.analysis === null) {
1812
+ writeJson(res, 200, { ok: false, syncSessionId: '', snapshotId: preview.snapshotId, items: [], needsReview: false, compatibility: 'unsupported', message: preview.message ?? '同步预览失败' })
1813
+ return
1814
+ }
1815
+ const syncSessionId = syncSessions.set({
1816
+ zipPath: preview.zipPath,
1817
+ plan: preview.plan,
1818
+ analysis: preview.analysis,
1819
+ snapshotId: preview.snapshotId,
1820
+ repoUrl,
1821
+ gitBin,
1822
+ })
1823
+ const items = planToConfirmItems(preview.plan)
1824
+ const needsReview = items.some((i) => REVIEW_KINDS.has(i.kind)) || preview.analysis.pathIssues.length > 0
1825
+ writeJson(res, 200, {
1826
+ ok: true,
1827
+ syncSessionId,
1828
+ snapshotId: preview.snapshotId,
1829
+ items,
1830
+ needsReview,
1831
+ compatibility: preview.analysis.compatibility,
1832
+ })
1833
+ } catch (error) {
1834
+ writeSyncRouteError(res, error)
1835
+ }
1836
+ },
1837
+ },
1838
+ // ------------------------------------------------------ sync/apply-items
1839
+ // m-sync-v2:一键同步第二步 —— 按用户对差异项的逐项决策执行导入。
1840
+ {
1841
+ kind: 'exact',
1842
+ path: API.syncApplyItems,
1843
+ handler: async (req, res) => {
1844
+ if (!guard(req, res, 'POST')) return
1845
+ const body = await readJsonBody(req)
1846
+ if (body === undefined) {
1847
+ writeJson(res, 400, { error: 'invalid JSON body' })
1848
+ return
1849
+ }
1850
+ try {
1851
+ const syncSessionId = typeof body['syncSessionId'] === 'string' ? body['syncSessionId'] : ''
1852
+ const session = syncSessions.get(syncSessionId)
1853
+ if (session === undefined) {
1854
+ writeJson(res, 400, { error: '同步会话不存在或已过期,请重新拉取预览' })
1855
+ return
1856
+ }
1857
+ const adoptions = Array.isArray(body['adoptions']) ? body['adoptions'] : []
1858
+ // 构造子计划(仅含采纳项)
1859
+ const byId = new Map<string, { adopt: boolean; resolution?: string }>()
1860
+ for (const a of adoptions as Array<Record<string, unknown>>) {
1861
+ if (typeof a?.['itemId'] !== 'string') continue
1862
+ byId.set(a['itemId'], { adopt: a['adopt'] === true, resolution: typeof a['resolution'] === 'string' ? a['resolution'] : undefined })
1863
+ }
1864
+ const subItems = session.plan.items.filter((item) => {
1865
+ const d = byId.get(item.id)
1866
+ if (d === undefined || !d.adopt) return false
1867
+ // Conflict 项必须有 resolution,且 keepLocal/skip 从子计划剔除
1868
+ if (item.kind === 'Conflict') {
1869
+ if (d.resolution === undefined) throw new SyncRouteError(`冲突项 ${item.id} 必须提供 resolution(useRemote/keepLocal/skip)`)
1870
+ if (d.resolution === 'keepLocal' || d.resolution === 'skip') return false
1871
+ }
1872
+ return true
1873
+ })
1874
+ const subPlan: ImportPlan = {
1875
+ ...session.plan,
1876
+ items: subItems,
1877
+ }
1878
+ // 消费会话(同一 session 只允许一次 apply-items)
1879
+ syncSessions.delete(syncSessionId)
1880
+ await fs.rm(dirname(session.zipPath), { recursive: true, force: true }).catch(() => { /* 尽力清理临时 ZIP */ })
1881
+ const engine = makeSyncEngine(session.repoUrl, session.gitBin)
1882
+ const report = await engine.applyItems(session.zipPath, subPlan, {
1883
+ onItem: (info) => { /* 进度可选:runs 已由 applyItems 内部处理 */ },
1884
+ })
1885
+ writeJson(res, 200, {
1886
+ ok: report.ok,
1887
+ applied: report.applied,
1888
+ skipped: subItems.map((i) => i.id),
1889
+ needsRestart: report.needsRestart === true,
1890
+ warnings: report.warnings,
1891
+ restoreId: report.restoreId,
1892
+ rolledBack: report.rolledBack,
1893
+ failed: report.failed,
1894
+ result: report.result,
1895
+ })
1896
+ } catch (error) {
1897
+ writeSyncRouteError(res, error)
1898
+ }
1899
+ },
1900
+ },
1901
+ // ------------------------------------------------------ sync/cancel
1902
+ // m-sync-v2:取消 / 清理差异确认会话(丢弃临时 ZIP,零副作用)。
1903
+ {
1904
+ kind: 'exact',
1905
+ path: API.syncCancel,
1906
+ handler: async (req, res) => {
1907
+ if (!guard(req, res, 'POST')) return
1908
+ const body = await readJsonBody(req)
1909
+ if (body === undefined) {
1910
+ writeJson(res, 400, { error: 'invalid JSON body' })
1911
+ return
1912
+ }
1913
+ try {
1914
+ const syncSessionId = typeof body['syncSessionId'] === 'string' ? body['syncSessionId'] : ''
1915
+ if (syncSessionId !== '') {
1916
+ const session = syncSessions.get(syncSessionId)
1917
+ if (session !== undefined) {
1918
+ await fs.rm(dirname(session.zipPath), { recursive: true, force: true }).catch(() => { /* 尽力清理临时 ZIP */ })
1919
+ }
1920
+ syncSessions.delete(syncSessionId)
1921
+ }
1922
+ writeJson(res, 200, { ok: true })
1923
+ } catch (error) {
1924
+ writeSyncRouteError(res, error)
1925
+ }
1926
+ },
1927
+ },
1928
+ // ------------------------------------------------------ sync/autosync
1929
+ // m-sync-v2:自动同步配置读写(总开关 + 间隔 + 启动阈值 + 状态)。
1930
+ {
1931
+ kind: 'exact',
1932
+ path: API.syncAutosync,
1933
+ handler: async (req, res) => {
1934
+ if (!guard(req, res, 'GET')) return
1935
+ try {
1936
+ writeJson(res, 200, await buildAutosyncStatus(syncDir))
1937
+ } catch (error) {
1938
+ writeSyncRouteError(res, error)
1939
+ }
1940
+ },
1941
+ },
1942
+ {
1943
+ kind: 'exact',
1944
+ path: API.syncAutosync,
1945
+ handler: async (req, res) => {
1946
+ if (!guard(req, res, 'POST')) return
1947
+ const body = await readJsonBody(req)
1948
+ if (body === undefined) {
1949
+ writeJson(res, 400, { error: 'invalid JSON body' })
1950
+ return
1951
+ }
1952
+ try {
1953
+ const cfg = await readAutosyncConfig(syncDir)
1954
+ if (typeof body['enabled'] === 'boolean') cfg.enabled = body['enabled']
1955
+ if (typeof body['interval'] === 'string' && isAutosyncInterval(body['interval'])) cfg.interval = body['interval']
1956
+ if (typeof body['startupMinIntervalMs'] === 'number' && Number.isFinite(body['startupMinIntervalMs']) && body['startupMinIntervalMs'] > 0) {
1957
+ cfg.startupMinIntervalMs = body['startupMinIntervalMs']
1958
+ }
1959
+ await writeAutosyncConfig(syncDir, cfg)
1960
+ if (scheduler) scheduler.reload().catch(() => { /* 尽力而为 */ })
1961
+ writeJson(res, 200, await buildAutosyncStatus(syncDir))
1962
+ } catch (error) {
1963
+ writeSyncRouteError(res, error)
1964
+ }
1965
+ },
1966
+ },
1967
+ // ------------------------------------------------------ sync/rollback
1968
+ // P2:UI 一键回滚入口(按 apply 返回的 restoreId 调用 backup→rollback)。
1969
+ {
1970
+ kind: 'exact',
1971
+ path: API.syncRollback,
1972
+ handler: async (req, res) => {
1973
+ if (!guard(req, res, 'POST')) return
1974
+ const body = await readJsonBody(req)
1975
+ if (body === undefined) {
1976
+ writeJson(res, 400, { error: 'invalid JSON body' })
1977
+ return
1978
+ }
1979
+ try {
1980
+ const restoreId = typeof body['restoreId'] === 'string' ? body['restoreId'] : ''
1981
+ if (restoreId === '') {
1982
+ writeJson(res, 400, { error: 'restoreId required' })
1983
+ return
1984
+ }
1985
+ const store = new FileSnapshotStore({ dir: join(syncDir, 'snapshots') })
1986
+ const snap = await store.load(restoreId)
1987
+ const report = await performRollback({ ctx: host, snapshot: snap, store, adapters })
1988
+ writeJson(res, 200, { ok: true, full: report.full })
1989
+ } catch (error) {
1990
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
1991
+ }
1992
+ },
1993
+ },
1577
1994
  ]
1578
1995
  }
1579
1996
 
@@ -0,0 +1,152 @@
1
+ /**
2
+ * m-sync-flow:ancestor 存储助手测试。
3
+ * - loadAncestor: 存在返回 SyncSnapshot;不存在抛错
4
+ * - writeAncestor: 写出目录能被 readSnapshotFromDir 完整读回,分区 hash 一致
5
+ * - pruneAncestors: 仅删超出 keep 的最旧副本;保留集合完整无损
6
+ */
7
+ import test from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import { loadAncestor, pruneAncestors, writeAncestor, DEFAULT_ANCESTOR_KEEP } from './ancestor.ts';
14
+ import { hashSection, loadSyncState, saveSyncState } from './sync-state.ts';
15
+ import type { SectionData, FilesSection } from '../schema/types.ts';
16
+ import type { SyncSnapshot } from './transport.ts';
17
+
18
+ function mkSnapshot(id: string, createdAt: string, sections: SyncSnapshot['sections']): SyncSnapshot {
19
+ return {
20
+ id,
21
+ createdAt,
22
+ manifest: {
23
+ schemaVersion: 1,
24
+ dshVersion: 'test',
25
+ platform: 'linux',
26
+ sectionIds: Object.keys(sections) as SyncSnapshot['manifest']['sectionIds'],
27
+ containsSecrets: false,
28
+ },
29
+ sections,
30
+ };
31
+ }
32
+
33
+ test('loadAncestor: 存在 → 返回 SyncSnapshot(与 writeAncestor 写入一致)', async () => {
34
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ancestor-load-'));
35
+ try {
36
+ const snap = mkSnapshot('sync-1', '2026-08-16T10:00:00.000Z', {
37
+ settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } } as SectionData,
38
+ });
39
+ await writeAncestor(dir, snap);
40
+ const loaded = await loadAncestor(dir, 'sync-1');
41
+ assert.equal(loaded.id, 'sync-1');
42
+ assert.equal(loaded.createdAt, '2026-08-16T10:00:00.000Z');
43
+ assert.equal(loaded.sections.settings && hashSection(loaded.sections.settings as SectionData), hashSection(snap.sections.settings as SectionData));
44
+ } finally {
45
+ await fs.rm(dir, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ test('loadAncestor: 不存在 → 抛错(不静默降级)', async () => {
50
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ancestor-missing-'));
51
+ try {
52
+ await assert.rejects(() => loadAncestor(dir, 'sync-nonexistent'));
53
+ await assert.rejects(() => loadAncestor(dir, ''), /snapshotId/);
54
+ } finally {
55
+ await fs.rm(dir, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test('writeAncestor: 写出目录可被 readSnapshotFromDir 读回,分区 hash 一致', async () => {
60
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ancestor-write-'));
61
+ try {
62
+ const files: FilesSection = {
63
+ version: 1,
64
+ files: [
65
+ { relativePath: 'a.md', data: new TextEncoder().encode('# A\n'), contentHash: '' },
66
+ { relativePath: 'b/skill.md', data: new TextEncoder().encode('# B\n'), contentHash: '' },
67
+ ],
68
+ };
69
+ const snap = mkSnapshot('sync-2', '2026-08-16T11:00:00.000Z', { skills: files as unknown as SectionData });
70
+ await writeAncestor(dir, snap);
71
+ const loaded = await loadAncestor(dir, 'sync-2');
72
+ assert.equal(hashSection(loaded.sections.skills as SectionData), hashSection(files as unknown as SectionData));
73
+ assert.equal((loaded.sections.skills as FilesSection).files.length, 2);
74
+ } finally {
75
+ await fs.rm(dir, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ test('pruneAncestors: 仅删超出 keep 的最旧副本;保留集合完整无损', async () => {
80
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ancestor-prune-'));
81
+ try {
82
+ for (let i = 0; i < 12; i++) {
83
+ const t = `2026-08-16T10:0${i % 10}:00.000Z`.replace('10:00', `10:${String(i).padStart(2, '0')}`);
84
+ const iso = i < 10
85
+ ? `2026-08-16T10:0${i}:00.000Z`
86
+ : `2026-08-16T10:${i}:00.000Z`;
87
+ const snap = mkSnapshot(`sync-${String(i).padStart(2, '0')}`, iso, {
88
+ settings: { version: 1, namespaces: {} } as SectionData,
89
+ });
90
+ await writeAncestor(dir, snap);
91
+ }
92
+ const removed = await pruneAncestors(dir, 10);
93
+ assert.equal(removed.length, 2, '12 - 10 = 2 个最旧被删');
94
+ assert.equal(removed[0], 'sync-00');
95
+ assert.equal(removed[1], 'sync-01');
96
+ // 剩余 10 个仍在
97
+ for (let i = 2; i < 12; i++) {
98
+ const loaded = await loadAncestor(dir, `sync-${String(i).padStart(2, '0')}`);
99
+ assert.equal(loaded.id, `sync-${String(i).padStart(2, '0')}`);
100
+ }
101
+ await assert.rejects(() => loadAncestor(dir, 'sync-00'));
102
+ await assert.rejects(() => loadAncestor(dir, 'sync-01'));
103
+ } finally {
104
+ await fs.rm(dir, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ test('pruneAncestors: keep<=0 视为保留全部', async () => {
109
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ancestor-prune0-'));
110
+ try {
111
+ for (let i = 0; i < 3; i++) {
112
+ const snap = mkSnapshot(`s-${i}`, `2026-08-16T10:0${i}:00.000Z`, {
113
+ settings: { version: 1, namespaces: {} } as SectionData,
114
+ });
115
+ await writeAncestor(dir, snap);
116
+ }
117
+ const removed = await pruneAncestors(dir, 0);
118
+ assert.equal(removed.length, 0);
119
+ for (let i = 0; i < 3; i++) {
120
+ const loaded = await loadAncestor(dir, `s-${i}`);
121
+ assert.equal(loaded.id, `s-${i}`);
122
+ }
123
+ } finally {
124
+ await fs.rm(dir, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ test('DEFAULT_ANCESTOR_KEEP 默认值符合规划(10)', () => {
129
+ assert.equal(DEFAULT_ANCESTOR_KEEP, 10);
130
+ });
131
+
132
+ // 同时验证 sync-state 在 v2 下 lastSnapshotId 可被 save/load 完整往返(与 M1 互补)
133
+ test('saveSyncState + loadSyncState: 写入 v2 含 lastSnapshotId 往返完整(与 M1 互补验证)', async () => {
134
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-state-ancestor-rt-'));
135
+ try {
136
+ await saveSyncState(tmp, {
137
+ schemaVersion: 2,
138
+ lastSyncAt: '2026-08-16T12:00:00.000Z',
139
+ sections: {},
140
+ lastSnapshotId: 'sync-1',
141
+ });
142
+ const s = await loadSyncState(tmp);
143
+ assert.equal(s.lastSnapshotId, 'sync-1');
144
+ } finally {
145
+ await fs.rm(tmp, { recursive: true, force: true });
146
+ }
147
+ });
148
+
149
+ // 抑制 unused import 警告(hashSection/saveSyncState 通过 M1 测试已用到,此处仅 import 即可)
150
+ void hashSection;
151
+ void saveSyncState;
152
+ void path;