dsh-remote-agent 0.9.4 → 0.9.6

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.
@@ -242,6 +242,18 @@ export function withHandshake(Base) {
242
242
 
243
243
  if (transport === 'direct') this.sendWelcome(rec)
244
244
  else this.sendIdentityFrame(rec) // relay:hub 在握手期对副机静默,身份帧必须在上线后追发
245
+ // 记一次成功的连接尝试(2026-10-20,体验提升 ①)——
246
+ // 面板据此区分"从没连上过"与"连上又断了",这是排查方向的分水岭。
247
+ try {
248
+ this.noteConnectionAttempt({
249
+ result: 'ok',
250
+ nodeId: hello.nodeId,
251
+ remote: remoteOf(conn),
252
+ transport,
253
+ })
254
+ } catch {
255
+ /* 诊断记录绝不能影响上线流程 */
256
+ }
245
257
  this.log('info', 'node online', { nodeId: hello.nodeId, os: hello.os, transport })
246
258
  return rec
247
259
  }
@@ -388,6 +400,20 @@ export function withHandshake(Base) {
388
400
 
389
401
  /** 在指定连接上回 error 帧然后关闭。 */
390
402
  rejectConn(conn, code, message, nodeId) {
403
+ // 记一次失败的连接尝试(2026-10-20,体验提升 ①)——
404
+ // 副机连不上时,面板要能说清"为什么",而不是只显示 0 在线。
405
+ try {
406
+ this.noteConnectionAttempt({
407
+ result: 'fail',
408
+ code: typeof code === 'string' ? code : '',
409
+ message: typeof message === 'string' ? message : '',
410
+ nodeId: typeof nodeId === 'string' ? nodeId : '',
411
+ remote: remoteOf(conn),
412
+ transport: 'direct',
413
+ })
414
+ } catch {
415
+ /* 诊断记录绝不能影响拒绝流程本身 */
416
+ }
391
417
  try {
392
418
  conn.send(encodeFrame(envelope(HUB_TO_NODE.ERROR, { code, message, nodeId })))
393
419
  } catch {
@@ -401,3 +427,21 @@ export function withHandshake(Base) {
401
427
  }
402
428
  }
403
429
  }
430
+
431
+ /**
432
+ * 从连接对象上尽力取远端地址(用于诊断记录)。
433
+ * WSConnection 不一定暴露 remote,取不到就返回空串。
434
+ */
435
+ function remoteOf(conn) {
436
+ try {
437
+ const s = conn?.socket
438
+ const addr = s?.remoteAddress
439
+ const port = s?.remotePort
440
+ if (typeof addr === 'string' && addr !== '') {
441
+ return typeof port === 'number' ? `${addr}:${port}` : addr
442
+ }
443
+ } catch {
444
+ /* 取不到就算了 */
445
+ }
446
+ return ''
447
+ }
@@ -441,9 +441,37 @@ export function registerUiRoutes({
441
441
  if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
442
442
  const nodes = registry.list().map(nodeView)
443
443
  const tasks = registry.listTasks().map((t) => registry.taskView(t))
444
+ // 连接尝试历史(2026-10-20,体验提升 ①):副机连不上时面板要能说清原因。
445
+ // 逐字段投影(不 spread)—— 与其他 UI 响应同一条硬规则。
446
+ const connReport = (() => {
447
+ try {
448
+ const r = registry.connectionReport(20)
449
+ const mapEntry = (e) => ({
450
+ at: Number.isFinite(e.at) ? e.at : 0,
451
+ result: e.result === 'ok' ? 'ok' : 'fail',
452
+ code: typeof e.code === 'string' ? e.code : '',
453
+ nodeId: typeof e.nodeId === 'string' ? e.nodeId : '',
454
+ remote: typeof e.remote === 'string' ? e.remote : '',
455
+ title: typeof e.title === 'string' ? e.title : '',
456
+ detail: typeof e.detail === 'string' ? e.detail : '',
457
+ hint: typeof e.hint === 'string' ? e.hint : '',
458
+ raw: typeof e.message === 'string' ? e.message : '',
459
+ })
460
+ return {
461
+ everConnected: r.everConnected === true,
462
+ byNode: Array.isArray(r.byNode) ? r.byNode.slice(0, 20).map(mapEntry) : [],
463
+ recent: Array.isArray(r.recent) ? r.recent.slice(0, 20).map(mapEntry) : [],
464
+ }
465
+ } catch {
466
+ // 诊断数据取不到不该让整个面板挂掉
467
+ return { everConnected: false, byNode: [], recent: [] }
468
+ }
469
+ })()
444
470
  writeJson(res, 200, {
445
471
  ok: true,
446
472
  nodes,
473
+ // 连接尝试历史:面板据此显示"最后尝试 2 分钟前 —— 握手超时 → …"
474
+ connection: connReport,
447
475
  counts: {
448
476
  nodes: nodes.length,
449
477
  online: nodes.filter((n) => n.online).length,
package/lib/index.js CHANGED
@@ -138,6 +138,31 @@ async function applyWorkerBranch(ctx, config, log) {
138
138
  }
139
139
  }
140
140
 
141
+ /**
142
+ * 每个"注册宿主"上当前这一代的清理函数。
143
+ *
144
+ * 键选 **`ctx.webServer` 服务实例**,不是 `ctx`:
145
+ * 撞车的是**宿主的路由表**(`webServer.upgrades` 这张 Map)。同一个 ctx 当然
146
+ * 共用同一张表,但 DSH 热重载时也可能给每代一个新的子 ctx —— 那时按 ctx 记
147
+ * 就会漏掉,照样撞车。按服务实例记才对应"真正会冲突的那个东西"。
148
+ *
149
+ * 读不到 `ctx.webServer`(副机 profile 的 `inject: []` 一读就抛)时退回 ctx,
150
+ * 这条兜底只是让字典有键,不影响正确性。
151
+ *
152
+ * 用 WeakMap:既不阻止 GC,也不会把多个 profile / 多个 host 实例串在一起。
153
+ */
154
+ const GENERATIONS = new WeakMap()
155
+
156
+ /** 取"注册宿主"作为字典键。读服务可能抛(无 inject),所以必须 try。 */
157
+ function generationKey(ctx) {
158
+ try {
159
+ const ws = ctx && ctx.webServer
160
+ return ws !== undefined && ws !== null ? ws : ctx
161
+ } catch {
162
+ return ctx
163
+ }
164
+ }
165
+
141
166
  /**
142
167
  * DSH 插件入口。
143
168
  * @param {object} ctx Cordis 上下文
@@ -188,6 +213,64 @@ export function apply(ctx, config = {}) {
188
213
  }
189
214
  const handles = { role, hub: undefined, worker: undefined, unconfigured: unconfigured === true }
190
215
 
216
+ // ══════════════════════════════════════════════════════════════════════════
217
+ // 可重入:同一个 ctx 上只允许存在**一代**注册(0.9.5 修 duplicate 激活失败)
218
+ // ══════════════════════════════════════════════════════════════════════════
219
+ //
220
+ // 0.9.4 装上后 DSH 报:
221
+ //
222
+ // webserver: duplicate upgrade route "/api/remote-agent/node"
223
+ // 1 entry did not activate remote-agent (dsh-remote-agent)
224
+ //
225
+ // 根因不是"注册了两次"写错,而是**我们的注册不可回收**:
226
+ //
227
+ // · DSH 的 `webServer.registerUpgrade(path)` 是**按 path 查表**的,
228
+ // 同路径第二次注册直接抛(这是它的组合级契约,合理):
229
+ // if (this.upgrades.has(route.path)) throw new Error('webserver: duplicate upgrade route ...')
230
+ // · 我们的注册全部收集在 `__disposers` 里、由 `applyHub()` 的返回值
231
+ // `disposeAll` 交给调用方保管 —— 但 **DSH 不认识这个返回值**,
232
+ // 热重载/安装后 reload 时它只会重新 `apply`,不会回头调我们的 disposer。
233
+ // · 于是第二代在第一代的残留上注册 → 撞车 → 整个 entry 激活失败。
234
+ //
235
+ // 为什么以前没暴露:只有"同一个 ctx 上重新 apply"才触发。DSH 在
236
+ // 插件安装/升级后会热重载,这正是用户装 0.9.4 时踩到的那一步。
237
+ //
238
+ // 修法:**不指望宿主帮我们清理**。按 ctx 记住上一代的 dispose,重新
239
+ // apply 时先自己摘干净再装。这样无论宿主是否调用 disposer,都不会重复注册。
240
+ const genKey = generationKey(ctx)
241
+ const prevDispose = GENERATIONS.get(genKey)
242
+ if (typeof prevDispose === 'function') {
243
+ try {
244
+ prevDispose()
245
+ log('info', 're-apply on the same host: previous generation disposed first')
246
+ } catch (err) {
247
+ log('warn', 'previous generation dispose failed (continuing)', {
248
+ error: String(err?.message ?? err),
249
+ })
250
+ }
251
+ }
252
+
253
+ let released = false
254
+ const dispose = () => {
255
+ if (released) return
256
+ released = true
257
+ // 只摘自己:新一代可能已经顶替了这一格,别把它的注册也拆了
258
+ if (GENERATIONS.get(genKey) === dispose) GENERATIONS.delete(genKey)
259
+ // 顺序:hub 的路由/监听 → setup 路由 → worker 拨号。
260
+ // 每一步独立兜底:一个失败不能让后面的漏摘(否则又变成"摘一半")。
261
+ try { handles.hub?.disposeAll?.() } catch (err) {
262
+ log('warn', 'hub dispose failed', { error: String(err?.message ?? err) })
263
+ }
264
+ if (Array.isArray(handles.setup)) {
265
+ for (const d of handles.setup) {
266
+ try { if (typeof d === 'function') d() } catch { /* 已摘 */ }
267
+ }
268
+ }
269
+ try { if (typeof handles.worker === 'function') handles.worker() } catch { /* 已摘 */ }
270
+ }
271
+ GENERATIONS.set(genKey, dispose)
272
+ handles.dispose = dispose
273
+
191
274
  // ── 未配置:什么都不注册 ────────────────────────────────────────────────
192
275
  //
193
276
  // 为什么要有这个分支:首次安装时**插件无法知道这台机器是主机还是副机** ——
@@ -252,6 +335,12 @@ export function apply(ctx, config = {}) {
252
335
  // 动态 import 是异步的;不 await —— apply 本身是同步契约。
253
336
  // 失败已在 applyWorkerBranch 内部记日志,不让 host 因副机缺件而起不来。
254
337
  void applyWorkerBranch(ctx, config, log).then((h) => {
338
+ // 竞态:这一代已经被 dispose(热重载/卸载)了,把刚挂上的 worker 立刻摘掉。
339
+ // 否则它会留在后台继续拨号 —— 一份回收不掉的僵尸连接。
340
+ if (released) {
341
+ try { if (typeof h === 'function') h() } catch { /* 已摘 */ }
342
+ return
343
+ }
255
344
  handles.worker = h
256
345
  })
257
346
  }
package/lib/node/index.js CHANGED
@@ -45,9 +45,12 @@ import { expandPairConfig } from '../pairing.js'
45
45
  import { selectRunner } from './runner.js'
46
46
  // 资源遥测:CPU(增量)/内存/负载采样,经 node/resource 帧上报 hub。
47
47
  import { RESOURCE_INTERVAL_MS, createResourceSampler } from './resource.js'
48
- // friendlyConnectError 来自向导的纯逻辑层(零依赖、无副作用)——
49
- // worker 的 error 事件与 --doctor 的网络探活共用同一份"怎么修"口径。
50
- import { friendlyConnectError } from '../setup.js'
48
+ // 统一错误翻译层(2026-10-20,体验提升 ④):覆盖协议错误码(17 个)+ HTTP
49
+ // 状态 + errno,每条都给"下一步做什么"。面板与日志共用同一份口径。
50
+ //
51
+ // 注:原先这里还 import 了 setup.js 的 friendlyConnectError,2026-10-20 起
52
+ // 运行时错误统一走 explain-error.js(覆盖面更广),那个 import 已无用。
53
+ import { explainError, formatExplained } from '../explain-error.js'
51
54
  import { scanRepos } from './repos.js'
52
55
  import { TaskEngine } from './task-engine.js'
53
56
  import { TaskStore } from './task-store.js'
@@ -655,6 +658,18 @@ function startWorker(ctx, config) {
655
658
 
656
659
  /* 6) 发送助手:永不抛、失败即丢帧(断连期间任务照跑) ---------------- */
657
660
  let lastError = ''
661
+ /**
662
+ * 最近一次连接失败的结构化详情(2026-10-20,体验提升 ①)。
663
+ *
664
+ * `lastError` 只是原始字符串,面板没法据此给用户"下一步做什么"。
665
+ * 这里存翻译后的三件套:{ at, title, detail, hint, raw, code }。
666
+ *
667
+ * 面板据此显示:
668
+ * ✗ 最后尝试 2 分钟前 —— 主机上没有服务在监听
669
+ * → 检查:① 主机的 DSH 是否在跑;② 端口是否写对…
670
+ * 原始错误:connect ECONNREFUSED 100.86.48.54:19387
671
+ */
672
+ let lastErrorDetail = null
658
673
  const send = (type, data, opts) => {
659
674
  try {
660
675
  return transport.send(type, data, opts) === true
@@ -1104,10 +1119,25 @@ function startWorker(ctx, config) {
1104
1119
  })
1105
1120
  on('error', (err) => {
1106
1121
  lastError = msgOf(err)
1107
- // 底层错误码(ECONNREFUSED 等)对小白是天书。这里加一行"哪里没对 + 怎么修"
1108
- // 的人话版本;原始错误照记(排查仍需要)。映射表在 lib/setup.js 的
1109
- // friendlyConnectError,与 --doctor 的网络探活共用同一份口径。
1110
- logger.warn(`连接问题:${friendlyConnectError(err, {})}`, { nodeId, error: lastError })
1122
+ // 底层错误码(ECONNREFUSED 等)对小白是天书。这里用**统一翻译层**
1123
+ // (lib/explain-error.js)给出"哪里没对 + 下一步做什么";原始错误照记
1124
+ // (排查仍需要)。
1125
+ //
1126
+ // 2026-10-20 改:从 setup.js 的 friendlyConnectError 升级到 explain-error.js
1127
+ // —— 后者覆盖协议错误码(17 个)+ HTTP 状态 + 握手超时等,不只是 5 类网络错误。
1128
+ //
1129
+ // ⚠ 结构化保存:面板要显示"最后尝试时间 + 人话 + 原始错误"三件套,
1130
+ // 只存字符串的话面板只能原样打印。
1131
+ const explained = explainError(err, { isHub: false, context: '连接主机' })
1132
+ lastErrorDetail = {
1133
+ at: Date.now(),
1134
+ title: explained.title,
1135
+ detail: explained.detail,
1136
+ hint: explained.hint,
1137
+ raw: explained.raw,
1138
+ code: typeof err?.code === 'string' ? err.code : '',
1139
+ }
1140
+ logger.warn(`连接问题:${formatExplained(explained)}`, { nodeId, error: lastError })
1111
1141
  })
1112
1142
  // P2-11:hub 身份验证状态。'pending' 期间(relay 宽限窗口内)task/start 会被
1113
1143
  // 上面的 guard 拒绝;direct 模式在 welcome 时就已决出,宽限窗口实际只作用于
@@ -1197,6 +1227,9 @@ function startWorker(ctx, config) {
1197
1227
  },
1198
1228
  reconcile,
1199
1229
  lastError: lastError || null,
1230
+ // 结构化的人话错误(面板据此显示"最后尝试 + 原因 + 下一步")。
1231
+ // 旧版副机没有这个字段 → 面板按 null 处理,退回只显示 lastError。
1232
+ lastErrorDetail: lastErrorDetail || null,
1200
1233
  updatedAt: Date.now(),
1201
1234
  }
1202
1235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-agent",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "description": "把另一台电脑变成你 DSH 的远程 Agent 同事:副机本地持久跑任务、独立 Agent 循环,只回传 handoff 摘要与产物清单,成品按需拉取。",
6
6
  "license": "MIT",
@@ -1177,6 +1177,72 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
1177
1177
  'lib/listen-server.js(拒 0.0.0.0 + Tailscale 优先)+ hub/index.js(测试环境跳过)')
1178
1178
  }
1179
1179
 
1180
+ /* ── 88~90. 用户体验五项(2026-10-20)──
1181
+ *
1182
+ * 实测用户反馈:"出了问题不知道原因"。五个断层:
1183
+ * ① 副机连不上时主机只显示「0 在线」,没有原因
1184
+ * ② 体检能查出来,但修法要手抄 YAML
1185
+ * ③ 首次使用不知道从哪开始
1186
+ * ④ 原始错误(ECONNREFUSED / 401 / timeout after 40000ms)直接抛给用户
1187
+ * ⑤ 长任务只能看到"运行中",不知道在干什么
1188
+ *
1189
+ * 这三条断言钉住"每条都有对应实现",防止后续重构把它们悄悄拿掉。
1190
+ */
1191
+ {
1192
+ const explainSrc = read('lib/explain-error.js')
1193
+ const regSrc = read('lib/hub/node-registry.js')
1194
+ const clientSrc6 = read('lib/client.js')
1195
+ const uiSrc5 = read('lib/hub/ui-routes.js')
1196
+
1197
+ check('88', '体验④:统一错误翻译层覆盖 errno + 协议码 + HTTP + 文案特征',
1198
+ /export function explainError/.test(explainSrc)
1199
+ // errno 分支
1200
+ && /ECONNREFUSED/.test(explainSrc)
1201
+ && /ETIMEDOUT/.test(explainSrc)
1202
+ && /EADDRINUSE/.test(explainSrc)
1203
+ // 协议码(用 ERROR_CODE 常量,不写死字符串)
1204
+ && /ERROR_CODE\.UNAUTHORIZED/.test(explainSrc)
1205
+ && /ERROR_CODE\.PROTOCOL_MISMATCH/.test(explainSrc)
1206
+ // HTTP 状态
1207
+ && /case 401:/.test(explainSrc)
1208
+ && /case 404:/.test(explainSrc)
1209
+ // 文案特征
1210
+ && /handshake timeout/.test(explainSrc)
1211
+ // 每条都有 hint(下一步做什么)
1212
+ && /hint:/.test(explainSrc),
1213
+ 'lib/explain-error.js(errno + 17 个协议码 + HTTP + 文案特征,每条带 hint)')
1214
+
1215
+ check('89', '体验①:连接尝试历史(记录 + 面板空态诊断)',
1216
+ // 注册表记录每次尝试
1217
+ /noteConnectionAttempt/.test(regSrc)
1218
+ && /connectionLog/.test(regSrc)
1219
+ && /connectionReport/.test(regSrc)
1220
+ // 拒绝点与成功点都要记
1221
+ && /noteConnectionAttempt/.test(read('lib/hub/registry-handshake.js'))
1222
+ // 路由透出
1223
+ && /connection: connReport/.test(uiSrc5)
1224
+ // 面板渲染诊断(区分"从没连过"与"连过又断")
1225
+ && /connDiag/.test(clientSrc6)
1226
+ && /everConnected/.test(clientSrc6),
1227
+ 'node-registry.js + registry-handshake.js + ui-routes.js + client.js')
1228
+
1229
+ check('90', '体验②③⑤:复制修法按钮 + 编号步骤 + 任务进度摘要',
1230
+ // ② 复制修法
1231
+ /复制修法/.test(clientSrc6)
1232
+ && /copyText\(it\.fix\)/.test(clientSrc6)
1233
+ // ③ 编号步骤(hub 侧 1-4 步)
1234
+ && /'重启 DSH'/.test(clientSrc6)
1235
+ && /'生成配对码'/.test(clientSrc6)
1236
+ && /'在副机上粘贴'/.test(clientSrc6)
1237
+ && /'等它连上'/.test(clientSrc6)
1238
+ // ③ 角色选择提示
1239
+ && /你现在用的这台/.test(clientSrc6)
1240
+ // ⑤ 任务行进度摘要
1241
+ && /progressLine/.test(clientSrc6)
1242
+ && /最近:/.test(clientSrc6),
1243
+ 'lib/client.js(复制按钮 + 编号步骤 + 角色提示 + 任务进度摘要)')
1244
+ }
1245
+
1180
1246
  /* ── 输出 ── */
1181
1247
  const failed = results.filter((r) => !r.ok)
1182
1248
  if (process.argv.includes('--json')) {