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.
- package/lib/client.d.ts +319 -2
- package/lib/client.js +1271 -109
- 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 +408 -2
- package/lib/index.js.map +1 -1
- package/lib/sync/ancestor.d.ts +17 -0
- package/lib/sync/ancestor.js +64 -0
- package/lib/sync/ancestor.js.map +1 -0
- 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/merge.d.ts +42 -0
- package/lib/sync/merge.js +325 -0
- package/lib/sync/merge.js.map +1 -0
- package/lib/sync/review-queue.d.ts +51 -0
- package/lib/sync/review-queue.js +119 -0
- package/lib/sync/review-queue.js.map +1 -0
- package/lib/sync/risk.d.ts +59 -0
- package/lib/sync/risk.js +76 -0
- package/lib/sync/risk.js.map +1 -0
- package/lib/sync/sync-config.d.ts +7 -2
- package/lib/sync/sync-config.js +24 -4
- package/lib/sync/sync-config.js.map +1 -1
- package/lib/sync/sync-engine.d.ts +88 -2
- package/lib/sync/sync-engine.js +333 -8
- 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/sync/sync-state.d.ts +7 -2
- package/lib/sync/sync-state.js +9 -5
- package/lib/sync/sync-state.js.map +1 -1
- 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.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +155 -0
- package/src/client/sync/SyncSettingsView.tsx +241 -12
- package/src/client/sync/history-model.test.ts +82 -0
- package/src/client/sync/history-model.ts +112 -0
- package/src/client/sync/sync-api.test.ts +155 -0
- package/src/client/sync/sync-api.ts +219 -0
- 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 +420 -3
- package/src/sync/ancestor.test.ts +152 -0
- package/src/sync/ancestor.ts +81 -0
- 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/merge.test.ts +204 -0
- package/src/sync/merge.ts +360 -0
- package/src/sync/review-queue.test.ts +120 -0
- package/src/sync/review-queue.ts +167 -0
- package/src/sync/risk.test.ts +131 -0
- package/src/sync/risk.ts +122 -0
- package/src/sync/sync-config.test.ts +106 -0
- package/src/sync/sync-config.ts +23 -4
- package/src/sync/sync-engine.test.ts +392 -3
- package/src/sync/sync-engine.ts +383 -10
- 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/sync/sync-state.test.ts +48 -2
- package/src/sync/sync-state.ts +11 -5
- package/src/ui/i18n.ts +132 -3
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutoSyncScheduler:宿主后台自动同步调度器。
|
|
3
|
+
*
|
|
4
|
+
* 生命周期:
|
|
5
|
+
* - start():读 autosync-config;若 enabled 启动定时器(按 interval);无条件执行一次
|
|
6
|
+
* 「启动触发下载合并」(受 startupMinIntervalMs 阈值约束)。
|
|
7
|
+
* - stop():清定时器、标记不再调度。
|
|
8
|
+
* - runOnce():执行一次完整双向自动同步(§6.1 流程)。
|
|
9
|
+
*
|
|
10
|
+
* 核心逻辑(runOnce):
|
|
11
|
+
* - 读配置;若 !enabled → return
|
|
12
|
+
* - runs.register('autosync') 防重复;同 kind running → 跳过
|
|
13
|
+
* - readSyncConfig → repoUrl 无 → 记 skipped(未配置) → return
|
|
14
|
+
* - Phase A: engine.merge() 三方合并 → 判定 needsReview(冲突/缺失依赖/Install/Error)
|
|
15
|
+
* - 冲突 → 跳过 + 写历史 skipped + conflictedSections[] → return
|
|
16
|
+
* - Phase B: 无冲突 → engine.applyMergePlan(apply) 写入本地
|
|
17
|
+
* - Phase C: 完整双向 → engine.push() 上传
|
|
18
|
+
* - 收尾:写 autosync-config(lastRunAt, lastRunStatus, consecutiveFailures, lastRunHistoryId)
|
|
19
|
+
*
|
|
20
|
+
* 连续失败计数:只对网络/传输/apply 真实失败计数;skipped(未配置/冲突跳过/无远端)不计。
|
|
21
|
+
* 连续失败 ≥ 3 → host.log.warn 通知 + 记 notifiedAt。
|
|
22
|
+
*/
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
import type { Logger } from '../utils/logger.ts';
|
|
26
|
+
import type { MsgFunc } from '../core/messages.ts';
|
|
27
|
+
import type { SectionId } from '../schema/types.ts';
|
|
28
|
+
import type { RunRegistry } from '../core/run-registry.ts';
|
|
29
|
+
import type { SyncEngine } from './sync-engine.ts';
|
|
30
|
+
import { readAutosyncConfig, writeAutosyncConfig } from './autosync-config.ts';
|
|
31
|
+
import type { AutosyncConfig, AutosyncInterval, AutosyncRunStatus } from './autosync-config.ts';
|
|
32
|
+
import { readSyncConfig } from './sync-config.ts';
|
|
33
|
+
import type { SyncConfig } from './sync-config.ts';
|
|
34
|
+
import { readSyncHistory, appendAutosyncEntry } from './sync-history.ts';
|
|
35
|
+
import type { AutosyncHistoryEntry } from './sync-history.ts';
|
|
36
|
+
import type { MergePlan, MergeSectionResult } from './merge.ts';
|
|
37
|
+
import type { SyncApplyPlan } from './risk.ts';
|
|
38
|
+
|
|
39
|
+
/** 间隔 → ms 换算(§4.3) */
|
|
40
|
+
export function intervalToMs(interval: AutosyncInterval): number {
|
|
41
|
+
const table: Record<AutosyncInterval, number> = {
|
|
42
|
+
'5m': 5 * 60 * 1000,
|
|
43
|
+
'15m': 15 * 60 * 1000,
|
|
44
|
+
'30m': 30 * 60 * 1000,
|
|
45
|
+
'60m': 60 * 60 * 1000,
|
|
46
|
+
'6h': 6 * 60 * 60 * 1000,
|
|
47
|
+
'12h': 12 * 60 * 60 * 1000,
|
|
48
|
+
'24h': 24 * 60 * 60 * 1000,
|
|
49
|
+
};
|
|
50
|
+
return table[interval];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 启动触发下载合并且满足阈值(now - lastRunAt >= startupMinIntervalMs)?
|
|
55
|
+
* lastRunAt 为 undefined(从未运行)→ true。
|
|
56
|
+
*/
|
|
57
|
+
export function shouldTriggerStartupRun(
|
|
58
|
+
lastRunAt: string | undefined,
|
|
59
|
+
startupMinIntervalMs: number,
|
|
60
|
+
nowMs: number = Date.now(),
|
|
61
|
+
): boolean {
|
|
62
|
+
if (lastRunAt === undefined || lastRunAt === '') return true;
|
|
63
|
+
const lastMs = Date.parse(lastRunAt);
|
|
64
|
+
if (Number.isNaN(lastMs)) return true;
|
|
65
|
+
return nowMs - lastMs >= startupMinIntervalMs;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** runOnce 执行结果 */
|
|
69
|
+
export interface AutosyncRunResult {
|
|
70
|
+
status: 'success' | 'skipped' | 'failed' | 'partial';
|
|
71
|
+
direction: 'pull' | 'push' | 'both' | 'none';
|
|
72
|
+
skipReason?: string;
|
|
73
|
+
conflictedSections?: SectionId[];
|
|
74
|
+
appliedSections?: SectionId[];
|
|
75
|
+
pushedSnapshotId?: string;
|
|
76
|
+
pulledSnapshotId?: string;
|
|
77
|
+
error?: string;
|
|
78
|
+
historyId: string;
|
|
79
|
+
consecutiveFailures: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AutoSyncSchedulerOptions {
|
|
83
|
+
syncDir: string;
|
|
84
|
+
host: { log: Logger };
|
|
85
|
+
makeSyncEngine: (repoUrl: string, gitBin?: string) => SyncEngine;
|
|
86
|
+
/** 消息翻译器 */
|
|
87
|
+
msg: MsgFunc;
|
|
88
|
+
runs: RunRegistry;
|
|
89
|
+
/** 时间源(测试注入) */
|
|
90
|
+
now?: () => Date;
|
|
91
|
+
/** 注入 autosync-config 读写(测试可内存实现) */
|
|
92
|
+
readConfig?: () => Promise<AutosyncConfig>;
|
|
93
|
+
writeConfig?: (cfg: AutosyncConfig) => Promise<void>;
|
|
94
|
+
/** 注入 sync-config 读取 */
|
|
95
|
+
readSyncConfigFn?: () => Promise<SyncConfig | null>;
|
|
96
|
+
/** 注入 sync-history 读写 */
|
|
97
|
+
readHistoryFn?: () => Promise<Awaited<ReturnType<typeof readSyncHistory>>>;
|
|
98
|
+
appendHistoryFn?: (entry: AutosyncHistoryEntry) => Promise<void>;
|
|
99
|
+
/** 注入计时器(测试用;缺省 setInterval/clearInterval) */
|
|
100
|
+
setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
101
|
+
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class AutoSyncScheduler {
|
|
105
|
+
private readonly syncDir: string;
|
|
106
|
+
private readonly host: { log: Logger };
|
|
107
|
+
private readonly makeSyncEngine: (repoUrl: string, gitBin?: string) => SyncEngine;
|
|
108
|
+
private readonly msg: MsgFunc;
|
|
109
|
+
private readonly runs: RunRegistry;
|
|
110
|
+
private readonly now: () => Date;
|
|
111
|
+
private readonly readConfig: () => Promise<AutosyncConfig>;
|
|
112
|
+
private readonly writeConfig: (cfg: AutosyncConfig) => Promise<void>;
|
|
113
|
+
private readonly readSyncConfigFn: () => Promise<SyncConfig | null>;
|
|
114
|
+
private readonly readHistoryFn: () => Promise<Awaited<ReturnType<typeof readSyncHistory>>>;
|
|
115
|
+
private readonly appendHistoryFn: (entry: AutosyncHistoryEntry) => Promise<void>;
|
|
116
|
+
private readonly setTimer: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
117
|
+
private readonly clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
|
|
118
|
+
|
|
119
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
120
|
+
private stopped = false;
|
|
121
|
+
private running = false;
|
|
122
|
+
|
|
123
|
+
constructor(opts: AutoSyncSchedulerOptions) {
|
|
124
|
+
this.syncDir = opts.syncDir;
|
|
125
|
+
this.host = opts.host;
|
|
126
|
+
this.makeSyncEngine = opts.makeSyncEngine;
|
|
127
|
+
this.msg = opts.msg;
|
|
128
|
+
this.runs = opts.runs;
|
|
129
|
+
this.now = opts.now ?? (() => new Date());
|
|
130
|
+
this.readConfig = opts.readConfig ?? (() => readAutosyncConfig(this.syncDir));
|
|
131
|
+
this.writeConfig = opts.writeConfig ?? ((cfg) => writeAutosyncConfig(this.syncDir, cfg));
|
|
132
|
+
this.readSyncConfigFn = opts.readSyncConfigFn ?? (() => readSyncConfig(this.syncDir));
|
|
133
|
+
this.readHistoryFn = opts.readHistoryFn ?? (() => readSyncHistory(this.syncDir));
|
|
134
|
+
this.appendHistoryFn = opts.appendHistoryFn ?? ((entry) => appendAutosyncEntry(this.syncDir, entry));
|
|
135
|
+
this.setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
136
|
+
this.clearTimer = opts.clearTimer ?? ((t) => clearTimeout(t));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 启动:读配置 → 若 enabled 启动定时器 → 无条件执行一次启动触发下载合并。 */
|
|
140
|
+
start(): void {
|
|
141
|
+
if (this.stopped) return;
|
|
142
|
+
this.refreshTimer();
|
|
143
|
+
void this.startupRun();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 停止:清定时器、标记不再调度;正在执行的任务允许自然结束。 */
|
|
147
|
+
stop(): void {
|
|
148
|
+
this.stopped = true;
|
|
149
|
+
if (this.timer !== null) {
|
|
150
|
+
this.clearTimer(this.timer);
|
|
151
|
+
this.timer = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 重新加载配置(路由 POST /sync/autosync 后调用)。 */
|
|
156
|
+
async reload(): Promise<void> {
|
|
157
|
+
if (this.stopped) return;
|
|
158
|
+
this.refreshTimer();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private refreshTimer(): void {
|
|
162
|
+
if (this.timer !== null) {
|
|
163
|
+
this.clearTimer(this.timer);
|
|
164
|
+
this.timer = null;
|
|
165
|
+
}
|
|
166
|
+
void this.readConfig().then((cfg) => {
|
|
167
|
+
if (this.stopped || !cfg.enabled) return;
|
|
168
|
+
const ms = intervalToMs(cfg.interval);
|
|
169
|
+
this.timer = this.setTimer(() => {
|
|
170
|
+
if (this.stopped) return;
|
|
171
|
+
void this.runOnce().catch((err) => {
|
|
172
|
+
this.host.log.error('自动同步定时触发失败', { error: err instanceof Error ? err.message : String(err) });
|
|
173
|
+
});
|
|
174
|
+
}, ms);
|
|
175
|
+
}).catch(() => { /* 读配置失败静默 */ });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 启动触发下载合并(受 startupMinIntervalMs 阈值约束)。 */
|
|
179
|
+
private async startupRun(): Promise<void> {
|
|
180
|
+
try {
|
|
181
|
+
const cfg = await this.readConfig();
|
|
182
|
+
if (!cfg.enabled) return; // 总开关关闭 → 启动触发不执行
|
|
183
|
+
if (!shouldTriggerStartupRun(cfg.lastRunAt, cfg.startupMinIntervalMs, this.now().getTime())) {
|
|
184
|
+
this.host.log.info('自动同步启动触发跳过:距上次运行未达阈值');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
await this.runOnce({ startup: true });
|
|
188
|
+
} catch (err) {
|
|
189
|
+
this.host.log.error('启动触发下载合并失败', { error: err instanceof Error ? err.message : String(err) });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 执行一次自动同步(§6.1)。
|
|
195
|
+
* @param opts.startup - true 表示启动触发变体(只做 Phase A+B,不做 Phase C push)
|
|
196
|
+
*/
|
|
197
|
+
async runOnce(opts: { startup?: boolean } = {}): Promise<AutosyncRunResult> {
|
|
198
|
+
if (this.running) return { status: 'skipped', direction: 'none', skipReason: 'running', historyId: '', consecutiveFailures: 0 };
|
|
199
|
+
const cfg = await this.readConfig();
|
|
200
|
+
if (!cfg.enabled) {
|
|
201
|
+
return { status: 'skipped', direction: 'none', skipReason: 'disabled', historyId: '', consecutiveFailures: cfg.consecutiveFailures };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.running = true;
|
|
205
|
+
const nowIso = this.now().toISOString();
|
|
206
|
+
const historyId = `autosync-${crypto.randomUUID()}`;
|
|
207
|
+
|
|
208
|
+
// runs 防重复:同 kind running → 跳过(内部语义,不打搅用户)
|
|
209
|
+
let runId: string | null = null;
|
|
210
|
+
try {
|
|
211
|
+
const run = this.runs.register('autosync');
|
|
212
|
+
runId = run.runId;
|
|
213
|
+
} catch {
|
|
214
|
+
this.running = false;
|
|
215
|
+
return { status: 'skipped', direction: 'none', skipReason: 'conflict', historyId, consecutiveFailures: cfg.consecutiveFailures };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
// 读 sync-config → repoUrl
|
|
220
|
+
const syncCfg = await this.readSyncConfigFn();
|
|
221
|
+
if (syncCfg === null || syncCfg.repoUrl === '') {
|
|
222
|
+
const result: AutosyncRunResult = {
|
|
223
|
+
status: 'skipped', direction: 'none', skipReason: 'unconfigured', historyId,
|
|
224
|
+
consecutiveFailures: cfg.consecutiveFailures,
|
|
225
|
+
};
|
|
226
|
+
await this.appendHistoryFn({
|
|
227
|
+
direction: 'both',
|
|
228
|
+
status: 'skipped',
|
|
229
|
+
skipReason: 'unconfigured',
|
|
230
|
+
createdAt: nowIso,
|
|
231
|
+
failureCountAtRun: cfg.consecutiveFailures,
|
|
232
|
+
});
|
|
233
|
+
await this.writeFinalConfig(cfg, result, nowIso, historyId);
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const engine = this.makeSyncEngine(syncCfg.repoUrl, syncCfg.gitBin);
|
|
238
|
+
|
|
239
|
+
// Phase A: pull 合并(下载)
|
|
240
|
+
let mergePlan: MergePlan;
|
|
241
|
+
try {
|
|
242
|
+
mergePlan = await engine.merge();
|
|
243
|
+
} catch (err) {
|
|
244
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
245
|
+
const result: AutosyncRunResult = {
|
|
246
|
+
status: 'failed', direction: 'pull', error, historyId,
|
|
247
|
+
consecutiveFailures: cfg.consecutiveFailures + 1,
|
|
248
|
+
};
|
|
249
|
+
await this.appendHistoryFn({
|
|
250
|
+
direction: 'pull', status: 'failed', error, createdAt: nowIso,
|
|
251
|
+
failureCountAtRun: cfg.consecutiveFailures + 1,
|
|
252
|
+
});
|
|
253
|
+
await this.writeFinalConfig(cfg, result, nowIso, historyId);
|
|
254
|
+
this.maybeNotify(cfg.consecutiveFailures + 1, historyId, nowIso);
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 判定 needsReview
|
|
259
|
+
const reviewSections = mergePlan.sections.filter((s) => s.decision === 'conflict');
|
|
260
|
+
if (reviewSections.length > 0) {
|
|
261
|
+
const conflictedSections = reviewSections.map((s) => s.id);
|
|
262
|
+
const result: AutosyncRunResult = {
|
|
263
|
+
status: 'skipped', direction: 'pull', skipReason: 'conflict',
|
|
264
|
+
conflictedSections, historyId,
|
|
265
|
+
consecutiveFailures: cfg.consecutiveFailures,
|
|
266
|
+
};
|
|
267
|
+
await this.appendHistoryFn({
|
|
268
|
+
direction: 'pull', status: 'skipped', skipReason: 'conflict',
|
|
269
|
+
conflictedSections, createdAt: nowIso,
|
|
270
|
+
failureCountAtRun: cfg.consecutiveFailures,
|
|
271
|
+
});
|
|
272
|
+
await this.writeFinalConfig(cfg, result, nowIso, historyId);
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 无冲突:构造 SyncApplyPlan(autoApply = 所有 useRemote/keepLocal 项;skipped = skip 项)
|
|
277
|
+
const apply = buildAutoApplyPlan(mergePlan);
|
|
278
|
+
if (apply.autoApply.length === 0) {
|
|
279
|
+
// 无物可应用(全部 skip / 无变化)
|
|
280
|
+
const result: AutosyncRunResult = {
|
|
281
|
+
status: 'success', direction: 'pull', skipReason: 'unchanged', historyId,
|
|
282
|
+
consecutiveFailures: 0,
|
|
283
|
+
};
|
|
284
|
+
await this.appendHistoryFn({
|
|
285
|
+
direction: 'pull', status: 'success', skipReason: 'unchanged',
|
|
286
|
+
createdAt: nowIso, failureCountAtRun: 0,
|
|
287
|
+
});
|
|
288
|
+
await this.writeFinalConfig(cfg, result, nowIso, historyId);
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Phase B: 写入本地(applyMergePlan,无 review-queue 写)
|
|
293
|
+
const applyReport = await engine.applyMergePlan(apply);
|
|
294
|
+
const appliedSections = applyReport.applied as SectionId[];
|
|
295
|
+
if (!applyReport.ok) {
|
|
296
|
+
const error = applyReport.warnings.join('; ') || 'applyMergePlan 执行失败';
|
|
297
|
+
const result: AutosyncRunResult = {
|
|
298
|
+
status: 'failed', direction: 'pull', error, historyId,
|
|
299
|
+
consecutiveFailures: cfg.consecutiveFailures + 1,
|
|
300
|
+
};
|
|
301
|
+
await this.appendHistoryFn({
|
|
302
|
+
direction: 'pull', status: 'failed', error, createdAt: nowIso,
|
|
303
|
+
failureCountAtRun: cfg.consecutiveFailures + 1,
|
|
304
|
+
});
|
|
305
|
+
await this.writeFinalConfig(cfg, result, nowIso, historyId);
|
|
306
|
+
this.maybeNotify(cfg.consecutiveFailures + 1, historyId, nowIso);
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Phase C: push 上传(完整双向)
|
|
311
|
+
if (!opts.startup) {
|
|
312
|
+
try {
|
|
313
|
+
const pushReport = await engine.push();
|
|
314
|
+
if (!pushReport.ok) {
|
|
315
|
+
const error = pushReport.message ?? 'push 失败';
|
|
316
|
+
const result: AutosyncRunResult = {
|
|
317
|
+
status: 'failed', direction: 'both', appliedSections, error, historyId,
|
|
318
|
+
consecutiveFailures: cfg.consecutiveFailures + 1,
|
|
319
|
+
};
|
|
320
|
+
await this.appendHistoryFn({
|
|
321
|
+
direction: 'both', status: 'failed', appliedSections, error,
|
|
322
|
+
createdAt: nowIso, failureCountAtRun: cfg.consecutiveFailures + 1,
|
|
323
|
+
});
|
|
324
|
+
await this.writeFinalConfig(cfg, result, nowIso, historyId);
|
|
325
|
+
this.maybeNotify(cfg.consecutiveFailures + 1, historyId, nowIso);
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
const result: AutosyncRunResult = {
|
|
329
|
+
status: 'success', direction: 'both', appliedSections,
|
|
330
|
+
pushedSnapshotId: pushReport.snapshotId, historyId, consecutiveFailures: 0,
|
|
331
|
+
};
|
|
332
|
+
await this.appendHistoryFn({
|
|
333
|
+
direction: 'both', status: 'success', appliedSections,
|
|
334
|
+
pushedSnapshotId: pushReport.snapshotId, createdAt: nowIso, failureCountAtRun: 0,
|
|
335
|
+
});
|
|
336
|
+
await this.writeFinalConfig(cfg, result, nowIso, historyId);
|
|
337
|
+
return result;
|
|
338
|
+
} catch (err) {
|
|
339
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
340
|
+
const result: AutosyncRunResult = {
|
|
341
|
+
status: 'failed', direction: 'both', appliedSections, error, historyId,
|
|
342
|
+
consecutiveFailures: cfg.consecutiveFailures + 1,
|
|
343
|
+
};
|
|
344
|
+
await this.appendHistoryFn({
|
|
345
|
+
direction: 'both', status: 'failed', appliedSections, error,
|
|
346
|
+
createdAt: nowIso, failureCountAtRun: cfg.consecutiveFailures + 1,
|
|
347
|
+
});
|
|
348
|
+
await this.writeFinalConfig(cfg, result, nowIso, historyId);
|
|
349
|
+
this.maybeNotify(cfg.consecutiveFailures + 1, historyId, nowIso);
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// startup 变体:只做 pull 合并(不上传)
|
|
355
|
+
const result: AutosyncRunResult = {
|
|
356
|
+
status: 'success', direction: 'pull', appliedSections, historyId, consecutiveFailures: 0,
|
|
357
|
+
};
|
|
358
|
+
await this.appendHistoryFn({
|
|
359
|
+
direction: 'pull', status: 'success', appliedSections,
|
|
360
|
+
createdAt: nowIso, failureCountAtRun: 0,
|
|
361
|
+
});
|
|
362
|
+
await this.writeFinalConfig(cfg, result, nowIso, historyId);
|
|
363
|
+
return result;
|
|
364
|
+
} catch (err) {
|
|
365
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
366
|
+
const result: AutosyncRunResult = {
|
|
367
|
+
status: 'failed', direction: 'none', error, historyId,
|
|
368
|
+
consecutiveFailures: cfg.consecutiveFailures + 1,
|
|
369
|
+
};
|
|
370
|
+
await this.appendHistoryFn({
|
|
371
|
+
direction: 'both', status: 'failed', error,
|
|
372
|
+
createdAt: nowIso, failureCountAtRun: cfg.consecutiveFailures + 1,
|
|
373
|
+
});
|
|
374
|
+
await this.writeFinalConfig(cfg, result, nowIso, historyId);
|
|
375
|
+
this.maybeNotify(cfg.consecutiveFailures + 1, historyId, nowIso);
|
|
376
|
+
return result;
|
|
377
|
+
} finally {
|
|
378
|
+
this.running = false;
|
|
379
|
+
if (runId !== null) {
|
|
380
|
+
// 完成标记(不抛错,尽力而为)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** 收尾:写 autosync-config(lastRunAt, lastRunStatus, consecutiveFailures, lastRunHistoryId)。 */
|
|
386
|
+
private async writeFinalConfig(
|
|
387
|
+
base: AutosyncConfig,
|
|
388
|
+
result: AutosyncRunResult,
|
|
389
|
+
nowIso: string,
|
|
390
|
+
historyId: string,
|
|
391
|
+
): Promise<void> {
|
|
392
|
+
await this.writeConfig({
|
|
393
|
+
...base,
|
|
394
|
+
lastRunAt: nowIso,
|
|
395
|
+
lastRunStatus: result.status,
|
|
396
|
+
consecutiveFailures: result.consecutiveFailures,
|
|
397
|
+
lastRunHistoryId: historyId,
|
|
398
|
+
...(result.error !== undefined ? { lastRunMessage: result.error } : {}),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 连续失败 ≥ 3 → 通知(host.log.warn)。 */
|
|
403
|
+
private maybeNotify(failures: number, historyId: string, nowIso: string): void {
|
|
404
|
+
if (failures >= 3) {
|
|
405
|
+
this.host.log.warn(`自动同步连续失败 ${failures} 次,请检查仓库配置/凭据`);
|
|
406
|
+
// 记录 notifiedAt(更新历史 entry)
|
|
407
|
+
void this.readHistoryFn().then(async (hist) => {
|
|
408
|
+
const entry = hist.autosyncEntries.find((e) => e.createdAt === nowIso);
|
|
409
|
+
if (entry) {
|
|
410
|
+
entry.notifiedAt = nowIso;
|
|
411
|
+
await this.writeConfig({
|
|
412
|
+
...(await this.readConfig()),
|
|
413
|
+
lastRunMessage: `连续失败 ${failures} 次,已通知`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}).catch(() => { /* 尽力而为 */ });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** 从 MergePlan 构造 SyncApplyPlan(autoApply = 所有非 skip 非 conflict 项)。 */
|
|
422
|
+
export function buildAutoApplyPlan(plan: MergePlan): SyncApplyPlan {
|
|
423
|
+
const autoApply: MergeSectionResult[] = [];
|
|
424
|
+
const review: MergeSectionResult[] = [];
|
|
425
|
+
const skipped: MergeSectionResult[] = [];
|
|
426
|
+
for (const s of plan.sections) {
|
|
427
|
+
if (s.decision === 'skip') {
|
|
428
|
+
skipped.push(s);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (s.decision === 'conflict') {
|
|
432
|
+
review.push(s);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
// useRemote / keepLocal:都有 merged 数据
|
|
436
|
+
if (s.merged !== undefined) {
|
|
437
|
+
autoApply.push(s);
|
|
438
|
+
} else {
|
|
439
|
+
skipped.push(s);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return { autoApply, review, skipped };
|
|
443
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-flow:三方合并引擎测试。
|
|
3
|
+
* 覆盖:
|
|
4
|
+
* - 分区级四象限(useRemote / keepLocal / skip / conflict)
|
|
5
|
+
* - JSON 分区键级精细合并(非重叠键自动合、双侧均改且不同留冲突)
|
|
6
|
+
* - FilesSection 文件级精细合并
|
|
7
|
+
* - MergePlan 输出形状
|
|
8
|
+
* - 无祖先退化为整分区冲突
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
|
|
13
|
+
import { merge } from './merge.ts';
|
|
14
|
+
import type { FilesSection, SectionData, SettingsSection } from '../schema/types.ts';
|
|
15
|
+
|
|
16
|
+
const settings = (ns: Record<string, { value: unknown; revision: number }>): SettingsSection => ({
|
|
17
|
+
version: 1,
|
|
18
|
+
namespaces: Object.fromEntries(
|
|
19
|
+
Object.entries(ns).map(([k, v]) => [k, { value: v.value, revision: v.revision, secrets: [] }]),
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const files = (entries: Array<[string, string]>): FilesSection => ({
|
|
24
|
+
version: 1,
|
|
25
|
+
files: entries.map(([p, c]) => ({ relativePath: p, data: new TextEncoder().encode(c), contentHash: '' })),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('merge: 本地==远端 → skip', () => {
|
|
29
|
+
const s = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
30
|
+
const a = s;
|
|
31
|
+
const plan = merge({ settings: s as SectionData }, { settings: s as SectionData }, { settings: a as SectionData });
|
|
32
|
+
assert.equal(plan.sections.length, 1);
|
|
33
|
+
assert.equal(plan.sections[0]!.decision, 'skip');
|
|
34
|
+
assert.equal(plan.sections[0]!.conflicts.length, 0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('merge: 本地==祖先 & 远端!=祖先 → useRemote', () => {
|
|
38
|
+
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
39
|
+
const loc = anc;
|
|
40
|
+
const rem = settings({ general: { value: { theme: 'light' }, revision: 2 } });
|
|
41
|
+
const plan = merge(
|
|
42
|
+
{ settings: loc as SectionData },
|
|
43
|
+
{ settings: rem as SectionData },
|
|
44
|
+
{ settings: anc as SectionData },
|
|
45
|
+
);
|
|
46
|
+
assert.equal(plan.sections[0]!.decision, 'useRemote');
|
|
47
|
+
assert.deepEqual((plan.sections[0]!.merged as SettingsSection).namespaces.general!.value, { theme: 'light' });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('merge: 本地!=祖先 & 远端==祖先 → keepLocal', () => {
|
|
51
|
+
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
52
|
+
const loc = settings({ general: { value: { theme: 'custom' }, revision: 2 } });
|
|
53
|
+
const rem = anc;
|
|
54
|
+
const plan = merge(
|
|
55
|
+
{ settings: loc as SectionData },
|
|
56
|
+
{ settings: rem as SectionData },
|
|
57
|
+
{ settings: anc as SectionData },
|
|
58
|
+
);
|
|
59
|
+
assert.equal(plan.sections[0]!.decision, 'keepLocal');
|
|
60
|
+
assert.deepEqual((plan.sections[0]!.merged as SettingsSection).namespaces.general!.value, { theme: 'custom' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('merge: 双侧相对祖先都改且分区 hash 不同 → conflict', () => {
|
|
64
|
+
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
65
|
+
const loc = settings({ general: { value: { theme: 'blue' }, revision: 2 } });
|
|
66
|
+
const rem = settings({ general: { value: { theme: 'red' }, revision: 2 } });
|
|
67
|
+
const plan = merge(
|
|
68
|
+
{ settings: loc as SectionData },
|
|
69
|
+
{ settings: rem as SectionData },
|
|
70
|
+
{ settings: anc as SectionData },
|
|
71
|
+
);
|
|
72
|
+
assert.equal(plan.sections[0]!.decision, 'conflict');
|
|
73
|
+
assert.ok(plan.sections[0]!.conflicts.length >= 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('merge: 无 ancestor → 整分区 conflict(不猜测)', () => {
|
|
77
|
+
const loc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
78
|
+
const rem = settings({ general: { value: { theme: 'light' }, revision: 2 } });
|
|
79
|
+
const plan = merge({ settings: loc as SectionData }, { settings: rem as SectionData });
|
|
80
|
+
assert.equal(plan.sections[0]!.decision, 'conflict');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('merge: JSON 分区键级精细合并 —— 非重叠键各自变更自动合', () => {
|
|
84
|
+
// 祖先:{ general: {theme:'dark'}, editor: {fontSize:12} }
|
|
85
|
+
const anc = settings({
|
|
86
|
+
general: { value: { theme: 'dark' }, revision: 1 },
|
|
87
|
+
editor: { value: { fontSize: 12 }, revision: 1 },
|
|
88
|
+
});
|
|
89
|
+
// 本地:改 editor
|
|
90
|
+
const loc = settings({
|
|
91
|
+
general: { value: { theme: 'dark' }, revision: 1 },
|
|
92
|
+
editor: { value: { fontSize: 14 }, revision: 2 },
|
|
93
|
+
});
|
|
94
|
+
// 远端:改 general
|
|
95
|
+
const rem = settings({
|
|
96
|
+
general: { value: { theme: 'light' }, revision: 2 },
|
|
97
|
+
editor: { value: { fontSize: 12 }, revision: 1 },
|
|
98
|
+
});
|
|
99
|
+
const plan = merge(
|
|
100
|
+
{ settings: loc as SectionData },
|
|
101
|
+
{ settings: rem as SectionData },
|
|
102
|
+
{ settings: anc as SectionData },
|
|
103
|
+
);
|
|
104
|
+
assert.equal(plan.sections[0]!.decision, 'useRemote', '精细合并后无非冲突 → useRemote(承载合并结果)');
|
|
105
|
+
const merged = plan.sections[0]!.merged as SettingsSection;
|
|
106
|
+
assert.deepEqual(merged.namespaces.general!.value, { theme: 'light' });
|
|
107
|
+
assert.deepEqual(merged.namespaces.editor!.value, { fontSize: 14 });
|
|
108
|
+
assert.equal(plan.sections[0]!.conflicts.length, 0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('merge: JSON 分区键级精细合并 —— 双侧均改同一 key 且值不同 → 冲突', () => {
|
|
112
|
+
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
113
|
+
const loc = settings({ general: { value: { theme: 'blue' }, revision: 2 } });
|
|
114
|
+
const rem = settings({ general: { value: { theme: 'red' }, revision: 2 } });
|
|
115
|
+
const plan = merge(
|
|
116
|
+
{ settings: loc as SectionData },
|
|
117
|
+
{ settings: rem as SectionData },
|
|
118
|
+
{ settings: anc as SectionData },
|
|
119
|
+
);
|
|
120
|
+
assert.equal(plan.sections[0]!.decision, 'conflict');
|
|
121
|
+
assert.equal(plan.sections[0]!.conflicts.length, 1);
|
|
122
|
+
assert.equal(plan.sections[0]!.conflicts[0]!.path, 'general');
|
|
123
|
+
assert.equal(plan.sections[0]!.conflicts[0]!.kind, 'key');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('merge: FilesSection 文件级精细合并 —— 非重叠文件自动合', () => {
|
|
127
|
+
const anc = files([['a.md', '# A old\n'], ['b.md', '# B\n']]);
|
|
128
|
+
const loc = files([['a.md', '# A new local\n'], ['b.md', '# B\n']]);
|
|
129
|
+
const rem = files([['a.md', '# A old\n'], ['b.md', '# B remote\n']]);
|
|
130
|
+
const plan = merge(
|
|
131
|
+
{ skills: loc as SectionData },
|
|
132
|
+
{ skills: rem as SectionData },
|
|
133
|
+
{ skills: anc as SectionData },
|
|
134
|
+
);
|
|
135
|
+
assert.equal(plan.sections[0]!.decision, 'useRemote');
|
|
136
|
+
const merged = plan.sections[0]!.merged as FilesSection;
|
|
137
|
+
const byPath = new Map(merged.files.map((f) => [f.relativePath, new TextDecoder().decode(f.data)]));
|
|
138
|
+
assert.equal(byPath.get('a.md'), '# A new local\n');
|
|
139
|
+
assert.equal(byPath.get('b.md'), '# B remote\n');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('merge: FilesSection 文件级精细合并 —— 双侧均改同一文件 → 冲突', () => {
|
|
143
|
+
const anc = files([['a.md', '# A old\n']]);
|
|
144
|
+
const loc = files([['a.md', '# A local\n']]);
|
|
145
|
+
const rem = files([['a.md', '# A remote\n']]);
|
|
146
|
+
const plan = merge(
|
|
147
|
+
{ skills: loc as SectionData },
|
|
148
|
+
{ skills: rem as SectionData },
|
|
149
|
+
{ skills: anc as SectionData },
|
|
150
|
+
);
|
|
151
|
+
assert.equal(plan.sections[0]!.decision, 'conflict');
|
|
152
|
+
assert.equal(plan.sections[0]!.conflicts[0]!.kind, 'file');
|
|
153
|
+
assert.equal(plan.sections[0]!.conflicts[0]!.path, 'a.md');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('merge: 远端缺 + 本地等于祖先 → skip', () => {
|
|
157
|
+
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
158
|
+
const plan = merge(
|
|
159
|
+
{ settings: anc as SectionData },
|
|
160
|
+
{}, // 远端无此分区
|
|
161
|
+
{ settings: anc as SectionData },
|
|
162
|
+
);
|
|
163
|
+
assert.equal(plan.sections[0]!.decision, 'skip');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('merge: MergePlan 输出形状符合 contract', () => {
|
|
167
|
+
const anc = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
168
|
+
const plan = merge({ settings: anc as SectionData }, { settings: anc as SectionData }, { settings: anc as SectionData });
|
|
169
|
+
assert.ok(Array.isArray(plan.sections));
|
|
170
|
+
const r = plan.sections[0]!;
|
|
171
|
+
assert.equal(typeof r.id, 'string');
|
|
172
|
+
assert.ok(['useRemote', 'keepLocal', 'skip', 'conflict'].includes(r.decision));
|
|
173
|
+
assert.ok(Array.isArray(r.conflicts));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('merge: 多分区混合场景(settings skip / providers keepLocal / skills useRemote)', () => {
|
|
177
|
+
const sA = settings({ general: { value: { theme: 'dark' }, revision: 1 } });
|
|
178
|
+
const pA: SectionData = { version: 1, providers: { openai: { route: 'https://api.openai.com/v1' } } };
|
|
179
|
+
const fA = files([['a.md', '# A\n']]);
|
|
180
|
+
|
|
181
|
+
const plan = merge(
|
|
182
|
+
{
|
|
183
|
+
settings: sA as SectionData,
|
|
184
|
+
providers: { version: 1, providers: { openai: { route: 'https://changed.com' } } } as SectionData,
|
|
185
|
+
skills: fA as SectionData,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
settings: sA as SectionData,
|
|
189
|
+
providers: pA,
|
|
190
|
+
skills: files([['a.md', '# A\n'], ['b.md', '# B\n']]) as SectionData,
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
settings: sA as SectionData,
|
|
194
|
+
providers: pA,
|
|
195
|
+
skills: fA as SectionData,
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
const byId = new Map(plan.sections.map((s) => [s.id, s]));
|
|
199
|
+
assert.equal(byId.get('settings')!.decision, 'skip');
|
|
200
|
+
// providers:本地改了 route、远端 == 祖先 → keepLocal
|
|
201
|
+
assert.equal(byId.get('providers')!.decision, 'keepLocal');
|
|
202
|
+
// skills:本地 == 祖先、远端新增 b.md → useRemote
|
|
203
|
+
assert.equal(byId.get('skills')!.decision, 'useRemote');
|
|
204
|
+
});
|