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,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutoSyncScheduler 测试:interval 换算、shouldTriggerStartupRun 阈值、
|
|
3
|
+
* enabled=false 不执行、连续失败通知、冲突跳过。
|
|
4
|
+
*
|
|
5
|
+
* 采用真实 RunRegistry + 注入 readConfig/writeConfig/readSyncConfigFn/readHistoryFn/
|
|
6
|
+
* appendHistoryFn/makeSyncEngine/now,全程不触碰真实网络与真实定时器。
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
AutoSyncScheduler, intervalToMs, shouldTriggerStartupRun, buildAutoApplyPlan,
|
|
16
|
+
} from './autosync-scheduler.ts';
|
|
17
|
+
import { RunRegistry } from '../core/run-registry.ts';
|
|
18
|
+
import { nullLogger } from '../utils/logger.ts';
|
|
19
|
+
import type { AutosyncConfig } from './autosync-config.ts';
|
|
20
|
+
import type { AutosyncHistoryEntry } from './sync-history.ts';
|
|
21
|
+
import type { MergePlan, MergeSectionResult } from './merge.ts';
|
|
22
|
+
import type { SyncEngine } from './sync-engine.ts';
|
|
23
|
+
|
|
24
|
+
test('intervalToMs: 间隔换算正确', () => {
|
|
25
|
+
assert.equal(intervalToMs('5m'), 5 * 60 * 1000);
|
|
26
|
+
assert.equal(intervalToMs('15m'), 15 * 60 * 1000);
|
|
27
|
+
assert.equal(intervalToMs('30m'), 30 * 60 * 1000);
|
|
28
|
+
assert.equal(intervalToMs('60m'), 60 * 60 * 1000);
|
|
29
|
+
assert.equal(intervalToMs('6h'), 6 * 60 * 60 * 1000);
|
|
30
|
+
assert.equal(intervalToMs('12h'), 12 * 60 * 60 * 1000);
|
|
31
|
+
assert.equal(intervalToMs('24h'), 24 * 60 * 60 * 1000);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('shouldTriggerStartupRun: 阈值判断', () => {
|
|
35
|
+
const threshold = 5 * 60 * 1000;
|
|
36
|
+
const now = 1_000_000_000_000;
|
|
37
|
+
assert.equal(shouldTriggerStartupRun(new Date(now - 60 * 1000).toISOString(), threshold, now), false);
|
|
38
|
+
assert.equal(shouldTriggerStartupRun(new Date(now - 6 * 60 * 1000).toISOString(), threshold, now), true);
|
|
39
|
+
assert.equal(shouldTriggerStartupRun(undefined, threshold, now), true);
|
|
40
|
+
assert.equal(shouldTriggerStartupRun(new Date(now - threshold).toISOString(), threshold, now), true);
|
|
41
|
+
assert.equal(shouldTriggerStartupRun(new Date(now - threshold - 1).toISOString(), threshold, now), true);
|
|
42
|
+
assert.equal(shouldTriggerStartupRun(new Date(now - threshold + 1).toISOString(), threshold, now), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** 构造一个可控 scheduler:注入全部 fs/engine 依赖,验证 runOnce 行为。 */
|
|
46
|
+
function makeScheduler(opts: {
|
|
47
|
+
cfg: AutosyncConfig;
|
|
48
|
+
engine: Partial<SyncEngine>;
|
|
49
|
+
history: AutosyncHistoryEntry[];
|
|
50
|
+
}) {
|
|
51
|
+
const runs = new RunRegistry();
|
|
52
|
+
const entries: AutosyncHistoryEntry[] = [...opts.history];
|
|
53
|
+
let config = opts.cfg;
|
|
54
|
+
const scheduler = new AutoSyncScheduler({
|
|
55
|
+
syncDir: '/tmp',
|
|
56
|
+
host: { log: nullLogger() },
|
|
57
|
+
makeSyncEngine: () => opts.engine as SyncEngine,
|
|
58
|
+
msg: (k: string) => k,
|
|
59
|
+
runs,
|
|
60
|
+
now: () => new Date(1_000_000_000_000),
|
|
61
|
+
readConfig: async () => config,
|
|
62
|
+
writeConfig: async (c) => { config = c; },
|
|
63
|
+
readSyncConfigFn: async () => ({ repoUrl: 'git@github.com:foo/bar.git' }),
|
|
64
|
+
readHistoryFn: async () => ({ schemaVersion: 1, autosyncEntries: entries, updatedAt: '' }),
|
|
65
|
+
appendHistoryFn: async (e) => { entries.push(e); },
|
|
66
|
+
// 测试不用真实定时器:不调 start()
|
|
67
|
+
});
|
|
68
|
+
return { scheduler, runs, getConfig: () => config, getEntries: () => entries };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mergeResult(id: string, decision: MergeSectionResult['decision']): MergeSectionResult {
|
|
72
|
+
return { id: id as never, decision, conflicts: [], merged: {} as never };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeMergePlan(ids: Array<[string, MergeSectionResult['decision']]>): MergePlan {
|
|
76
|
+
return { sections: ids.map(([id, decision]) => mergeResult(id, decision)) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('runOnce: enabled=false → skipped(disabled),不写历史', async () => {
|
|
80
|
+
const cfg: AutosyncConfig = { enabled: false, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
|
|
81
|
+
const { scheduler, getConfig, getEntries } = makeScheduler({ cfg, engine: {}, history: [] });
|
|
82
|
+
const result = await scheduler.runOnce();
|
|
83
|
+
assert.equal(result.status, 'skipped');
|
|
84
|
+
assert.equal(result.skipReason, 'disabled');
|
|
85
|
+
assert.equal(getEntries().length, 0, 'disabled 不写历史');
|
|
86
|
+
assert.equal(getConfig().consecutiveFailures, 0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('runOnce: 未配置仓库 → skipped(unconfigured),写历史但不计失败', async () => {
|
|
90
|
+
const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
|
|
91
|
+
const scheduler = new AutoSyncScheduler({
|
|
92
|
+
syncDir: '/tmp',
|
|
93
|
+
host: { log: nullLogger() },
|
|
94
|
+
makeSyncEngine: () => ({} as SyncEngine),
|
|
95
|
+
msg: (k: string) => k,
|
|
96
|
+
runs: new RunRegistry(),
|
|
97
|
+
now: () => new Date(1_000_000_000_000),
|
|
98
|
+
readConfig: async () => cfg,
|
|
99
|
+
writeConfig: async () => {},
|
|
100
|
+
readSyncConfigFn: async () => null,
|
|
101
|
+
readHistoryFn: async () => ({ schemaVersion: 1, autosyncEntries: [], updatedAt: '' }),
|
|
102
|
+
appendHistoryFn: async () => {},
|
|
103
|
+
});
|
|
104
|
+
const result = await scheduler.runOnce();
|
|
105
|
+
assert.equal(result.status, 'skipped');
|
|
106
|
+
assert.equal(result.skipReason, 'unconfigured');
|
|
107
|
+
assert.equal(result.consecutiveFailures, 0, '未配置不累计失败');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('runOnce: merge 抛错 → failed,连续失败计数 +1', async () => {
|
|
111
|
+
const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 2 };
|
|
112
|
+
const engine = { merge: async () => { throw new Error('network down'); } };
|
|
113
|
+
const { scheduler, getConfig, getEntries } = makeScheduler({ cfg, engine, history: [] });
|
|
114
|
+
const result = await scheduler.runOnce();
|
|
115
|
+
assert.equal(result.status, 'failed');
|
|
116
|
+
assert.equal(result.consecutiveFailures, 3, '连续失败 2→3');
|
|
117
|
+
assert.equal(getConfig().consecutiveFailures, 3);
|
|
118
|
+
assert.ok(getEntries().some((e) => e.status === 'failed'), '写入失败历史');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('runOnce: 有冲突 → skipped(conflict),不写本地,不计失败', async () => {
|
|
122
|
+
const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 1 };
|
|
123
|
+
const engine = {
|
|
124
|
+
merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'conflict']]),
|
|
125
|
+
};
|
|
126
|
+
const { scheduler, getConfig, getEntries } = makeScheduler({ cfg, engine, history: [] });
|
|
127
|
+
const result = await scheduler.runOnce();
|
|
128
|
+
assert.equal(result.status, 'skipped');
|
|
129
|
+
assert.equal(result.skipReason, 'conflict');
|
|
130
|
+
assert.deepEqual(result.conflictedSections, ['settings']);
|
|
131
|
+
assert.equal(result.consecutiveFailures, 1, '冲突跳过不计失败');
|
|
132
|
+
assert.equal(getConfig().consecutiveFailures, 1);
|
|
133
|
+
assert.ok(getEntries().some((e) => e.skipReason === 'conflict'), '写入冲突跳过历史');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('runOnce: 无冲突且无变化 → success(pull,unchanged)', async () => {
|
|
137
|
+
const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
|
|
138
|
+
const engine = {
|
|
139
|
+
merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'skip']]),
|
|
140
|
+
};
|
|
141
|
+
const { scheduler } = makeScheduler({ cfg, engine, history: [] });
|
|
142
|
+
const result = await scheduler.runOnce();
|
|
143
|
+
assert.equal(result.status, 'success');
|
|
144
|
+
assert.equal(result.direction, 'pull');
|
|
145
|
+
assert.equal(result.skipReason, 'unchanged');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('runOnce: 完整双向 → 无冲突合并写本地 + push', async () => {
|
|
149
|
+
const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
|
|
150
|
+
const applyCalls: string[] = [];
|
|
151
|
+
const engine = {
|
|
152
|
+
merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'useRemote']]),
|
|
153
|
+
applyMergePlan: async () => { applyCalls.push('apply'); return { ok: true, applied: ['settings'], restoreId: 'r1', rolledBack: false, review: [], warnings: [] }; },
|
|
154
|
+
push: async () => ({ ok: true, snapshotId: 'snap-push', sections: ['settings'] as never, warnings: [] }),
|
|
155
|
+
};
|
|
156
|
+
const { scheduler, getEntries } = makeScheduler({ cfg, engine, history: [] });
|
|
157
|
+
const result = await scheduler.runOnce();
|
|
158
|
+
assert.equal(result.status, 'success');
|
|
159
|
+
assert.equal(result.direction, 'both');
|
|
160
|
+
assert.deepEqual(result.appliedSections, ['settings']);
|
|
161
|
+
assert.equal(result.pushedSnapshotId, 'snap-push');
|
|
162
|
+
assert.equal(applyCalls.length, 1);
|
|
163
|
+
assert.ok(getEntries().some((e) => e.status === 'success' && e.direction === 'both'), '写入双向成功历史');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('runOnce: startup 变体 → 只做 pull 合并,不上传', async () => {
|
|
167
|
+
const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
|
|
168
|
+
const pushCalls: string[] = [];
|
|
169
|
+
const engine = {
|
|
170
|
+
merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'useRemote']]),
|
|
171
|
+
applyMergePlan: async () => ({ ok: true, applied: ['settings'], restoreId: 'r1', rolledBack: false, review: [], warnings: [] }),
|
|
172
|
+
push: async () => { pushCalls.push('push'); return { ok: true, snapshotId: 'x', sections: [] as never, warnings: [] }; },
|
|
173
|
+
};
|
|
174
|
+
const { scheduler } = makeScheduler({ cfg, engine, history: [] });
|
|
175
|
+
const result = await scheduler.runOnce({ startup: true });
|
|
176
|
+
assert.equal(result.direction, 'pull', 'startup 不上传');
|
|
177
|
+
assert.equal(pushCalls.length, 0, 'startup 变体不调用 push');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('buildAutoApplyPlan: 把非 skip 非 conflict 项归入 autoApply', () => {
|
|
181
|
+
const plan = makeMergePlan([
|
|
182
|
+
['settings', 'useRemote'],
|
|
183
|
+
['providers', 'keepLocal'],
|
|
184
|
+
['plugins', 'skip'],
|
|
185
|
+
['mcp', 'conflict'],
|
|
186
|
+
]);
|
|
187
|
+
const apply = buildAutoApplyPlan(plan);
|
|
188
|
+
assert.deepEqual(apply.autoApply.map((s) => s.id), ['settings', 'providers']);
|
|
189
|
+
assert.deepEqual(apply.review.map((s) => s.id), ['mcp']);
|
|
190
|
+
assert.deepEqual(apply.skipped.map((s) => s.id), ['plugins']);
|
|
191
|
+
});
|
|
@@ -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
|
+
}
|