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
@@ -0,0 +1,360 @@
1
+ /**
2
+ * m-sync-flow:三方合并引擎(纯函数)。
3
+ *
4
+ * 输入:local / remote / ancestor 各分区的 SectionData + 分区 hash。
5
+ * 行为(每分区):
6
+ * - localHash == remoteHash → skip(无变化)
7
+ * - localHash != remoteHash:
8
+ * - localHash == ancestorHash → useRemote(远端独有改动)
9
+ * - remoteHash == ancestorHash → keepLocal(本地独有改动)
10
+ * - 其余 → 进入精细合并:JSON 分区按 top-level key 合并、FilesSection 按 relativePath 合并;
11
+ * 仅双侧均改且值不同的键/文件留为 conflict,其余非重叠变更自动合入。
12
+ *
13
+ * 输出:MergePlan(纯数据,不含副作用),由上层(SyncEngine.merge)返回给 UI 决策。
14
+ *
15
+ * 安全约束:ancestor 为 undefined 时退化为两方合并:local != remote 视为整分区 conflict(拒绝猜测共同祖先)。
16
+ */
17
+ import type { FilesSection, SectionData, SectionId } from '../schema/types.ts';
18
+ import { hashSection } from './sync-state.ts';
19
+
20
+ export type MergeDecision = 'useRemote' | 'keepLocal' | 'skip';
21
+
22
+ /** 分区内精细合并产生的单条冲突(JSON key 或 FilesSection 单文件)。 */
23
+ export interface MergeConflict {
24
+ /** 冲突位置:JSON 分区是 top-level key;FilesSection 是 relativePath。 */
25
+ path: string;
26
+ kind: 'key' | 'file';
27
+ local?: unknown;
28
+ remote?: unknown;
29
+ ancestor?: unknown;
30
+ }
31
+
32
+ export interface MergeSectionResult {
33
+ id: SectionId;
34
+ /** 分区级决策;'skip' 时无 merged/conflicts;'conflict' 时 merged 为 null(仅 conflicts)。 */
35
+ decision: MergeDecision | 'conflict';
36
+ /** 当 decision 为 'useRemote' / 'keepLocal' 时:合并后的整分区数据(= 胜出方原值)。 */
37
+ merged?: SectionData;
38
+ /** 仅 decision === 'conflict' 时非空:分区级冲突或精细合并后剩余冲突。 */
39
+ conflicts: MergeConflict[];
40
+ }
41
+
42
+ export interface MergePlan {
43
+ /** 每分区的合并结果;仅含 local/remote/ancestor 三者任一出现的分区。 */
44
+ sections: MergeSectionResult[];
45
+ }
46
+
47
+ /** 内部:三态分区 hash(缺则为 undefined)。 */
48
+ interface TripleHashes {
49
+ local?: string;
50
+ remote?: string;
51
+ ancestor?: string;
52
+ }
53
+
54
+ /** 收集 local/remote/ancestor 三个快照中出现过的所有分区 id。 */
55
+ function unionSectionIds(
56
+ local: Record<string, SectionData>,
57
+ remote: Record<string, SectionData>,
58
+ ancestor: Record<string, SectionData> | undefined,
59
+ ): SectionId[] {
60
+ const ids = new Set<SectionId>();
61
+ for (const id of Object.keys(local)) ids.add(id as SectionId);
62
+ for (const id of Object.keys(remote)) ids.add(id as SectionId);
63
+ if (ancestor) for (const id of Object.keys(ancestor)) ids.add(id as SectionId);
64
+ return [...ids];
65
+ }
66
+
67
+ function computeHashes(
68
+ local: Record<string, SectionData>,
69
+ remote: Record<string, SectionData>,
70
+ ancestor: Record<string, SectionData> | undefined,
71
+ ids: SectionId[],
72
+ ): Map<SectionId, TripleHashes> {
73
+ const m = new Map<SectionId, TripleHashes>();
74
+ for (const id of ids) {
75
+ const lh = local[id] !== undefined ? hashSection(local[id]) : undefined;
76
+ const rh = remote[id] !== undefined ? hashSection(remote[id]) : undefined;
77
+ const ah = ancestor && ancestor[id] !== undefined ? hashSection(ancestor[id]) : undefined;
78
+ m.set(id, { local: lh, remote: rh, ancestor: ah });
79
+ }
80
+ return m;
81
+ }
82
+
83
+ /** 顶层 JSON 分区对象:键序无关判等(深度递归,深层数组也判等)。 */
84
+ function deepEqual(a: unknown, b: unknown): boolean {
85
+ if (a === b) return true;
86
+ if (a === null || b === null) return a === b;
87
+ if (typeof a !== typeof b) return false;
88
+ if (typeof a !== 'object') return a === b;
89
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
90
+ if (Array.isArray(a)) {
91
+ const aa = a as unknown[];
92
+ const bb = b as unknown[];
93
+ if (aa.length !== bb.length) return false;
94
+ for (let i = 0; i < aa.length; i++) if (!deepEqual(aa[i], bb[i])) return false;
95
+ return true;
96
+ }
97
+ const ao = a as Record<string, unknown>;
98
+ const bo = b as Record<string, unknown>;
99
+ const ak = Object.keys(ao).sort();
100
+ const bk = Object.keys(bo).sort();
101
+ if (ak.length !== bk.length) return false;
102
+ for (let i = 0; i < ak.length; i++) if (ak[i] !== bk[i]) return false;
103
+ for (const k of ak) if (!deepEqual(ao[k], bo[k])) return false;
104
+ return true;
105
+ }
106
+
107
+ /** JSON 分区精细合并:按 top-level key 走三方合并。
108
+ * - 双侧均未改 → 跳过
109
+ * - 仅一侧改 → 采纳该侧
110
+ * - 双侧均改 → 若 deepEqual 自动合并;否则留为冲突 */
111
+ function mergeJsonSectionGranular(
112
+ local: Record<string, unknown>,
113
+ remote: Record<string, unknown>,
114
+ ancestor: Record<string, unknown> | undefined,
115
+ ): { merged: Record<string, unknown>; conflicts: MergeConflict[] } {
116
+ const merged: Record<string, unknown> = {};
117
+ const conflicts: MergeConflict[] = [];
118
+ const keys = new Set<string>([...Object.keys(local), ...Object.keys(remote), ...(ancestor ? Object.keys(ancestor) : [])]);
119
+ for (const k of keys) {
120
+ const lHas = Object.prototype.hasOwnProperty.call(local, k);
121
+ const rHas = Object.prototype.hasOwnProperty.call(remote, k);
122
+ const aHas = ancestor ? Object.prototype.hasOwnProperty.call(ancestor, k) : false;
123
+ const lv = lHas ? local[k] : undefined;
124
+ const rv = rHas ? remote[k] : undefined;
125
+ const av = aHas ? ancestor![k] : undefined;
126
+ if (lHas && rHas) {
127
+ if (deepEqual(lv, rv)) {
128
+ merged[k] = lv;
129
+ continue;
130
+ }
131
+ // 双侧都有该 key 但值不同 → 看是否都改过
132
+ const lChanged = !aHas || !deepEqual(lv, av);
133
+ const rChanged = !aHas || !deepEqual(rv, av);
134
+ if (lChanged && rChanged) {
135
+ if (deepEqual(lv, rv)) {
136
+ merged[k] = lv; // 两侧改后恰好相同
137
+ } else {
138
+ conflicts.push({ path: k, kind: 'key', local: lv, remote: rv, ancestor: av });
139
+ }
140
+ } else if (lChanged) {
141
+ merged[k] = lv;
142
+ } else if (rChanged) {
143
+ merged[k] = rv;
144
+ } else {
145
+ merged[k] = lv; // 双侧都未相对祖先改但 deepEqual 已失败——保持一致
146
+ }
147
+ } else if (lHas && !rHas) {
148
+ // 仅本地有:删/改/增?
149
+ const lChanged = !aHas || !deepEqual(lv, av);
150
+ if (lChanged) {
151
+ // 本地增/改,远程删 → 冲突
152
+ conflicts.push({ path: k, kind: 'key', local: lv, remote: undefined, ancestor: av });
153
+ } else {
154
+ // 本地等于祖先、远程缺:远程删 → 跟随删除
155
+ }
156
+ } else if (!lHas && rHas) {
157
+ const rChanged = !aHas || !deepEqual(rv, av);
158
+ if (rChanged) {
159
+ // 本地删、远程增 → 冲突
160
+ conflicts.push({ path: k, kind: 'key', local: undefined, remote: rv, ancestor: av });
161
+ } else {
162
+ // 远程等于祖先、本地缺:本地删 → 跟随删除
163
+ }
164
+ } else {
165
+ // 仅祖先有:双侧都删 → 跟随删除
166
+ }
167
+ }
168
+ return { merged, conflicts };
169
+ }
170
+
171
+ /** FilesSection 精细合并:按 relativePath 走三方合并(按文件字节 deepEqual)。 */
172
+ function mergeFilesSectionGranular(
173
+ local: FilesSection,
174
+ remote: FilesSection,
175
+ ancestor: FilesSection | undefined,
176
+ ): { merged: FilesSection; conflicts: MergeConflict[] } {
177
+ const lMap = new Map<string, Uint8Array>();
178
+ for (const f of local.files) lMap.set(f.relativePath, f.data);
179
+ const rMap = new Map<string, Uint8Array>();
180
+ for (const f of remote.files) rMap.set(f.relativePath, f.data);
181
+ const aMap = new Map<string, Uint8Array>();
182
+ if (ancestor) for (const f of ancestor.files) aMap.set(f.relativePath, f.data);
183
+
184
+ const paths = new Set<string>([...lMap.keys(), ...rMap.keys(), ...aMap.keys()]);
185
+ const mergedFiles: FilesSection['files'] = [];
186
+ const conflicts: MergeConflict[] = [];
187
+ for (const p of paths) {
188
+ const l = lMap.get(p);
189
+ const r = rMap.get(p);
190
+ const a = aMap.get(p);
191
+ const eq = (x: Uint8Array | undefined, y: Uint8Array | undefined) => {
192
+ if (x === undefined || y === undefined) return x === y;
193
+ if (x.length !== y.length) return false;
194
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
195
+ return true;
196
+ };
197
+ if (l && r) {
198
+ if (eq(l, r)) {
199
+ mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
200
+ continue;
201
+ }
202
+ const lChanged = !a || !eq(l, a);
203
+ const rChanged = !a || !eq(r, a);
204
+ if (lChanged && rChanged) {
205
+ conflicts.push({ path: p, kind: 'file', local: l, remote: r, ancestor: a });
206
+ } else if (lChanged) {
207
+ mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
208
+ } else if (rChanged) {
209
+ mergedFiles.push({ relativePath: p, data: r, contentHash: '' });
210
+ } else {
211
+ mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
212
+ }
213
+ } else if (l && !r) {
214
+ const aExists = a !== undefined;
215
+ const lChanged = aExists ? !eq(l, a) : true;
216
+ if (lChanged) {
217
+ if (!aExists) {
218
+ // 纯本地新增(祖先里也没有)→ keepLocal
219
+ mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
220
+ } else {
221
+ // 本地改/增 vs 远程删 → 冲突
222
+ conflicts.push({ path: p, kind: 'file', local: l, remote: undefined, ancestor: a });
223
+ }
224
+ }
225
+ } else if (!l && r) {
226
+ const aExists = a !== undefined;
227
+ const rChanged = aExists ? !eq(r, a) : true;
228
+ if (rChanged) {
229
+ if (!aExists) {
230
+ // 纯远程新增(祖先里也没有)→ useRemote
231
+ mergedFiles.push({ relativePath: p, data: r, contentHash: '' });
232
+ } else {
233
+ // 本地删 vs 远程改/增 → 冲突
234
+ conflicts.push({ path: p, kind: 'file', local: undefined, remote: r, ancestor: a });
235
+ }
236
+ }
237
+ }
238
+ }
239
+ return { merged: { version: 1, files: mergedFiles }, conflicts };
240
+ }
241
+
242
+ /** 识别 JSON 分区的「键级合并」键值映射(如 settings/ui 的 namespaces、providers 的 providers)。
243
+ * 返回 undefined → 该分区无法做键级合并,调用方应退化为整分区冲突。 */
244
+ function keyMapOf(data: SectionData): Record<string, unknown> | undefined {
245
+ const obj = data as unknown as Record<string, unknown>;
246
+ if (obj && typeof obj === 'object' && obj['version'] === 1) {
247
+ const ns = obj['namespaces'];
248
+ if (ns && typeof ns === 'object' && !Array.isArray(ns)) return ns as Record<string, unknown>;
249
+ const prov = obj['providers'];
250
+ if (prov && typeof prov === 'object' && !Array.isArray(prov)) return prov as Record<string, unknown>;
251
+ }
252
+ return undefined;
253
+ }
254
+
255
+ /** 主入口:三方合并。纯函数,无副作用。 */
256
+ export function merge(
257
+ local: Partial<Record<SectionId, SectionData>>,
258
+ remote: Partial<Record<SectionId, SectionData>>,
259
+ ancestor?: Partial<Record<SectionId, SectionData>>,
260
+ ): MergePlan {
261
+ const l = local as Record<string, SectionData>;
262
+ const r = remote as Record<string, SectionData>;
263
+ const a = ancestor as Record<string, SectionData> | undefined;
264
+ const ids = unionSectionIds(l, r, a);
265
+ const hashes = computeHashes(l, r, a, ids);
266
+ const results: MergeSectionResult[] = [];
267
+
268
+ for (const id of ids) {
269
+ const h = hashes.get(id)!;
270
+ const lData = l[id];
271
+ const rData = r[id];
272
+ const aData = a ? a[id] : undefined;
273
+
274
+ // 远端无 → skip(不在本轮考虑;本地有但远端没有 = 远端"删了",视作 useRemote=删除)
275
+ // 本实现按远端视角:远端缺 + 本地存在 + 本地未改祖先 → skip(无变化);否则冲突。
276
+ if (rData === undefined && lData !== undefined) {
277
+ const lChanged = h.ancestor === undefined || h.local !== h.ancestor;
278
+ if (!lChanged) {
279
+ results.push({ id, decision: 'skip', conflicts: [] });
280
+ continue;
281
+ }
282
+ results.push({ id, decision: 'conflict', conflicts: [{ path: '$', kind: 'key', local: lData, remote: undefined, ancestor: aData }] });
283
+ continue;
284
+ }
285
+ // 本地无、远端有:远端新增;本地删除过(=等于祖先空)+祖先空→skip;否则按 useRemote。
286
+ if (lData === undefined && rData !== undefined) {
287
+ const rChanged = h.ancestor === undefined || h.remote !== h.ancestor;
288
+ if (!rChanged) {
289
+ results.push({ id, decision: 'skip', conflicts: [] });
290
+ continue;
291
+ }
292
+ results.push({ id, decision: 'useRemote', merged: rData, conflicts: [] });
293
+ continue;
294
+ }
295
+ // 双方都没有 → skip
296
+ if (lData === undefined && rData === undefined) {
297
+ results.push({ id, decision: 'skip', conflicts: [] });
298
+ continue;
299
+ }
300
+ // 双方都有
301
+ if (h.local === h.remote) {
302
+ results.push({ id, decision: 'skip', conflicts: [] });
303
+ continue;
304
+ }
305
+ if (h.ancestor === undefined) {
306
+ // 无祖先 → 两方差异视为整分区冲突(不猜测)
307
+ results.push({ id, decision: 'conflict', conflicts: [{ path: '$', kind: 'key', local: lData, remote: rData, ancestor: undefined }] });
308
+ continue;
309
+ }
310
+ if (h.local === h.ancestor) {
311
+ results.push({ id, decision: 'useRemote', merged: rData, conflicts: [] });
312
+ continue;
313
+ }
314
+ if (h.remote === h.ancestor) {
315
+ results.push({ id, decision: 'keepLocal', merged: lData, conflicts: [] });
316
+ continue;
317
+ }
318
+ // 双侧相对祖先都改 → 进入精细合并
319
+ if (isFilesSectionData(lData as SectionData)) {
320
+ const { merged, conflicts } = mergeFilesSectionGranular(lData as FilesSection, rData as FilesSection, aData as FilesSection | undefined);
321
+ if (conflicts.length === 0) {
322
+ results.push({ id, decision: 'useRemote', merged: merged as SectionData, conflicts: [] });
323
+ } else {
324
+ results.push({ id, decision: 'conflict', conflicts, merged: undefined });
325
+ }
326
+ continue;
327
+ }
328
+ // JSON 分区:识别可键级合并的子映射(namespaces / providers)
329
+ const lKeyMap = keyMapOf(lData as unknown as SectionData);
330
+ const rKeyMap = keyMapOf(rData as unknown as SectionData);
331
+ const aKeyMap = aData ? keyMapOf(aData as unknown as SectionData) : undefined;
332
+ if (lKeyMap && rKeyMap) {
333
+ const { merged: mergedKeys, conflicts } = mergeJsonSectionGranular(lKeyMap, rKeyMap, aKeyMap);
334
+ // 重组 SectionData:用 l/r 中较新者保留非键级合并字段(version 等),键级合并字段用 mergedKeys
335
+ const lObj = lData as unknown as Record<string, unknown>;
336
+ const rObj = rData as unknown as Record<string, unknown>;
337
+ const base: Record<string, unknown> = { ...lObj, ...rObj };
338
+ // 找到键级合并字段在 base 中的键名
339
+ const keyFieldName = lKeyMap === lObj['namespaces'] ? 'namespaces'
340
+ : lKeyMap === lObj['providers'] ? 'providers' : undefined;
341
+ if (keyFieldName) base[keyFieldName] = mergedKeys;
342
+ if (conflicts.length === 0) {
343
+ results.push({ id, decision: 'useRemote', merged: base as unknown as SectionData, conflicts: [] });
344
+ } else {
345
+ results.push({ id, decision: 'conflict', conflicts, merged: undefined });
346
+ }
347
+ continue;
348
+ }
349
+ // 非键级合并 JSON 分区(plugins/mcp/prompts/workspaces 等):退化为整分区冲突
350
+ results.push({ id, decision: 'conflict', conflicts: [{ path: '$', kind: 'key', local: lData, remote: rData, ancestor: aData }] });
351
+ }
352
+ return { sections: results };
353
+ }
354
+
355
+ /** 分区是否为文件类(FilesSection)—— 鸭子类型,复用 sync-state 的判定以保持一致。 */
356
+ function isFilesSectionData(data: SectionData): data is FilesSection {
357
+ if (data === null || typeof data !== 'object') return false;
358
+ const obj = data as { version?: unknown; files?: unknown };
359
+ return obj.version === 1 && Array.isArray(obj.files);
360
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * m-sync-flow:待审队列持久化测试。
3
+ * - readReviewQueue:文件不存在 → 空;损坏 → 抛错。
4
+ * - writeReviewQueue + readReviewQueue:往返一致;原子重命名。
5
+ * - enqueueItems:去重(同 id 不重复);resolveItem:找到 → 设置 decision/decidedAt;找不到抛错。
6
+ * - 并发写两次:均成功;最终内容是后写者(原子 rename 保证)。
7
+ */
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import {
14
+ enqueueItems, readReviewQueue, resolveItem, REVIEW_QUEUE_FILE, tmpStateDir,
15
+ writeReviewQueue, EMPTY_REVIEW_QUEUE,
16
+ } from './review-queue.ts';
17
+
18
+ test('readReviewQueue:文件不存在 → 返回空队列', async () => {
19
+ const dir = await tmpStateDir('dsh-rq-empty-');
20
+ try {
21
+ const q = await readReviewQueue(dir);
22
+ assert.deepEqual(q.items, []);
23
+ assert.equal(q.updatedAt, '');
24
+ } finally { await fsRm(dir); }
25
+ });
26
+
27
+ test('writeReviewQueue + readReviewQueue:往返一致', async () => {
28
+ const dir = await tmpStateDir('dsh-rq-rt-');
29
+ try {
30
+ const queue = {
31
+ items: [{
32
+ id: 'a1',
33
+ sectionId: 'settings',
34
+ kind: 'key' as const,
35
+ description: 'general.theme conflict',
36
+ local: { theme: 'dark' },
37
+ remote: { theme: 'light' },
38
+ ancestor: { theme: 'dark' },
39
+ }],
40
+ updatedAt: '2026-08-17T00:00:00.000Z',
41
+ };
42
+ await writeReviewQueue(dir, queue);
43
+ const loaded = await readReviewQueue(dir);
44
+ assert.deepEqual(loaded, queue);
45
+ assert.ok(pathExists(path.join(dir, REVIEW_QUEUE_FILE)));
46
+ } finally { await fsRm(dir); }
47
+ });
48
+
49
+ test('readReviewQueue:损坏 JSON → 抛错(不静默降级)', async () => {
50
+ const dir = await tmpStateDir('dsh-rq-bad-');
51
+ try {
52
+ await writeText(path.join(dir, REVIEW_QUEUE_FILE), '{not-json');
53
+ await assert.rejects(() => readReviewQueue(dir), /JSON|损坏/);
54
+ } finally { await fsRm(dir); }
55
+ });
56
+
57
+ test('enqueueItems:追加新项;同 id 视为重复,不重复入队', async () => {
58
+ const dir = await tmpStateDir('dsh-rq-enq-');
59
+ try {
60
+ const item = {
61
+ sectionId: 'settings',
62
+ kind: 'key' as const,
63
+ description: 'general.theme conflict',
64
+ local: { theme: 'dark' },
65
+ remote: { theme: 'light' },
66
+ };
67
+ await enqueueItems(dir, [item, item, item]);
68
+ const q = await readReviewQueue(dir);
69
+ assert.equal(q.items.length, 1, '三次追加同 id → 仅入队一次');
70
+ assert.equal(q.items[0]!.sectionId, 'settings');
71
+ assert.equal(q.items[0]!.kind, 'key');
72
+ assert.ok(typeof q.items[0]!.id === 'string' && q.items[0]!.id.length === 16);
73
+ } finally { await fsRm(dir); }
74
+ });
75
+
76
+ test('resolveItem:找到 → 设置 decision/decidedAt;找不到抛错', async () => {
77
+ const dir = await tmpStateDir('dsh-rq-res-');
78
+ try {
79
+ await enqueueItems(dir, [{
80
+ sectionId: 'providers',
81
+ kind: 'key' as const,
82
+ description: 'openai.route conflict',
83
+ local: { route: 'a' },
84
+ remote: { route: 'b' },
85
+ }]);
86
+ const before = await readReviewQueue(dir);
87
+ const id = before.items[0]!.id;
88
+ await resolveItem(dir, id, 'useRemote');
89
+ const after = await readReviewQueue(dir);
90
+ assert.equal(after.items[0]!.decision, 'useRemote');
91
+ assert.ok(after.items[0]!.decidedAt && after.items[0]!.decidedAt.startsWith('2026-'));
92
+ await assert.rejects(() => resolveItem(dir, 'nonexistent', 'skip'), /未找到/);
93
+ } finally { await fsRm(dir); }
94
+ });
95
+
96
+ test('writeReviewQueue:原子提交(不残留临时文件;并发写两次均成功)', async () => {
97
+ const dir = await tmpStateDir('dsh-rq-atomic-');
98
+ try {
99
+ await writeReviewQueue(dir, { items: [{ id: 'a', sectionId: 's', kind: 'key', description: 'd' }], updatedAt: 't1' });
100
+ await writeReviewQueue(dir, { items: [{ id: 'b', sectionId: 's', kind: 'key', description: 'd' }], updatedAt: 't2' });
101
+ const final = await readReviewQueue(dir);
102
+ assert.equal(final.updatedAt, 't2', '后写者赢');
103
+ assert.equal(final.items[0]!.id, 'b');
104
+ // 无 .tmp 残留
105
+ const entries = await fsReaddir(dir);
106
+ assert.ok(!entries.some((n) => n.endsWith('.tmp')), '无 .tmp 残留');
107
+ } finally { await fsRm(dir); }
108
+ });
109
+
110
+ // —— 私有 fs 辅助(避免顶部 import 污染) ——
111
+ const { promises: fsp } = await import('node:fs');
112
+ async function fsRm(p: string): Promise<void> { await fsp.rm(p, { recursive: true, force: true }); }
113
+ async function pathExists(p: string): Promise<boolean> {
114
+ try { await fsp.stat(p); return true; } catch { return false; }
115
+ }
116
+ async function writeText(p: string, s: string): Promise<void> { await fsp.writeFile(p, s, 'utf8'); }
117
+ async function fsReaddir(p: string): Promise<string[]> { return await fsp.readdir(p); }
118
+
119
+ void EMPTY_REVIEW_QUEUE;
120
+ void os;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * m-sync-flow:待审队列持久化(独立文件 sync-review-queue.json)。
3
+ *
4
+ * 与 sync-state.json 分文件存放:sync-state 是机器可读同步元数据,
5
+ * 待审队列是用户可见/可决策的工作队列;混在一起会让同步引擎误读。
6
+ *
7
+ * 设计:
8
+ * - 只存差异摘要(items 数组),不存全量 payload——避免双写;
9
+ * UI 需要完整数据时回拉远端或复用运行时 MergePlan。
10
+ * - 写入用 writeFile + rename 原子提交:半成品文件不会污染读侧。
11
+ * - 损坏 JSON 严格拒绝(不静默降级到空队列)。
12
+ */
13
+ import fs from 'node:fs/promises';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import crypto from 'node:crypto';
17
+
18
+ import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
19
+
20
+ export const REVIEW_QUEUE_FILE = 'sync-review-queue.json';
21
+
22
+ /** 单条待审项(决策前 description/local/remote/ancestor 必填;decision 可选) */
23
+ export interface ReviewQueueItem {
24
+ id: string;
25
+ /** 分区 id(settings / providers / workspaces / plugins / mcp 等) */
26
+ sectionId: string;
27
+ /** 'key' | 'file' | 'section':冲突粒度(来自 MergeConflict.kind 或整分区冲突) */
28
+ kind: 'key' | 'file' | 'section';
29
+ description: string;
30
+ local?: unknown;
31
+ remote?: unknown;
32
+ ancestor?: unknown;
33
+ /** 用户决策(resolve 后写入) */
34
+ decision?: 'useRemote' | 'keepLocal' | 'skip';
35
+ decidedAt?: string;
36
+ /** 关联的远端快照 id(追溯来源) */
37
+ snapshotId?: string;
38
+ }
39
+
40
+ export interface ReviewQueue {
41
+ items: ReviewQueueItem[];
42
+ updatedAt: string;
43
+ }
44
+
45
+ export const EMPTY_REVIEW_QUEUE: ReviewQueue = { items: [], updatedAt: '' };
46
+
47
+ /** 读取待审队列;文件不存在 → 返回空队列;损坏 JSON 抛错。 */
48
+ export async function readReviewQueue(
49
+ stateDir: string,
50
+ fsx: Pick<typeof fs, 'readFile' | 'writeFile' | 'rename' | 'stat' | 'mkdir'> = fs,
51
+ ): Promise<ReviewQueue> {
52
+ const file = path.join(stateDir, REVIEW_QUEUE_FILE);
53
+ try {
54
+ const raw = await fsx.readFile(file, 'utf8');
55
+ const parsed = parseJsonSafe(raw);
56
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
57
+ throw new Error('sync-review-queue.json 损坏:必须是对象');
58
+ }
59
+ const obj = parsed as Record<string, unknown>;
60
+ if (!Array.isArray(obj['items'])) {
61
+ throw new Error('sync-review-queue.json 损坏:items 必须是数组');
62
+ }
63
+ const items: ReviewQueueItem[] = [];
64
+ for (const it of obj['items']) {
65
+ if (it === null || typeof it !== 'object' || Array.isArray(it)) {
66
+ throw new Error('sync-review-queue.json 损坏:每个 item 必须是对象');
67
+ }
68
+ const i = it as Record<string, unknown>;
69
+ if (typeof i['id'] !== 'string' || typeof i['sectionId'] !== 'string'
70
+ || typeof i['kind'] !== 'string' || typeof i['description'] !== 'string') {
71
+ throw new Error('sync-review-queue.json 损坏:item 必须含字符串 id/sectionId/kind/description');
72
+ }
73
+ items.push(i as unknown as ReviewQueueItem);
74
+ }
75
+ return {
76
+ items,
77
+ updatedAt: typeof obj['updatedAt'] === 'string' ? obj['updatedAt'] : '',
78
+ };
79
+ } catch (err) {
80
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ...EMPTY_REVIEW_QUEUE };
81
+ throw err;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 原子写入:先写临时文件 + rename 到目标文件(POSIX/Windows 均支持 rename 原子替换)。
87
+ * 同一 stateDir 下并发调用不会产生半成品文件(写完临时再 rename)。
88
+ */
89
+ export async function writeReviewQueue(
90
+ stateDir: string,
91
+ queue: ReviewQueue,
92
+ fsx: Pick<typeof fs, 'readFile' | 'writeFile' | 'rename' | 'stat' | 'mkdir'> = fs,
93
+ ): Promise<void> {
94
+ await fsx.mkdir(stateDir, { recursive: true });
95
+ const target = path.join(stateDir, REVIEW_QUEUE_FILE);
96
+ const tmp = path.join(
97
+ stateDir,
98
+ `.${REVIEW_QUEUE_FILE}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
99
+ );
100
+ const data = stringifyJsonSafe(queue, { space: 2 });
101
+ try {
102
+ await fsx.writeFile(tmp, data, 'utf8');
103
+ await fsx.rename(tmp, target);
104
+ } catch (err) {
105
+ // 清理可能残留的临时文件
106
+ try { await fsx.stat(tmp); await fsx.rename(tmp, target); } catch { /* ignore */ }
107
+ throw err;
108
+ }
109
+ }
110
+
111
+ /** 生成稳定 id(基于内容的 hex;同 description → 同 id,便于去重) */
112
+ function hashItemId(sectionId: string, kind: string, description: string, path?: string): string {
113
+ const h = crypto.createHash('sha256');
114
+ h.update(`${sectionId}\u0000${kind}\u0000${path ?? ''}\u0000${description}`);
115
+ return h.digest('hex').slice(0, 16);
116
+ }
117
+
118
+ /** 把多 item 追加到队列(同 id 视为同一项,不重复追加) */
119
+ export async function enqueueItems(
120
+ stateDir: string,
121
+ items: Omit<ReviewQueueItem, 'id' | 'decidedAt' | 'decision'>[],
122
+ now: () => Date = () => new Date(),
123
+ fsx: Pick<typeof fs, 'readFile' | 'writeFile' | 'rename' | 'stat' | 'mkdir'> = fs,
124
+ ): Promise<ReviewQueue> {
125
+ const current = await readReviewQueue(stateDir, fsx);
126
+ const seen = new Set(current.items.map((i) => i.id));
127
+ for (const it of items) {
128
+ const id = hashItemId(it.sectionId, it.kind, it.description);
129
+ if (seen.has(id)) continue;
130
+ current.items.push({ ...it, id });
131
+ seen.add(id);
132
+ }
133
+ current.updatedAt = now().toISOString();
134
+ await writeReviewQueue(stateDir, current, fsx);
135
+ return current;
136
+ }
137
+
138
+ /** 把整组合并后的 autoApply 项(失败回滚后)入队 */
139
+ export async function enqueueAutoAppliedOnFailure(
140
+ stateDir: string,
141
+ items: Omit<ReviewQueueItem, 'id' | 'decidedAt' | 'decision'>[],
142
+ fsx: Pick<typeof fs, 'readFile' | 'writeFile' | 'rename' | 'stat' | 'mkdir'> = fs,
143
+ ): Promise<ReviewQueue> {
144
+ return enqueueItems(stateDir, items, undefined, fsx);
145
+ }
146
+
147
+ /** 用户对某条 item 做了决策(keepLocal / useRemote / skip);未找到抛错。 */
148
+ export async function resolveItem(
149
+ stateDir: string,
150
+ itemId: string,
151
+ decision: 'useRemote' | 'keepLocal' | 'skip',
152
+ now: () => Date = () => new Date(),
153
+ fsx: Pick<typeof fs, 'readFile' | 'writeFile' | 'rename' | 'stat' | 'mkdir'> = fs,
154
+ ): Promise<ReviewQueue> {
155
+ const current = await readReviewQueue(stateDir, fsx);
156
+ const idx = current.items.findIndex((i) => i.id === itemId);
157
+ if (idx === -1) throw new Error(`resolveItem: 未找到 itemId ${itemId}`);
158
+ current.items[idx] = { ...current.items[idx]!, decision, decidedAt: now().toISOString() };
159
+ current.updatedAt = now().toISOString();
160
+ await writeReviewQueue(stateDir, current, fsx);
161
+ return current;
162
+ }
163
+
164
+ /** 工具:在临时目录构造 stateDir 的便捷工厂(测试用) */
165
+ export function tmpStateDir(prefix: string): Promise<string> {
166
+ return fs.mkdtemp(path.join(os.tmpdir(), prefix));
167
+ }