dsh-config-manager 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/README.zh-CN.md +30 -0
- package/lib/adapters/test-helpers.d.ts +4 -2
- package/lib/adapters/test-helpers.js +11 -3
- package/lib/adapters/test-helpers.js.map +1 -1
- package/lib/cli/index.d.ts +44 -0
- package/lib/cli/index.js +230 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/client.d.ts +260 -5
- package/lib/client.js +2519 -820
- package/lib/client.js.map +1 -1
- package/lib/core/analyzer.d.ts +18 -1
- package/lib/core/analyzer.js +29 -0
- package/lib/core/analyzer.js.map +1 -1
- package/lib/core/backup.d.ts +3 -1
- package/lib/core/backup.js +70 -0
- package/lib/core/backup.js.map +1 -1
- package/lib/core/exporter.d.ts +11 -0
- package/lib/core/exporter.js +11 -0
- package/lib/core/exporter.js.map +1 -1
- package/lib/core/importer.d.ts +3 -0
- package/lib/core/importer.js +1 -0
- package/lib/core/importer.js.map +1 -1
- package/lib/core/index.d.ts +1 -0
- package/lib/core/index.js +1 -0
- package/lib/core/index.js.map +1 -1
- package/lib/core/restore.d.ts +92 -0
- package/lib/core/restore.js +505 -0
- package/lib/core/restore.js.map +1 -0
- package/lib/core/run-registry.d.ts +65 -0
- package/lib/core/run-registry.js +120 -0
- package/lib/core/run-registry.js.map +1 -0
- package/lib/core/types.d.ts +20 -0
- package/lib/core/types.js.map +1 -1
- package/lib/index.d.ts +45 -0
- package/lib/index.js +442 -8
- package/lib/index.js.map +1 -1
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +47 -0
- package/lib/sync/fs.js.map +1 -0
- package/lib/sync/git/git-transport.d.ts +87 -0
- package/lib/sync/git/git-transport.js +333 -0
- package/lib/sync/git/git-transport.js.map +1 -0
- package/lib/sync/layout.d.ts +29 -0
- package/lib/sync/layout.js +166 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/sync-config.d.ts +16 -0
- package/lib/sync/sync-config.js +67 -0
- package/lib/sync/sync-config.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +96 -0
- package/lib/sync/sync-engine.js +236 -0
- package/lib/sync/sync-engine.js.map +1 -0
- package/lib/sync/sync-state.d.ts +31 -0
- package/lib/sync/sync-state.js +103 -0
- package/lib/sync/sync-state.js.map +1 -0
- package/lib/sync/transport.d.ts +50 -0
- package/lib/sync/transport.js +22 -0
- package/lib/sync/transport.js.map +1 -0
- package/package.json +4 -1
- package/src/adapters/test-helpers.ts +10 -3
- package/src/cli/index.ts +278 -0
- package/src/client/ConfigManagerSection.tsx +49 -14
- package/src/client/api.ts +53 -5
- package/src/client/client-types.ts +0 -6
- package/src/client/common/ProgressBar.tsx +31 -19
- package/src/client/common/progress-view-edge.test.ts +60 -0
- package/src/client/common/progress-view.test.ts +79 -0
- package/src/client/common/progress-view.ts +94 -0
- package/src/client/config-manager.module.css +99 -20
- package/src/client/export/ExportView.tsx +63 -49
- package/src/client/import/ImportWizardView.tsx +107 -66
- package/src/client/index.ts +22 -0
- package/src/client/locales.ts +53 -0
- package/src/client/run-store.test.ts +500 -0
- package/src/client/run-store.ts +772 -0
- package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
- package/src/client/sync/SyncSettingsView.tsx +261 -0
- package/src/client/sync/sync-api.test.ts +117 -0
- package/src/client/sync/sync-api.ts +132 -0
- package/src/client/sync/sync-locales.ts +77 -0
- package/src/client/sync/sync-view.test.ts +211 -0
- package/src/client/sync/sync-view.ts +202 -0
- package/src/core/analyzer.ts +50 -1
- package/src/core/backup.ts +78 -2
- package/src/core/exporter.ts +21 -0
- package/src/core/importer.ts +4 -0
- package/src/core/index.ts +5 -0
- package/src/core/restore.ts +587 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +139 -0
- package/src/core/run-registry.ts +166 -0
- package/src/core/smoke.test.ts +5 -0
- package/src/core/types.ts +22 -0
- package/src/index.ts +476 -8
- package/src/security/security.test.ts +5 -0
- package/src/sync/fs.ts +46 -0
- package/src/sync/git/git-transport.test.ts +456 -0
- package/src/sync/git/git-transport.ts +390 -0
- package/src/sync/layout.test.ts +226 -0
- package/src/sync/layout.ts +183 -0
- package/src/sync/sync-config.ts +72 -0
- package/src/sync/sync-engine.test.ts +299 -0
- package/src/sync/sync-engine.ts +318 -0
- package/src/sync/sync-state.test.ts +118 -0
- package/src/sync/sync-state.ts +123 -0
- package/src/sync/transport.test.ts +110 -0
- package/src/sync/transport.ts +73 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m4 测试补齐(任务 t4):m1 真实进度埋点 —— Exporter onSection / Analyzer onItem。
|
|
3
|
+
*
|
|
4
|
+
* 背景:m1 在 core 侧加入进度埋点(exporter.ts onSection、analyzer.ts onItem,
|
|
5
|
+
* importer.ts 透传),Host 路由用它们更新 RunRegistry(/progress 轮询数据源,
|
|
6
|
+
* m2 store / m3 ProgressBar 徽章消费)。既有 tests/core/exporter.test.ts 与
|
|
7
|
+
* tests/core/importer.test.ts 未覆盖埋点的参数语义与「埋点失败不影响业务」的
|
|
8
|
+
* 容错保证,本文件补齐(仅新增测试,不改业务代码)。
|
|
9
|
+
*
|
|
10
|
+
* 覆盖(验收 m4-green):
|
|
11
|
+
* - Exporter.onSection:每导出一个选中分区前调用一次;index 从 1 起严格递增、
|
|
12
|
+
* total=选中分区总数、section=adapter id;only 过滤时只对选中分区触发;
|
|
13
|
+
* 回调抛错不得中断导出(进度是尽力而为);
|
|
14
|
+
* - Analyzer.executeImportPlan onItem(经 Importer 透传):每个计划项完成后调用;
|
|
15
|
+
* index 1-based 递增、total=将执行计划项总数(含 skip 类信息项)、
|
|
16
|
+
* status=该项实际状态(ok/skipped…)、detail=计划项 id;
|
|
17
|
+
* 冲突未解决(skipped)与缺失秘密(skipped)同样计数;
|
|
18
|
+
* 回调抛错不得中断导入执行。
|
|
19
|
+
*/
|
|
20
|
+
import test from 'node:test'
|
|
21
|
+
import assert from 'node:assert/strict'
|
|
22
|
+
import fs from 'node:fs/promises'
|
|
23
|
+
import os from 'node:os'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
|
|
26
|
+
import { Exporter, type SectionProgress } from './exporter.ts'
|
|
27
|
+
import { Importer } from './importer.ts'
|
|
28
|
+
import type { PlanItemProgress } from './analyzer.ts'
|
|
29
|
+
import { createAdapters } from '../adapters/index.ts'
|
|
30
|
+
import { makeContext, MemSnapshotStore, type MockHostContext } from '../adapters/test-helpers.ts'
|
|
31
|
+
import type { ImportPlan } from './types.ts'
|
|
32
|
+
|
|
33
|
+
const NS = ['general', 'llm-deepseek']
|
|
34
|
+
|
|
35
|
+
async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
36
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-run-progress-'))
|
|
37
|
+
try {
|
|
38
|
+
return await fn(dir)
|
|
39
|
+
} finally {
|
|
40
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 最小源:settings(general + llm-deepseek 带 apiKeyEnv/secret 标记) + credentials
|
|
45
|
+
* (llm-deepseek 的 secret 标记使 credentialsStatus 分区产生 configured 凭据,
|
|
46
|
+
* 导入时成为 MissingSecret 补录占位 —— 与 tests/core/importer.test.ts 的 I-01 同款) */
|
|
47
|
+
async function seedSource(ctx: MockHostContext): Promise<void> {
|
|
48
|
+
ctx.settings.ns.set('general', { value: { theme: 'dark', language: 'zh-CN' }, revision: 3, secrets: [] })
|
|
49
|
+
ctx.settings.ns.set('llm-deepseek', {
|
|
50
|
+
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat' },
|
|
51
|
+
revision: 5,
|
|
52
|
+
secrets: [{ path: ['apiKey'], set: true }],
|
|
53
|
+
})
|
|
54
|
+
ctx.credentials.values.set('DEEPSEEK_API_KEY', 'sk-super-secret-value-123')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function exportFixture(src: MockHostContext, outPath: string): Promise<void> {
|
|
58
|
+
const adapters = createAdapters({ namespaces: NS })
|
|
59
|
+
await new Exporter({ ctx: src, adapters, now: () => new Date('2026-08-14T12:00:00.000Z') })
|
|
60
|
+
.export({ includeSecrets: false, outPath })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 与 Importer 默认参数一致的目标侧 importer */
|
|
64
|
+
function makeImporter(dst: MockHostContext): Importer {
|
|
65
|
+
return new Importer({
|
|
66
|
+
ctx: dst,
|
|
67
|
+
adapters: createAdapters({ namespaces: NS }),
|
|
68
|
+
snapshotStore: new MemSnapshotStore(),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ================================================= Exporter onSection 埋点 */
|
|
73
|
+
|
|
74
|
+
test('m1 埋点: onSection 每导出一个选中分区前调用(index 1-based 递增、total=选中数、section=adapter id)', async () => {
|
|
75
|
+
await withTmp(async (dir) => {
|
|
76
|
+
const src = makeContext('win32', 'C:\\Users\\alice')
|
|
77
|
+
await seedSource(src)
|
|
78
|
+
const adapters = createAdapters({ namespaces: NS })
|
|
79
|
+
const selected = adapters.filter((a) => a.defaultIncluded).map((a) => a.id)
|
|
80
|
+
assert.ok(selected.length >= 2, '默认选中分区应 ≥2(本最小源至少 settings+credentialsStatus)')
|
|
81
|
+
|
|
82
|
+
const calls: SectionProgress[] = []
|
|
83
|
+
await new Exporter({
|
|
84
|
+
ctx: src,
|
|
85
|
+
adapters,
|
|
86
|
+
now: () => new Date('2026-08-14T12:00:00.000Z'),
|
|
87
|
+
onSection: (info) => { calls.push(info) },
|
|
88
|
+
}).export({ includeSecrets: false, outPath: path.join(dir, 'sections.zip') })
|
|
89
|
+
|
|
90
|
+
assert.equal(calls.length, selected.length, '每个选中分区都应触发一次 onSection')
|
|
91
|
+
calls.forEach((c, i) => {
|
|
92
|
+
assert.equal(c.index, i + 1, `index 从 1 起严格递增(第 ${i} 次调用)`)
|
|
93
|
+
assert.equal(c.total, selected.length, 'total=选中分区总数')
|
|
94
|
+
assert.equal(c.section, selected[i], 'section=当前 adapter id,顺序与导出顺序一致')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('m1 埋点: only 过滤时只对选中分区触发(index/total 按选中集计算)', async () => {
|
|
100
|
+
await withTmp(async (dir) => {
|
|
101
|
+
const src = makeContext('linux', '/home/alice')
|
|
102
|
+
await seedSource(src)
|
|
103
|
+
const adapters = createAdapters({ namespaces: NS })
|
|
104
|
+
const only = ['settings', 'plugins'] as const
|
|
105
|
+
|
|
106
|
+
const calls: SectionProgress[] = []
|
|
107
|
+
await new Exporter({
|
|
108
|
+
ctx: src,
|
|
109
|
+
adapters,
|
|
110
|
+
now: () => new Date('2026-08-14T12:00:00.000Z'),
|
|
111
|
+
onSection: (info) => { calls.push(info) },
|
|
112
|
+
}).export({ includeSecrets: false, only: [...only], outPath: path.join(dir, 'only.zip') })
|
|
113
|
+
|
|
114
|
+
assert.equal(calls.length, only.length, '只对 only 中选中的分区触发')
|
|
115
|
+
assert.deepEqual(calls.map((c) => c.section), [...only], '触发顺序与 adapters 注册顺序一致')
|
|
116
|
+
for (const c of calls) {
|
|
117
|
+
assert.equal(c.total, only.length, 'total=only 过滤后的选中分区数')
|
|
118
|
+
}
|
|
119
|
+
assert.equal(calls[0]?.index, 1, 'only 集内 index 从 1 起')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('m1 埋点: onSection 抛错不影响导出(进度是尽力而为)', async () => {
|
|
124
|
+
await withTmp(async (dir) => {
|
|
125
|
+
const src = makeContext('win32', 'C:\\Users\\alice')
|
|
126
|
+
await seedSource(src)
|
|
127
|
+
const adapters = createAdapters({ namespaces: NS })
|
|
128
|
+
let calls = 0
|
|
129
|
+
|
|
130
|
+
const result = await new Exporter({
|
|
131
|
+
ctx: src,
|
|
132
|
+
adapters,
|
|
133
|
+
now: () => new Date('2026-08-14T12:00:00.000Z'),
|
|
134
|
+
onSection: (info) => {
|
|
135
|
+
calls += 1
|
|
136
|
+
if (calls === 1) throw new Error('埋点回调故障')
|
|
137
|
+
},
|
|
138
|
+
}).export({ includeSecrets: false, outPath: path.join(dir, 'resilient.zip') })
|
|
139
|
+
|
|
140
|
+
assert.ok(calls > 0, '埋点被触发过')
|
|
141
|
+
assert.ok(result.zipPath.endsWith('.zip'), '导出仍成功完成')
|
|
142
|
+
const stat = await fs.stat(result.zipPath)
|
|
143
|
+
assert.ok(stat.size > 0, 'ZIP 正常落盘')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/* ================================================= Analyzer onItem 埋点 */
|
|
148
|
+
|
|
149
|
+
test('m1 埋点: onItem 每个计划项完成后调用(index 递增、total=计划项数、status/detail 正确)', async () => {
|
|
150
|
+
await withTmp(async (dir) => {
|
|
151
|
+
const src = makeContext('win32', 'C:\\Users\\alice')
|
|
152
|
+
await seedSource(src)
|
|
153
|
+
const zipPath = path.join(dir, 'items.zip')
|
|
154
|
+
await exportFixture(src, zipPath)
|
|
155
|
+
|
|
156
|
+
const dst = makeContext('win32', 'C:\\Users\\bob')
|
|
157
|
+
dst.settings.registered.add('general')
|
|
158
|
+
dst.settings.registered.add('llm-deepseek')
|
|
159
|
+
const importer = makeImporter(dst)
|
|
160
|
+
|
|
161
|
+
const plan: ImportPlan = await importer.createImportPlan(zipPath, { strategy: 'merge', resolutions: {}, pathMappings: [] })
|
|
162
|
+
assert.ok(plan.items.length > 0, '计划应包含待执行项')
|
|
163
|
+
|
|
164
|
+
const calls: PlanItemProgress[] = []
|
|
165
|
+
const result = await importer.executeImportPlan(zipPath, plan, {
|
|
166
|
+
confirm: true,
|
|
167
|
+
onItem: (info) => { calls.push(info) },
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
assert.equal(result.ok, true, '导入成功')
|
|
171
|
+
assert.equal(calls.length, plan.items.length, '每个计划项都应触发一次 onItem')
|
|
172
|
+
calls.forEach((c, i) => {
|
|
173
|
+
assert.equal(c.index, i + 1, 'index 从 1 起严格递增')
|
|
174
|
+
assert.equal(c.total, calls.length, 'total=将执行计划项总数(含 skip 类信息项)')
|
|
175
|
+
assert.ok(
|
|
176
|
+
c.status === 'ok' || c.status === 'skipped' || c.status === 'warning' || c.status === 'failed',
|
|
177
|
+
`status 应为合法值,实际 ${c.status}`,
|
|
178
|
+
)
|
|
179
|
+
assert.ok(typeof c.detail === 'string' && c.detail !== '', 'detail=计划项 id(非敏感)')
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('m1 埋点: 冲突未解决(skipped)与缺失秘密(skipped)同样计数', async () => {
|
|
185
|
+
await withTmp(async (dir) => {
|
|
186
|
+
const src = makeContext('win32', 'C:\\Users\\alice')
|
|
187
|
+
await seedSource(src)
|
|
188
|
+
const zipPath = path.join(dir, 'conflict.zip')
|
|
189
|
+
await exportFixture(src, zipPath)
|
|
190
|
+
|
|
191
|
+
const dst = makeContext('win32', 'C:\\Users\\bob')
|
|
192
|
+
// 目标已有同名 general → merge 无决策 → Conflict → skipped
|
|
193
|
+
dst.settings.ns.set('general', { value: { theme: 'light' }, revision: 7, secrets: [] })
|
|
194
|
+
const importer = makeImporter(dst)
|
|
195
|
+
|
|
196
|
+
const plan = await importer.createImportPlan(zipPath, { strategy: 'merge', resolutions: {}, pathMappings: [] })
|
|
197
|
+
assert.ok(plan.items.some((i) => i.id === 'settings:general' && i.kind === 'Conflict'), '应有未决策冲突项')
|
|
198
|
+
assert.ok(plan.items.some((i) => i.kind === 'MissingSecret'), '应有缺失秘密补录占位')
|
|
199
|
+
|
|
200
|
+
const calls: PlanItemProgress[] = []
|
|
201
|
+
await importer.executeImportPlan(zipPath, plan, {
|
|
202
|
+
confirm: true,
|
|
203
|
+
onItem: (info) => { calls.push(info) },
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const conflict = calls.find((c) => c.detail === 'settings:general')
|
|
207
|
+
assert.equal(conflict?.status, 'skipped', '冲突未解决 → skipped 且上报')
|
|
208
|
+
const secret = calls.find((c) => c.detail === 'secret:DEEPSEEK_API_KEY')
|
|
209
|
+
assert.equal(secret?.status, 'skipped', '秘密未补录 → skipped 且上报(不泄露值)')
|
|
210
|
+
// skipped 项同样占用 index/total 计数(进度条整体推进)
|
|
211
|
+
assert.ok(conflict !== undefined && conflict.index > 0, '冲突项计入进度计数')
|
|
212
|
+
assert.ok(secret !== undefined && secret.index > conflict!.index, '秘密项在冲突项之后计数')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('m1 埋点: onItem 抛错不影响导入执行(进度是尽力而为)', async () => {
|
|
217
|
+
await withTmp(async (dir) => {
|
|
218
|
+
const src = makeContext('win32', 'C:\\Users\\alice')
|
|
219
|
+
await seedSource(src)
|
|
220
|
+
const zipPath = path.join(dir, 'resilient.zip')
|
|
221
|
+
await exportFixture(src, zipPath)
|
|
222
|
+
|
|
223
|
+
const dst = makeContext('win32', 'C:\\Users\\bob')
|
|
224
|
+
dst.settings.registered.add('general')
|
|
225
|
+
dst.settings.registered.add('llm-deepseek')
|
|
226
|
+
const importer = makeImporter(dst)
|
|
227
|
+
|
|
228
|
+
const plan = await importer.createImportPlan(zipPath, { strategy: 'merge', resolutions: {}, pathMappings: [] })
|
|
229
|
+
const result = await importer.executeImportPlan(zipPath, plan, {
|
|
230
|
+
confirm: true,
|
|
231
|
+
onItem: () => { throw new Error('埋点回调故障') },
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
assert.equal(result.ok, true, '导入不受埋点故障影响')
|
|
235
|
+
assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark', language: 'zh-CN' }, '数据正常写入')
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunRegistry 基础单测(m1 自测,m4 会补全埋点/路由/轮询测试):
|
|
3
|
+
* - register:不可猜 runId、running 初始态、同 kind 并发拒绝(不同 kind 互不阻塞)
|
|
4
|
+
* - update:进度字段落账并刷新 updatedAt;完成/失败后晚到更新被忽略
|
|
5
|
+
* - finish/fail:状态与 result/error 落账
|
|
6
|
+
* - get/listActive:过滤语义与不存在处理
|
|
7
|
+
* - 保留期清理:超过 retentionMs 后不可见(含长期 running 僵死任务)
|
|
8
|
+
*/
|
|
9
|
+
import test from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_RUN_RETENTION_MS, RunConflictError, RunRegistry,
|
|
14
|
+
} from './run-registry.ts'
|
|
15
|
+
|
|
16
|
+
test('register: 生成不可猜的 32 hex runId 且初始为 running', () => {
|
|
17
|
+
const reg = new RunRegistry()
|
|
18
|
+
const run = reg.register('export')
|
|
19
|
+
assert.match(run.runId, /^[0-9a-f]{32}$/)
|
|
20
|
+
assert.equal(run.status, 'running')
|
|
21
|
+
assert.equal(run.kind, 'export')
|
|
22
|
+
assert.equal(run.section, null)
|
|
23
|
+
assert.equal(run.item, null)
|
|
24
|
+
assert.ok(run.createdAt > 0)
|
|
25
|
+
assert.equal(run.updatedAt, run.createdAt)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('register: 同 kind 进行中拒绝新 run(RunConflictError 携带既有 runId)', () => {
|
|
29
|
+
const reg = new RunRegistry()
|
|
30
|
+
const first = reg.register('export')
|
|
31
|
+
assert.throws(
|
|
32
|
+
() => reg.register('export'),
|
|
33
|
+
(err: unknown) => {
|
|
34
|
+
assert.ok(err instanceof RunConflictError)
|
|
35
|
+
assert.equal((err as RunConflictError).runId, first.runId)
|
|
36
|
+
return true
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('register: 不同 kind 互不阻塞(导出进行中可导入)', () => {
|
|
42
|
+
const reg = new RunRegistry()
|
|
43
|
+
reg.register('export')
|
|
44
|
+
const imp = reg.register('import')
|
|
45
|
+
assert.equal(imp.kind, 'import')
|
|
46
|
+
assert.equal(reg.listActive().length, 2)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('register: 完成/失败后的 run 不阻塞同 kind 新 run', () => {
|
|
50
|
+
const reg = new RunRegistry()
|
|
51
|
+
const first = reg.register('export')
|
|
52
|
+
reg.finish(first.runId, { ok: true })
|
|
53
|
+
const second = reg.register('export')
|
|
54
|
+
assert.notEqual(second.runId, first.runId)
|
|
55
|
+
|
|
56
|
+
const failed = reg.register('import')
|
|
57
|
+
reg.fail(failed.runId, 'boom')
|
|
58
|
+
const retry = reg.register('import')
|
|
59
|
+
assert.notEqual(retry.runId, failed.runId)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('update: 进度字段落账并刷新 updatedAt', () => {
|
|
63
|
+
let now = 1000
|
|
64
|
+
const reg = new RunRegistry({ now: () => now })
|
|
65
|
+
const run = reg.register('import')
|
|
66
|
+
now = 1200
|
|
67
|
+
reg.update(run.runId, { section: 'plugins', item: 3, itemTotal: 10, detail: 'plugin:pkg-a' })
|
|
68
|
+
const state = reg.get(run.runId)
|
|
69
|
+
assert.equal(state?.section, 'plugins')
|
|
70
|
+
assert.equal(state?.item, 3)
|
|
71
|
+
assert.equal(state?.itemTotal, 10)
|
|
72
|
+
assert.equal(state?.detail, 'plugin:pkg-a')
|
|
73
|
+
assert.equal(state?.updatedAt, 1200, 'update 必须刷新 updatedAt')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('update: 完成/失败后的晚到更新被忽略(异步回调竞态防御)', () => {
|
|
77
|
+
const reg = new RunRegistry()
|
|
78
|
+
const run = reg.register('export')
|
|
79
|
+
reg.update(run.runId, { detail: 'settings' })
|
|
80
|
+
reg.finish(run.runId, { zipPath: 'x.zip' })
|
|
81
|
+
reg.update(run.runId, { detail: 'late-write' })
|
|
82
|
+
const state = reg.get(run.runId)
|
|
83
|
+
assert.equal(state?.status, 'done')
|
|
84
|
+
assert.equal(state?.detail, 'settings', 'finish 后的 update 不得覆盖')
|
|
85
|
+
assert.deepEqual(state?.result, { zipPath: 'x.zip' })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('finish/fail: 状态与 result/error 落账', () => {
|
|
89
|
+
const reg = new RunRegistry()
|
|
90
|
+
const done = reg.register('export')
|
|
91
|
+
reg.finish(done.runId, { zipPath: 'dsh-config.zip', manifest: { schemaVersion: 1 } })
|
|
92
|
+
const after = reg.get(done.runId)
|
|
93
|
+
assert.equal(after?.status, 'done')
|
|
94
|
+
assert.deepEqual(after?.result, { zipPath: 'dsh-config.zip', manifest: { schemaVersion: 1 } })
|
|
95
|
+
|
|
96
|
+
const failed = reg.register('import')
|
|
97
|
+
reg.fail(failed.runId, '安装插件失败')
|
|
98
|
+
const f = reg.get(failed.runId)
|
|
99
|
+
assert.equal(f?.status, 'failed')
|
|
100
|
+
assert.equal(f?.error, '安装插件失败')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('get/listActive: 只列出 running run;不存在返回 undefined', () => {
|
|
104
|
+
const reg = new RunRegistry()
|
|
105
|
+
assert.equal(reg.get('nope'), undefined)
|
|
106
|
+
const a = reg.register('export')
|
|
107
|
+
const b = reg.register('import')
|
|
108
|
+
reg.finish(a.runId, {})
|
|
109
|
+
const active = reg.listActive()
|
|
110
|
+
assert.equal(active.length, 1)
|
|
111
|
+
assert.equal(active[0]?.runId, b.runId)
|
|
112
|
+
// 返回副本:外部改动不影响内部状态
|
|
113
|
+
active[0]!.detail = 'hacked'
|
|
114
|
+
assert.notEqual(reg.get(b.runId)?.detail, 'hacked')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('保留期清理: 超过 retentionMs 后不可见(含长期 running 僵死任务)', () => {
|
|
118
|
+
let now = 0
|
|
119
|
+
const reg = new RunRegistry({ retentionMs: 1000, now: () => now })
|
|
120
|
+
const run = reg.register('export')
|
|
121
|
+
now = 500
|
|
122
|
+
reg.finish(run.runId, { ok: true })
|
|
123
|
+
assert.ok(reg.get(run.runId), '保留期内可见')
|
|
124
|
+
now = 1501
|
|
125
|
+
assert.equal(reg.get(run.runId), undefined, '超过保留期清理')
|
|
126
|
+
assert.equal(reg.listActive().length, 0)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('保留期清理: 长时间不 settle 的 running run 同样被清理', () => {
|
|
130
|
+
let now = 0
|
|
131
|
+
const reg = new RunRegistry({ retentionMs: 60000, now: () => now })
|
|
132
|
+
reg.register('import')
|
|
133
|
+
now = 60001
|
|
134
|
+
assert.equal(reg.listActive().length, 0, '僵死 running run 也要清理')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('默认保留期常量为 30 分钟', () => {
|
|
138
|
+
assert.equal(DEFAULT_RUN_RETENTION_MS, 30 * 60 * 1000)
|
|
139
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host 端 run 注册表(m1):记录导出/导入任务的运行状态与真实进度。
|
|
3
|
+
*
|
|
4
|
+
* 浏览器侧通过 `/api/dsh-config-manager/progress?runId=…` 与
|
|
5
|
+
* `/api/dsh-config-manager/runs` 轮询/恢复进度(m2 store 刷新恢复、m3 进度 UI
|
|
6
|
+
* 消费);本模块是 Host 侧的单一事实源,不依赖任何 DSH 运行时服务。
|
|
7
|
+
*
|
|
8
|
+
* 安全纪律:
|
|
9
|
+
* - runId 由 crypto.randomBytes(16) 生成(32 hex 字符),不可猜测;
|
|
10
|
+
* - RunState 只存非敏感摘要(分区/计数/详情文案),绝不写入密钥、密码、
|
|
11
|
+
* 秘密补录值或任何敏感内容;
|
|
12
|
+
* - result 只存可 JSON 序列化的业务结果(导出为 { zipPath, manifest, report },
|
|
13
|
+
* 导入为 ImportResult),不含秘密值。
|
|
14
|
+
*
|
|
15
|
+
* 生命周期:register(running)→ update(进度)→ finish(done+result)/
|
|
16
|
+
* fail(failed+error);超过保留期(默认 30 分钟)的 run 在任意读操作时惰性清理。
|
|
17
|
+
* 并发纪律:同一 kind 已有 running 的 run 时,register 抛 RunConflictError
|
|
18
|
+
* (宿主路由以 409 拒绝新任务,防止重复导出/导入)。
|
|
19
|
+
*/
|
|
20
|
+
import { randomBytes } from 'node:crypto'
|
|
21
|
+
|
|
22
|
+
/** run 类型:导出 / 导入。 */
|
|
23
|
+
export type RunKind = 'export' | 'import'
|
|
24
|
+
|
|
25
|
+
/** run 状态:进行中 / 完成 / 失败。 */
|
|
26
|
+
export type RunStatus = 'running' | 'done' | 'failed'
|
|
27
|
+
|
|
28
|
+
/** 单个 run 的实时状态(/progress 返回体;纯 JSON 可序列化)。 */
|
|
29
|
+
export interface RunState {
|
|
30
|
+
/** 不可猜测的 run id(32 hex 字符)。 */
|
|
31
|
+
runId: string
|
|
32
|
+
kind: RunKind
|
|
33
|
+
status: RunStatus
|
|
34
|
+
/** 当前分区(adapter id 或导入阶段名);无则 null。 */
|
|
35
|
+
section: string | null
|
|
36
|
+
/** 分区总数(导出 = 选中分区数;导入无此语义 = null)。 */
|
|
37
|
+
sectionTotal: number | null
|
|
38
|
+
/** 已完成的内部计数(导出 = 当前分区序号;导入 = 已处理计划项数)。 */
|
|
39
|
+
item: number | null
|
|
40
|
+
/** 内部计数总数(导出 = 分区总数;导入 = 计划项总数)。 */
|
|
41
|
+
itemTotal: number | null
|
|
42
|
+
/** 非敏感进度摘要(当前分区 / 当前计划项 id)。 */
|
|
43
|
+
detail: string | null
|
|
44
|
+
/** 完成时的业务结果(仅 JSON 可序列化、不含秘密值)。 */
|
|
45
|
+
result?: unknown
|
|
46
|
+
/** 失败时的错误消息(非敏感)。 */
|
|
47
|
+
error?: string
|
|
48
|
+
createdAt: number
|
|
49
|
+
updatedAt: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 进行中同 kind 已有 run 时注册被拒(宿主路由 → 409)。 */
|
|
53
|
+
export class RunConflictError extends Error {
|
|
54
|
+
/** 已在进行中的 run id(供客户端定位既有任务)。 */
|
|
55
|
+
readonly runId: string
|
|
56
|
+
constructor(runId: string, kind: RunKind) {
|
|
57
|
+
super(
|
|
58
|
+
kind === 'export'
|
|
59
|
+
? `已有进行中的导出任务(${runId}),请等待其完成后再试`
|
|
60
|
+
: `已有进行中的导入任务(${runId}),请等待其完成后再试`,
|
|
61
|
+
)
|
|
62
|
+
this.name = 'RunConflictError'
|
|
63
|
+
this.runId = runId
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 完成/失败后的默认保留时长(30 分钟)。 */
|
|
68
|
+
export const DEFAULT_RUN_RETENTION_MS = 30 * 60 * 1000
|
|
69
|
+
|
|
70
|
+
export interface RunRegistryOptions {
|
|
71
|
+
/** 完成/失败后保留时长(默认 30 分钟);超期 run 在下次访问时清理。 */
|
|
72
|
+
retentionMs?: number
|
|
73
|
+
/** 时间源(测试注入)。 */
|
|
74
|
+
now?: () => number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 内存 run 注册表。Node 单线程 + 同步 Map 读写,无并发竞争;
|
|
79
|
+
* 所有公开读方法返回状态副本,防止外部直接篡改内部状态。
|
|
80
|
+
*/
|
|
81
|
+
export class RunRegistry {
|
|
82
|
+
private readonly runs = new Map<string, RunState>()
|
|
83
|
+
private readonly retentionMs: number
|
|
84
|
+
private readonly now: () => number
|
|
85
|
+
|
|
86
|
+
constructor(opts: RunRegistryOptions = {}) {
|
|
87
|
+
this.retentionMs = opts.retentionMs ?? DEFAULT_RUN_RETENTION_MS
|
|
88
|
+
this.now = opts.now ?? (() => Date.now())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 注册新 run;同 kind 已有 running 时抛 RunConflictError。 */
|
|
92
|
+
register(kind: RunKind): RunState {
|
|
93
|
+
this.prune()
|
|
94
|
+
for (const run of this.runs.values()) {
|
|
95
|
+
if (run.kind === kind && run.status === 'running') throw new RunConflictError(run.runId, kind)
|
|
96
|
+
}
|
|
97
|
+
const now = this.now()
|
|
98
|
+
const state: RunState = {
|
|
99
|
+
runId: randomBytes(16).toString('hex'),
|
|
100
|
+
kind,
|
|
101
|
+
status: 'running',
|
|
102
|
+
section: null,
|
|
103
|
+
sectionTotal: null,
|
|
104
|
+
item: null,
|
|
105
|
+
itemTotal: null,
|
|
106
|
+
detail: null,
|
|
107
|
+
createdAt: now,
|
|
108
|
+
updatedAt: now,
|
|
109
|
+
}
|
|
110
|
+
this.runs.set(state.runId, state)
|
|
111
|
+
return { ...state }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 更新进度;run 不存在返回 undefined;已完成/失败后的晚到更新被忽略(防御异步回调竞态)。 */
|
|
115
|
+
update(
|
|
116
|
+
runId: string,
|
|
117
|
+
patch: Partial<Pick<RunState, 'section' | 'sectionTotal' | 'item' | 'itemTotal' | 'detail'>>,
|
|
118
|
+
): RunState | undefined {
|
|
119
|
+
const run = this.runs.get(runId)
|
|
120
|
+
if (run === undefined) return undefined
|
|
121
|
+
if (run.status !== 'running') return { ...run }
|
|
122
|
+
Object.assign(run, patch, { updatedAt: this.now() })
|
|
123
|
+
return { ...run }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 读单个 run;超过保留期或不存在返回 undefined。 */
|
|
127
|
+
get(runId: string): RunState | undefined {
|
|
128
|
+
this.prune()
|
|
129
|
+
const run = this.runs.get(runId)
|
|
130
|
+
return run === undefined ? undefined : { ...run }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 列出活跃(running)run(刷新恢复时经 /runs 重订阅用)。 */
|
|
134
|
+
listActive(): RunState[] {
|
|
135
|
+
this.prune()
|
|
136
|
+
return [...this.runs.values()].filter((r) => r.status === 'running').map((r) => ({ ...r }))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 标记完成并写入业务结果;run 不存在返回 undefined。 */
|
|
140
|
+
finish(runId: string, result: unknown): RunState | undefined {
|
|
141
|
+
const run = this.runs.get(runId)
|
|
142
|
+
if (run === undefined) return undefined
|
|
143
|
+
run.status = 'done'
|
|
144
|
+
run.result = result
|
|
145
|
+
run.updatedAt = this.now()
|
|
146
|
+
return { ...run }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 标记失败并写入错误消息;run 不存在返回 undefined。 */
|
|
150
|
+
fail(runId: string, error: string): RunState | undefined {
|
|
151
|
+
const run = this.runs.get(runId)
|
|
152
|
+
if (run === undefined) return undefined
|
|
153
|
+
run.status = 'failed'
|
|
154
|
+
run.error = error
|
|
155
|
+
run.updatedAt = this.now()
|
|
156
|
+
return { ...run }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 惰性清理:超过保留期的 run(含长期 running 的僵死任务)全部移除。 */
|
|
160
|
+
private prune(): void {
|
|
161
|
+
const cutoff = this.now() - this.retentionMs
|
|
162
|
+
for (const [id, run] of this.runs) {
|
|
163
|
+
if (run.updatedAt < cutoff) this.runs.delete(id)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/core/smoke.test.ts
CHANGED
|
@@ -152,6 +152,11 @@ class MemSnapshotStore implements SnapshotStore {
|
|
|
152
152
|
if (!v) throw new Error(`blob not found: ${id}/${blobPath}`);
|
|
153
153
|
return v;
|
|
154
154
|
}
|
|
155
|
+
async updateStatus(id: string, status: Snapshot['status']): Promise<void> {
|
|
156
|
+
const s = this.snapshots.get(id);
|
|
157
|
+
if (!s) throw new Error(`snapshot not found: ${id}`);
|
|
158
|
+
s.status = status;
|
|
159
|
+
}
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
class MockHostContext implements HostContext {
|
package/src/core/types.ts
CHANGED
|
@@ -114,6 +114,8 @@ export interface HostContext {
|
|
|
114
114
|
workspace: WorkspaceFacade;
|
|
115
115
|
patchFile: PatchFileFacade;
|
|
116
116
|
fs: FileSystemFacade;
|
|
117
|
+
/** 当前管理的 DSH profile 名(如 web);引擎用它定位 profiles/<profile>/cordis.patch.yml。宿主不暴露时缺省 */
|
|
118
|
+
profile?: string;
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
/* ---------------- 导入计划(§13.3 十类 + 决策) ---------------- */
|
|
@@ -240,11 +242,29 @@ export interface SnapshotEntry {
|
|
|
240
242
|
snapshotId?: string;
|
|
241
243
|
}
|
|
242
244
|
|
|
245
|
+
/** 快照生命周期状态(M1 增强;旧快照无此字段,视为未知/兼容) */
|
|
246
|
+
export type SnapshotStatus = 'pending' | 'done' | 'rolled-back';
|
|
247
|
+
|
|
248
|
+
/** 宿主整文件备份登记(M1):导入前对 $DSH_HOME 关键文件的整文件快照。
|
|
249
|
+
* relPath 相对 $DSH_HOME(如 settings.yaml / cordis.patch.yml / profiles/<p>/cordis.patch.yml),
|
|
250
|
+
* blobPath 为快照内 blob 路径(existed=false 时为空串,表示该文件当时不存在)。 */
|
|
251
|
+
export interface HostFileBackup {
|
|
252
|
+
relPath: string;
|
|
253
|
+
blobPath: string;
|
|
254
|
+
existed: boolean;
|
|
255
|
+
}
|
|
256
|
+
|
|
243
257
|
export interface Snapshot {
|
|
244
258
|
id: string;
|
|
245
259
|
createdAt: string;
|
|
246
260
|
sourceZip: string;
|
|
247
261
|
entries: SnapshotEntry[];
|
|
262
|
+
/** 生命周期状态:pending(已生成,导入未完成)/ done(导入成功)/ rolled-back(失败已回滚)。旧快照缺省 */
|
|
263
|
+
status?: SnapshotStatus;
|
|
264
|
+
/** 导入前已安装插件清单(M2 restore 撤销插件时与当前已装对比)。旧快照缺省 */
|
|
265
|
+
beforePlugins?: PluginInfo[];
|
|
266
|
+
/** 宿主整文件备份(settings.yaml / settings.json / cordis.patch.yml / profiles/<p>/cordis.patch.yml)。旧快照缺省 */
|
|
267
|
+
hostFileBackups?: HostFileBackup[];
|
|
248
268
|
}
|
|
249
269
|
|
|
250
270
|
/** 快照存储(默认文件实现见 core/backup.ts;测试可用内存实现) */
|
|
@@ -252,6 +272,8 @@ export interface SnapshotStore {
|
|
|
252
272
|
save(snapshot: Snapshot, blobs?: Map<string, Uint8Array>): Promise<string>;
|
|
253
273
|
load(id: string): Promise<Snapshot>;
|
|
254
274
|
readBlob(id: string, blobPath: string): Promise<Uint8Array>;
|
|
275
|
+
/** 标记快照生命周期状态(导入成功→done;失败回滚→rolled-back;持久化由实现负责) */
|
|
276
|
+
updateStatus(id: string, status: SnapshotStatus): Promise<void>;
|
|
255
277
|
}
|
|
256
278
|
|
|
257
279
|
export interface RollbackReport {
|