@weibaohui/dsh-sync 0.1.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/src/index.js ADDED
@@ -0,0 +1,852 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * dsh-plugin-dsh-sync — Host half
5
+ *
6
+ * A small git-based sync system for multiple dsh replicas. Each instance
7
+ * mirrors its skills / sessions / settings / plugins into a private GitCode
8
+ * repository through a branch → PR → merge flow, so two replicas that both
9
+ * touch the same file surface as a pull request instead of a silent
10
+ * overwrite. Deterministic work (fetch / branch / commit / push) is done by
11
+ * the git CLI directly; only the conflict step — which needs semantic
12
+ * judgement — hands off to an in-process agent (same channel skills-management
13
+ * share uses). Token is write-only through the host settings service and
14
+ * never travels to the client in cleartext.
15
+ *
16
+ * Architecture: a shadow working tree at $DSH_HOME/dsh-sync/repo mirrors
17
+ * selected live roots. Push = fetch origin/main → reset shadow to origin/main
18
+ * → overlay live snapshot → branch → commit → push → create PR → mergeable?
19
+ * merge : surface a conflict action. Pull = fetch → for files remote changed
20
+ * since lastSyncedCommit, write the remote version back to live only when the
21
+ * local copy is untouched (three-way; locally-modified files wait for the
22
+ * next push). Conflicts an agent cannot auto-resolve stay open as PRs.
23
+ */
24
+
25
+ const { execFile } = require('node:child_process')
26
+ const { randomUUID } = require('node:crypto')
27
+ const fsP = require('node:fs/promises')
28
+ const { join, relative, resolve, sep } = require('node:path')
29
+ const { homedir, hostname } = require('node:os')
30
+ // settings 服务要求 schemastery schema(可调用 + toJSON;zod 不兼容,register 会抛错被吞)。
31
+ // 宿主沙箱内解析打包依赖可能抛 ERR_INTERNAL_ASSERTION(.pnpm 软链),因此优先沿
32
+ // dsh 全局安装取 settings 服务自用的那份副本,本地开发/测试再退回标准 require。
33
+ function loadSchemastery() {
34
+ const errors = []
35
+ const { createRequire } = require('node:module')
36
+ for (const prefix of [process.env.DSH_GLOBAL_PREFIX, join(homedir(), '.local')].filter(Boolean)) {
37
+ const hostCopy = join(prefix, 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai', 'schemastery', 'lib', 'index.cjs')
38
+ try { return createRequire(hostCopy)(hostCopy) } catch (e) { errors.push(String(e && e.code || e)) }
39
+ }
40
+ try { return require('@deepseek-ai/schemastery') } catch (e) { errors.push(String(e && e.code || e)) }
41
+ if (process.env.DSHSYNC_DEBUG) console.warn(`[dsh-sync] schemastery unavailable: ${errors.join(' | ')}`)
42
+ return null
43
+ }
44
+ const Schema = loadSchemastery()
45
+
46
+ const GITCODE_API_BASE = 'https://api.gitcode.com/api/v5'
47
+ const MAX_BODY_BYTES = 64 * 1024
48
+ const SYNC_TIMEOUT_MS = 10 * 60 * 1000
49
+ const CONFLICT_RUN_TIMEOUT_MS = 30 * 60 * 1000
50
+ const CONFLICT_RUN_OUTPUT_CAP = 256 * 1024
51
+
52
+ const DEFAULT_SYNC_SETTINGS = {
53
+ repoUrl: '',
54
+ branch: 'main',
55
+ gitBinary: 'git',
56
+ autoSync: true,
57
+ syncOnStartup: false,
58
+ intervalMinutes: 30,
59
+ conflictMode: 'ai', // 'ai' (action button → in-process agent) | 'manual'
60
+ syncSkills: true,
61
+ syncSessions: false,
62
+ syncSettings: true,
63
+ syncPlugins: true,
64
+ }
65
+
66
+ // ── Shared helpers (ported from skills-management so conventions match) ──
67
+
68
+ function dshHome() { return process.env.DSH_HOME ? resolve(process.env.DSH_HOME) : join(homedir(), '.dsh') }
69
+
70
+ function displayPath(p) {
71
+ const home = homedir()
72
+ if (p === home) return '~'
73
+ if (p.startsWith(home + sep)) return '~' + p.slice(home.length)
74
+ return p
75
+ }
76
+
77
+ function expandTilde(p) {
78
+ return p === '~' || p.startsWith('~/') || p.startsWith('~\\') ? join(homedir(), p.slice(2)) : p
79
+ }
80
+
81
+ function readJsonBody(req) {
82
+ return new Promise((fulfil, reject) => {
83
+ let size = 0, chunks = []
84
+ req.on('data', (chunk) => {
85
+ size += chunk.length
86
+ if (size > MAX_BODY_BYTES) { reject(new Error('request body too large')); req.destroy(); return }
87
+ chunks.push(chunk)
88
+ })
89
+ req.on('end', () => {
90
+ try { fulfil(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8'))) }
91
+ catch (error) { reject(new Error(`invalid JSON body: ${error && error.message}`)) }
92
+ })
93
+ req.on('error', reject)
94
+ })
95
+ }
96
+
97
+ function sendJson(res, status, payload) {
98
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
99
+ res.end(JSON.stringify(payload))
100
+ }
101
+
102
+ async function atomicWriteFile(file, content) {
103
+ await fsP.mkdir(join(file, '..'), { recursive: true })
104
+ const temp = join(join(file, '..'), `.${randomUUID()}.tmp`)
105
+ await fsP.writeFile(temp, content)
106
+ await fsP.rename(temp, file)
107
+ }
108
+
109
+ // ── Git CLI (token stays out of .git/config — authed URL per command) ──
110
+
111
+ function gitExec(binary, args, cwd) {
112
+ return new Promise((fulfil, reject) => {
113
+ execFile(binary, args, { cwd, timeout: 10 * 60 * 1000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
114
+ if (error) {
115
+ const tail = String(stderr || error.message || '').split(/\r?\n/).filter(Boolean).slice(-3).join(' ')
116
+ reject(new Error(`git ${args[0]}: ${tail || error.message}`))
117
+ return
118
+ }
119
+ fulfil(String(stdout))
120
+ })
121
+ })
122
+ }
123
+
124
+ function gitShowBuf(binary, rev, cwd) {
125
+ // raw bytes for binary-safe compare/copy (session logs are zstd)
126
+ return new Promise((fulfil, reject) => {
127
+ execFile(binary, ['show', rev], { cwd, maxBuffer: 64 * 1024 * 1024, encoding: 'buffer' }, (error, stdout) => {
128
+ if (error) { reject(new Error(`git show: ${String(error.message || '')}`)); return }
129
+ fulfil(stdout)
130
+ })
131
+ })
132
+ }
133
+
134
+ async function gitAvailable(binary) {
135
+ try { await gitExec(binary, ['--version']); return true } catch { return false }
136
+ }
137
+
138
+ async function gitCurrentCommit(binary, repo) {
139
+ try { return (await gitExec(binary, ['rev-parse', 'HEAD'], repo)).trim() } catch { return undefined }
140
+ }
141
+
142
+ /** Embed an access token in an https remote URL (gitcode/oauth2 style).
143
+ * Credentials stay out of .git/config — every remote-touching command
144
+ * receives the authed URL directly and nothing is persisted. */
145
+ function authedUrl(url, token) {
146
+ if (!token) return url
147
+ return String(url).replace(/^(https?:\/\/)([^@/]+@)?/, `$1oauth2:${encodeURIComponent(token)}@`)
148
+ }
149
+
150
+ // ── Cross-process lock: tui + web profiles run the same $DSH_HOME, so two
151
+ // sync loops could write the shadow tree at once. O_EXCL atomic create. ──
152
+
153
+ async function acquireLock(lockFile) {
154
+ const fs = require('node:fs')
155
+ try {
156
+ const handle = fs.openSync(lockFile, 'wx')
157
+ fs.writeSync(handle, String(process.pid))
158
+ fs.closeSync(handle)
159
+ return () => { try { fs.unlinkSync(lockFile) } catch {} }
160
+ } catch (e) {
161
+ if (e.code === 'EEXIST') {
162
+ // stale-lock recovery: a crashed process leaves a lock; if its pid is
163
+ // gone, steal it. Otherwise someone else is syncing.
164
+ try {
165
+ const pid = parseInt(String(fs.readFileSync(lockFile, 'utf8')).trim(), 10)
166
+ if (Number.isFinite(pid)) {
167
+ try { process.kill(pid, 0); return null } catch { /* pid dead → steal */ }
168
+ }
169
+ fs.unlinkSync(lockFile)
170
+ const handle = fs.openSync(lockFile, 'wx')
171
+ fs.writeSync(handle, String(process.pid))
172
+ fs.closeSync(handle)
173
+ return () => { try { fs.unlinkSync(lockFile) } catch {} }
174
+ } catch { return null }
175
+ }
176
+ throw e
177
+ }
178
+ }
179
+
180
+ // ── GitCode REST: repo privacy check + PR create / detail / merge ──
181
+
182
+ /** Parse `https://gitcode.com/<owner>/<repo>(.git)` → { owner, repo }. */
183
+ function parseRepoUrl(url) {
184
+ const m = String(url || '').match(/gitcode\.com\/([^/]+)\/([^/?.]+?)(?:\.git)?(?:[/?#]|$)/i)
185
+ if (!m) return null
186
+ return { owner: m[1], repo: m[2] }
187
+ }
188
+
189
+ async function gitcodeRequest(token, method, path, body, { apiBase = GITCODE_API_BASE } = {}) {
190
+ const url = apiBase + path
191
+ // 认证必须用 PRIVATE-TOKEN(实测 GitCode 子资源端点 branches/pulls 对
192
+ // Authorization: Bearer 有 bug——带 Bearer 查 project 一律 404 not found,
193
+ // 匿名 / PRIVATE-TOKEN / access_token query 均正常)
194
+ const init = { method, headers: { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json' } }
195
+ if (body !== undefined) init.body = JSON.stringify(body)
196
+ const r = await fetch(url, init)
197
+ const text = await r.text()
198
+ let json = null
199
+ try { json = text === '' ? null : JSON.parse(text) } catch {}
200
+ return { ok: r.ok, status: r.status, json, text }
201
+ }
202
+
203
+ /** Verify the configured repo exists AND is private. Public repos are refused
204
+ * because sync carries credentials (settings.yaml is mirrored wholesale). */
205
+ async function checkRepoPrivate(token, repoUrl) {
206
+ const parsed = parseRepoUrl(repoUrl)
207
+ if (!parsed) return { ok: false, error: '无法解析仓库地址(需要 https://gitcode.com/<owner>/<repo>)' }
208
+ const r = await gitcodeRequest(token, 'GET', `/repos/${parsed.owner}/${parsed.repo}`)
209
+ if (!r.ok) return { ok: false, error: `无法访问仓库(HTTP ${r.status}):${(r.json && r.json.message) || r.text.slice(0, 120)}` }
210
+ const priv = r.json && (r.json.private === true || r.json.private === 'true')
211
+ if (!priv) return { ok: false, error: '检测到公共仓库。dsh-sync 会同步含凭证的 settings.yaml,必须使用私有仓库——请到 gitcode.com 将该仓库设为私有,或新建私有仓库后再填地址。', isPublic: true }
212
+ return { ok: true, owner: parsed.owner, repo: parsed.repo, defaultBranch: r.json && (r.json.default_branch || 'main') }
213
+ }
214
+
215
+ async function createPullRequest(token, owner, repo, { head, base, title, body }) {
216
+ return gitcodeRequest(token, 'POST', `/repos/${owner}/${repo}/pulls`, { head, base, title: title || 'dsh-sync', body: body || '' })
217
+ }
218
+
219
+ async function getPullRequest(token, owner, repo, number) {
220
+ return gitcodeRequest(token, 'GET', `/repos/${owner}/${repo}/pulls/${number}`)
221
+ }
222
+
223
+ async function mergePullRequest(token, owner, repo, number, method) {
224
+ return gitcodeRequest(token, 'PUT', `/repos/${owner}/${repo}/pulls/${number}/merge`, method ? { merge_method: method } : {})
225
+ }
226
+
227
+ // ── Sync spec: which live roots mirror into which shadow paths ──
228
+ // Four toggle groups; a group's sources are only active when its switch
229
+ // is on. Built fresh each cycle from the effective settings. `roots` is
230
+ // injectable so tests never touch the real $HOME.
231
+
232
+ function defaultRoots() {
233
+ const home = homedir()
234
+ const dh = dshHome()
235
+ return {
236
+ dshSkills: join(dh, 'skills'),
237
+ agentsSkills: join(home, '.agents', 'skills'),
238
+ agentsLock: join(home, '.agents', '.skill-lock.json'),
239
+ sessions: join(dh, 'sessions'),
240
+ settingsFile: join(dh, 'settings.yaml'),
241
+ profiles: join(dh, 'profiles'),
242
+ }
243
+ }
244
+
245
+ function syncSpec(eff, roots = defaultRoots()) {
246
+ const groups = []
247
+ if (eff.syncSkills) groups.push({
248
+ name: 'skills',
249
+ sources: [
250
+ { from: roots.dshSkills, to: 'skills/dsh' },
251
+ // 软链解引用成实文件:跨机不能指望同一个 link target 存在
252
+ { from: roots.agentsSkills, to: 'skills/agents', followSymlinks: true },
253
+ { from: roots.agentsLock, to: 'skills/.skill-lock.json', file: true },
254
+ ],
255
+ })
256
+ if (eff.syncSessions) groups.push({
257
+ name: 'sessions',
258
+ sources: [{ from: roots.sessions, to: 'sessions', excludeNames: new Set(['session_projcache.json']) }],
259
+ })
260
+ if (eff.syncSettings) groups.push({
261
+ name: 'settings',
262
+ // 整文件同步、不脱敏——前提是私仓校验通过
263
+ sources: [{ from: roots.settingsFile, to: 'settings/settings.yaml', file: true }],
264
+ })
265
+ if (eff.syncPlugins) groups.push({
266
+ name: 'plugins',
267
+ sources: [{
268
+ from: roots.profiles, to: 'plugins',
269
+ // 只存声明:package.json / patch / 锁文件。node_modules 按机重装,
270
+ // .dsh-market 是市场缓存,cordis.yml 是 loader 产物(可重建)
271
+ includeFiles: new Set(['package.json', 'cordis.patch.yml', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']),
272
+ excludeDirs: new Set(['node_modules', '.dsh-market']),
273
+ excludeNames: new Set(['cordis.yml']),
274
+ }],
275
+ })
276
+ return groups
277
+ }
278
+
279
+ async function copyTree(from, to, opts) {
280
+ const { includeFiles, excludeDirs, excludeNames, followSymlinks } = opts || {}
281
+ await fsP.mkdir(to, { recursive: true })
282
+ let entries
283
+ try { entries = await fsP.readdir(from, { withFileTypes: true }) } catch { return }
284
+ for (const ent of entries) {
285
+ if (ent.name === '.git') continue
286
+ if (ent.isDirectory()) {
287
+ if (excludeDirs && excludeDirs.has(ent.name)) continue
288
+ await copyTree(join(from, ent.name), join(to, ent.name), opts)
289
+ } else {
290
+ if (excludeNames && excludeNames.has(ent.name)) continue
291
+ if (includeFiles && !includeFiles.has(ent.name)) continue
292
+ let stat
293
+ try { stat = followSymlinks ? await fsP.stat(join(from, ent.name)) : ent } catch { continue }
294
+ if (!stat || !stat.isFile()) continue
295
+ try { await fsP.copyFile(join(from, ent.name), join(to, ent.name)) } catch {}
296
+ }
297
+ }
298
+ }
299
+
300
+ /** Push a live snapshot into the shadow tree (shadow = live after this). */
301
+ async function mirrorLiveToShadow(spec, shadowDir) {
302
+ for (const group of spec) {
303
+ for (const src of group.sources) {
304
+ const target = join(shadowDir, src.to)
305
+ if (src.file) {
306
+ try {
307
+ await fsP.access(src.from)
308
+ await fsP.mkdir(join(target, '..'), { recursive: true })
309
+ await fsP.copyFile(src.from, target)
310
+ } catch { try { await fsP.unlink(target) } catch {} }
311
+ } else {
312
+ await fsP.rm(target, { recursive: true, force: true }).catch(() => {})
313
+ await copyTree(src.from, target, {
314
+ includeFiles: src.includeFiles,
315
+ excludeDirs: src.excludeDirs,
316
+ excludeNames: src.excludeNames,
317
+ followSymlinks: src.followSymlinks,
318
+ })
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ /** Reverse-resolve a shadow-relative path back to its live absolute path. */
325
+ function resolveLivePath(spec, shadowRel) {
326
+ const norm = shadowRel.split(sep).join('/')
327
+ for (const group of spec) {
328
+ for (const src of group.sources) {
329
+ const to = src.to.split(sep).join('/')
330
+ if (src.file) {
331
+ if (norm === to) return src.from
332
+ } else if (norm === to) {
333
+ return src.from
334
+ } else if (norm.startsWith(to + '/')) {
335
+ return join(src.from, norm.slice(to.length + 1))
336
+ }
337
+ }
338
+ }
339
+ return undefined
340
+ }
341
+
342
+ // ── Shadow repo lifecycle ──
343
+
344
+ async function ensureShadowRepo(binary, eff, repoDir) {
345
+ const remote = authedUrl(eff.repoUrl, eff.token)
346
+ let exists = false
347
+ try { await fsP.access(join(repoDir, '.git')); exists = true } catch { exists = false }
348
+ if (!exists) {
349
+ await fsP.rm(repoDir, { recursive: true, force: true }).catch(() => {})
350
+ await fsP.mkdir(join(repoDir, '..'), { recursive: true })
351
+ // Try a shallow clone first; an empty repo (first ever sync) fails, in
352
+ // which case init locally and let the first push seed the remote.
353
+ try {
354
+ await gitExec(binary, ['clone', '-b', eff.branch, '--depth', '1', remote, repoDir])
355
+ // clone 会把带 token 的 URL 写进 .git/config——立刻换回干净地址,
356
+ // 后续 fetch/push 一律显式传 authedUrl,凭证不落盘
357
+ await gitExec(binary, ['remote', 'set-url', 'origin', eff.repoUrl], repoDir).catch(() => {})
358
+ } catch {
359
+ await fsP.mkdir(repoDir, { recursive: true })
360
+ await gitExec(binary, ['init', '-b', eff.branch], repoDir)
361
+ // .gitattributes: append-only jsonl logs merge as union, not conflict
362
+ await atomicWriteFile(join(repoDir, '.gitattributes'), '*.jsonl merge=union\n')
363
+ }
364
+ }
365
+ return remote
366
+ }
367
+
368
+ // ── Three-way push: local deltas → branch → PR → merge | conflict ──
369
+
370
+ async function runPush(binary, eff, { repoDir, instanceId, state, logger, roots }) {
371
+ const remote = await ensureShadowRepo(binary, eff, repoDir)
372
+ const spec = syncSpec(eff, roots)
373
+
374
+ // 1. fetch origin/main → FETCH_HEAD (canonical baseline)
375
+ try { await gitExec(binary, ['fetch', remote, eff.branch], repoDir) } catch (e) {
376
+ // first-ever push to an empty remote: no main yet, skip fetch
377
+ if (!/Could not find|doesn't exist|no such|empty/i.test(String(e && e.message))) throw e
378
+ }
379
+
380
+ // 2. branch off FETCH_HEAD (or HEAD if remote was empty), reset shadow to it
381
+ const hasRemote = (await gitExec(binary, ['rev-parse', '--verify', 'FETCH_HEAD'], repoDir).then(() => true).catch(() => false))
382
+ const baseRef = hasRemote ? 'FETCH_HEAD' : 'HEAD'
383
+ const branch = `sync/${instanceId}/${Date.now()}`
384
+ // detach onto base so the working tree reflects the canonical baseline
385
+ await gitExec(binary, ['checkout', '--detach', baseRef], repoDir).catch(() => {})
386
+ // 3. overlay live snapshot onto the baseline: shadow now = baseline + local deltas
387
+ await mirrorLiveToShadow(spec, repoDir)
388
+
389
+ // 4. commit on a fresh branch
390
+ await gitExec(binary, ['checkout', '-b', branch], repoDir)
391
+ await gitExec(binary, ['add', '-A'], repoDir)
392
+ let commitOk = false
393
+ try { await gitExec(binary, ['-c', 'user.name=dsh-sync', '-c', 'user.email=dsh-sync@local', 'commit', '-m', `sync ${instanceId} ${new Date().toISOString()}`], repoDir); commitOk = true } catch { /* nothing to commit */ }
394
+ if (!commitOk) return { pushed: false, nothingToCommit: true }
395
+
396
+ // 5. push the branch (token in URL, not in config)
397
+ await gitExec(binary, ['push', remote, `HEAD:${branch}`], repoDir)
398
+
399
+ // 6. create PR + mergeable check
400
+ const parsed = parseRepoUrl(eff.repoUrl)
401
+ if (!parsed) {
402
+ // non-GitCode remote (local test, self-hosted git): push the branch only;
403
+ // PR create/merge is GitCode-specific and skipped. Advance shadow onto
404
+ // main as the next cycle's pull baseline.
405
+ await gitExec(binary, ['fetch', remote, eff.branch], repoDir).catch(() => {})
406
+ await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
407
+ await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
408
+ state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
409
+ state.lastPushedBranch = branch
410
+ return { pushed: true, prSkipped: true, branch }
411
+ }
412
+ // 同仓库 PR 的 head 就是分支名(`user:branch` 是 fork PR 语法,GitCode 会 400)
413
+ const prBody = { head: branch, base: eff.branch, title: `dsh-sync ${instanceId}`, body: `Auto sync from ${instanceId}` }
414
+ const prRes = await createPullRequest(eff.token, parsed.owner, parsed.repo, prBody)
415
+ if (!prRes.ok) {
416
+ // 409 = branch already has an open PR (idempotent retry); try to find it
417
+ if (prRes.status === 409) return { pushed: true, prConflict: true, message: '已有进行中的同步 PR' }
418
+ throw new Error(`创建 PR 失败(HTTP ${prRes.status}):${(prRes.json && prRes.json.message) || prRes.text.slice(0, 160)}`)
419
+ }
420
+ const prNumber = prRes.json && (prRes.json.number || prRes.json.id)
421
+ state.lastPushedBranch = branch
422
+ state.lastPrNumber = prNumber
423
+
424
+ // 7. mergeable?
425
+ let mergeable = false, conflict = false
426
+ try {
427
+ const det = await getPullRequest(eff.token, parsed.owner, parsed.repo, prNumber)
428
+ mergeable = det.ok && det.json && det.json.mergeable === true
429
+ conflict = det.ok && det.json && det.json.mergeable === false
430
+ } catch {}
431
+
432
+ if (mergeable) {
433
+ const mr = await mergePullRequest(eff.token, parsed.owner, parsed.repo, prNumber, 'squash')
434
+ if (!mr.ok) throw new Error(`合并 PR 失败(HTTP ${mr.status})`)
435
+ // advance shadow to the merged main
436
+ await gitExec(binary, ['fetch', remote, eff.branch], repoDir).catch(() => {})
437
+ await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
438
+ await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
439
+ state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
440
+ return { pushed: true, merged: true, prNumber }
441
+ }
442
+ // conflict → leave PR open; client shows the "AI 解决冲突" action button
443
+ return { pushed: true, prConflict: true, prNumber, conflict: true }
444
+ }
445
+
446
+ // ── Three-way pull: remote deltas → live, only for untouched files ──
447
+
448
+ async function runPull(binary, eff, { repoDir, state, logger, roots }) {
449
+ const remote = authedUrl(eff.repoUrl, eff.token)
450
+ const spec = syncSpec(eff, roots)
451
+ const lastSynced = state.lastSyncedCommit
452
+ try { await gitExec(binary, ['fetch', remote, eff.branch], repoDir) } catch (e) {
453
+ if (!/Could not find|doesn't exist|empty/i.test(String(e && e.message))) throw e
454
+ return { pulled: false, empty: true }
455
+ }
456
+ const hasFetch = await gitExec(binary, ['rev-parse', '--verify', 'FETCH_HEAD'], repoDir).then(() => true).catch(() => false)
457
+ if (!hasFetch) return { pulled: false, empty: true }
458
+ if (!lastSynced) {
459
+ // never synced before: nothing to diff against; just record baseline
460
+ await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
461
+ await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
462
+ state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
463
+ return { pulled: false, firstBaseline: true }
464
+ }
465
+ // files remote changed since lastSyncedCommit
466
+ let changedRaw = ''
467
+ try { changedRaw = await gitExec(binary, ['diff', '--name-only', lastSynced, 'FETCH_HEAD'], repoDir) } catch {}
468
+ const changed = changedRaw.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
469
+ let applied = 0, skipped = 0
470
+ for (const p of changed) {
471
+ const livePath = resolveLivePath(spec, p)
472
+ if (!livePath) { skipped++; continue }
473
+ let liveBuf = null
474
+ try { liveBuf = await fsP.readFile(livePath) } catch {}
475
+ let lastSyncedBuf = null
476
+ try { lastSyncedBuf = await gitShowBuf(binary, `${lastSynced}:${p}`, repoDir) } catch { lastSyncedBuf = Buffer.alloc(0) }
477
+ const untouched = liveBuf === null ? (lastSyncedBuf.length === 0) : Buffer.compare(liveBuf, lastSyncedBuf) === 0
478
+ if (!untouched) { skipped++; continue } // 本地动过 → 留给下个 push
479
+ try {
480
+ const remoteBuf = await gitShowBuf(binary, `FETCH_HEAD:${p}`, repoDir)
481
+ await atomicWriteFile(livePath, remoteBuf)
482
+ applied++
483
+ } catch { skipped++ }
484
+ }
485
+ // advance shadow baseline to the freshly-pulled main
486
+ await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
487
+ await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
488
+ state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
489
+ return { pulled: true, applied, skipped, changed: changed.length }
490
+ }
491
+
492
+ // ── Conflict-resolution action button: in-process agent (same channel as
493
+ // skills-management share-run). The agent operates the shadow repo's git
494
+ // directly + merges the PR via REST. Only this step needs semantic
495
+ // judgement — everything deterministic stayed in the CLI. ──
496
+
497
+ const CONFLICT_PROMPT_ZH = [
498
+ '请解决 dsh-sync 同步仓库的冲突 PR,使该 PR 可被合并,然后合并它。',
499
+ '',
500
+ '## 关键信息',
501
+ '- 同步仓库:{{repoUrl}}(GitCode,API base = https://api.gitcode.com)',
502
+ '- 本地工作树(影子仓库):{{shadowDir}}(需 checkout 到冲突分支 {{branch}})',
503
+ '- PR 编号:#{{prNumber}}',
504
+ '- 访问令牌:{{token}}(下方步骤直接用此字符串,不要 printenv、不要回显明文)。',
505
+ '',
506
+ '## 工具限制(硬性)',
507
+ '- 只允许使用 bash(git/curl 命令)和 HTTP 请求工具。',
508
+ '- **严禁**使用任何 return / deliver / 投递 / IM 文件类工具(如 dsh_im_return_file)。不要把任何文件“投递”或“返回”出去。',
509
+ '- **不要读取 ~/.dsh/settings.yaml**——token 已在上方给你,别碰配置文件。',
510
+ '- token 是敏感凭据,任何输出、日志、结果里都不要回显其明文。',
511
+ '',
512
+ '## 执行步骤',
513
+ '1. token 已在上方「访问令牌」行给出,后续步骤直接用该字符串(不要 printenv)。',
514
+ '2. 在影子仓库内:`cd {{shadowDir}} && git fetch https://oauth2:{{token}}@gitcode.com/<owner>/<repo>.git main`(token 嵌 URL、不落 .git/config),然后 `git checkout {{branch}}`,再 `git merge FETCH_HEAD` 触发冲突。',
515
+ '3. 查看冲突文件:`git diff --name-only --diff-filter=U` 和 `git status`。对每个冲突文件分析两边版本决定取舍或合并(保留两边有效改动;README 等无语义文件取任一即可)。',
516
+ '4. 解决后:`git add -A && git -c user.name=dsh-sync -c user.email=dsh-sync@local commit --no-edit`,再 `git push https://oauth2:{{token}}@gitcode.com/<owner>/<repo>.git HEAD:{{branch}}`。',
517
+ '5. 查 PR 可合并:`curl -s -H "PRIVATE-TOKEN: {{token}}" https://api.gitcode.com/api/v5/repos/<owner>/<repo>/pulls/{{prNumber}}`,确认 mergeable 为 true。',
518
+ '6. 合并:`curl -s -X PUT -H "PRIVATE-TOKEN: {{token}}" -H "Content-Type: application/json" -d \'{"merge_method":"squash"}\' https://api.gitcode.com/api/v5/repos/<owner>/<repo>/pulls/{{prNumber}}/merge`。',
519
+ '7. 完成后输出 PR 网页链接。',
520
+ '',
521
+ '## 注意',
522
+ '- 认证头必须用 PRIVATE-TOKEN(不要用 Authorization: Bearer,GitCode 子资源端点对 Bearer 有 bug 会 404)。',
523
+ '- 不读 settings.yaml;不回显 token;不用投递类工具。',
524
+ '- 若失败先看错误信息,不盲目重试。全程与最终汇报都使用中文。',
525
+ ].join('\n')
526
+
527
+ function substituteParams(template, params) {
528
+ let out = template
529
+ for (const [key, value] of Object.entries(params)) {
530
+ out = out.split(`{{${key}}}`).join(String(value))
531
+ }
532
+ return out
533
+ }
534
+
535
+ // apiproxy client: dsh web 的 /api HTTP RPC(web 客户端同款),创建主对话级 session。
536
+ // 关键区别:apiproxy session.create 建的是 web 主对话级 agent(agentPreset=standard
537
+ // + dsh-base 全工具,含 bash——实测 tool/call=bash + pwd 跑通);而 agents.create 子 agent
538
+ // 是精简 scope(只有 thinking + 插件全局工具,无 bash)。故 conflict 走 apiproxy 不走
539
+ // agents.create。base URL 可由 DSH_WEB_URL 覆盖,默认本地 3080。
540
+ const APIPROXY_BASE = process.env.DSH_WEB_URL || 'http://127.0.0.1:3080'
541
+ async function apiproxy(method, payload) {
542
+ const rpcId = 'dshsync-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
543
+ const r = await fetch(APIPROXY_BASE + '/api/' + method, {
544
+ method: 'POST',
545
+ headers: { 'Content-Type': 'application/json' },
546
+ body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
547
+ })
548
+ const j = await r.json().catch(() => ({}))
549
+ const res = j.result
550
+ if (!res || !res.ok) throw new Error('apiproxy ' + method + ' 失败: ' + JSON.stringify(j).slice(0, 200))
551
+ return res.value
552
+ }
553
+
554
+ async function runConflictViaApiproxy({ prompt, dir, job, sessions, logger, token }) {
555
+ // token 经 prompt 内联({{token}})--apiproxy 主对话级 session 的 bash 是 host-plane
556
+ // executor,不继承 dsh web 进程的 process.env,故不能像 headless spawn 那样 env 注入
557
+ try {
558
+ // 1. 创建主对话级 session(有 bash)+ 发 prompt
559
+ const created = await apiproxy('session.create', { cwd: dir })
560
+ const sessionId = created && created.sessionId
561
+ if (!sessionId) throw new Error('session.create 未返回 sessionId')
562
+ job.sessionId = sessionId
563
+ await apiproxy('session.prompt', { sessionId, mode: 'queue', content: [{ type: 'text', text: prompt }] })
564
+ // 2. events 泵:ctx.sessions.get(sessionId) 同进程读活会话 events,300ms 取新
565
+ let session
566
+ try { session = sessions.get(sessionId) } catch (e) { throw new Error('ctx.sessions.get(' + sessionId + ') 失败: ' + (e && e.message)) }
567
+ const seen = new Set()
568
+ const liveLine = (text) => { job.output = (job.output + text).slice(-CONFLICT_RUN_OUTPUT_CAP) }
569
+ let finished = false
570
+ const pump = () => {
571
+ const evs = (session && Array.isArray(session.events)) ? session.events : []
572
+ for (const ev of evs) {
573
+ const seq = ev.seq
574
+ if (seq != null && seen.has(seq)) continue
575
+ if (seq != null) seen.add(seq)
576
+ const d = ev.data || ev
577
+ const ty = ev.type
578
+ if (ty === 'assistant/chunk' && d.chunk && d.chunk.type === 'text' && d.chunk.text) liveLine(d.chunk.text)
579
+ else if (ty === 'tool/call') {
580
+ const args = d.arguments || d.input || {}
581
+ const cmd = (args && typeof args === 'object' ? (args.command || JSON.stringify(args)) : String(args))
582
+ liveLine('\n[tool] ' + (d.name || '?') + ' ' + String(cmd).slice(0, 200) + '\n')
583
+ }
584
+ else if (ty === 'tool/result') {
585
+ let rc = ''
586
+ const msg = d.message || d
587
+ const outer = (msg && Array.isArray(msg.content)) ? msg.content : (Array.isArray(d.content) ? d.content : [])
588
+ for (const it of outer) {
589
+ const inner = it && it.content
590
+ if (Array.isArray(inner)) { for (const x of inner) { if (x && x.text) rc += x.text } }
591
+ else if (typeof inner === 'string') rc += inner
592
+ }
593
+ if (rc) liveLine('-> ' + rc.slice(0, 240) + '\n')
594
+ }
595
+ else if (ty === 'turn/end') finished = true
596
+ }
597
+ }
598
+ const timer = setInterval(pump, 300)
599
+ if (typeof timer.unref === 'function') timer.unref()
600
+ // 3. 等跑完(turn/end)或超时
601
+ const deadline = Date.now() + CONFLICT_RUN_TIMEOUT_MS
602
+ await new Promise((resolve) => {
603
+ const wait = setInterval(() => { if (finished || Date.now() > deadline) { clearInterval(wait); resolve() } }, 500)
604
+ if (typeof wait.unref === 'function') wait.unref()
605
+ })
606
+ clearInterval(timer); pump()
607
+ job.status = finished ? 'done' : 'error'
608
+ job.code = finished ? 0 : 1
609
+ if (!finished) job.output += '\n[超时未完成]'
610
+ } finally {}
611
+ return job
612
+ }
613
+
614
+ function createConflictRunJob({ prompt, dir, jobs, logger, sessions, token }) {
615
+ const id = 'cf' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
616
+ const job = { id, status: 'running', startedAt: new Date().toISOString(), dir, output: '', code: null }
617
+ jobs.set(id, job)
618
+ // 走 apiproxy 创建主对话级 session(standard preset + dsh-base 全工具,含 bash),
619
+ // 不是 agents.create 子 agent(精简无 bash)。token 注入 env,events 经 ctx.sessions.get 流式读。
620
+ if (!sessions || typeof sessions.get !== 'function') {
621
+ job.status = 'error'
622
+ job.output = 'sessions 服务不可用(动态 ctx.inject 失败)'
623
+ return job
624
+ }
625
+ runConflictViaApiproxy({ prompt, dir, job, sessions, logger, token })
626
+ .catch(e => { job.status = 'error'; job.output = (job.output + '\n' + String(e && e.message)).slice(-CONFLICT_RUN_OUTPUT_CAP) })
627
+ return job
628
+ }
629
+
630
+ module.exports = {
631
+ name: 'dsh-sync',
632
+ inject: ['webServer', 'settings'],
633
+ __internals: { syncSpec, defaultRoots, parseRepoUrl, authedUrl, mirrorLiveToShadow, resolveLivePath, copyTree, gitExec, acquireLock, checkRepoPrivate, gitcodeRequest, ensureShadowRepo, runPush, runPull, gitCurrentCommit, atomicWriteFile, DEFAULT_SYNC_SETTINGS, CONFLICT_PROMPT_ZH, substituteParams },
634
+
635
+ apply(ctx, config = {}) {
636
+ const dh = dshHome()
637
+ const syncDir = join(dh, 'dsh-sync')
638
+ const repoDir = join(syncDir, 'repo')
639
+ const stateFile = join(syncDir, 'state.json')
640
+ const lockFile = join(syncDir, '.lock')
641
+
642
+ // ── Settings namespace (write-only token, hasToken-only on read) ──
643
+ // 命名空间必须匹配 /^[a-z][a-z0-9-]*$/ —— 点号形式会被 settings 写入通道拒绝
644
+ const SYNC_SETTINGS_NS = 'dsh-sync'
645
+ const baseSettings = () => {
646
+ const cfg = (config.sync && typeof config.sync === 'object') ? config.sync : {}
647
+ const base = { ...DEFAULT_SYNC_SETTINGS }
648
+ for (const key of Object.keys(base)) if (cfg[key] !== undefined) base[key] = cfg[key]
649
+ if (config.repoUrl !== undefined) base.repoUrl = config.repoUrl
650
+ return base
651
+ }
652
+ let settingsScope = null
653
+ const settingsOverrides = {}
654
+ if (Schema && ctx.settings && typeof ctx.settings.register === 'function') {
655
+ try {
656
+ settingsScope = ctx.settings.register(SYNC_SETTINGS_NS, Schema.object({
657
+ repoUrl: Schema.string(),
658
+ branch: Schema.string(),
659
+ gitBinary: Schema.string(),
660
+ autoSync: Schema.boolean(),
661
+ syncOnStartup: Schema.boolean(),
662
+ intervalMinutes: Schema.number(),
663
+ conflictMode: Schema.string(),
664
+ syncSkills: Schema.boolean(),
665
+ syncSessions: Schema.boolean(),
666
+ syncSettings: Schema.boolean(),
667
+ syncPlugins: Schema.boolean(),
668
+ token: Schema.string(),
669
+ }), { base: baseSettings() })
670
+ } catch (e) { ctx.logger.warn(`dsh-sync: settings register: ${e && e.message}`) }
671
+ }
672
+ const syncSettings = () => {
673
+ if (settingsScope && typeof settingsScope.get === 'function') {
674
+ const v = settingsScope.get()
675
+ if (v && typeof v === 'object') return { ...baseSettings(), ...v }
676
+ }
677
+ return { ...baseSettings(), ...settingsOverrides }
678
+ }
679
+
680
+ // ── State (instanceId + lastSyncedCommit + lastResult) ──
681
+ let state = { instanceId: undefined, lastSyncedCommit: undefined, lastSyncAt: undefined, lastResult: undefined }
682
+ const stateLoaded = fsP.readFile(stateFile, 'utf8').then(raw => {
683
+ try { Object.assign(state, JSON.parse(raw)) } catch {}
684
+ }).catch(() => {})
685
+ // first boot: mint a stable instance id (hostname + short uuid). Persisted,
686
+ // never synced (it lives outside the shadow tree).
687
+ stateLoaded.then(async () => {
688
+ if (!state.instanceId) {
689
+ state.instanceId = `${String(hostname() || 'host').split('.')[0].slice(0, 16)}-${randomUUID().slice(0, 8)}`
690
+ try { await fsP.mkdir(syncDir, { recursive: true }); await fsP.writeFile(stateFile, JSON.stringify(state, null, 2), { mode: 0o600 }) } catch {}
691
+ }
692
+ })
693
+ const saveState = async () => {
694
+ try { await fsP.mkdir(syncDir, { recursive: true }); await fsP.writeFile(stateFile, JSON.stringify(state, null, 2), { mode: 0o600 }) } catch {}
695
+ }
696
+
697
+ // ── Sync run: lock → push → pull → save ──
698
+ let syncRun = null
699
+ const runSync = async () => {
700
+ if (syncRun !== null) return syncRun
701
+ syncRun = (async () => {
702
+ await stateLoaded
703
+ const eff = syncSettings()
704
+ if (!eff.repoUrl || !eff.token) throw new Error('未配置仓库地址或访问令牌(到 ⚙ 同步设置 中填写)')
705
+ if (!(await gitAvailable(eff.gitBinary))) throw new Error('PATH 上找不到 git')
706
+ const release = await acquireLock(lockFile)
707
+ if (release === null) throw new Error('另一个同步进程正在运行(已跳过)')
708
+ const started = Date.now()
709
+ let result = { pushed: false, pulled: false }
710
+ try {
711
+ const ctx2 = { repoDir, instanceId: state.instanceId, state, logger: ctx.logger }
712
+ result.push = await runPush(eff.gitBinary, eff, ctx2).catch(e => { result.pushError = String(e && e.message); return null })
713
+ result.pull = await runPull(eff.gitBinary, eff, ctx2).catch(e => { result.pullError = String(e && e.message); return null })
714
+ state.lastSyncAt = new Date().toISOString()
715
+ state.lastResult = { ...result, at: state.lastSyncAt, durationMs: Date.now() - started }
716
+ await saveState()
717
+ } finally { release() }
718
+ return result
719
+ })().finally(() => { syncRun = null })
720
+ return syncRun
721
+ }
722
+
723
+ // ── Conflict-resolution jobs (action button → apiproxy 主对话级 session) ──
724
+ const conflictRunJobs = new Map()
725
+ // 动态注入 sessions 服务:conflict 走 apiproxy 创建主对话级 session 后,
726
+ // 用 ctx.sessions.get(sessionId).events 流式读 agent 输出(像 agents.create 事件泵,
727
+ // 但这个 agent 有 bash)
728
+ let sessionsSvc = null
729
+ try {
730
+ if (ctx.inject && typeof ctx.inject === 'function') {
731
+ ctx.inject(['sessions'], (svcs) => { sessionsSvc = svcs && svcs.sessions })
732
+ }
733
+ } catch {}
734
+
735
+ // ── Startup + periodic auto-sync ──
736
+ ctx.effect(() => {
737
+ const fireIfDue = async (reason) => {
738
+ await stateLoaded
739
+ const eff = syncSettings()
740
+ if (!eff.autoSync) return
741
+ if (reason === 'startup' && !eff.syncOnStartup) return
742
+ runSync().catch(e => ctx.logger.warn(`dsh-sync: ${reason} sync: ${e && e.message}`))
743
+ }
744
+ fireIfDue('startup')
745
+ const timer = setInterval(() => fireIfDue('interval'), Math.max(5, (syncSettings().intervalMinutes || 30)) * 60 * 1000)
746
+ if (typeof timer.unref === 'function') timer.unref()
747
+ return () => clearInterval(timer)
748
+ }, 'dsh-sync: auto-sync')
749
+
750
+ // ── HTTP API ──
751
+ ctx.effect(() => ctx.webServer.register({
752
+ kind: 'prefix',
753
+ path: '/dsh-sync/api',
754
+ handler: async (req, res) => {
755
+ try {
756
+ const url = new URL(req.url || '/', 'http://dsh.local')
757
+ const apiPath = url.pathname.replace(/\/+$/, '')
758
+ const query = url.searchParams
759
+
760
+ // GET /dsh-sync/api/status
761
+ if (req.method === 'GET' && apiPath.endsWith('/dsh-sync/api/status')) {
762
+ await stateLoaded
763
+ const eff = syncSettings()
764
+ const { token, ...safe } = eff
765
+ const repoExists = await fsP.access(join(repoDir, '.git')).then(() => true).catch(() => false)
766
+ sendJson(res, 200, {
767
+ repoUrl: eff.repoUrl, branch: eff.branch, dir: displayPath(repoDir), repoExists,
768
+ instanceId: state.instanceId,
769
+ gitAvailable: await gitAvailable(eff.gitBinary),
770
+ lastSyncAt: state.lastSyncAt, lastResult: state.lastResult,
771
+ autoSync: eff.autoSync, syncOnStartup: eff.syncOnStartup,
772
+ intervalMinutes: eff.intervalMinutes, conflictMode: eff.conflictMode,
773
+ syncSkills: eff.syncSkills, syncSessions: eff.syncSessions,
774
+ syncSettings: eff.syncSettings, syncPlugins: eff.syncPlugins,
775
+ hasToken: typeof token === 'string' && token !== '',
776
+ syncing: syncRun !== null,
777
+ pendingConflict: state.lastResult && state.lastResult.push && state.lastResult.push.conflict === true
778
+ ? { branch: state.lastPushedBranch, prNumber: state.lastPrNumber } : null,
779
+ })
780
+ return
781
+ }
782
+
783
+ // POST /dsh-sync/api/sync
784
+ if (req.method === 'POST' && apiPath.endsWith('/dsh-sync/api/sync')) {
785
+ try {
786
+ const result = await runSync()
787
+ sendJson(res, 200, result)
788
+ } catch (e) { sendJson(res, 400, { error: String(e && e.message || e) }) }
789
+ return
790
+ }
791
+
792
+ // PUT /dsh-sync/api/settings
793
+ if (req.method === 'PUT' && apiPath.endsWith('/dsh-sync/api/settings')) {
794
+ const body = await readJsonBody(req)
795
+ await stateLoaded
796
+ const patch = {}
797
+ for (const key of ['repoUrl', 'branch', 'gitBinary', 'conflictMode']) {
798
+ if (typeof body[key] === 'string' && body[key] !== '') patch[key] = body[key]
799
+ }
800
+ for (const key of ['autoSync', 'syncOnStartup', 'syncSkills', 'syncSessions', 'syncSettings', 'syncPlugins']) {
801
+ if (typeof body[key] === 'boolean') patch[key] = body[key]
802
+ }
803
+ if (typeof body.intervalMinutes === 'number' && body.intervalMinutes >= 1) patch.intervalMinutes = body.intervalMinutes
804
+ // token: non-empty sets; null/'' clears. Never echoed.
805
+ if (typeof body.token === 'string' && body.token !== '') patch.token = body.token
806
+ if (body.token === null || body.token === '') patch.token = undefined
807
+ // 私仓硬校验:带 repoUrl+token(首次或换仓库)时拒绝公共仓库
808
+ if (patch.token && (patch.repoUrl || syncSettings().repoUrl)) {
809
+ const checkUrl = patch.repoUrl || syncSettings().repoUrl
810
+ const check = await checkRepoPrivate(patch.token, checkUrl)
811
+ if (!check.ok) { sendJson(res, 400, { error: check.error, isPublic: !!check.isPublic }); return }
812
+ }
813
+ if (settingsScope && typeof settingsScope.update === 'function') await settingsScope.update(patch)
814
+ else Object.assign(settingsOverrides, patch)
815
+ const eff = syncSettings()
816
+ const { token, ...safe } = eff
817
+ sendJson(res, 200, { settings: safe, hasToken: typeof token === 'string' && token !== '' })
818
+ return
819
+ }
820
+
821
+ // POST /dsh-sync/api/conflict/run {prNumber?, branch?} → AI resolves
822
+ if (req.method === 'POST' && apiPath.endsWith('/dsh-sync/api/conflict/run')) {
823
+ const body = await readJsonBody(req)
824
+ await stateLoaded
825
+ const eff = syncSettings()
826
+ if (!eff.repoUrl || !eff.token) { sendJson(res, 400, { error: '未配置仓库或令牌' }); return }
827
+ const branch = body.branch || state.lastPushedBranch
828
+ const prNumber = body.prNumber || state.lastPrNumber
829
+ if (!branch || !prNumber) { sendJson(res, 400, { error: '没有待解决的冲突 PR' }); return }
830
+ const prompt = substituteParams(CONFLICT_PROMPT_ZH, {
831
+ repoUrl: eff.repoUrl, shadowDir: repoDir, branch, prNumber, token: eff.token,
832
+ })
833
+ const job = createConflictRunJob({ prompt, dir: repoDir, jobs: conflictRunJobs, logger: ctx.logger, sessions: sessionsSvc, token: eff.token })
834
+ sendJson(res, 202, { jobId: job.id, status: job.status })
835
+ return
836
+ }
837
+
838
+ // GET /dsh-sync/api/conflict/run?id= → job status/output
839
+ if (req.method === 'GET' && apiPath.endsWith('/dsh-sync/api/conflict/run')) {
840
+ const id = query.get('id') || ''
841
+ const job = conflictRunJobs.get(id)
842
+ if (job === undefined) { sendJson(res, 404, { error: 'job not found' }); return }
843
+ sendJson(res, 200, { ...job, output: (job.output || '').slice(-32 * 1024) })
844
+ return
845
+ }
846
+
847
+ sendJson(res, 404, { error: 'not found' })
848
+ } catch (error) { sendJson(res, 400, { error: String(error && error.message || error) }) }
849
+ },
850
+ }), 'dsh-sync: api route')
851
+ },
852
+ }