dsh-remote-agent 0.0.0-stage → 0.4.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +700 -0
  2. package/CONTRIBUTING.md +358 -0
  3. package/KNOWN-ISSUES.md +601 -0
  4. package/LICENSE +21 -0
  5. package/README.en.md +353 -0
  6. package/README.md +317 -2
  7. package/SECURITY.md +348 -0
  8. package/bin/dsh-remote-relay.js +257 -0
  9. package/bin/dsh-remote-setup.js +440 -0
  10. package/bin//345/217/214/345/207/273/350/277/220/350/241/214-/351/205/215/347/275/256/345/220/221/345/257/274.cmd +28 -0
  11. package/cordis.patch.yml +87 -0
  12. package/deploy/relay-vps/Caddyfile +24 -0
  13. package/deploy/relay-vps/README.md +132 -0
  14. package/deploy/relay-vps/dsh-remote-relay.service +47 -0
  15. package/deploy/relay-vps/nginx.conf +49 -0
  16. package/deploy/relay-vps/relay.env.example +20 -0
  17. package/docs/dispatch-automation-eval.md +140 -0
  18. package/docs/screenshots/panel-main.png +0 -0
  19. package/docs/screenshots/panel-task-detail.png +0 -0
  20. package/docs/verification-agent-e2e.md +425 -0
  21. package/docs/verification-repos-routing-real.md +521 -0
  22. package/docs/verification-resume-real.md +254 -0
  23. package/docs/worker-setup-pitfalls.md +266 -0
  24. package/lib/artifacts.js +451 -0
  25. package/lib/client.js +1752 -0
  26. package/lib/e2e.js +304 -0
  27. package/lib/glob.js +225 -0
  28. package/lib/hub/audit-log.js +161 -0
  29. package/lib/hub/hub-util.js +114 -0
  30. package/lib/hub/index.js +500 -0
  31. package/lib/hub/node-registry.js +943 -0
  32. package/lib/hub/pull-manager.js +781 -0
  33. package/lib/hub/registry-handshake.js +403 -0
  34. package/lib/hub/registry-ingest.js +346 -0
  35. package/lib/hub/relay-link.js +228 -0
  36. package/lib/hub/subagent-provider.js +424 -0
  37. package/lib/hub/templates.js +693 -0
  38. package/lib/hub/tools.js +1059 -0
  39. package/lib/hub/ui-routes.js +856 -0
  40. package/lib/hub/views.js +351 -0
  41. package/lib/identity.js +271 -0
  42. package/lib/index.js +272 -0
  43. package/lib/node/artifact-service.js +231 -0
  44. package/lib/node/diff.js +124 -0
  45. package/lib/node/exec.js +536 -0
  46. package/lib/node/hub-pin.js +71 -0
  47. package/lib/node/index.js +1022 -0
  48. package/lib/node/repos.js +134 -0
  49. package/lib/node/resource.js +63 -0
  50. package/lib/node/runner.js +792 -0
  51. package/lib/node/task-engine.js +712 -0
  52. package/lib/node/task-store.js +273 -0
  53. package/lib/node/transport.js +763 -0
  54. package/lib/pairing.js +186 -0
  55. package/lib/protocol.js +414 -0
  56. package/lib/relay-config.js +317 -0
  57. package/lib/relay.js +732 -0
  58. package/lib/setup-routes.js +313 -0
  59. package/lib/setup.js +522 -0
  60. package/lib/ws.js +466 -0
  61. package/package.json +103 -4
  62. package/profiles/README.md +573 -0
  63. package/profiles/hub.cordis.yml +84 -0
  64. package/profiles/worker.cordis.yml +60 -0
  65. package/scripts/check-doc-drift.mjs +785 -0
  66. package/scripts/check-packaging.mjs +130 -0
  67. package/scripts/diag-inject.mjs +243 -0
  68. package/scripts/e2e-live-check.mjs +181 -0
  69. package/scripts/relay-init.ps1 +286 -0
  70. package/scripts/relay-init.sh +199 -0
@@ -0,0 +1,712 @@
1
+ /**
2
+ * 任务执行编排:把一条 TaskSpec 变成副机本地的一次真实运行,并产出 handoff 结果。
3
+ *
4
+ * 生命周期:
5
+ * accept → running → (exec | agent) → 建 manifest → 生成 handoff → end
6
+ *
7
+ * 核心不变式:
8
+ * - 断连不终止:运行中的任务完全不依赖连接状态,连接断了照跑
9
+ * - 幂等:taskId 已存在直接回 accept(duplicate=true),不重跑
10
+ * - 可取消:task/cancel 会打断 agent 或杀进程树,并把状态落成 aborted
11
+ * - 状态先落盘再上报:崩溃后重启,task/sync 能如实反映最后状态
12
+ */
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import {
16
+ ARTIFACT_ROLE,
17
+ ERROR_CODE,
18
+ RemoteAgentError,
19
+ TASK_MODE,
20
+ TASK_STATUS,
21
+ } from '../protocol.js'
22
+ import { hashText } from '../artifacts.js'
23
+ import { unifiedDiff } from './diff.js'
24
+ import { isDenied, resolveWorkspace, runCommand } from './exec.js'
25
+
26
+ /** 摘要里最多保留多少条 changed 记录。 */
27
+ const MAX_CHANGED = 500
28
+
29
+ export class TaskEngine {
30
+ /**
31
+ * @param {object} o
32
+ * @param {import('./task-store.js').TaskStore} o.store
33
+ * @param {import('./artifact-service.js').ArtifactService} o.artifacts
34
+ * @param {object} o.runner 选定的 runner(InProcessRunner | HeadlessRunner)
35
+ * @param {string} o.nodeId
36
+ * @param {string[]} o.roots 允许的工作区根
37
+ * @param {string[]} o.denyPaths
38
+ * @param {string} o.defaultWorkspace
39
+ * @param {boolean} o.caseInsensitive
40
+ * @param {(type:string, data:object)=>void} o.send 发送业务帧
41
+ * @param {object} [o.logger]
42
+ */
43
+ constructor(o) {
44
+ this.store = o.store
45
+ this.artifacts = o.artifacts
46
+ this.runner = o.runner
47
+ this.nodeId = o.nodeId
48
+ this.roots = o.roots
49
+ this.denyPaths = o.denyPaths ?? []
50
+ this.defaultWorkspace = o.defaultWorkspace
51
+ this.caseInsensitive = o.caseInsensitive ?? true
52
+ this.send = o.send
53
+ this.logger = o.logger
54
+ /** taskId → { controller, startedAt } */
55
+ this.running = new Map()
56
+ /** 任务并发上限,防止副机被一次派单压垮 */
57
+ this.maxConcurrent = o.maxConcurrent ?? 2
58
+ this.queue = []
59
+ /** 卸载闸门:stop() 置 true 后不再接受/启动新任务(见 stop() 注释)。 */
60
+ this.stopped = false
61
+ }
62
+
63
+ log(level, msg, extra) {
64
+ if (this.logger?.info) this.logger[level === 'warn' ? 'warn' : 'info']?.(`[remote-agent] ${msg}`, extra)
65
+ }
66
+
67
+ /** 接受一条任务。幂等。 */
68
+ accept(spec) {
69
+ const existing = this.store.get(spec.taskId)
70
+ if (existing) {
71
+ this.send('task/accept', {
72
+ taskId: spec.taskId,
73
+ duplicate: true,
74
+ status: existing.status,
75
+ nodeId: this.nodeId,
76
+ })
77
+ return { duplicate: true, record: existing }
78
+ }
79
+
80
+ // 工作区必须在允许根内 —— 这一步先于任何副作用
81
+ let workspace
82
+ try {
83
+ workspace = resolveWorkspace(spec.workspace, {
84
+ roots: this.roots,
85
+ caseInsensitive: this.caseInsensitive,
86
+ defaultDir: this.defaultWorkspace,
87
+ })
88
+ } catch (err) {
89
+ const code = err instanceof RemoteAgentError ? err.code : ERROR_CODE.WORKSPACE_DENIED
90
+ this.send('task/end', {
91
+ taskId: spec.taskId,
92
+ nodeId: this.nodeId,
93
+ status: TASK_STATUS.FAILED,
94
+ error: { code, message: String(err?.message ?? err) },
95
+ })
96
+ return { rejected: true, error: String(err?.message ?? err) }
97
+ }
98
+
99
+ fs.mkdirSync(workspace, { recursive: true })
100
+
101
+ const { record } = this.store.create({ ...spec, workspace }, { nodeId: this.nodeId, workspace })
102
+ this.send('task/accept', {
103
+ taskId: spec.taskId,
104
+ duplicate: false,
105
+ status: TASK_STATUS.QUEUED,
106
+ nodeId: this.nodeId,
107
+ workspace,
108
+ runner: this.runner.kind,
109
+ queueDepth: this.queue.length,
110
+ })
111
+
112
+ this.enqueue(record.taskId)
113
+ return { accepted: true, record }
114
+ }
115
+
116
+ enqueue(taskId) {
117
+ if (this.stopped) return
118
+ this.queue.push(taskId)
119
+ void this.pump()
120
+ }
121
+
122
+ /**
123
+ * 队列泵。
124
+ *
125
+ * `stopped` 闸门(E5-1 修复):teardown 会清空 queue 并置 stopped=true,
126
+ * 但 execute() 的 finally 里有 `setTimeout(() => this.pump(), 0)` ——
127
+ * 没有这个闸门,一次已经在途的 pump 会在卸载后继续启动任务
128
+ * (实测卸载后仍启动了 2 个排队任务)。
129
+ * 注意:stopped 只阻止**执行**,磁盘上的 queued 记录原样保留,
130
+ * 下次启动的 reconcile 仍能重排。
131
+ */
132
+ async pump() {
133
+ if (this.stopped) return
134
+ while (this.running.size < this.maxConcurrent && this.queue.length > 0) {
135
+ const taskId = this.queue.shift()
136
+ const rec = this.store.get(taskId)
137
+ if (!rec) continue
138
+ void this.execute(taskId)
139
+ }
140
+ }
141
+
142
+ /** 停止接受与启动新任务(不取消在跑的)。卸载时由 teardown 调用。 */
143
+ stop() {
144
+ this.stopped = true
145
+ this.queue.length = 0
146
+ }
147
+
148
+ /** 执行一条任务。不 await —— 由 running 表跟踪。 */
149
+ async execute(taskId) {
150
+ if (this.stopped) return
151
+ const rec = this.store.get(taskId)
152
+ if (!rec) return
153
+ if (this.running.has(taskId)) return
154
+
155
+ const controller = new AbortController()
156
+ this.running.set(taskId, { controller, startedAt: Date.now() })
157
+
158
+ this.store.update(taskId, {
159
+ status: TASK_STATUS.RUNNING,
160
+ startedAt: Date.now(),
161
+ attempts: (rec.attempts ?? 0) + 1,
162
+ })
163
+ this.send('task/progress', {
164
+ taskId,
165
+ nodeId: this.nodeId,
166
+ status: TASK_STATUS.RUNNING,
167
+ phase: 'started',
168
+ at: Date.now(),
169
+ })
170
+
171
+ const before = this.snapshotWorkspace(rec.workspace, { withContent: true })
172
+
173
+ try {
174
+ const result = rec.mode === TASK_MODE.EXEC
175
+ ? await this.runExec(rec, controller)
176
+ : await this.runAgent(rec, controller)
177
+
178
+ const manifest = await this.artifacts.build({
179
+ workspace: rec.workspace,
180
+ outputs: rec.outputs,
181
+ artifactRules: rec.artifactRules ?? undefined,
182
+ denyPaths: [...this.denyPaths, ...(rec.constraints?.denyPaths ?? [])],
183
+ signal: controller.signal,
184
+ })
185
+
186
+ const after = this.snapshotWorkspace(rec.workspace)
187
+ const changed = diffSnapshots(before, after, rec.workspace, this.caseInsensitive)
188
+ const diffs = buildDiffs({
189
+ workspace: rec.workspace,
190
+ changed,
191
+ before,
192
+ caseInsensitive: this.caseInsensitive,
193
+ })
194
+
195
+ // 状态映射必须把 aborted 纳入 —— 否则用户主动取消会被显示成"任务失败",
196
+ // 自动化流程可能据此误判需要重试。abort 时 exec.js 给的是
197
+ // ok=false / timedOut=false / aborted=true,旧映射会落进 FAILED。
198
+ const status = result.aborted
199
+ ? TASK_STATUS.ABORTED
200
+ : result.timedOut
201
+ ? TASK_STATUS.INTERRUPTED
202
+ : result.ok
203
+ ? TASK_STATUS.COMPLETED
204
+ : TASK_STATUS.FAILED
205
+
206
+ const failure = result.aborted || result.timedOut || result.ok
207
+ ? null
208
+ : {
209
+ code: 'task-failed',
210
+ message: result.stderr || 'task failed',
211
+ // 让 hub 侧能区分"命令真的失败了"与"取消/超时",
212
+ // 而不是只能看到一个笼统的 error=null
213
+ exitCode: result.exitCode ?? null,
214
+ }
215
+
216
+ this.store.update(taskId, {
217
+ status,
218
+ endedAt: Date.now(),
219
+ exit: {
220
+ code: result.exitCode ?? null,
221
+ timedOut: result.timedOut === true,
222
+ aborted: result.aborted === true,
223
+ runner: this.runner.kind,
224
+ toolCalls: result.toolCalls?.length ?? 0,
225
+ },
226
+ sessionId: result.sessionId ?? null,
227
+ manifest,
228
+ changed,
229
+ error: failure,
230
+ })
231
+
232
+ this.send('task/summary', {
233
+ taskId,
234
+ nodeId: this.nodeId,
235
+ handoff: buildHandoff({
236
+ rec: this.store.get(taskId),
237
+ nodeId: this.nodeId,
238
+ result,
239
+ manifest,
240
+ changed,
241
+ diffs,
242
+ workspace: rec.workspace,
243
+ }),
244
+ })
245
+
246
+ // 失败路径也要带 error —— 此前 catch 路径带、正常失败路径不带,
247
+ // 两条路径不一致,hub 投影里普通失败任务的 error 恒为 null。
248
+ this.send('task/end', {
249
+ taskId,
250
+ nodeId: this.nodeId,
251
+ status,
252
+ durationMs: Date.now() - (rec.startedAt ?? Date.now()),
253
+ manifestCounts: manifest.counts,
254
+ ...(failure ? { error: failure } : {}),
255
+ })
256
+ } catch (err) {
257
+ const code = err instanceof RemoteAgentError ? err.code : ERROR_CODE.INTERNAL
258
+ this.store.update(taskId, {
259
+ status: TASK_STATUS.FAILED,
260
+ endedAt: Date.now(),
261
+ error: { code, message: String(err?.message ?? err) },
262
+ })
263
+ this.send('task/end', {
264
+ taskId,
265
+ nodeId: this.nodeId,
266
+ status: TASK_STATUS.FAILED,
267
+ error: { code, message: String(err?.message ?? err) },
268
+ })
269
+ } finally {
270
+ this.running.delete(taskId)
271
+ // 队列推进:一条跑完再起下一条
272
+ setTimeout(() => void this.pump(), 0)
273
+ }
274
+ }
275
+
276
+ /**
277
+ * 输出行心跳(提案 #15):每 2s 检查一次行数,**有新增才发**一帧
278
+ * phase:'output' 的进度。返回 stop() —— 调用方必须在任务收尾时调用
279
+ * (try/finally),否则定时器会挂在已完成任务上空转。
280
+ */
281
+ outputHeartbeat(taskId, getLines) {
282
+ let last = -1
283
+ const timer = setInterval(() => {
284
+ const n = getLines()
285
+ if (n === last) return
286
+ last = n
287
+ this.send('task/progress', {
288
+ taskId,
289
+ nodeId: this.nodeId,
290
+ phase: 'output',
291
+ detail: `已输出 ${n} 行`,
292
+ at: Date.now(),
293
+ })
294
+ }, 2000)
295
+ if (timer.unref) timer.unref()
296
+ return () => clearInterval(timer)
297
+ }
298
+
299
+ /** exec 模式:直接跑命令。 */
300
+ async runExec(rec, controller) {
301
+ const command = rec.goal
302
+ this.store.appendLog(rec.taskId, `$ ${command}`)
303
+ this.send('task/progress', {
304
+ taskId: rec.taskId,
305
+ nodeId: this.nodeId,
306
+ phase: 'exec',
307
+ command: command.slice(0, 500),
308
+ at: Date.now(),
309
+ })
310
+
311
+ // 输出行心跳(提案 #15):长命令跑着时每 2s 报一次"已输出 N 行",
312
+ // 面板不用等 handoff/终态就知道"活着且在吐东西"。只对 exec 做 ——
313
+ // agent 任务的工具事件本身就是步骤流,别把 hub 侧 20 条的
314
+ // progressTail 刷成纯输出心跳。
315
+ let outLines = 0
316
+ const stopHeartbeat = this.outputHeartbeat(rec.taskId, () => outLines)
317
+
318
+ let res
319
+ try {
320
+ res = await runCommand(command, {
321
+ cwd: rec.workspace,
322
+ timeoutMs: rec.constraints?.timeoutMs ?? 60 * 60 * 1000,
323
+ env: rec.constraints?.extraEnv ?? {},
324
+ signal: controller.signal,
325
+ onData: (chunk, stream) => {
326
+ for (const line of chunk.split(/\r?\n/)) {
327
+ if (line.trim() !== '') {
328
+ this.store.appendLog(rec.taskId, `[${stream}] ${line}`)
329
+ outLines += 1
330
+ }
331
+ }
332
+ },
333
+ })
334
+ } finally {
335
+ stopHeartbeat()
336
+ }
337
+
338
+ if (res.stdout) this.store.appendLog(rec.taskId, res.stdout.slice(-2000))
339
+ if (res.stderr) this.store.appendLog(rec.taskId, `[stderr] ${res.stderr.slice(-2000)}`)
340
+
341
+ return {
342
+ ok: res.ok,
343
+ exitCode: res.exitCode,
344
+ timedOut: res.timedOut,
345
+ aborted: res.aborted,
346
+ final: res.stdout.slice(-8000),
347
+ stderr: res.stderr.slice(-4000),
348
+ toolCalls: [],
349
+ durationMs: res.durationMs,
350
+ sessionId: null,
351
+ }
352
+ }
353
+
354
+ /** agent 模式:在副机本地起一个真实 agent 会话。 */
355
+ async runAgent(rec, controller) {
356
+ this.send('task/progress', {
357
+ taskId: rec.taskId,
358
+ nodeId: this.nodeId,
359
+ phase: 'agent-start',
360
+ runner: this.runner.kind,
361
+ at: Date.now(),
362
+ })
363
+
364
+ const res = await this.runner.run({
365
+ taskId: rec.taskId,
366
+ goal: buildTaskBrief(rec),
367
+ cwd: rec.workspace,
368
+ // 主机指定的模型选择(可选,protocol.normalizeModelSelection 已归一)。
369
+ // 落盘为 null 表示未指定 → 传 undefined,让 runner 走"副机默认"路径。
370
+ // in-process 后端会把它叠加到副机默认选择上;headless 后端会明确拒绝。
371
+ model: rec.model ?? undefined,
372
+ timeoutMs: rec.constraints?.timeoutMs ?? 60 * 60 * 1000,
373
+ signal: controller.signal,
374
+ onData: (chunk, stream) => {
375
+ for (const line of chunk.split(/\r?\n/)) {
376
+ if (line.trim() !== '') this.store.appendLog(rec.taskId, `[${stream}] ${line}`)
377
+ }
378
+ },
379
+ onEvent: (evt) => {
380
+ this.store.appendProgress(rec.taskId, evt)
381
+ this.send('task/progress', {
382
+ taskId: rec.taskId,
383
+ nodeId: this.nodeId,
384
+ phase: evt.kind,
385
+ detail: evt.text ?? evt.name,
386
+ at: Date.now(),
387
+ })
388
+ },
389
+ })
390
+
391
+ if (res.final) this.store.appendLog(rec.taskId, res.final.slice(-4000))
392
+ if (res.stderr) this.store.appendLog(rec.taskId, `[stderr] ${res.stderr.slice(-2000)}`)
393
+
394
+ return res
395
+ }
396
+
397
+ /** 取消一条任务。 */
398
+ async cancel(taskId) {
399
+ const live = this.running.get(taskId)
400
+ const rec = this.store.get(taskId)
401
+ if (!rec) {
402
+ this.send('task/end', {
403
+ taskId,
404
+ nodeId: this.nodeId,
405
+ status: TASK_STATUS.FAILED,
406
+ error: { code: ERROR_CODE.TASK_UNKNOWN, message: 'unknown taskId' },
407
+ })
408
+ return false
409
+ }
410
+
411
+ // 从队列里摘掉(还没开始跑的)
412
+ const qi = this.queue.indexOf(taskId)
413
+ if (qi >= 0) {
414
+ this.queue.splice(qi, 1)
415
+ this.store.update(taskId, {
416
+ status: TASK_STATUS.ABORTED,
417
+ endedAt: Date.now(),
418
+ error: { code: 'cancelled', message: 'cancelled before start' },
419
+ })
420
+ this.send('task/end', { taskId, nodeId: this.nodeId, status: TASK_STATUS.ABORTED })
421
+ return true
422
+ }
423
+
424
+ if (!live) return false
425
+
426
+ if (typeof this.runner.cancel === 'function') {
427
+ await this.runner.cancel(taskId)
428
+ }
429
+ live.controller.abort()
430
+ this.store.appendLog(taskId, '[system] cancel requested by hub')
431
+ return true
432
+ }
433
+
434
+ /**
435
+ * 工作区快照:用于计算 changed。只存 path → {bytes, mtimeMs}。
436
+ *
437
+ * withContent(提案 #21):额外为**文本前像**读进内容(≤256KB、前 64 字节
438
+ * 无 NUL、最多 64 个文件、总量 ≤4MB)—— diff 需要"任务开始前长什么样",
439
+ * 任务跑完再想拿前像就晚了。只对 before 侧开(after 侧的 diff 后文按需
440
+ * 现读,避免无谓的双倍 I/O)。捕获上限保证快照成本有界:大文件/二进制/
441
+ * 深层堆积都不进内容库,这些文件的 diff 会缺席(changed 元数据仍在)。
442
+ */
443
+ snapshotWorkspace(workspace, { withContent = false } = {}) {
444
+ const map = new Map()
445
+ const stack = [{ abs: workspace, rel: '', depth: 0 }]
446
+ const deny = [...this.denyPaths, ...(this.store.list().flatMap((r) => r.constraints?.denyPaths ?? []))]
447
+ let contentFiles = 0
448
+ let contentBytes = 0
449
+ while (stack.length > 0 && map.size < MAX_CHANGED * 4) {
450
+ const cur = stack.pop()
451
+ if (cur.depth > 12) continue
452
+ let entries
453
+ try {
454
+ entries = fs.readdirSync(cur.abs, { withFileTypes: true })
455
+ } catch {
456
+ continue
457
+ }
458
+ for (const e of entries) {
459
+ if (e.isSymbolicLink()) continue
460
+ const childAbs = path.join(cur.abs, e.name)
461
+ const childRel = cur.rel === '' ? e.name : `${cur.rel}/${e.name}`
462
+ if (isDenied(childAbs, deny, { caseInsensitive: this.caseInsensitive })) continue
463
+ if (e.isDirectory()) {
464
+ if (IGNORED_DIRS.has(e.name.toLowerCase())) continue
465
+ stack.push({ abs: childAbs, rel: childRel, depth: cur.depth + 1 })
466
+ continue
467
+ }
468
+ if (!e.isFile()) continue
469
+ try {
470
+ const st = fs.statSync(childAbs)
471
+ const entry = { bytes: st.size, mtimeMs: Math.round(st.mtimeMs) }
472
+ if (withContent && contentFiles < MAX_SNAPSHOT_CONTENT_FILES && contentBytes < MAX_SNAPSHOT_CONTENT_TOTAL_BYTES && st.size > 0 && st.size <= MAX_SNAPSHOT_FILE_BYTES) {
473
+ try {
474
+ const buf = fs.readFileSync(childAbs)
475
+ // 二进制挡板:前 64 字节出现 NUL 视为二进制,不进内容库
476
+ if (!buf.subarray(0, 64).includes(0)) {
477
+ entry.content = buf.toString('utf8')
478
+ contentFiles += 1
479
+ contentBytes += buf.length
480
+ }
481
+ } catch {
482
+ /* 竞态:文件刚被删 */
483
+ }
484
+ }
485
+ map.set(childRel, entry)
486
+ } catch {
487
+ /* 竞态:文件刚被删 */
488
+ }
489
+ }
490
+ }
491
+ return map
492
+ }
493
+
494
+ /** 重连后向 hub 上报全部任务状态。 */
495
+ syncAll() {
496
+ this.send('task/sync', {
497
+ nodeId: this.nodeId,
498
+ tasks: this.store.list().map((r) => this.store.view(r)),
499
+ })
500
+ }
501
+ }
502
+
503
+ const IGNORED_DIRS = new Set([
504
+ 'node_modules', '.git', '.cache', '.tmp', 'temp', 'tmp',
505
+ '.next', '.nuxt', '.turbo', '.parcel-cache', '.vite',
506
+ '__pycache__', '.pytest_cache', '.mypy_cache', '.dsh-remote-agent',
507
+ ])
508
+
509
+ // 快照内容库的硬上限(提案 #21):快照成本必须有界 —— 大文件/二进制不进
510
+ // 内容库(它们的 diff 缺席但 changed 元数据仍在),也不会把任务启动拖慢。
511
+ const MAX_SNAPSHOT_FILE_BYTES = 256 * 1024
512
+ const MAX_SNAPSHOT_CONTENT_FILES = 64
513
+ const MAX_SNAPSHOT_CONTENT_TOTAL_BYTES = 4 * 1024 * 1024
514
+
515
+ // buildDiffs 的输出上限:diff 是"证据链视图"不是全量备份,12 个文件 /
516
+ // 每个 16KB / 总 96KB 之后宁可缺席(面板显示"已截断")。
517
+ const DIFF_MAX_FILES = 12
518
+ const DIFF_MAX_TOTAL_BYTES = 96 * 1024
519
+
520
+ /**
521
+ * 计算工作区变更(新增 / 修改 / 删除)。
522
+ * 这是 hub 侧"信息汇聚"最重要的输入:主电脑只想知道"到底动了什么"。
523
+ */
524
+ export function diffSnapshots(before, after, workspace, caseInsensitive = true) {
525
+ const changed = []
526
+ const keyOf = (p) => (caseInsensitive ? p.toLowerCase() : p)
527
+
528
+ const beforeByKey = new Map()
529
+ for (const [k, v] of before) beforeByKey.set(keyOf(k), { path: k, ...v })
530
+ const afterByKey = new Map()
531
+ for (const [k, v] of after) afterByKey.set(keyOf(k), { path: k, ...v })
532
+
533
+ for (const [k, a] of afterByKey) {
534
+ const b = beforeByKey.get(k)
535
+ if (!b) {
536
+ changed.push({ path: a.path, op: 'add', bytes: a.bytes })
537
+ } else if (b.bytes !== a.bytes || b.mtimeMs !== a.mtimeMs) {
538
+ changed.push({
539
+ path: a.path,
540
+ op: 'modify',
541
+ bytes: a.bytes,
542
+ deltaBytes: a.bytes - b.bytes,
543
+ })
544
+ }
545
+ }
546
+ for (const [k, b] of beforeByKey) {
547
+ if (!afterByKey.has(k)) changed.push({ path: b.path, op: 'delete', bytes: 0 })
548
+ }
549
+
550
+ changed.sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0))
551
+ return changed.slice(0, MAX_CHANGED)
552
+ }
553
+
554
+ /**
555
+ * 为 changed 条目生成 unified diff(提案 #21)。
556
+ *
557
+ * 前像来自快照内容库(snapshotWorkspace 的 withContent 捕获);后像在任务
558
+ * 收尾时现读(只需为进入 diff 的文件读,≤12 个)。任何一环不满足
559
+ * (前像缺席 / 文件过大 / 二进制 / 读失败)就跳过该文件 —— diff 是
560
+ * 增强证据,缺了不致命,绝不能因为它拖垮任务收尾。
561
+ *
562
+ * @param {object} o
563
+ * @param {string} o.workspace 任务工作区(绝对路径)
564
+ * @param {Array<{path:string, op:string}>} o.changed diffSnapshots 的输出
565
+ * @param {Map<string, {bytes:number, mtimeMs:number, content?:string}>} o.before 任务开始前的快照
566
+ * @param {boolean} [o.caseInsensitive] 与 diffSnapshots 同一大小写口径
567
+ * @returns {Array<{path:string, diff:string, truncated:boolean}>}
568
+ */
569
+ export function buildDiffs({ workspace, changed, before, caseInsensitive = true }) {
570
+ const diffs = []
571
+ if (!Array.isArray(changed) || changed.length === 0) return diffs
572
+ let totalBytes = 0
573
+ const keyOf = (p) => (caseInsensitive ? p.toLowerCase() : p)
574
+ const beforeByKey = new Map()
575
+ for (const [k, v] of before) beforeByKey.set(keyOf(k), { path: k, ...v })
576
+
577
+ for (const c of changed) {
578
+ if (diffs.length >= DIFF_MAX_FILES || totalBytes >= DIFF_MAX_TOTAL_BYTES) break
579
+ if (!c || typeof c.path !== 'string' || c.path === '') continue
580
+ const beforeEntry = beforeByKey.get(keyOf(c.path))
581
+ let beforeText = ''
582
+ if (c.op === 'modify' || c.op === 'delete') {
583
+ // 前像缺席(文件太大 / 二进制 / 当时不在线)就放弃这个文件的 diff
584
+ if (typeof beforeEntry?.content !== 'string') continue
585
+ beforeText = beforeEntry.content
586
+ }
587
+ let afterText = ''
588
+ if (c.op === 'modify' || c.op === 'add') {
589
+ const text = readTextForDiff(path.join(workspace, c.path))
590
+ if (text === null) continue
591
+ afterText = text
592
+ }
593
+ let d
594
+ try {
595
+ d = unifiedDiff({ path: c.path, beforeText, afterText })
596
+ } catch {
597
+ continue // diff 生成失败不致命
598
+ }
599
+ if (!d) continue
600
+ diffs.push(d)
601
+ totalBytes += Buffer.byteLength(d.diff, 'utf8')
602
+ }
603
+ return diffs
604
+ }
605
+
606
+ /** 读一个文件做 diff 后像:≤256KB 且前 64 字节无 NUL;否则返回 null。 */
607
+ function readTextForDiff(abs) {
608
+ try {
609
+ const buf = fs.readFileSync(abs)
610
+ if (buf.length === 0 || buf.length > MAX_SNAPSHOT_FILE_BYTES) return null
611
+ if (buf.subarray(0, 64).includes(0)) return null
612
+ return buf.toString('utf8')
613
+ } catch {
614
+ return null
615
+ }
616
+ }
617
+
618
+ /**
619
+ * 任务书包装。
620
+ *
621
+ * 关键:副机的 agent 是**独立 Agent**,它不知道自己在被远程派单。
622
+ * 所以任务书要自带上下文,而不是指望它去猜。同时明确告诉它产物规则,
623
+ * 让它把成品放在工作区里 —— 否则 manifest 什么也捞不到。
624
+ */
625
+ export function buildTaskBrief(rec) {
626
+ const lines = [
627
+ '# 远程任务',
628
+ '',
629
+ '你正在一台独立机器上执行一条由远程同事派发的任务。',
630
+ '请在这个工作区里完成它,并把**成品**留在工作区内(会被自动收集成产物清单)。',
631
+ '',
632
+ `工作区:${rec.workspace}`,
633
+ '',
634
+ '## 任务',
635
+ '',
636
+ rec.goal,
637
+ '',
638
+ ]
639
+ if (rec.outputs?.length) {
640
+ lines.push('## 期望产出(glob)', '', ...rec.outputs.map((o) => `- ${o}`), '')
641
+ }
642
+ lines.push(
643
+ '## 约定',
644
+ '',
645
+ '- 编译产物、报告文档、源码变更、媒体文件会作为「成品」上报(role:final)',
646
+ '- 日志、缓存、中间构建文件只上报元数据,不会被传回(role:intermediate)',
647
+ '- 完成后用一段简洁的中文说明:做了什么、结果如何、遗留什么问题',
648
+ '',
649
+ )
650
+ return lines.join('\n')
651
+ }
652
+
653
+ /**
654
+ * 生成 handoff 摘要 —— 主电脑真正消费的东西。
655
+ *
656
+ * 设计原则:这张卡要能让主机 agent 在不看副机任何日志的情况下做决策
657
+ * (要不要拉某个产物、要不要重派、要不要人工介入)。
658
+ */
659
+ export function buildHandoff({ rec, nodeId, result, manifest, changed, diffs, workspace }) {
660
+ const finalItems = manifest.items.filter((i) => i.role === ARTIFACT_ROLE.FINAL)
661
+ const intermediateCount = manifest.items.filter((i) => i.role === ARTIFACT_ROLE.INTERMEDIATE).length
662
+
663
+ const summaryText = (result.final ?? '').trim()
664
+ const fallback = result.ok
665
+ ? `任务完成,产出 ${finalItems.length} 个成品。`
666
+ : `任务未成功:${(result.stderr ?? '').slice(0, 300) || 'no diagnostics'}`
667
+
668
+ return {
669
+ taskId: rec.taskId,
670
+ nodeId,
671
+ goal: rec.goal,
672
+ mode: rec.mode,
673
+ workspace,
674
+ status: rec.status,
675
+ runner: result.runner ?? rec.exit?.runner ?? 'unknown',
676
+ sessionId: result.sessionId ?? null,
677
+ durationMs: result.durationMs ?? (rec.endedAt ?? Date.now()) - (rec.startedAt ?? Date.now()),
678
+ startedAt: rec.startedAt,
679
+ endedAt: rec.endedAt,
680
+ exit: {
681
+ code: result.exitCode ?? null,
682
+ timedOut: result.timedOut === true,
683
+ toolCalls: result.toolCalls?.length ?? 0,
684
+ },
685
+ summary: {
686
+ text: summaryText || fallback,
687
+ sha256: hashText(summaryText || fallback),
688
+ source: summaryText ? 'agent' : 'engine',
689
+ },
690
+ changed,
691
+ diffs,
692
+ artifacts: {
693
+ counts: manifest.counts,
694
+ bytesFinal: manifest.bytesFinal,
695
+ final: finalItems.map((i) => ({
696
+ path: i.path,
697
+ kind: i.kind,
698
+ bytes: i.bytes,
699
+ sha256: i.sha256 ?? null,
700
+ })),
701
+ intermediateCount,
702
+ note: '只有 role:final 可以 pull;intermediate 仅上报元数据,留在副机。',
703
+ },
704
+ logs: {
705
+ tail: (rec.logs ?? []).slice(-20).map((l) => l.line),
706
+ full: `dsh-remote-agent://tasks/${rec.taskId}/logs`,
707
+ },
708
+ pullHint: finalItems.length > 0
709
+ ? `用 remote_pull 拉取,例如:${finalItems.slice(0, 3).map((i) => i.path).join(', ')}`
710
+ : '本次没有 role:final 产物,无需 pull。',
711
+ }
712
+ }