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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-ui (方案 A):同步历史投影(含自动同步记录)纯函数测试。
|
|
3
|
+
* TDD:先写失败测试,再实现 history-model.ts 对应函数。
|
|
4
|
+
*/
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
|
|
8
|
+
import type { SyncHistoryEntry } from './sync-api.ts';
|
|
9
|
+
import {
|
|
10
|
+
autosyncStatusLabel, describeSkipReason, directionLabel, formatDateTime,
|
|
11
|
+
projectAutosyncEntry, projectSyncHistoryEntries,
|
|
12
|
+
} from './history-model.ts';
|
|
13
|
+
import type { AutosyncHistoryEntry } from './sync-api.ts';
|
|
14
|
+
|
|
15
|
+
const autosyncEntry = (overrides: Partial<AutosyncHistoryEntry>): AutosyncHistoryEntry => ({
|
|
16
|
+
direction: 'both',
|
|
17
|
+
status: 'skipped',
|
|
18
|
+
skipReason: 'conflict',
|
|
19
|
+
conflictedSections: ['settings', 'plugins'],
|
|
20
|
+
appliedSections: [],
|
|
21
|
+
failureCountAtRun: 0,
|
|
22
|
+
createdAt: '2026-08-17T10:00:00.000Z',
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('projectSyncHistoryEntries:快照 + 自动同步 按 createdAt 倒序合并', () => {
|
|
27
|
+
const entries: SyncHistoryEntry[] = [
|
|
28
|
+
{ id: 'a', createdAt: '2026-08-17T10:00:00.000Z', kind: 'apply', sectionCount: 3, reviewCount: 0 },
|
|
29
|
+
{
|
|
30
|
+
id: 'b', createdAt: '2026-08-17T12:00:00.000Z', kind: 'autosync',
|
|
31
|
+
autosync: autosyncEntry({ createdAt: '2026-08-17T12:00:00.000Z' }),
|
|
32
|
+
},
|
|
33
|
+
{ id: 'c', createdAt: '2026-08-17T11:00:00.000Z', kind: 'apply', sectionCount: 2, reviewCount: 0 },
|
|
34
|
+
];
|
|
35
|
+
const sorted = projectSyncHistoryEntries(entries);
|
|
36
|
+
assert.equal(sorted[0]!.id, 'b');
|
|
37
|
+
assert.equal(sorted[1]!.id, 'c');
|
|
38
|
+
assert.equal(sorted[2]!.id, 'a');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('directionLabel / autosyncStatusLabel:方向与状态映射', () => {
|
|
42
|
+
assert.equal(directionLabel('pull'), '下载');
|
|
43
|
+
assert.equal(directionLabel('push'), '上传');
|
|
44
|
+
assert.equal(directionLabel('both'), '双向');
|
|
45
|
+
assert.equal(autosyncStatusLabel('success'), '成功');
|
|
46
|
+
assert.equal(autosyncStatusLabel('skipped'), '已跳过');
|
|
47
|
+
assert.equal(autosyncStatusLabel('failed'), '失败');
|
|
48
|
+
assert.equal(autosyncStatusLabel('partial'), '部分成功');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('describeSkipReason:已知原因映射,未知回退原串', () => {
|
|
52
|
+
assert.equal(describeSkipReason('conflict'), '冲突项被跳过');
|
|
53
|
+
assert.equal(describeSkipReason('no-remote'), '远端无快照');
|
|
54
|
+
assert.equal(describeSkipReason('not-configured'), '未配置仓库');
|
|
55
|
+
assert.equal(describeSkipReason('network'), '网络问题');
|
|
56
|
+
assert.equal(describeSkipReason('weird'), 'weird');
|
|
57
|
+
assert.equal(describeSkipReason(undefined), '未知');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('projectAutosyncEntry:摘要行 + 可展开明细(冲突分区 / 应用分区 / 错误)', () => {
|
|
61
|
+
const row = projectAutosyncEntry(autosyncEntry({}));
|
|
62
|
+
assert.equal(row.direction, '双向');
|
|
63
|
+
assert.equal(row.status, '已跳过');
|
|
64
|
+
assert.match(row.summary, /双向/);
|
|
65
|
+
assert.match(row.summary, /已跳过/);
|
|
66
|
+
assert.match(row.summary, /冲突项被跳过/);
|
|
67
|
+
assert.deepEqual(row.conflictedSections, ['settings', 'plugins']);
|
|
68
|
+
assert.equal(row.hasDetail, true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('projectAutosyncEntry:无冲突/无应用/无错误 → hasDetail=false', () => {
|
|
72
|
+
const row = projectAutosyncEntry(autosyncEntry({
|
|
73
|
+
conflictedSections: undefined, appliedSections: undefined, error: undefined,
|
|
74
|
+
}));
|
|
75
|
+
assert.equal(row.hasDetail, false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('formatDateTime:合法 ISO → 本地格式;空/非法回退', () => {
|
|
79
|
+
assert.match(formatDateTime('2026-08-17T10:30:00.000Z'), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
|
|
80
|
+
assert.equal(formatDateTime(''), '—');
|
|
81
|
+
assert.equal(formatDateTime('not-a-date'), 'not-a-date');
|
|
82
|
+
});
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* m-sync-ui (P2b):SyncHistoryView 的纯函数投影。
|
|
3
|
+
*
|
|
4
|
+
* 现状:本地祖先快照目录(manifest.json)→ SnapshotHistoryEntry;倒序排序 + ISO 格式化。
|
|
5
|
+
* 方案 A 扩展:/sync/history 现返回 { entries: SyncHistoryEntry[] }(快照 kind=apply
|
|
6
|
+
* + 自动同步 kind=autosync),投影需统一处理两源,并生成自动同步跳过冲突的可读明细。
|
|
3
7
|
*/
|
|
8
|
+
import type { AutosyncHistoryEntry, SyncHistoryEntry } from './sync-api.ts';
|
|
9
|
+
|
|
10
|
+
/** 兼容旧快照条目(manifest.json 投影)。 */
|
|
4
11
|
export interface SnapshotHistoryEntry {
|
|
5
12
|
id: string;
|
|
6
13
|
createdAt: string;
|
|
@@ -10,9 +17,18 @@ export interface SnapshotHistoryEntry {
|
|
|
10
17
|
reviewCount: number;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
|
-
/**
|
|
20
|
+
/** 把快照 entries 排序(createdAt 倒序)并组装展示字段。 */
|
|
14
21
|
export function projectHistoryRows(entries: readonly SnapshotHistoryEntry[]): SnapshotHistoryEntry[] {
|
|
15
|
-
return [...entries].sort(
|
|
22
|
+
return [...entries].sort(byCreatedAtDesc);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 统一历史条目(快照 + 自动同步)按 createdAt 倒序排序。 */
|
|
26
|
+
export function projectSyncHistoryEntries(entries: readonly SyncHistoryEntry[]): SyncHistoryEntry[] {
|
|
27
|
+
return [...entries].sort(byCreatedAtDesc);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function byCreatedAtDesc(a: { createdAt: string }, b: { createdAt: string }): number {
|
|
31
|
+
return a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0;
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
/** ISO 时间 → 本地可读字符串(短格式) */
|
|
@@ -23,3 +39,74 @@ export function formatDateTime(iso: string): string {
|
|
|
23
39
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
24
40
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
25
41
|
}
|
|
42
|
+
|
|
43
|
+
/* ---------------------------------------------------------------- 自动同步记录投影 */
|
|
44
|
+
|
|
45
|
+
/** 自动同步执行记录的方向可读标签。 */
|
|
46
|
+
export function directionLabel(direction: AutosyncHistoryEntry['direction']): string {
|
|
47
|
+
switch (direction) {
|
|
48
|
+
case 'pull': return '下载';
|
|
49
|
+
case 'push': return '上传';
|
|
50
|
+
default: return '双向';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 自动同步执行状态可读标签。 */
|
|
55
|
+
export function autosyncStatusLabel(status: AutosyncHistoryEntry['status']): string {
|
|
56
|
+
switch (status) {
|
|
57
|
+
case 'success': return '成功';
|
|
58
|
+
case 'skipped': return '已跳过';
|
|
59
|
+
case 'partial': return '部分成功';
|
|
60
|
+
default: return '失败';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 跳过原因 → 可读描述(host 透传语义;未知原因回退原串)。 */
|
|
65
|
+
export function describeSkipReason(reason: string | undefined): string {
|
|
66
|
+
switch (reason) {
|
|
67
|
+
case 'conflict': return '冲突项被跳过';
|
|
68
|
+
case 'no-remote': return '远端无快照';
|
|
69
|
+
case 'not-configured': return '未配置仓库';
|
|
70
|
+
case 'network': return '网络问题';
|
|
71
|
+
default: return reason ?? '未知';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 把一条自动同步记录投影为展示行(摘要文本 + 可展开的被跳过冲突分区明细)。 */
|
|
76
|
+
export interface AutosyncHistoryRow {
|
|
77
|
+
id: string;
|
|
78
|
+
createdAt: string;
|
|
79
|
+
direction: string;
|
|
80
|
+
status: string;
|
|
81
|
+
/** 摘要行文本(如「下载 · 已跳过 · 冲突项被跳过」)。 */
|
|
82
|
+
summary: string;
|
|
83
|
+
/** 被跳过的冲突分区 id(展开明细用);无则 undefined。 */
|
|
84
|
+
conflictedSections?: string[];
|
|
85
|
+
/** 实际应用的分区 id。 */
|
|
86
|
+
appliedSections?: string[];
|
|
87
|
+
error?: string;
|
|
88
|
+
notifiedAt?: string;
|
|
89
|
+
/** 是否有关联的跳过分区明细可展开。 */
|
|
90
|
+
hasDetail: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 自动同步记录 → 展示行投影。 */
|
|
94
|
+
export function projectAutosyncEntry(entry: AutosyncHistoryEntry): AutosyncHistoryRow {
|
|
95
|
+
const parts: string[] = [directionLabel(entry.direction), autosyncStatusLabel(entry.status)];
|
|
96
|
+
if (entry.skipReason !== undefined) parts.push(describeSkipReason(entry.skipReason));
|
|
97
|
+
return {
|
|
98
|
+
id: entry.createdAt,
|
|
99
|
+
createdAt: entry.createdAt,
|
|
100
|
+
direction: directionLabel(entry.direction),
|
|
101
|
+
status: autosyncStatusLabel(entry.status),
|
|
102
|
+
summary: parts.join(' · '),
|
|
103
|
+
conflictedSections: entry.conflictedSections,
|
|
104
|
+
appliedSections: entry.appliedSections,
|
|
105
|
+
error: entry.error,
|
|
106
|
+
notifiedAt: entry.notifiedAt,
|
|
107
|
+
hasDetail:
|
|
108
|
+
(entry.conflictedSections !== undefined && entry.conflictedSections.length > 0) ||
|
|
109
|
+
(entry.appliedSections !== undefined && entry.appliedSections.length > 0) ||
|
|
110
|
+
entry.error !== undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -177,3 +177,158 @@ test('S-09 api.githubCancel():POST /sync/github/cancel 携带 flowId', async (
|
|
|
177
177
|
const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
|
|
178
178
|
assert.equal(sent['flowId'], 'flow-1');
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
/* ------------------------------------------------ 一键同步(方案 A)端点契约 */
|
|
182
|
+
|
|
183
|
+
test('S-10 api.snapshotsList():POST /sync/snapshots-list,解析倒序快照列表 + currentSnapshotId', async () => {
|
|
184
|
+
const body = {
|
|
185
|
+
ok: true,
|
|
186
|
+
snapshots: [
|
|
187
|
+
{ id: 'sync-2', createdAt: '2026-08-17T10:00:00.000Z', sectionCount: 3, platform: 'darwin', dshVersion: '1.0.0' },
|
|
188
|
+
{ id: 'sync-1', createdAt: '2026-08-16T10:00:00.000Z', sectionCount: 2, platform: 'darwin', dshVersion: '1.0.0' },
|
|
189
|
+
],
|
|
190
|
+
currentSnapshotId: 'sync-1',
|
|
191
|
+
};
|
|
192
|
+
const calls: FetchCall[] = [];
|
|
193
|
+
installFetchMock((call) => {
|
|
194
|
+
calls.push(call);
|
|
195
|
+
return jsonResponse(200, body);
|
|
196
|
+
});
|
|
197
|
+
const api = new SyncApi();
|
|
198
|
+
const result = await api.snapshotsList({ repoUrl: 'https://github.com/u/r.git' });
|
|
199
|
+
assert.equal(result.ok, true);
|
|
200
|
+
assert.equal(result.snapshots.length, 2);
|
|
201
|
+
assert.equal(result.snapshots[0]?.id, 'sync-2');
|
|
202
|
+
assert.equal(result.currentSnapshotId, 'sync-1');
|
|
203
|
+
assert.equal(calls[0]?.url, SYNC_API.snapshotsList);
|
|
204
|
+
assert.equal(calls[0]?.init?.method, 'POST');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('S-11 api.sync():POST /sync/sync,请求体携带 snapshotId;响应含 items/needsReview/compatibility', async () => {
|
|
208
|
+
const body = {
|
|
209
|
+
ok: true,
|
|
210
|
+
syncSessionId: 'sess-1',
|
|
211
|
+
snapshotId: 'sync-3',
|
|
212
|
+
items: [{ itemId: 'settings:a', adapter: 'settings', kind: 'Update', description: '更新', severity: 'info', defaultAdopt: true, adopt: true }],
|
|
213
|
+
needsReview: false,
|
|
214
|
+
compatibility: 'good',
|
|
215
|
+
};
|
|
216
|
+
const calls: FetchCall[] = [];
|
|
217
|
+
installFetchMock((call) => {
|
|
218
|
+
calls.push(call);
|
|
219
|
+
return jsonResponse(200, body);
|
|
220
|
+
});
|
|
221
|
+
const api = new SyncApi();
|
|
222
|
+
const result = await api.sync({ repoUrl: 'https://github.com/u/r.git', snapshotId: 'sync-3' });
|
|
223
|
+
assert.equal(result.syncSessionId, 'sess-1');
|
|
224
|
+
assert.equal(result.items.length, 1);
|
|
225
|
+
assert.equal(result.compatibility, 'good');
|
|
226
|
+
assert.equal(calls[0]?.url, SYNC_API.sync);
|
|
227
|
+
const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
|
|
228
|
+
assert.equal(sent['snapshotId'], 'sync-3');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('S-12 api.applyItems():POST /sync/apply-items,携带 adoptions(含 Conflict resolution)', async () => {
|
|
232
|
+
const body = {
|
|
233
|
+
ok: true, applied: ['settings'], skipped: ['plugin:x'], needsRestart: false,
|
|
234
|
+
warnings: [], restoreId: 'rest-1', rolledBack: false, failed: [], result: {},
|
|
235
|
+
};
|
|
236
|
+
const calls: FetchCall[] = [];
|
|
237
|
+
installFetchMock((call) => {
|
|
238
|
+
calls.push(call);
|
|
239
|
+
return jsonResponse(200, body);
|
|
240
|
+
});
|
|
241
|
+
const api = new SyncApi();
|
|
242
|
+
const result = await api.applyItems({
|
|
243
|
+
syncSessionId: 'sess-1',
|
|
244
|
+
adoptions: [
|
|
245
|
+
{ itemId: 'settings:a', adopt: true },
|
|
246
|
+
{ itemId: 'plugin:x', adopt: true, resolution: 'useRemote' },
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
assert.equal(result.applied[0], 'settings');
|
|
250
|
+
assert.equal(result.restoreId, 'rest-1');
|
|
251
|
+
assert.equal(calls[0]?.url, SYNC_API.applyItems);
|
|
252
|
+
const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
|
|
253
|
+
assert.equal(sent['syncSessionId'], 'sess-1');
|
|
254
|
+
const adoptions = sent['adoptions'] as Array<Record<string, unknown>>;
|
|
255
|
+
assert.equal(adoptions[1]?.['resolution'], 'useRemote');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('S-13 api.cancel():POST /sync/cancel 携带 syncSessionId', async () => {
|
|
259
|
+
const calls: FetchCall[] = [];
|
|
260
|
+
installFetchMock((call) => {
|
|
261
|
+
calls.push(call);
|
|
262
|
+
return jsonResponse(200, { ok: true });
|
|
263
|
+
});
|
|
264
|
+
const api = new SyncApi();
|
|
265
|
+
const result = await api.cancel('sess-1');
|
|
266
|
+
assert.equal(result.ok, true);
|
|
267
|
+
assert.equal(calls[0]?.url, SYNC_API.cancel);
|
|
268
|
+
const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
|
|
269
|
+
assert.equal(sent['syncSessionId'], 'sess-1');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
/* ------------------------------------------------ 自动同步端点契约 */
|
|
273
|
+
|
|
274
|
+
test('S-14 api.autosyncStatus():GET /sync/autosync,解析 enabled/interval/elapsedMs', async () => {
|
|
275
|
+
const body = {
|
|
276
|
+
enabled: true, interval: '30m', lastRunAt: '2026-08-17T10:00:00.000Z',
|
|
277
|
+
lastRunStatus: 'success', consecutiveFailures: 0, elapsedMs: 60000,
|
|
278
|
+
};
|
|
279
|
+
let called: FetchCall | null = null;
|
|
280
|
+
installFetchMock((call) => {
|
|
281
|
+
called = call;
|
|
282
|
+
return jsonResponse(200, body);
|
|
283
|
+
});
|
|
284
|
+
const lastCall = (): FetchCall | null => called;
|
|
285
|
+
const api = new SyncApi();
|
|
286
|
+
const result = await api.autosyncStatus();
|
|
287
|
+
assert.equal(result.enabled, true);
|
|
288
|
+
assert.equal(result.interval, '30m');
|
|
289
|
+
assert.equal(result.consecutiveFailures, 0);
|
|
290
|
+
assert.equal(result.elapsedMs, 60000);
|
|
291
|
+
assert.equal(lastCall()?.url, SYNC_API.autosync);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('S-15 api.autosyncUpdate():POST /sync/autosync,请求体携带 enabled/interval', async () => {
|
|
295
|
+
const body = {
|
|
296
|
+
enabled: true, interval: '60m', consecutiveFailures: 0, elapsedMs: -1,
|
|
297
|
+
};
|
|
298
|
+
const calls: FetchCall[] = [];
|
|
299
|
+
installFetchMock((call) => {
|
|
300
|
+
calls.push(call);
|
|
301
|
+
return jsonResponse(200, body);
|
|
302
|
+
});
|
|
303
|
+
const api = new SyncApi();
|
|
304
|
+
const result = await api.autosyncUpdate({ enabled: true, interval: '60m' });
|
|
305
|
+
assert.equal(result.enabled, true);
|
|
306
|
+
assert.equal(result.interval, '60m');
|
|
307
|
+
assert.equal(calls[0]?.url, SYNC_API.autosync);
|
|
308
|
+
assert.equal(calls[0]?.init?.method, 'POST');
|
|
309
|
+
const sent = JSON.parse(String(calls[0]?.init?.body ?? '{}')) as Record<string, unknown>;
|
|
310
|
+
assert.equal(sent['enabled'], true);
|
|
311
|
+
assert.equal(sent['interval'], '60m');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('S-16 api.history():GET /sync/history,解析 { entries }(含 autosync 记录)', async () => {
|
|
315
|
+
const body = {
|
|
316
|
+
entries: [
|
|
317
|
+
{ id: 'sync-1', createdAt: '2026-08-17T10:00:00.000Z', kind: 'apply', sectionCount: 3, reviewCount: 0 },
|
|
318
|
+
{
|
|
319
|
+
id: '2026-08-17T09:00:00.000Z', createdAt: '2026-08-17T09:00:00.000Z', kind: 'autosync',
|
|
320
|
+
autosync: {
|
|
321
|
+
direction: 'both', status: 'skipped', skipReason: 'conflict',
|
|
322
|
+
conflictedSections: ['settings'], failureCountAtRun: 0, createdAt: '2026-08-17T09:00:00.000Z',
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
};
|
|
327
|
+
installFetchMock(() => jsonResponse(200, body));
|
|
328
|
+
const api = new SyncApi();
|
|
329
|
+
const result = await api.history();
|
|
330
|
+
assert.equal(result.entries.length, 2);
|
|
331
|
+
assert.equal(result.entries[0]?.kind, 'apply');
|
|
332
|
+
assert.equal(result.entries[1]?.kind, 'autosync');
|
|
333
|
+
assert.deepEqual(result.entries[1]?.autosync?.conflictedSections, ['settings']);
|
|
334
|
+
});
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
* - 错误消息由 Host 侧已脱敏(GitTransport 统一 [REDACTED]),UI 侧再经 ErrorBanner redact 兜底;
|
|
23
23
|
* - 本文件不 import 任何 node 模块(纯浏览器 bundle;sync-engine 仅作 type-only 引用)。
|
|
24
24
|
*/
|
|
25
|
-
import type {
|
|
25
|
+
import type { SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts';
|
|
26
|
+
import type { PlanItemKind } from '../../core/types.ts';
|
|
27
|
+
import type { SectionId } from '../../schema/types.ts';
|
|
26
28
|
import { ConfigManagerApiError } from '../api.ts';
|
|
27
29
|
import { zhUiT, type UiT } from '../../ui/i18n.ts';
|
|
28
30
|
|
|
@@ -36,7 +38,11 @@ export const SYNC_API = {
|
|
|
36
38
|
githubPoll: '/api/dsh-config-manager/sync/github/poll',
|
|
37
39
|
githubCancel: '/api/dsh-config-manager/sync/github/cancel',
|
|
38
40
|
history: '/api/dsh-config-manager/sync/history',
|
|
39
|
-
|
|
41
|
+
snapshotsList: '/api/dsh-config-manager/sync/snapshots-list',
|
|
42
|
+
sync: '/api/dsh-config-manager/sync/sync',
|
|
43
|
+
applyItems: '/api/dsh-config-manager/sync/apply-items',
|
|
44
|
+
cancel: '/api/dsh-config-manager/sync/cancel',
|
|
45
|
+
autosync: '/api/dsh-config-manager/sync/autosync',
|
|
40
46
|
rollback: '/api/dsh-config-manager/sync/rollback',
|
|
41
47
|
} as const;
|
|
42
48
|
|
|
@@ -59,6 +65,8 @@ export interface SyncStatusResponse {
|
|
|
59
65
|
/** sync-state.sections 条目数 */
|
|
60
66
|
sectionCount: number;
|
|
61
67
|
transport?: { type: string; ref: string };
|
|
68
|
+
/** 自动同步当前状态(供 UI 顶部开关回填;§3.9) */
|
|
69
|
+
autosync?: AutosyncStatusResponse;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
/** push 请求体(token 可选:非空则 Host 先写入 DSH credentials 再使用) */
|
|
@@ -74,6 +82,172 @@ export interface SyncPullPayload extends SyncPushPayload {
|
|
|
74
82
|
snapshotId?: string;
|
|
75
83
|
}
|
|
76
84
|
|
|
85
|
+
/* ---------------------------------------------------------------- 一键同步(方案 A) */
|
|
86
|
+
|
|
87
|
+
/** GET /sync/snapshots-list 响应:远端历史快照列表(按 createdAt 倒序)。 */
|
|
88
|
+
export interface SyncSnapshotsListResponse {
|
|
89
|
+
ok: boolean;
|
|
90
|
+
/** 按 createdAt 倒序(最新在前) */
|
|
91
|
+
snapshots: SyncSnapshotLite[];
|
|
92
|
+
/** 当前本地祖先指针(sync-state.lastSnapshotId),用于高亮当前基线 */
|
|
93
|
+
currentSnapshotId?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 远端快照摘要(「选择历史快照」下拉项)。 */
|
|
97
|
+
export interface SyncSnapshotLite {
|
|
98
|
+
id: string;
|
|
99
|
+
createdAt: string;
|
|
100
|
+
sectionCount: number;
|
|
101
|
+
platform: string;
|
|
102
|
+
dshVersion: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** POST /sync/sync 请求体(一键同步第一步:拉取 → 差异确认会话)。 */
|
|
106
|
+
export interface SyncStartPayload {
|
|
107
|
+
repoUrl: string;
|
|
108
|
+
gitBin?: string;
|
|
109
|
+
token?: string;
|
|
110
|
+
/** 缺省 = 最新快照;传入则对该历史快照拉取 */
|
|
111
|
+
snapshotId?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** POST /sync/sync 响应:差异确认会话(items 供 UI 逐项确认)。 */
|
|
115
|
+
export interface SyncStartResponse {
|
|
116
|
+
ok: boolean;
|
|
117
|
+
/** 差异确认会话 id:后续 apply-items / cancel 引用 */
|
|
118
|
+
syncSessionId: string;
|
|
119
|
+
/** 被拉取的远端快照 id */
|
|
120
|
+
snapshotId: string;
|
|
121
|
+
items: SyncConfirmItem[];
|
|
122
|
+
/** 是否包含任何需人工决策项 */
|
|
123
|
+
needsReview: boolean;
|
|
124
|
+
compatibility: 'excellent' | 'good' | 'partial' | 'unsupported';
|
|
125
|
+
message?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 单条可确认的差异项(由 ImportPlan.item 投影 + 冲突详情)。 */
|
|
129
|
+
export interface SyncConfirmItem {
|
|
130
|
+
itemId: string;
|
|
131
|
+
adapter: SectionId;
|
|
132
|
+
kind: PlanItemKind;
|
|
133
|
+
description: string;
|
|
134
|
+
severity: 'info' | 'warning' | 'error';
|
|
135
|
+
/** 默认采纳方向;Conflict/MissingSecret 等人工项默认 false */
|
|
136
|
+
defaultAdopt: boolean;
|
|
137
|
+
/** 用户最终决策(缺省 = defaultAdopt) */
|
|
138
|
+
adopt: boolean;
|
|
139
|
+
/** 冲突项内联解决所需详情(仅 Conflict 项非空) */
|
|
140
|
+
conflict?: SyncConflictDetail;
|
|
141
|
+
/** 该项若采用将写入的目标摘要 */
|
|
142
|
+
target?: { adapter: SectionId; ref: string };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 冲突项内联解决详情(来源 MergeConflict + 可读 diff)。 */
|
|
146
|
+
export interface SyncConflictDetail {
|
|
147
|
+
path: string;
|
|
148
|
+
kind: 'key' | 'file' | 'section';
|
|
149
|
+
local?: unknown;
|
|
150
|
+
remote?: unknown;
|
|
151
|
+
ancestor?: unknown;
|
|
152
|
+
diff?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** POST /sync/apply-items 请求体(一键同步第二步:按逐项决策执行导入)。 */
|
|
156
|
+
export interface ApplyItemsPayload {
|
|
157
|
+
syncSessionId: string;
|
|
158
|
+
/** 每项的最终采纳决策(未列出项视为 adopt=false) */
|
|
159
|
+
adoptions: SyncItemAdoption[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 单条采纳决策。 */
|
|
163
|
+
export interface SyncItemAdoption {
|
|
164
|
+
itemId: string;
|
|
165
|
+
adopt: boolean;
|
|
166
|
+
/** 冲突项解决方案(仅当该项是 Conflict 且 adopt=true 时必须) */
|
|
167
|
+
resolution?: 'useRemote' | 'keepLocal' | 'skip';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** POST /sync/apply-items 响应。 */
|
|
171
|
+
export interface ApplyItemsResponse {
|
|
172
|
+
ok: boolean;
|
|
173
|
+
applied: string[];
|
|
174
|
+
skipped: string[];
|
|
175
|
+
needsRestart: boolean;
|
|
176
|
+
warnings: string[];
|
|
177
|
+
/** 应用前快照 id(UI 一键回滚用) */
|
|
178
|
+
restoreId: string;
|
|
179
|
+
/** 任一失败是否整体回滚 */
|
|
180
|
+
rolledBack: boolean;
|
|
181
|
+
failed: { itemId: string; message?: string }[];
|
|
182
|
+
result: unknown;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ---------------------------------------------------------------- 自动同步 */
|
|
186
|
+
|
|
187
|
+
/** 统一间隔类型。 */
|
|
188
|
+
export type AutosyncInterval = '5m' | '15m' | '30m' | '60m' | '6h' | '12h' | '24h';
|
|
189
|
+
|
|
190
|
+
/** 最近一次自动同步执行状态。 */
|
|
191
|
+
export type AutosyncRunStatus = 'success' | 'skipped' | 'failed' | 'partial';
|
|
192
|
+
|
|
193
|
+
/** GET/POST /sync/autosync 响应:自动同步状态。 */
|
|
194
|
+
export interface AutosyncStatusResponse {
|
|
195
|
+
enabled: boolean;
|
|
196
|
+
interval: AutosyncInterval;
|
|
197
|
+
lastRunAt?: string;
|
|
198
|
+
lastRunStatus?: AutosyncRunStatus;
|
|
199
|
+
lastRunMessage?: string;
|
|
200
|
+
consecutiveFailures: number;
|
|
201
|
+
/** 距上次自动同步已过 ms(host 计算,供 UI 倒计时/立即触发判断) */
|
|
202
|
+
elapsedMs: number;
|
|
203
|
+
lastRunHistoryId?: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** POST /sync/autosync 请求体。 */
|
|
207
|
+
export interface AutosyncUpdatePayload {
|
|
208
|
+
enabled: boolean;
|
|
209
|
+
interval?: AutosyncInterval;
|
|
210
|
+
startupMinIntervalMs?: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ---------------------------------------------------------------- 同步历史 */
|
|
214
|
+
|
|
215
|
+
/** 自动同步执行记录(§3.7 AutosyncHistoryEntry)。 */
|
|
216
|
+
export interface AutosyncHistoryEntry {
|
|
217
|
+
direction: 'pull' | 'push' | 'both';
|
|
218
|
+
status: 'success' | 'skipped' | 'failed' | 'partial';
|
|
219
|
+
/** 跳过原因(冲突项 / 缺失依赖 / Install / 错误 / 无远端 / 网络) */
|
|
220
|
+
skipReason?: string;
|
|
221
|
+
/** 被跳过的冲突分区 id(冲突跳过时列出) */
|
|
222
|
+
conflictedSections?: string[];
|
|
223
|
+
/** 本次自动合并实际写入的分区 */
|
|
224
|
+
appliedSections?: string[];
|
|
225
|
+
/** 本次 push 产生的快照 id */
|
|
226
|
+
pushedSnapshotId?: string;
|
|
227
|
+
/** 本次 pull 来源快照 id */
|
|
228
|
+
pulledSnapshotId?: string;
|
|
229
|
+
error?: string;
|
|
230
|
+
notifiedAt?: string;
|
|
231
|
+
/** 本次触发时的连续失败计数 */
|
|
232
|
+
failureCountAtRun: number;
|
|
233
|
+
createdAt: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 同步历史条目(Host 端返回;kind='autosync' 时 autosync 非空)。 */
|
|
237
|
+
export interface SyncHistoryEntry {
|
|
238
|
+
id: string;
|
|
239
|
+
createdAt: string;
|
|
240
|
+
kind: 'push' | 'pull' | 'apply' | 'autosync' | 'rollback';
|
|
241
|
+
sectionCount?: number;
|
|
242
|
+
reviewCount?: number;
|
|
243
|
+
autosync?: AutosyncHistoryEntry;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** GET /sync/history 响应:{ entries }。 */
|
|
247
|
+
export interface SyncHistoryResponse {
|
|
248
|
+
entries: SyncHistoryEntry[];
|
|
249
|
+
}
|
|
250
|
+
|
|
77
251
|
/* ---------------------------------------------------------------- GitHub OAuth device flow */
|
|
78
252
|
|
|
79
253
|
/** POST /sync/github/start 响应:UI 展示用(device_code 只存宿主,绝不回传) */
|
|
@@ -193,15 +367,41 @@ export class SyncApi {
|
|
|
193
367
|
return postJson<{ ok: boolean }>(SYNC_API.githubCancel, { flowId }, this.t);
|
|
194
368
|
}
|
|
195
369
|
|
|
196
|
-
/**
|
|
197
|
-
async history(): Promise<
|
|
370
|
+
/** 同步历史:列出本地祖先快照 + 自动同步执行记录(按 createdAt 倒序合并)。 */
|
|
371
|
+
async history(): Promise<SyncHistoryResponse> {
|
|
198
372
|
const response = await fetch(SYNC_API.history);
|
|
199
|
-
return readJson<
|
|
373
|
+
return readJson<SyncHistoryResponse>(response, this.t);
|
|
200
374
|
}
|
|
201
375
|
|
|
202
|
-
/**
|
|
203
|
-
async
|
|
204
|
-
return postJson<
|
|
376
|
+
/** 远端历史快照列表(供「选择历史快照」下拉)。 */
|
|
377
|
+
async snapshotsList(payload: SyncPushPayload): Promise<SyncSnapshotsListResponse> {
|
|
378
|
+
return postJson<SyncSnapshotsListResponse>(SYNC_API.snapshotsList, payload, this.t);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 一键同步第一步:拉取 → 差异确认会话(items 逐项确认,暂不导入)。 */
|
|
382
|
+
async sync(payload: SyncStartPayload): Promise<SyncStartResponse> {
|
|
383
|
+
return postJson<SyncStartResponse>(SYNC_API.sync, payload, this.t);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 一键同步第二步:按用户对差异项的逐项决策执行导入。 */
|
|
387
|
+
async applyItems(payload: ApplyItemsPayload): Promise<ApplyItemsResponse> {
|
|
388
|
+
return postJson<ApplyItemsResponse>(SYNC_API.applyItems, payload, this.t);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 取消/清理差异确认会话(丢弃临时 ZIP,零副作用)。 */
|
|
392
|
+
async cancel(syncSessionId: string): Promise<{ ok: boolean }> {
|
|
393
|
+
return postJson<{ ok: boolean }>(SYNC_API.cancel, { syncSessionId }, this.t);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 自动同步状态(GET /sync/autosync)。 */
|
|
397
|
+
async autosyncStatus(): Promise<AutosyncStatusResponse> {
|
|
398
|
+
const response = await fetch(SYNC_API.autosync);
|
|
399
|
+
return readJson<AutosyncStatusResponse>(response, this.t);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 自动同步配置更新(POST /sync/autosync)。 */
|
|
403
|
+
async autosyncUpdate(payload: AutosyncUpdatePayload): Promise<AutosyncStatusResponse> {
|
|
404
|
+
return postJson<AutosyncStatusResponse>(SYNC_API.autosync, payload, this.t);
|
|
205
405
|
}
|
|
206
406
|
|
|
207
407
|
/** 一键回滚:按 restoreId 调用 backup→rollback */
|
|
@@ -209,11 +409,3 @@ export class SyncApi {
|
|
|
209
409
|
return postJson<{ ok: boolean; full: boolean }>(SYNC_API.rollback, payload, this.t);
|
|
210
410
|
}
|
|
211
411
|
}
|
|
212
|
-
|
|
213
|
-
/** 同步历史条目(Host 端返回) */
|
|
214
|
-
export interface SyncHistoryEntry {
|
|
215
|
-
id: string;
|
|
216
|
-
createdAt: string;
|
|
217
|
-
sectionCount: number;
|
|
218
|
-
reviewCount: number;
|
|
219
|
-
}
|