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.
- package/lib/client.d.ts +312 -46
- package/lib/client.js +1172 -205
- package/lib/client.js.map +1 -1
- package/lib/core/run-registry.d.ts +2 -2
- package/lib/core/run-registry.js +5 -1
- package/lib/core/run-registry.js.map +1 -1
- package/lib/index.js +292 -19
- package/lib/index.js.map +1 -1
- package/lib/sync/autosync-config.d.ts +34 -0
- package/lib/sync/autosync-config.js +116 -0
- package/lib/sync/autosync-config.js.map +1 -0
- package/lib/sync/autosync-scheduler.d.ts +95 -0
- package/lib/sync/autosync-scheduler.js +379 -0
- package/lib/sync/autosync-scheduler.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +59 -4
- package/lib/sync/sync-engine.js +138 -12
- package/lib/sync/sync-engine.js.map +1 -1
- package/lib/sync/sync-history.d.ts +39 -0
- package/lib/sync/sync-history.js +88 -0
- package/lib/sync/sync-history.js.map +1 -0
- package/lib/sync/sync-session.d.ts +39 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-session.js.map +1 -0
- package/lib/ui/i18n.d.ts +63 -0
- package/lib/ui/i18n.js +130 -1
- package/lib/ui/i18n.js.map +1 -1
- package/package.json +1 -1
- package/src/client/config-manager.module.css +18 -0
- package/src/client/sync/SyncConfirmView.tsx +301 -0
- package/src/client/sync/SyncHistoryView.tsx +107 -25
- package/src/client/sync/SyncSettingsView.tsx +226 -83
- package/src/client/sync/history-model.test.ts +82 -0
- package/src/client/sync/history-model.ts +89 -2
- package/src/client/sync/sync-api.test.ts +155 -0
- package/src/client/sync/sync-api.ts +208 -16
- package/src/client/sync/sync-locales.ts +162 -1
- package/src/client/sync/sync-view-v2.test.ts +131 -0
- package/src/client/sync/sync-view.ts +151 -4
- package/src/core/run-registry.ts +7 -3
- package/src/index.ts +314 -22
- package/src/sync/autosync-config.test.ts +93 -0
- package/src/sync/autosync-config.ts +137 -0
- package/src/sync/autosync-scheduler.test.ts +191 -0
- package/src/sync/autosync-scheduler.ts +443 -0
- package/src/sync/sync-engine.test.ts +107 -7
- package/src/sync/sync-engine.ts +176 -14
- package/src/sync/sync-history.test.ts +85 -0
- package/src/sync/sync-history.ts +126 -0
- package/src/sync/sync-session.test.ts +137 -0
- package/src/sync/sync-session.ts +76 -0
- package/src/ui/i18n.ts +132 -3
- package/src/client/sync/SyncPullPreviewView.test.ts +0 -54
- package/src/client/sync/SyncPullPreviewView.tsx +0 -165
- package/src/client/sync/pull-preview-model.ts +0 -44
package/src/index.ts
CHANGED
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
} from './core/plugin-cli.ts'
|
|
69
69
|
import type {
|
|
70
70
|
ConfigAdapter, CredentialsFacade, FileSystemFacade, HostContext, ImportDecisions,
|
|
71
|
-
ImportPlan, NamespaceInfo, PatchFileFacade, PluginInfo, PluginsFacade,
|
|
71
|
+
ImportPlan, NamespaceInfo, PatchFileFacade, PlanItemKind, PluginInfo, PluginsFacade,
|
|
72
72
|
SettingsFacade, WorkspaceFacade,
|
|
73
73
|
} from './core/types.ts'
|
|
74
74
|
import { createAdapters, USER_PATCH_FILE } from './adapters/index.ts'
|
|
@@ -77,9 +77,13 @@ import { createHardenedZipParser } from './security/zip-security.ts'
|
|
|
77
77
|
import { GitTransport } from './sync/git/git-transport.ts'
|
|
78
78
|
import { DeviceFlowStore, GitHubAuthClient } from './sync/github-auth.ts'
|
|
79
79
|
import { SyncEngine } from './sync/sync-engine.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'
|
|
80
85
|
import { loadSyncState, saveSyncState } from './sync/sync-state.ts'
|
|
81
86
|
import { readSyncConfig, writeSyncConfig, validateRepoUrl } from './sync/sync-config.ts'
|
|
82
|
-
import { classifyMergePlan } from './sync/risk.ts'
|
|
83
87
|
import { MANIFEST_FILE, parseManifest } from './schema/manifest.ts'
|
|
84
88
|
import { SECTION_IDS } from './schema/config.ts'
|
|
85
89
|
import type { Manifest, SectionId, WorkspaceRecord } from './schema/types.ts'
|
|
@@ -152,8 +156,13 @@ const API = {
|
|
|
152
156
|
syncGithubCancel: '/api/dsh-config-manager/sync/github/cancel',
|
|
153
157
|
// P2:同步历史 / 自动应用 / 一键回滚
|
|
154
158
|
syncHistory: '/api/dsh-config-manager/sync/history',
|
|
155
|
-
syncApply: '/api/dsh-config-manager/sync/apply',
|
|
156
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',
|
|
157
166
|
} as const
|
|
158
167
|
|
|
159
168
|
/**
|
|
@@ -828,6 +837,88 @@ export function writeSyncRouteError(res: ServerResponse, error: unknown): void {
|
|
|
828
837
|
writeJson(res, 500, { error: message })
|
|
829
838
|
}
|
|
830
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
|
+
|
|
831
922
|
/* -------------------------------------------------- restore 路由(M4) */
|
|
832
923
|
|
|
833
924
|
/** POST /restore 请求体校验(纯函数;snapshotId 拒绝路径分隔符防 join 越界)。 */
|
|
@@ -1049,6 +1140,21 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
|
|
|
1049
1140
|
});
|
|
1050
1141
|
}
|
|
1051
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
|
+
|
|
1052
1158
|
return [
|
|
1053
1159
|
// ------------------------------------------------------------- status
|
|
1054
1160
|
{
|
|
@@ -1407,6 +1513,7 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
|
|
|
1407
1513
|
lastSyncAt: state.lastSyncAt === '' ? undefined : state.lastSyncAt,
|
|
1408
1514
|
sectionCount: Object.keys(state.sections).length,
|
|
1409
1515
|
transport: state.transport,
|
|
1516
|
+
autosync: await buildAutosyncStatus(syncDir),
|
|
1410
1517
|
})
|
|
1411
1518
|
} catch (error) {
|
|
1412
1519
|
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
@@ -1627,21 +1734,28 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
|
|
|
1627
1734
|
} catch { /* skip */ }
|
|
1628
1735
|
}
|
|
1629
1736
|
rows.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))
|
|
1630
|
-
|
|
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 })
|
|
1631
1749
|
} catch (error) {
|
|
1632
1750
|
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
1633
1751
|
}
|
|
1634
1752
|
},
|
|
1635
1753
|
},
|
|
1636
|
-
// ------------------------------------------------------ sync/
|
|
1637
|
-
//
|
|
1638
|
-
// 1) engine.merge 拉取远端 → 三方合并 → classifyMergePlan(firstSync)
|
|
1639
|
-
// 2) engine.applyMergePlan(apply) → backup + Importer.executeImportPlan(rollbackOnError=true)
|
|
1640
|
-
// 失败 → rollback + enqueueItems → return ApplyReport{ok:false,rolledBack:true,review}
|
|
1641
|
-
// 3) 全成功 → recordBaseline + 标记 firstSyncCompleted
|
|
1754
|
+
// ------------------------------------------------------ sync/snapshots-list
|
|
1755
|
+
// m-sync-v2:远端历史快照列表(供「选择历史快照」下拉)。
|
|
1642
1756
|
{
|
|
1643
1757
|
kind: 'exact',
|
|
1644
|
-
path: API.
|
|
1758
|
+
path: API.syncSnapshotsList,
|
|
1645
1759
|
handler: async (req, res) => {
|
|
1646
1760
|
if (!guard(req, res, 'POST')) return
|
|
1647
1761
|
const body = await readJsonBody(req)
|
|
@@ -1652,21 +1766,199 @@ function makeRoutes(deps: RoutesDeps): WebRoute[] {
|
|
|
1652
1766
|
try {
|
|
1653
1767
|
const { repoUrl, gitBin } = await prepareSync(body)
|
|
1654
1768
|
const engine = makeSyncEngine(repoUrl, gitBin)
|
|
1655
|
-
const
|
|
1656
|
-
engine.
|
|
1769
|
+
const metas = await withTimeout(
|
|
1770
|
+
engine.listSnapshots(),
|
|
1657
1771
|
ROUTE_TIMEOUT_MS,
|
|
1658
|
-
'
|
|
1772
|
+
msg('host.syncPullTimeout'),
|
|
1659
1773
|
)
|
|
1660
|
-
|
|
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
|
+
}))
|
|
1661
1783
|
const state = await loadSyncState(syncDir)
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
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
|
|
1667
1814
|
}
|
|
1668
|
-
const
|
|
1669
|
-
|
|
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))
|
|
1670
1962
|
} catch (error) {
|
|
1671
1963
|
writeSyncRouteError(res, error)
|
|
1672
1964
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autosync-config 测试:sync-autosync.json 读写往返、缺省值、损坏 JSON 回退缺省、原子写。
|
|
3
|
+
*/
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
readAutosyncConfig, writeAutosyncConfig, AUTOSYNC_CONFIG_FILE,
|
|
12
|
+
AUTOSYNC_CONFIG_SCHEMA_VERSION, DEFAULT_AUTOSYNC_INTERVAL,
|
|
13
|
+
DEFAULT_STARTUP_MIN_INTERVAL_MS,
|
|
14
|
+
} from './autosync-config.ts';
|
|
15
|
+
|
|
16
|
+
test('writeAutosyncConfig + readAutosyncConfig:写入 → 读回字段一致', async () => {
|
|
17
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-rt-'));
|
|
18
|
+
try {
|
|
19
|
+
await writeAutosyncConfig(dir, {
|
|
20
|
+
enabled: true,
|
|
21
|
+
interval: '15m',
|
|
22
|
+
startupMinIntervalMs: 300000,
|
|
23
|
+
consecutiveFailures: 2,
|
|
24
|
+
lastRunAt: '2026-08-16T12:00:00.000Z',
|
|
25
|
+
lastRunStatus: 'skipped',
|
|
26
|
+
lastRunMessage: '有冲突项,跳过',
|
|
27
|
+
lastRunHistoryId: 'hist-1',
|
|
28
|
+
});
|
|
29
|
+
const cfg = await readAutosyncConfig(dir);
|
|
30
|
+
assert.equal(cfg.enabled, true);
|
|
31
|
+
assert.equal(cfg.interval, '15m');
|
|
32
|
+
assert.equal(cfg.startupMinIntervalMs, 300000);
|
|
33
|
+
assert.equal(cfg.consecutiveFailures, 2);
|
|
34
|
+
assert.equal(cfg.lastRunAt, '2026-08-16T12:00:00.000Z');
|
|
35
|
+
assert.equal(cfg.lastRunStatus, 'skipped');
|
|
36
|
+
assert.equal(cfg.lastRunMessage, '有冲突项,跳过');
|
|
37
|
+
assert.equal(cfg.lastRunHistoryId, 'hist-1');
|
|
38
|
+
// 原始文件校验
|
|
39
|
+
const raw = JSON.parse(await fs.readFile(path.join(dir, AUTOSYNC_CONFIG_FILE), 'utf8'));
|
|
40
|
+
assert.equal(raw.schemaVersion, AUTOSYNC_CONFIG_SCHEMA_VERSION);
|
|
41
|
+
assert.equal(raw.enabled, true);
|
|
42
|
+
assert.equal(raw.interval, '15m');
|
|
43
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('readAutosyncConfig:文件不存在 → 返回缺省值', async () => {
|
|
47
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-default-'));
|
|
48
|
+
try {
|
|
49
|
+
const cfg = await readAutosyncConfig(dir);
|
|
50
|
+
assert.equal(cfg.enabled, false);
|
|
51
|
+
assert.equal(cfg.interval, DEFAULT_AUTOSYNC_INTERVAL);
|
|
52
|
+
assert.equal(cfg.startupMinIntervalMs, DEFAULT_STARTUP_MIN_INTERVAL_MS);
|
|
53
|
+
assert.equal(cfg.consecutiveFailures, 0);
|
|
54
|
+
assert.equal(cfg.lastRunAt, undefined);
|
|
55
|
+
assert.equal(cfg.lastRunStatus, undefined);
|
|
56
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('readAutosyncConfig:损坏 JSON → 回退缺省', async () => {
|
|
60
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-corrupt-'));
|
|
61
|
+
try {
|
|
62
|
+
await fs.writeFile(path.join(dir, AUTOSYNC_CONFIG_FILE), '{not-json', 'utf8');
|
|
63
|
+
const cfg = await readAutosyncConfig(dir);
|
|
64
|
+
assert.equal(cfg.enabled, false);
|
|
65
|
+
assert.equal(cfg.interval, DEFAULT_AUTOSYNC_INTERVAL);
|
|
66
|
+
assert.equal(cfg.startupMinIntervalMs, DEFAULT_STARTUP_MIN_INTERVAL_MS);
|
|
67
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('readAutosyncConfig:不支持的 schemaVersion → 回退缺省', async () => {
|
|
71
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-badver-'));
|
|
72
|
+
try {
|
|
73
|
+
await fs.writeFile(path.join(dir, AUTOSYNC_CONFIG_FILE),
|
|
74
|
+
JSON.stringify({ schemaVersion: 99, enabled: true }), 'utf8');
|
|
75
|
+
const cfg = await readAutosyncConfig(dir);
|
|
76
|
+
assert.equal(cfg.enabled, false);
|
|
77
|
+
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('writeAutosyncConfig:原子写(自动创建目录)', async () => {
|
|
81
|
+
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-mkdir-'));
|
|
82
|
+
try {
|
|
83
|
+
const dir = path.join(base, 'nested', 'sync');
|
|
84
|
+
await writeAutosyncConfig(dir, { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 });
|
|
85
|
+
const cfg = await readAutosyncConfig(dir);
|
|
86
|
+
assert.ok(cfg);
|
|
87
|
+
assert.equal(cfg.enabled, true);
|
|
88
|
+
// 确认没有残留临时文件
|
|
89
|
+
const files = await fs.readdir(dir);
|
|
90
|
+
assert.ok(files.length >= 1, '应有配置文件');
|
|
91
|
+
assert.ok(!files.some((f) => f.includes('.tmp')), '不应残留 .tmp 临时文件');
|
|
92
|
+
} finally { await fs.rm(base, { recursive: true, force: true }); }
|
|
93
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-autosync:自动同步配置持久化(sync-autosync.json)。
|
|
3
|
+
*
|
|
4
|
+
* 与 sync-config.json 并列独立文件:语义清楚、schema 演进独立。
|
|
5
|
+
* schemaVersion:1,字段 { enabled, interval, startupMinIntervalMs, consecutiveFailures,
|
|
6
|
+
* lastRunAt, lastRunStatus, lastRunMessage, lastRunHistoryId }。
|
|
7
|
+
*
|
|
8
|
+
* 原子写(临时文件 + rename),损坏/不支持 schema 回退缺省(enabled=false,
|
|
9
|
+
* interval='30m', startupMinIntervalMs=5*60*1000)。
|
|
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
|
+
|
|
17
|
+
export const AUTOSYNC_CONFIG_FILE = 'sync-autosync.json';
|
|
18
|
+
export const AUTOSYNC_CONFIG_SCHEMA_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
/** 统一间隔类型 */
|
|
21
|
+
export type AutosyncInterval = '5m' | '15m' | '30m' | '60m' | '6h' | '12h' | '24h';
|
|
22
|
+
|
|
23
|
+
/** 缺省间隔 */
|
|
24
|
+
export const DEFAULT_AUTOSYNC_INTERVAL: AutosyncInterval = '30m';
|
|
25
|
+
|
|
26
|
+
/** 缺省启动触发最小间隔阈值(5 分钟,防频繁重启反复同步) */
|
|
27
|
+
export const DEFAULT_STARTUP_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** 最近一次自动同步执行状态 */
|
|
30
|
+
export type AutosyncRunStatus = 'success' | 'skipped' | 'failed' | 'partial';
|
|
31
|
+
|
|
32
|
+
/** 自动同步配置(持久化面) */
|
|
33
|
+
export interface AutosyncConfig {
|
|
34
|
+
/** 总开关(同时控制上传 + 下载) */
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
/** 统一间隔 */
|
|
37
|
+
interval: AutosyncInterval;
|
|
38
|
+
/** 重启触发的「自动下载合并」最小间隔阈值(ms) */
|
|
39
|
+
startupMinIntervalMs: number;
|
|
40
|
+
/** 连续失败计数(用于通知判定) */
|
|
41
|
+
consecutiveFailures: number;
|
|
42
|
+
/** 最近一次自动同步执行时间(ISO-8601 UTC);''/undefined = 从未执行 */
|
|
43
|
+
lastRunAt?: string;
|
|
44
|
+
/** 最近一次自动同步执行状态 */
|
|
45
|
+
lastRunStatus?: AutosyncRunStatus;
|
|
46
|
+
lastRunMessage?: string;
|
|
47
|
+
/** 最近一次自动同步触发的同步历史条目 id */
|
|
48
|
+
lastRunHistoryId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 缺省配置(首次无文件 / 损坏 / 不支持 schema 时回退) */
|
|
52
|
+
export function defaultAutosyncConfig(): AutosyncConfig {
|
|
53
|
+
return {
|
|
54
|
+
enabled: false,
|
|
55
|
+
interval: DEFAULT_AUTOSYNC_INTERVAL,
|
|
56
|
+
startupMinIntervalMs: DEFAULT_STARTUP_MIN_INTERVAL_MS,
|
|
57
|
+
consecutiveFailures: 0,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 读取自动同步配置;文件不存在 / 损坏 / 不支持 schema → 缺省值(不抛错)。 */
|
|
62
|
+
export async function readAutosyncConfig(dir: string): Promise<AutosyncConfig> {
|
|
63
|
+
const file = path.join(dir, AUTOSYNC_CONFIG_FILE);
|
|
64
|
+
let raw: string;
|
|
65
|
+
try {
|
|
66
|
+
raw = await fs.readFile(file, 'utf8');
|
|
67
|
+
} catch {
|
|
68
|
+
return defaultAutosyncConfig();
|
|
69
|
+
}
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = parseJsonSafe(raw);
|
|
73
|
+
} catch {
|
|
74
|
+
return defaultAutosyncConfig();
|
|
75
|
+
}
|
|
76
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
77
|
+
return defaultAutosyncConfig();
|
|
78
|
+
}
|
|
79
|
+
const obj = parsed as Record<string, unknown>;
|
|
80
|
+
// schemaVersion 必须为 1(不支持其他版本)
|
|
81
|
+
if (obj['schemaVersion'] !== undefined && obj['schemaVersion'] !== AUTOSYNC_CONFIG_SCHEMA_VERSION) {
|
|
82
|
+
return defaultAutosyncConfig();
|
|
83
|
+
}
|
|
84
|
+
if (obj['schemaVersion'] === undefined) {
|
|
85
|
+
return defaultAutosyncConfig();
|
|
86
|
+
}
|
|
87
|
+
const cfg = defaultAutosyncConfig();
|
|
88
|
+
if (typeof obj['enabled'] === 'boolean') cfg.enabled = obj['enabled'];
|
|
89
|
+
if (isAutosyncInterval(obj['interval'])) cfg.interval = obj['interval'];
|
|
90
|
+
if (typeof obj['startupMinIntervalMs'] === 'number' && Number.isFinite(obj['startupMinIntervalMs']) && obj['startupMinIntervalMs'] > 0) {
|
|
91
|
+
cfg.startupMinIntervalMs = obj['startupMinIntervalMs'];
|
|
92
|
+
}
|
|
93
|
+
if (typeof obj['consecutiveFailures'] === 'number' && Number.isFinite(obj['consecutiveFailures']) && obj['consecutiveFailures'] >= 0) {
|
|
94
|
+
cfg.consecutiveFailures = obj['consecutiveFailures'];
|
|
95
|
+
}
|
|
96
|
+
if (typeof obj['lastRunAt'] === 'string' && obj['lastRunAt'] !== '') cfg.lastRunAt = obj['lastRunAt'];
|
|
97
|
+
if (typeof obj['lastRunStatus'] === 'string' && (obj['lastRunStatus'] === 'success' || obj['lastRunStatus'] === 'skipped' || obj['lastRunStatus'] === 'failed' || obj['lastRunStatus'] === 'partial')) {
|
|
98
|
+
cfg.lastRunStatus = obj['lastRunStatus'];
|
|
99
|
+
}
|
|
100
|
+
if (typeof obj['lastRunMessage'] === 'string') cfg.lastRunMessage = obj['lastRunMessage'];
|
|
101
|
+
if (typeof obj['lastRunHistoryId'] === 'string') cfg.lastRunHistoryId = obj['lastRunHistoryId'];
|
|
102
|
+
return cfg;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 写入自动同步配置(原子写:临时文件 + rename;自动创建目录)。 */
|
|
106
|
+
export async function writeAutosyncConfig(dir: string, cfg: AutosyncConfig): Promise<void> {
|
|
107
|
+
await fs.mkdir(dir, { recursive: true });
|
|
108
|
+
const payload: Record<string, unknown> = {
|
|
109
|
+
schemaVersion: AUTOSYNC_CONFIG_SCHEMA_VERSION,
|
|
110
|
+
enabled: cfg.enabled,
|
|
111
|
+
interval: cfg.interval,
|
|
112
|
+
startupMinIntervalMs: cfg.startupMinIntervalMs,
|
|
113
|
+
consecutiveFailures: cfg.consecutiveFailures,
|
|
114
|
+
};
|
|
115
|
+
if (cfg.lastRunAt !== undefined && cfg.lastRunAt !== '') payload['lastRunAt'] = cfg.lastRunAt;
|
|
116
|
+
if (cfg.lastRunStatus !== undefined) payload['lastRunStatus'] = cfg.lastRunStatus;
|
|
117
|
+
if (cfg.lastRunMessage !== undefined && cfg.lastRunMessage !== '') payload['lastRunMessage'] = cfg.lastRunMessage;
|
|
118
|
+
if (cfg.lastRunHistoryId !== undefined && cfg.lastRunHistoryId !== '') payload['lastRunHistoryId'] = cfg.lastRunHistoryId;
|
|
119
|
+
|
|
120
|
+
const target = path.join(dir, AUTOSYNC_CONFIG_FILE);
|
|
121
|
+
const tmp = path.join(
|
|
122
|
+
dir,
|
|
123
|
+
`.${AUTOSYNC_CONFIG_FILE}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
|
|
124
|
+
);
|
|
125
|
+
const data = stringifyJsonSafe(payload, { space: 2 });
|
|
126
|
+
try {
|
|
127
|
+
await fs.writeFile(tmp, data, 'utf8');
|
|
128
|
+
await fs.rename(tmp, target);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
try { await fs.rm(tmp, { force: true }); } catch { /* ignore */ }
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isAutosyncInterval(v: unknown): v is AutosyncInterval {
|
|
136
|
+
return v === '5m' || v === '15m' || v === '30m' || v === '60m' || v === '6h' || v === '12h' || v === '24h';
|
|
137
|
+
}
|