@charara97/dsh-session-sync 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +234 -2
- package/client.js +1017 -0
- package/cordis.patch.yml +5 -0
- package/docs/design-sync-model.md +182 -0
- package/icon.svg +7 -0
- package/index.js +628 -0
- package/lib/engine.js +2613 -0
- package/lib/progress.js +96 -0
- package/lib/session.js +239 -0
- package/lib/util.js +214 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +56 -4
- package/tests/client-render.test.mjs +588 -0
- package/tests/delivery.test.mjs +399 -0
- package/tests/engine.test.mjs +373 -0
package/index.js
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-chat-sync —— DSH 会话与工作区文件同步(宿主端)。
|
|
3
|
+
*
|
|
4
|
+
* 与旧插件(dsh-chatsync)的关系:
|
|
5
|
+
* - 存储布局一致:DSH HOME 本身是一个 git 仓库(分支默认 main),每个工作区一个
|
|
6
|
+
* 影子仓库推送到同一个 GitHub 私有仓库的 ws/<workspaceId> 分支;
|
|
7
|
+
* - 操作模型不同:只有显式的 push / pull,范围可选单个会话或全部会话,
|
|
8
|
+
* 工作区文件是同一个操作里的开关,全程写过程日志,冲突一律交给用户裁决。
|
|
9
|
+
*
|
|
10
|
+
* 界面入口:设置 → 同步(settings.section)+ 侧栏底部按钮(sidebar.footer.action)。
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { SyncEngine, summarize, sessionIdOfPath, isSessionLogPath } from './lib/engine.js';
|
|
16
|
+
import { decodeSessionLog } from './lib/session.js';
|
|
17
|
+
import { clip } from './lib/util.js';
|
|
18
|
+
|
|
19
|
+
const name = 'dsh-chat-sync';
|
|
20
|
+
const inject = ['webServer'];
|
|
21
|
+
const PREFIX = '/chat-sync/api/';
|
|
22
|
+
|
|
23
|
+
function makeLogger(ctx) {
|
|
24
|
+
return (level, msg) => {
|
|
25
|
+
const line = `[dsh-chat-sync] ${msg}`;
|
|
26
|
+
try {
|
|
27
|
+
const l = ctx.logger;
|
|
28
|
+
if (typeof l === 'function') { l(line); return; }
|
|
29
|
+
if (l && typeof l[level] === 'function') { l[level](line); return; }
|
|
30
|
+
} catch { /* 落到 console */ }
|
|
31
|
+
if (level === 'error') console.error(line);
|
|
32
|
+
else if (level === 'warn') console.warn(line);
|
|
33
|
+
else console.log(line);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 从会话日志里抽出「最后几条消息」,用于冲突两侧的对比展示 */
|
|
38
|
+
function sessionPreview(buf, max = 12) {
|
|
39
|
+
const decoded = decodeSessionLog(buf);
|
|
40
|
+
if (!decoded) return { ok: false, reason: '无法解码这份会话日志' };
|
|
41
|
+
const msgs = [];
|
|
42
|
+
for (const row of decoded.parsed) {
|
|
43
|
+
if (row.type === 'user/message' && Array.isArray(row.data?.content)) {
|
|
44
|
+
const text = row.data.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
|
|
45
|
+
const images = row.data.content.filter((c) => c.type === 'image').length;
|
|
46
|
+
msgs.push({ role: 'user', time: row.time, text: text || (images ? `(${images} 张图片)` : '(空)') });
|
|
47
|
+
} else if (row.type === 'assistant/message' && Array.isArray(row.data?.message?.content)) {
|
|
48
|
+
const text = row.data.message.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
|
|
49
|
+
if (text.trim()) msgs.push({ role: 'assistant', time: row.time, text });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
id: decoded.header.id,
|
|
55
|
+
cwd: decoded.header.cwd || '',
|
|
56
|
+
events: decoded.rows.length - 1,
|
|
57
|
+
messageCount: msgs.length,
|
|
58
|
+
truncated: msgs.length > max,
|
|
59
|
+
messages: msgs.slice(-max).map((m) => ({ ...m, text: m.text.length > 1200 ? m.text.slice(0, 1199) + '…' : m.text })),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function textPreview(buf, maxBytes = 64 * 1024) {
|
|
64
|
+
if (!buf || buf.includes(0)) return { binary: true, bytes: buf ? buf.length : 0 };
|
|
65
|
+
const truncated = buf.length > maxBytes;
|
|
66
|
+
const text = (truncated ? buf.subarray(0, maxBytes) : buf).toString('utf8');
|
|
67
|
+
return { binary: false, bytes: buf.length, truncated, text: truncated ? text + '\n…(已截断)' : text };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function apply(ctx, config = {}) {
|
|
71
|
+
const home = config.home || process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
72
|
+
const overrides = { ...config };
|
|
73
|
+
delete overrides.home;
|
|
74
|
+
const log = makeLogger(ctx);
|
|
75
|
+
const engine = new SyncEngine(home, overrides, log);
|
|
76
|
+
const disposers = [];
|
|
77
|
+
|
|
78
|
+
const jsonHeaders = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
|
|
79
|
+
const json = (res, code, payload) => {
|
|
80
|
+
try {
|
|
81
|
+
res.writeHead(code, jsonHeaders);
|
|
82
|
+
res.end(JSON.stringify(payload));
|
|
83
|
+
} catch { /* 连接可能已断开 */ }
|
|
84
|
+
};
|
|
85
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
86
|
+
let buf = '';
|
|
87
|
+
req.on('data', (d) => { buf += d; if (buf.length > 2 * 1024 * 1024) { reject(new Error('请求体过大')); req.destroy(); } });
|
|
88
|
+
req.on('end', () => resolve(buf));
|
|
89
|
+
req.on('error', reject);
|
|
90
|
+
});
|
|
91
|
+
const readJsonBody = async (req) => {
|
|
92
|
+
const raw = await readBody(req);
|
|
93
|
+
if (!raw.trim()) return {};
|
|
94
|
+
const j = JSON.parse(raw);
|
|
95
|
+
if (!j || typeof j !== 'object' || Array.isArray(j)) throw new Error('请求体必须是 JSON 对象');
|
|
96
|
+
return j;
|
|
97
|
+
};
|
|
98
|
+
const route = (p, handler) => disposers.push(ctx.webServer.register({ kind: 'exact', path: PREFIX + p, handler }));
|
|
99
|
+
|
|
100
|
+
/* ================= 状态 ================= */
|
|
101
|
+
|
|
102
|
+
route('status', async (req, res) => {
|
|
103
|
+
try {
|
|
104
|
+
const s = await engine.status();
|
|
105
|
+
s.ok = true;
|
|
106
|
+
json(res, 200, s);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 轻量心跳:只回答「有没有在跑 / 上次结果 / 有几个待裁决」。
|
|
114
|
+
* 不碰 git、不扫会话 —— 侧栏按钮和面板的空闲轮询用它,避免每几秒起一堆 git 子进程。
|
|
115
|
+
*/
|
|
116
|
+
route('ping', (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const run = engine.currentRun && engine.currentRun.running ? engine.currentRun : engine.lastRun;
|
|
119
|
+
json(res, 200, {
|
|
120
|
+
ok: true,
|
|
121
|
+
running: engine.busy,
|
|
122
|
+
lastRun: engine.lastRun ? engine.lastRun.summary() : null,
|
|
123
|
+
current: engine.currentRun && engine.currentRun.running ? engine.currentRun.summary() : null,
|
|
124
|
+
pendingCount: engine.listPending().reduce((a, p) => a + p.conflicts.length, 0),
|
|
125
|
+
remote: engine.loadConfig().remote || '',
|
|
126
|
+
showSidebarButton: engine.loadConfig().showSidebarButton !== false,
|
|
127
|
+
});
|
|
128
|
+
} catch (e) {
|
|
129
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/* ================= 配置 ================= */
|
|
134
|
+
|
|
135
|
+
route('config', async (req, res) => {
|
|
136
|
+
try {
|
|
137
|
+
if (req.method === 'GET') {
|
|
138
|
+
const c = engine.loadConfig();
|
|
139
|
+
json(res, 200, {
|
|
140
|
+
ok: true,
|
|
141
|
+
config: {
|
|
142
|
+
remote: c.remote || '',
|
|
143
|
+
branch: c.branch,
|
|
144
|
+
proxy: c.proxy || '',
|
|
145
|
+
autoRepo: c.autoRepo !== false,
|
|
146
|
+
repoName: c.repoName,
|
|
147
|
+
repoOwner: c.repoOwner || '',
|
|
148
|
+
commitMessage: c.commitMessage,
|
|
149
|
+
workspaceBranchPrefix: c.workspaceBranchPrefix,
|
|
150
|
+
workspaceBase: c.workspaceBase || '',
|
|
151
|
+
showSidebarButton: c.showSidebarButton !== false,
|
|
152
|
+
workspaceExtraIgnore: c.workspaceExtraIgnore || [],
|
|
153
|
+
},
|
|
154
|
+
configPath: engine.configPath(),
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
159
|
+
if (engine.busy) throw new Error('已有同步任务进行中,请稍后重试。');
|
|
160
|
+
const body = await readJsonBody(req);
|
|
161
|
+
const patch = {};
|
|
162
|
+
if (body.remote !== undefined) {
|
|
163
|
+
const v = String(body.remote || '').trim();
|
|
164
|
+
if (v && !/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)[\w.-]+\/[\w.-]+\/?$/.test(v)) {
|
|
165
|
+
throw new Error('请填写 GitHub 仓库地址(HTTPS 或 SSH),且不要包含访问令牌。');
|
|
166
|
+
}
|
|
167
|
+
patch.remote = v.replace(/\.git$/, '');
|
|
168
|
+
}
|
|
169
|
+
if (body.branch !== undefined) {
|
|
170
|
+
const v = String(body.branch || '').trim();
|
|
171
|
+
if (!v) throw new Error('分支名不能为空。');
|
|
172
|
+
const check = await engine.git(['check-ref-format', '--branch', v], { timeoutMs: 20000 });
|
|
173
|
+
if (!engine.ok(check)) throw new Error('分支名不合法,例如 main。');
|
|
174
|
+
patch.branch = v;
|
|
175
|
+
}
|
|
176
|
+
if (body.proxy !== undefined) patch.proxy = String(body.proxy || '').trim();
|
|
177
|
+
if (body.autoRepo !== undefined) patch.autoRepo = body.autoRepo !== false;
|
|
178
|
+
if (body.repoName !== undefined) {
|
|
179
|
+
const v = String(body.repoName || '').trim();
|
|
180
|
+
if (!/^[\w.-]+$/.test(v)) throw new Error('仓库名仅可包含字母、数字、点、下划线、连字符。');
|
|
181
|
+
patch.repoName = v;
|
|
182
|
+
}
|
|
183
|
+
if (body.repoOwner !== undefined) patch.repoOwner = String(body.repoOwner || '').trim();
|
|
184
|
+
if (body.commitMessage !== undefined) patch.commitMessage = String(body.commitMessage || 'dsh-chat-sync').trim() || 'dsh-chat-sync';
|
|
185
|
+
if (body.showSidebarButton !== undefined) {
|
|
186
|
+
if (typeof body.showSidebarButton !== 'boolean') throw new Error('侧栏按钮开关须为布尔值。');
|
|
187
|
+
patch.showSidebarButton = body.showSidebarButton;
|
|
188
|
+
}
|
|
189
|
+
if (body.workspaceBase !== undefined) {
|
|
190
|
+
const v = String(body.workspaceBase || '').trim();
|
|
191
|
+
if (v && !path.isAbsolute(v)) throw new Error('工作区统一目录须为绝对路径,或留空。');
|
|
192
|
+
patch.workspaceBase = v;
|
|
193
|
+
}
|
|
194
|
+
const saved = engine.writeConfig(patch);
|
|
195
|
+
json(res, 200, { ok: true, config: saved, configPath: engine.configPath() });
|
|
196
|
+
} catch (e) {
|
|
197
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/* ================= 列表 ================= */
|
|
202
|
+
|
|
203
|
+
route('sessions', async (req, res) => {
|
|
204
|
+
try {
|
|
205
|
+
const s = await engine.status();
|
|
206
|
+
json(res, 200, { ok: true, sessions: s.sessions || [], workspaces: s.workspaces || [], repo: s.repo });
|
|
207
|
+
} catch (e) {
|
|
208
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
/* ================= 工作区本机路径(「换位置」是本机专属设置,不随仓库同步) ================= */
|
|
213
|
+
|
|
214
|
+
route('workspace/path', async (req, res) => {
|
|
215
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
216
|
+
try {
|
|
217
|
+
if (engine.busy) throw new Error('已有同步任务进行中,请稍后重试。');
|
|
218
|
+
const body = await readJsonBody(req);
|
|
219
|
+
const id = String(body.workspaceId || '').trim();
|
|
220
|
+
if (!id) throw new Error('缺少 workspaceId。');
|
|
221
|
+
const raw = String(body.path || '').trim();
|
|
222
|
+
if (raw) {
|
|
223
|
+
const abs = path.resolve(raw);
|
|
224
|
+
if (!path.isAbsolute(raw)) throw new Error('请填写绝对路径。');
|
|
225
|
+
fs.mkdirSync(abs, { recursive: true });
|
|
226
|
+
engine.setWorkspacePathOverride(id, abs);
|
|
227
|
+
json(res, 200, { ok: true, workspaceId: id, path: abs });
|
|
228
|
+
} else {
|
|
229
|
+
engine.setWorkspacePathOverride(id, null);
|
|
230
|
+
const ws = engine.workspaceRecords().find((w) => w.id === id);
|
|
231
|
+
json(res, 200, { ok: true, workspaceId: id, path: ws ? engine.resolveWorkspacePath(ws, engine.loadConfig()).path : null, cleared: true });
|
|
232
|
+
}
|
|
233
|
+
} catch (e) {
|
|
234
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/* ================= 本机同步偏好:忽略清单 ================= */
|
|
239
|
+
|
|
240
|
+
route('ignore', async (req, res) => {
|
|
241
|
+
try {
|
|
242
|
+
if (req.method === 'GET') {
|
|
243
|
+
const c = engine.ignoreConfig();
|
|
244
|
+
json(res, 200, { ok: true, ignore: c, path: engine.ignoreConfigPath() });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
248
|
+
if (engine.busy) throw new Error('已有同步任务进行中,请稍后重试。');
|
|
249
|
+
const body = await readJsonBody(req);
|
|
250
|
+
const patch = {};
|
|
251
|
+
const listOf = (v, what) => {
|
|
252
|
+
if (!Array.isArray(v)) throw new Error(`${what} 须为数组。`);
|
|
253
|
+
return v.map(String);
|
|
254
|
+
};
|
|
255
|
+
if (body.sessions !== undefined) patch.sessions = listOf(body.sessions, '忽略的会话');
|
|
256
|
+
if (body.workspaces !== undefined) patch.workspaces = listOf(body.workspaces, '忽略的工作区');
|
|
257
|
+
if (body.workspaceFiles !== undefined) {
|
|
258
|
+
if (!body.workspaceFiles || typeof body.workspaceFiles !== 'object') throw new Error('workspaceFiles 须为对象。');
|
|
259
|
+
const out = {};
|
|
260
|
+
for (const [k, v] of Object.entries(body.workspaceFiles)) out[String(k)] = listOf(v, '工作区忽略规则');
|
|
261
|
+
patch.workspaceFiles = out;
|
|
262
|
+
}
|
|
263
|
+
if (body.clearDecisions === true) patch.decisions = {};
|
|
264
|
+
const saved = engine.writeIgnoreConfig(patch);
|
|
265
|
+
json(res, 200, { ok: true, ignore: saved, path: engine.ignoreConfigPath() });
|
|
266
|
+
} catch (e) {
|
|
267
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/* ================= 逐单元状态(界面用) ================= */
|
|
272
|
+
|
|
273
|
+
route('units', async (req, res) => {
|
|
274
|
+
try {
|
|
275
|
+
const units = await engine.unitStates();
|
|
276
|
+
json(res, 200, { ok: true, units, ignore: engine.ignoreConfig() });
|
|
277
|
+
} catch (e) {
|
|
278
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/* ================= 重新对齐(纯快进,永远安全) ================= */
|
|
283
|
+
|
|
284
|
+
route('align', async (req, res) => {
|
|
285
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
286
|
+
try {
|
|
287
|
+
if (engine.busy) throw new Error('已有同步任务进行中,请稍后重试。');
|
|
288
|
+
const r = await engine.alignToRemote();
|
|
289
|
+
json(res, 200, r);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
/* ================= 工作区的忽略规则文件(用户自己编辑的 gitignore 风格文件) ================= */
|
|
296
|
+
|
|
297
|
+
const workspaceByRequest = (id) => engine.workspaceRecords().find((w) => w.id === String(id));
|
|
298
|
+
|
|
299
|
+
route('workspace/ignore', async (req, res) => {
|
|
300
|
+
try {
|
|
301
|
+
if (req.method === 'GET') {
|
|
302
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
303
|
+
const ws = workspaceByRequest(url.searchParams.get('workspaceId'));
|
|
304
|
+
if (!ws) throw new Error('未找到该工作区。');
|
|
305
|
+
const realPath = engine.resolveWorkspacePath(ws, engine.loadConfig()).path;
|
|
306
|
+
if (!realPath) throw new Error('该工作区在本机无可用路径。');
|
|
307
|
+
const p = engine.workspaceIgnoreFilePath(realPath);
|
|
308
|
+
json(res, 200, {
|
|
309
|
+
ok: true,
|
|
310
|
+
workspaceId: ws.id,
|
|
311
|
+
title: ws.title,
|
|
312
|
+
path: p,
|
|
313
|
+
exists: fs.existsSync(p),
|
|
314
|
+
content: engine.readWorkspaceIgnoreFile(realPath),
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
319
|
+
if (engine.busy) throw new Error('已有同步任务进行中,请稍后重试。');
|
|
320
|
+
const body = await readJsonBody(req);
|
|
321
|
+
const ws = workspaceByRequest(body.workspaceId);
|
|
322
|
+
if (!ws) throw new Error('未找到该工作区。');
|
|
323
|
+
const realPath = engine.resolveWorkspacePath(ws, engine.loadConfig()).path;
|
|
324
|
+
if (!realPath) throw new Error('该工作区在本机无可用路径。');
|
|
325
|
+
if (body.create === true) {
|
|
326
|
+
const p = engine.ensureWorkspaceIgnoreFile(realPath, engine.loadConfig());
|
|
327
|
+
json(res, 200, { ok: true, path: p, content: engine.readWorkspaceIgnoreFile(realPath) });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (typeof body.content !== 'string') throw new Error('需要 content(文件全文)。');
|
|
331
|
+
const p = engine.writeWorkspaceIgnoreFile(realPath, body.content);
|
|
332
|
+
json(res, 200, { ok: true, path: p, content: engine.readWorkspaceIgnoreFile(realPath) });
|
|
333
|
+
} catch (e) {
|
|
334
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
/* ================= 显式强制覆盖单个单元 ================= */
|
|
339
|
+
|
|
340
|
+
route('force', async (req, res) => {
|
|
341
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
342
|
+
try {
|
|
343
|
+
if (engine.busy) throw new Error('已有同步任务进行中,请稍后重试。');
|
|
344
|
+
const body = await readJsonBody(req);
|
|
345
|
+
const p = String(body.path || '').trim();
|
|
346
|
+
const mode = String(body.mode || '').trim();
|
|
347
|
+
if (!p) throw new Error('缺少 path。');
|
|
348
|
+
if (mode !== 'pull' && mode !== 'push') throw new Error('mode 仅可为 pull(以远端覆盖本机)或 push(以本机覆盖远端)。');
|
|
349
|
+
const r = await engine.forceUnit(p, mode);
|
|
350
|
+
json(res, 200, r);
|
|
351
|
+
} catch (e) {
|
|
352
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
/* ================= 执行 push / pull ================= */
|
|
357
|
+
|
|
358
|
+
route('run', async (req, res) => {
|
|
359
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
360
|
+
try {
|
|
361
|
+
const body = await readJsonBody(req);
|
|
362
|
+
const direction = body.direction === 'pull' ? 'pull' : 'push';
|
|
363
|
+
const scope = body.scope === 'all' ? 'all' : 'session';
|
|
364
|
+
const started = engine.start({
|
|
365
|
+
direction,
|
|
366
|
+
scope,
|
|
367
|
+
sessionIds: Array.isArray(body.sessionIds) ? body.sessionIds.map(String) : [],
|
|
368
|
+
includeWorkspace: body.includeWorkspace === true,
|
|
369
|
+
workspaceIds: Array.isArray(body.workspaceIds) ? body.workspaceIds.map(String) : [],
|
|
370
|
+
reason: typeof body.reason === 'string' ? body.reason : 'web',
|
|
371
|
+
});
|
|
372
|
+
json(res, 200, started);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
route('progress', async (req, res) => {
|
|
379
|
+
try {
|
|
380
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
381
|
+
const since = Number(url.searchParams.get('since') || 0) || 0;
|
|
382
|
+
const wantAll = url.searchParams.get('all') === '1';
|
|
383
|
+
const run = engine.currentRun || engine.lastRun;
|
|
384
|
+
if (!run) { json(res, 200, { ok: true, entries: [], running: false, summary: null }); return; }
|
|
385
|
+
const entries = wantAll ? run.progress.all() : run.progress.since(since);
|
|
386
|
+
json(res, 200, {
|
|
387
|
+
ok: true,
|
|
388
|
+
entries,
|
|
389
|
+
next: run.progress.next - 1,
|
|
390
|
+
running: run.running,
|
|
391
|
+
summary: run.summary(),
|
|
392
|
+
});
|
|
393
|
+
} catch (e) {
|
|
394
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
/* ================= 冲突裁决 ================= */
|
|
399
|
+
|
|
400
|
+
route('pending', async (req, res) => {
|
|
401
|
+
try {
|
|
402
|
+
const list = engine.listPending();
|
|
403
|
+
json(res, 200, { ok: true, pending: list.map((p) => ({ id: p.id, createdAt: p.createdAt, conflicts: p.conflicts })) });
|
|
404
|
+
} catch (e) {
|
|
405
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
route('pending/preview', async (req, res) => {
|
|
410
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
411
|
+
try {
|
|
412
|
+
const body = await readJsonBody(req);
|
|
413
|
+
const pending = engine.listPending().find((p) => p.id === body.pendingId);
|
|
414
|
+
if (!pending) throw new Error('未找到该待裁决记录。');
|
|
415
|
+
const c = pending.conflicts.find((x) => x.id === body.conflictId);
|
|
416
|
+
if (!c) throw new Error('未找到该冲突项。');
|
|
417
|
+
const localAbs = c.repo === 'main'
|
|
418
|
+
? path.join(home, c.path)
|
|
419
|
+
: path.join(engine.workspaceRecords().find((w) => `ws:${w.id}` === c.repo)?.path || '', c.path);
|
|
420
|
+
const theirsAbs = c.theirsCopy ? path.join(pending.dir, c.theirsCopy) : null;
|
|
421
|
+
const localBuf = fs.existsSync(localAbs) ? fs.readFileSync(localAbs) : null;
|
|
422
|
+
const theirsBuf = theirsAbs && fs.existsSync(theirsAbs) ? fs.readFileSync(theirsAbs) : null;
|
|
423
|
+
if (isSessionLogPath(c.path)) {
|
|
424
|
+
json(res, 200, {
|
|
425
|
+
ok: true,
|
|
426
|
+
kind: 'session',
|
|
427
|
+
sessionId: c.sessionId || sessionIdOfPath(c.path),
|
|
428
|
+
local: localBuf ? sessionPreview(localBuf) : { ok: false, reason: '本机没有这个文件' },
|
|
429
|
+
remote: c.theirsDeleted ? { ok: false, reason: '远端删除了这个会话' } : (theirsBuf ? sessionPreview(theirsBuf) : { ok: false, reason: '没有留下远端版本' }),
|
|
430
|
+
});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
json(res, 200, {
|
|
434
|
+
ok: true,
|
|
435
|
+
kind: 'text',
|
|
436
|
+
local: localBuf ? textPreview(localBuf) : null,
|
|
437
|
+
remote: theirsBuf ? textPreview(theirsBuf) : null,
|
|
438
|
+
});
|
|
439
|
+
} catch (e) {
|
|
440
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
route('pending/resolve', async (req, res) => {
|
|
445
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
446
|
+
try {
|
|
447
|
+
if (engine.busy) throw new Error('已有同步任务进行中,请稍后重试。');
|
|
448
|
+
const body = await readJsonBody(req);
|
|
449
|
+
if (!body.pendingId) throw new Error('缺少 pendingId。');
|
|
450
|
+
const decisions = Array.isArray(body.decisions) ? body.decisions : [];
|
|
451
|
+
for (const d of decisions) {
|
|
452
|
+
if (!d || typeof d.id !== 'string') throw new Error('裁决项须包含 id。');
|
|
453
|
+
if (!['local', 'remote', 'both', 'delete', 'keep'].includes(d.choice)) throw new Error(`未知的裁决:${d.choice}`);
|
|
454
|
+
}
|
|
455
|
+
const r = await engine.resolveConflicts({ pendingId: String(body.pendingId), decisions });
|
|
456
|
+
json(res, 200, r);
|
|
457
|
+
} catch (e) {
|
|
458
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
route('pending/discard', async (req, res) => {
|
|
463
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
464
|
+
try {
|
|
465
|
+
if (engine.busy) throw new Error('已有同步任务进行中,请稍后重试。');
|
|
466
|
+
const body = await readJsonBody(req);
|
|
467
|
+
if (!body.pendingId) throw new Error('缺少 pendingId。');
|
|
468
|
+
engine.clearPending(String(body.pendingId));
|
|
469
|
+
json(res, 200, { ok: true });
|
|
470
|
+
} catch (e) {
|
|
471
|
+
json(res, 200, { ok: false, error: String(e?.message || e) });
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
/* ================= Agent 工具 ================= */
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
disposers.push(ctx.tools.register({
|
|
479
|
+
name: 'chat_sync',
|
|
480
|
+
description: [
|
|
481
|
+
'在 GitHub 私有仓库与 DSH 会话 / 工作区文件之间手动同步。',
|
|
482
|
+
'action=push 为推送(本机 → 远端),action=pull 为取回(远端 → 本机)。',
|
|
483
|
+
'scope=all 处理全部会话,scope=session 仅处理 sessionIds 中的会话;',
|
|
484
|
+
'includeWorkspace=true 时同时同步工作区项目文件。',
|
|
485
|
+
'双方均已修改且无法自动合并的条目不会自动选边:操作暂停并返回待裁决冲突;',
|
|
486
|
+
'用 action=conflicts 查看,用 action=resolve 逐项选择 local / remote / both(删除类为 delete / keep)。',
|
|
487
|
+
].join(''),
|
|
488
|
+
parameters: {
|
|
489
|
+
action: {
|
|
490
|
+
type: 'string',
|
|
491
|
+
enum: ['status', 'units', 'push', 'pull', 'conflicts', 'resolve', 'preview', 'ignore', 'align', 'force'],
|
|
492
|
+
description: 'status 查看状态;units 列出各条目相对远端的差异;push/pull 执行同步;'
|
|
493
|
+
+ 'conflicts 列出待裁决冲突;resolve 提交裁决;preview 查看某项冲突的两侧内容;'
|
|
494
|
+
+ 'ignore 在本机忽略或取消忽略;align 重新对齐(快进);force 以指定一侧强制覆盖。',
|
|
495
|
+
},
|
|
496
|
+
scope: { type: 'string', enum: ['all', 'session'], description: 'push/pull 的范围,默认 all。' },
|
|
497
|
+
sessionIds: { type: 'array', items: { type: 'string' }, description: 'scope=session 时处理的会话 ID。' },
|
|
498
|
+
includeWorkspace: { type: 'boolean', description: '是否同时同步工作区项目文件。' },
|
|
499
|
+
pendingId: { type: 'string', description: 'action=resolve / preview 的待裁决记录 ID。' },
|
|
500
|
+
conflictId: { type: 'string', description: 'action=preview 的冲突项 ID。' },
|
|
501
|
+
ignore: {
|
|
502
|
+
type: 'object',
|
|
503
|
+
description: 'action=ignore 的本机忽略设置:{ sessions?: string[], workspaces?: string[], workspaceFiles?: { [工作区ID]: string[] } }。省略的字段保持不变。',
|
|
504
|
+
},
|
|
505
|
+
force: {
|
|
506
|
+
type: 'object',
|
|
507
|
+
description: 'action=force 时:{ path: string, mode: "pull" | "push" }。pull 以远端覆盖本机,push 以本机覆盖远端。',
|
|
508
|
+
},
|
|
509
|
+
decisions: {
|
|
510
|
+
type: 'array',
|
|
511
|
+
description: 'action=resolve 的裁决列表。choice=local(保留本机)/ remote(采用远端)/ both(两边都留);'
|
|
512
|
+
+ '删除类冲突用 delete / keep,具体含义随原因而异。',
|
|
513
|
+
items: {
|
|
514
|
+
type: 'object',
|
|
515
|
+
properties: {
|
|
516
|
+
id: { type: 'string' },
|
|
517
|
+
choice: { type: 'string', enum: ['local', 'remote', 'both', 'delete', 'keep'] },
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
output: {
|
|
523
|
+
schema: { type: 'string' },
|
|
524
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
525
|
+
},
|
|
526
|
+
async execute(args = {}) {
|
|
527
|
+
const action = args.action || 'status';
|
|
528
|
+
if (action === 'status') {
|
|
529
|
+
const s = await engine.status();
|
|
530
|
+
const lines = [
|
|
531
|
+
`本地仓库:${s.repo?.exists ? '已初始化' : '未初始化'};HEAD ${s.repo?.head || '(无)'};未提交 ${s.repo?.dirty ?? 0} 项`,
|
|
532
|
+
`远端:${s.remote || '(未配置)'}(分支 ${s.branch})`,
|
|
533
|
+
s.repo?.remoteHead ? `相对远端:领先 ${s.repo.ahead} 个提交,落后 ${s.repo.behind} 个提交` : '尚未取到远端分支(请先执行一次推送)',
|
|
534
|
+
`会话:本机 ${s.localSessionCount ?? 0},远端 ${s.remoteSessionCount ?? 0};工作区 ${s.workspaces?.length ?? 0}`,
|
|
535
|
+
`待裁决冲突:${s.pending?.length ? s.pending.reduce((a, p) => a + p.conflicts.length, 0) + ' 项' : '无'}`,
|
|
536
|
+
s.lastRun ? `上次操作:${s.lastRun.direction}${s.lastRun.running ? '(进行中)' : ''} ${s.lastRun.error || summarize(s.lastRun.result)}` : '尚未执行过同步',
|
|
537
|
+
];
|
|
538
|
+
return lines.join('\n');
|
|
539
|
+
}
|
|
540
|
+
if (action === 'units') {
|
|
541
|
+
const r = await engine.unitStates();
|
|
542
|
+
if (!r.ok) return r.reason === 'no-remote' ? '远端尚无基线,请先执行一次推送。' : '本机无提交。';
|
|
543
|
+
if (!r.units.length) return '所有条目均与远端一致。';
|
|
544
|
+
return r.units.slice(0, 80).map((u) => `${u.stateLabel}\t${u.title || u.path}${u.sessionId ? '\t' + u.sessionId : ''}`).join('\n')
|
|
545
|
+
+ (r.total > 80 ? `\n…共 ${r.total} 项` : '');
|
|
546
|
+
}
|
|
547
|
+
if (action === 'align') {
|
|
548
|
+
const r = await engine.alignToRemote();
|
|
549
|
+
if (!r.ok) return `重新对齐失败:${r.error}`;
|
|
550
|
+
if (r.upToDate) return r.ahead ? `本机已包含远端全部内容(领先 ${r.ahead} 个提交,均为本机此前交付的记录)。` : '本机与远端一致。';
|
|
551
|
+
return '已快进至远端。';
|
|
552
|
+
}
|
|
553
|
+
if (action === 'ignore') {
|
|
554
|
+
const patch = {};
|
|
555
|
+
if (args.ignore && Array.isArray(args.ignore.sessions)) patch.sessions = args.ignore.sessions.map(String);
|
|
556
|
+
if (args.ignore && Array.isArray(args.ignore.workspaces)) patch.workspaces = args.ignore.workspaces.map(String);
|
|
557
|
+
if (args.ignore && args.ignore.workspaceFiles && typeof args.ignore.workspaceFiles === 'object') {
|
|
558
|
+
const wf = {};
|
|
559
|
+
for (const [k, v] of Object.entries(args.ignore.workspaceFiles)) wf[String(k)] = Array.isArray(v) ? v.map(String) : [];
|
|
560
|
+
patch.workspaceFiles = wf;
|
|
561
|
+
}
|
|
562
|
+
if (!Object.keys(patch).length) return `当前本机忽略设置:${JSON.stringify(engine.ignoreConfig())}`;
|
|
563
|
+
const saved = engine.writeIgnoreConfig(patch);
|
|
564
|
+
return `已更新本机忽略设置:${JSON.stringify({ sessions: saved.sessions, workspaces: saved.workspaces, workspaceFiles: saved.workspaceFiles })}`;
|
|
565
|
+
}
|
|
566
|
+
if (action === 'force') {
|
|
567
|
+
if (!args.force || !args.force.path || !args.force.mode) return '需要 force: { path, mode }。';
|
|
568
|
+
const r = await engine.forceUnit(String(args.force.path), String(args.force.mode));
|
|
569
|
+
return r.ok
|
|
570
|
+
? `${args.force.mode === 'pull' ? '已用远端版本覆盖本机' : '已用本机版本覆盖远端'}:${args.force.path}`
|
|
571
|
+
: `强制覆盖失败:${r.error}`;
|
|
572
|
+
}
|
|
573
|
+
if (action === 'conflicts') {
|
|
574
|
+
const list = engine.listPending();
|
|
575
|
+
if (!list.length) return '当前无待裁决冲突。';
|
|
576
|
+
return list.map((p) => [
|
|
577
|
+
`待裁决记录 ${p.id}(${p.conflicts.length} 项)`,
|
|
578
|
+
...p.conflicts.map((c) => ` - [${c.id}] ${c.repo === 'main' ? '主数据' : c.repo} ${c.path}${c.title ? `(${c.title})` : ''} 原因:${c.reason}`),
|
|
579
|
+
].join('\n')).join('\n');
|
|
580
|
+
}
|
|
581
|
+
if (action === 'preview') {
|
|
582
|
+
if (!args.pendingId || !args.conflictId) return '需要 pendingId 与 conflictId。';
|
|
583
|
+
const pending = engine.listPending().find((p) => p.id === args.pendingId);
|
|
584
|
+
const c = pending?.conflicts.find((x) => x.id === args.conflictId);
|
|
585
|
+
if (!c) return '未找到该冲突项。';
|
|
586
|
+
return [
|
|
587
|
+
`冲突:${c.path}(${c.reason})`,
|
|
588
|
+
`本机版本:${c.repo === 'main' ? path.join(home, c.path) : c.path}`,
|
|
589
|
+
`远端版本:${c.theirsCopy ? path.join(pending.dir, c.theirsCopy) : '(未留存)'}`,
|
|
590
|
+
'用 action=resolve 选择 local(保留本机)/ remote(采用远端)/ both(两边都留)。',
|
|
591
|
+
].join('\n');
|
|
592
|
+
}
|
|
593
|
+
if (action === 'resolve') {
|
|
594
|
+
if (!args.pendingId) return '需要 pendingId。';
|
|
595
|
+
const decisions = Array.isArray(args.decisions) ? args.decisions : [];
|
|
596
|
+
if (!decisions.length) return '需要 decisions:[{id, choice}]。';
|
|
597
|
+
const r = await engine.resolveConflicts({ pendingId: args.pendingId, decisions });
|
|
598
|
+
return r.ok ? `裁决已应用(剩余 ${r.remaining ?? 0} 项待裁决):${JSON.stringify(r.detail)}` : `裁决失败:${r.error}`;
|
|
599
|
+
}
|
|
600
|
+
// push / pull
|
|
601
|
+
const r = await engine.execute({
|
|
602
|
+
direction: action === 'pull' ? 'pull' : 'push',
|
|
603
|
+
scope: args.scope === 'session' ? 'session' : 'all',
|
|
604
|
+
sessionIds: Array.isArray(args.sessionIds) ? args.sessionIds.map(String) : [],
|
|
605
|
+
includeWorkspace: args.includeWorkspace === true,
|
|
606
|
+
reason: 'agent-tool',
|
|
607
|
+
});
|
|
608
|
+
const run = engine.lastRun;
|
|
609
|
+
const lines = (run?.progress.all() || []).map((e) => `${e.level === 'error' ? '✗' : e.level === 'warn' ? '!' : '·'} ${e.text}`);
|
|
610
|
+
const tail = lines.slice(-60).join('\n');
|
|
611
|
+
return `结果:${r.ok ? '成功' : '未完成'}\n${summarize(r.result)}\n\n过程(最后 60 行):\n${tail}`;
|
|
612
|
+
},
|
|
613
|
+
}));
|
|
614
|
+
} catch (err) {
|
|
615
|
+
log('warn', '工具注册失败:' + String(err?.message || err));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
ctx.effect(() => () => {
|
|
619
|
+
for (const d of disposers) { try { d(); } catch { /* ignore */ } }
|
|
620
|
+
disposers.length = 0;
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const cfg = engine.loadConfig();
|
|
624
|
+
log('info', `dsh-chat-sync 已启动(home=${home},分支=${cfg.branch},远端=${cfg.remote || '(未配置,首次 push 时可自动创建私有仓库)'})。`);
|
|
625
|
+
log('info', `界面:设置 → 会话同步;侧栏底部按钮。API 前缀 ${PREFIX}。`);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export { name, inject, apply };
|