@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.100

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 (273) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +8 -7
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +487 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/agent-notice.ts +48 -0
  15. package/src/alias-filter.test.ts +72 -0
  16. package/src/alias-filter.ts +42 -0
  17. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  18. package/src/api-host-supervisors-fallback.test.ts +271 -0
  19. package/src/api-nodes-shape.test.ts +188 -0
  20. package/src/auth-kdf-migration.test.ts +378 -0
  21. package/src/auth-sessions-http.test.ts +250 -0
  22. package/src/auth-tokens.test.ts +18 -9
  23. package/src/auth-validate.test.ts +5 -1
  24. package/src/auth.ts +554 -38
  25. package/src/avatar-validate.test.ts +163 -0
  26. package/src/avatar-validate.ts +103 -0
  27. package/src/blocked-exit-on-reply.test.ts +122 -0
  28. package/src/boot-side-effect.test.ts +124 -0
  29. package/src/commhub-bin-args.test.ts +85 -0
  30. package/src/config-apply-sec1.test.ts +830 -0
  31. package/src/config-apply-validate.test.ts +352 -0
  32. package/src/config-apply-validate.ts +249 -0
  33. package/src/create-node-blocked-gate.test.ts +356 -0
  34. package/src/create-node-tool-schema.test.ts +39 -0
  35. package/src/create-node-validate.test.ts +275 -0
  36. package/src/create-node-validate.ts +288 -0
  37. package/src/create-node-workdir.test.ts +255 -0
  38. package/src/create-node.test.ts +354 -0
  39. package/src/create-node.ts +349 -0
  40. package/src/cross-tenant-injection.test.ts +110 -0
  41. package/src/dashboard-slash-routing-http.test.ts +107 -0
  42. package/src/db-adapter-guard.test.ts +148 -0
  43. package/src/db-adapter-translate.test.ts +128 -0
  44. package/src/db-adapter.ts +323 -87
  45. package/src/db-timestamp.test.ts +38 -0
  46. package/src/db-timestamp.ts +15 -0
  47. package/src/db.ts +1268 -42
  48. package/src/delivered-without-push.test.ts +140 -0
  49. package/src/department-groups-http.test.ts +209 -0
  50. package/src/department-groups-sync-http.test.ts +328 -0
  51. package/src/department-groups.ts +434 -0
  52. package/src/department-heads-http.test.ts +329 -0
  53. package/src/department-heads.ts +226 -0
  54. package/src/departments-http.test.ts +185 -0
  55. package/src/departments.ts +260 -0
  56. package/src/desktop-message-seam.test.ts +150 -0
  57. package/src/desktop-message.test.ts +368 -0
  58. package/src/dm-file-scope-http.test.ts +178 -0
  59. package/src/ext-token-shared.test.ts +60 -0
  60. package/src/ext-validation-regression.test.ts +131 -0
  61. package/src/external-schedule-edits-http.test.ts +321 -0
  62. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  63. package/src/external-schedule-edits.ts +327 -0
  64. package/src/file-download-authz-dev-open.test.ts +135 -0
  65. package/src/file-download-authz.test.ts +404 -0
  66. package/src/file-download-date-bucket.test.ts +436 -0
  67. package/src/file-network-scope-dev-open.test.ts +97 -0
  68. package/src/file-network-scope.test.ts +732 -0
  69. package/src/group-membership-events-http.test.ts +264 -0
  70. package/src/group-messages-http.test.ts +482 -0
  71. package/src/group-messages.ts +297 -0
  72. package/src/health-redaction.test.ts +642 -0
  73. package/src/http-gzip.test.ts +94 -0
  74. package/src/http-gzip.ts +112 -0
  75. package/src/hub-timestamp.test.ts +38 -0
  76. package/src/hub-timestamp.ts +27 -0
  77. package/src/human-dm.ts +215 -0
  78. package/src/inbox-count-sse.test.ts +310 -0
  79. package/src/inbox-count.ts +29 -0
  80. package/src/index.ts +15 -2128
  81. package/src/lifecycle-guard.ts +50 -0
  82. package/src/list-host-supervisors.test.ts +314 -0
  83. package/src/mcp-context-cost-http.test.ts +119 -0
  84. package/src/mcp-projects-events-http.test.ts +162 -0
  85. package/src/mcp-route-label-http.test.ts +120 -0
  86. package/src/mcp-write-network-resolution.test.ts +429 -0
  87. package/src/member-audit-network-id-http.test.ts +93 -0
  88. package/src/member-presence-http.test.ts +206 -0
  89. package/src/messages-ack-by-agent-http.test.ts +142 -0
  90. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  91. package/src/migrate-sqlite-to-pg.ts +263 -0
  92. package/src/model-auth-notify-http.test.ts +181 -0
  93. package/src/model-auth-notify.ts +116 -0
  94. package/src/network-name-http.test.ts +75 -0
  95. package/src/network-scope.ts +224 -0
  96. package/src/network-token-membership-http.test.ts +139 -0
  97. package/src/node-attrs-validate.ts +88 -0
  98. package/src/node-attrs.test.ts +217 -0
  99. package/src/node-create-request-status-http.test.ts +92 -0
  100. package/src/node-files-transport.test.ts +344 -0
  101. package/src/node-health-dispatch-http.test.ts +279 -0
  102. package/src/node-health-guard.ts +105 -0
  103. package/src/node-health-passthrough.test.ts +115 -0
  104. package/src/node-health-store.ts +72 -0
  105. package/src/node-id-alias.test.ts +188 -0
  106. package/src/node-identity-conflict-http.test.ts +269 -0
  107. package/src/node-identity-conflict.ts +142 -0
  108. package/src/node-identity-mismatch.test.ts +74 -0
  109. package/src/node-lifecycle-controllable.test.ts +64 -0
  110. package/src/node-lifecycle-controllable.ts +51 -0
  111. package/src/node-logs-transport.test.ts +263 -0
  112. package/src/node-permissions-http.test.ts +276 -0
  113. package/src/node-permissions.ts +242 -0
  114. package/src/node-request-retention.test.ts +281 -0
  115. package/src/node-request-retention.ts +120 -0
  116. package/src/node-skills-transport.test.ts +209 -0
  117. package/src/node-token-privilege-http.test.ts +149 -0
  118. package/src/observer-avatar-http.test.ts +431 -0
  119. package/src/observer-push.test.ts +252 -0
  120. package/src/package-version-consistency.test.ts +25 -0
  121. package/src/peer-reply-atomic.test.ts +455 -0
  122. package/src/peer-reply-capability.test.ts +70 -0
  123. package/src/pg-worker.ts +87 -0
  124. package/src/probe-validate.test.ts +221 -0
  125. package/src/probe-validate.ts +104 -0
  126. package/src/probe.test.ts +80 -0
  127. package/src/probe.ts +200 -0
  128. package/src/project-viewer-can-http.test.ts +114 -0
  129. package/src/push.ts +429 -10
  130. package/src/redact-tokens.test.ts +65 -0
  131. package/src/redact-tokens.ts +40 -0
  132. package/src/rename.ts +7 -2
  133. package/src/reply-target-derivation.test.ts +96 -0
  134. package/src/report-status-host-schema.test.ts +211 -0
  135. package/src/report-status-resume-id-handover.test.ts +150 -0
  136. package/src/require-explicit-test-db.ts +9 -0
  137. package/src/requirement-due-reminders-http.test.ts +390 -0
  138. package/src/requirement-due-reminders.ts +310 -0
  139. package/src/requirement-events-http.test.ts +257 -0
  140. package/src/requirement-events.test.ts +45 -0
  141. package/src/requirement-events.ts +144 -0
  142. package/src/requirement-notify.ts +165 -0
  143. package/src/requirement-participant-notify-http.test.ts +309 -0
  144. package/src/requirement-tag-ops-http.test.ts +266 -0
  145. package/src/requirement-tags.ts +89 -0
  146. package/src/requirements-comment-http.test.ts +165 -0
  147. package/src/requirements-completed-at-migration.test.ts +70 -0
  148. package/src/requirements-errors-mcp-http.test.ts +164 -0
  149. package/src/requirements-errors.ts +143 -0
  150. package/src/requirements-etag-http.test.ts +91 -0
  151. package/src/requirements-http.test.ts +709 -0
  152. package/src/requirements-last-event-http.test.ts +344 -0
  153. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  154. package/src/requirements-list-slim-http.test.ts +319 -0
  155. package/src/requirements-mcp.test.ts +163 -0
  156. package/src/requirements-migrate.ts +225 -0
  157. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  158. package/src/requirements-people-http.test.ts +114 -0
  159. package/src/requirements-people-mcp-http.test.ts +225 -0
  160. package/src/requirements-people.ts +127 -0
  161. package/src/requirements-priority-lowest.test.ts +114 -0
  162. package/src/requirements-search-http.test.ts +182 -0
  163. package/src/requirements-search.test.ts +32 -0
  164. package/src/requirements-search.ts +145 -0
  165. package/src/requirements-seq-http.test.ts +193 -0
  166. package/src/requirements-seq-migration.test.ts +63 -0
  167. package/src/requirements-start-date.test.ts +125 -0
  168. package/src/requirements-stats-http.test.ts +288 -0
  169. package/src/requirements-stats.ts +164 -0
  170. package/src/requirements-status-participants-http.test.ts +220 -0
  171. package/src/requirements-upgrade-e2e.test.ts +126 -0
  172. package/src/requirements-upgrade-migration.test.ts +98 -0
  173. package/src/requirements.ts +1761 -0
  174. package/src/resolve-port.test.ts +57 -0
  175. package/src/resolve-port.ts +44 -0
  176. package/src/response-charset.test.ts +130 -0
  177. package/src/rest-explicit-columns-http.test.ts +222 -0
  178. package/src/rest-identity.test.ts +129 -0
  179. package/src/rest-identity.ts +100 -0
  180. package/src/rest-projections.ts +68 -0
  181. package/src/rest-write-network-resolution.test.ts +133 -0
  182. package/src/restricted-files.ts +73 -0
  183. package/src/retention.test.ts +450 -0
  184. package/src/retention.ts +281 -0
  185. package/src/route-callers-http.test.ts +120 -0
  186. package/src/route-timing.test.ts +211 -0
  187. package/src/route-timing.ts +258 -0
  188. package/src/rules-file-duplicate-alias.test.ts +234 -0
  189. package/src/rules-file-session-target.test.ts +207 -0
  190. package/src/rules-file-transport.test.ts +237 -0
  191. package/src/runtime-label.test.ts +22 -0
  192. package/src/runtime-label.ts +21 -0
  193. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  194. package/src/scheduled-reply-unread-http.test.ts +212 -0
  195. package/src/scheduled-run-terminal.test.ts +268 -0
  196. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  197. package/src/scheduled-stuck-http.test.ts +238 -0
  198. package/src/scheduled-stuck.ts +153 -0
  199. package/src/scheduled-tasks-http.test.ts +638 -0
  200. package/src/scheduled-tasks.ts +641 -0
  201. package/src/send-dedup-request-id.test.ts +229 -0
  202. package/src/send-reply-agent-warning.test.ts +202 -0
  203. package/src/send-reply-alias-optional.test.ts +131 -0
  204. package/src/send-reply-attachments.test.ts +458 -0
  205. package/src/send_dedup.test.ts +54 -0
  206. package/src/send_dedup.ts +52 -6
  207. package/src/serve-error.test.ts +38 -0
  208. package/src/serve-error.ts +27 -0
  209. package/src/server.ts +5038 -0
  210. package/src/shared/external-schedule-contract.ts +60 -0
  211. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  212. package/src/shared/probe-host-allowlist.ts +128 -0
  213. package/src/shared/reserved-env-drift.test.ts +43 -0
  214. package/src/shared/reserved-env.test.ts +35 -0
  215. package/src/shared/reserved-env.ts +31 -0
  216. package/src/side-thread-command-transport.test.ts +312 -0
  217. package/src/side-thread-command-transport.ts +318 -0
  218. package/src/side-thread-http-integration.test.ts +233 -0
  219. package/src/side-thread-http.ts +504 -0
  220. package/src/side-thread-production.test.ts +72 -0
  221. package/src/side-thread-production.ts +118 -0
  222. package/src/side-thread.test.ts +1067 -0
  223. package/src/side-thread.ts +2499 -0
  224. package/src/skillhub-http.test.ts +108 -0
  225. package/src/sqlite-close-checkpoint.test.ts +78 -0
  226. package/src/stale-sweeper.test.ts +181 -0
  227. package/src/stale-sweeper.ts +114 -0
  228. package/src/start-node.test.ts +109 -0
  229. package/src/status-alias-filter-http.test.ts +106 -0
  230. package/src/status-alias-resolver-http.test.ts +163 -0
  231. package/src/status-alias-resolver.ts +19 -0
  232. package/src/status-node-id-filter-http.test.ts +161 -0
  233. package/src/status-read-cache-http.test.ts +229 -0
  234. package/src/status-read-cache.ts +122 -0
  235. package/src/stop-delete-node.test.ts +1164 -0
  236. package/src/task-access-http.test.ts +379 -0
  237. package/src/task-access.ts +413 -0
  238. package/src/task-auth-origin-http.test.ts +91 -0
  239. package/src/task-auth-origin.test.ts +24 -0
  240. package/src/task-auth-origin.ts +7 -0
  241. package/src/task-consumption.test.ts +482 -0
  242. package/src/task-diagnostic-http.test.ts +149 -0
  243. package/src/task-diagnostic.test.ts +115 -0
  244. package/src/task-diagnostic.ts +118 -0
  245. package/src/task-expiry-notice-http.test.ts +287 -0
  246. package/src/task-expiry-notice.ts +313 -0
  247. package/src/task-idempotency-mcp.test.ts +142 -0
  248. package/src/task-idempotency.test.ts +60 -0
  249. package/src/task-idempotency.ts +75 -0
  250. package/src/task-lifecycle-watcher.test.ts +204 -0
  251. package/src/task-lifecycle-watcher.ts +49 -0
  252. package/src/task-network-resolution-http.test.ts +141 -0
  253. package/src/task-queue-ahead-http.test.ts +309 -0
  254. package/src/task-queue-ahead-scale.test.ts +211 -0
  255. package/src/task-queue-ahead.ts +186 -0
  256. package/src/task-stale-open-http.test.ts +290 -0
  257. package/src/task-stale-open.ts +103 -0
  258. package/src/tasks-pagination-http.test.ts +141 -0
  259. package/src/tool-audience-http.test.ts +195 -0
  260. package/src/tool-audience.ts +109 -0
  261. package/src/tools-list-json-schema.test.ts +67 -0
  262. package/src/tools.ts +5426 -275
  263. package/src/update-provider.test.ts +234 -0
  264. package/src/upload-early-reject-guard.test.ts +155 -0
  265. package/src/uploads-http.test.ts +97 -15
  266. package/src/uploads.test.ts +66 -0
  267. package/src/uploads.ts +138 -5
  268. package/src/user-inbox-by-agent-http.test.ts +161 -0
  269. package/src/user-inbox-netid-notnull.test.ts +122 -0
  270. package/src/user-inbox-read-http.test.ts +223 -0
  271. package/src/user-inbox-schema.test.ts +54 -0
  272. package/src/vault.test.ts +200 -0
  273. package/src/vault.ts +156 -0
@@ -0,0 +1,105 @@
1
+ // #460(#448 的子项)—— 降级节点拒收新任务。
2
+ //
3
+ // 现场:节点在 Hub 上 idle / in_flight=0,实际 app-server 端口没人听、TUI 变成 `sleep infinity`、
4
+ // 或登录态 revoked —— 派过去的任务**静默排队**,谁都不知道它永远不会被执行。agent-node .94 起
5
+ // 把这几层报上来(report_status.health,node-health-store.ts 只存内存、10 分钟 TTL);这里在**派发**
6
+ // 那一刻读它:新鲜的健康报告说某一层坏了,就直接拒,带上是哪一层、怎么修。
7
+ //
8
+ // 🔴 只拒「确知坏了」的:没有报告(老 agent-node / claude-code 节点)、报告已过期(>TTL)、
9
+ // 或某层没报(比如非共存节点没有 tui)= 不知道 = 放行。宁可像以前一样排队,
10
+ // 也不能因为缺数据把一台正常的老节点挡在外面。
11
+ // 🔴 只管**新任务派发**(send_task / REST /api/task / 定时任务 / retry / reassign / broadcast)。
12
+ // 回复、send_message、ack 不经过这里 —— 降级节点发出的回复照常送达,发给它的普通消息也照常入库。
13
+ // 逃生口:用户(不是节点令牌)可以带 force=true 强制派发 —— 比如明知 TUI 不在、但 bridge 能干活。
14
+ // 节点令牌不能 force:不让 agent 绕过对另一个 agent 的健康判断。
15
+
16
+ import { readNodeHealth, type NodeHealth } from "./node-health-store.js";
17
+
18
+ export type DegradedLayer = {
19
+ layer: "app_server" | "tui" | "model_auth";
20
+ /** 机器可读的原因:app_server 的错误摘要 / tui 的 reason / model_auth 的状态。 */
21
+ reason: string;
22
+ /** 给人看的一句(中文,与 app 的「降级」徽标同一套措辞)。 */
23
+ label: string;
24
+ hint: string;
25
+ };
26
+
27
+ const TUI_LABEL: Record<string, string> = {
28
+ "session-missing": "TUI 会话不在",
29
+ "pane-dead": "TUI 已退出",
30
+ "sleep-placeholder": "TUI 被占位(sleep)",
31
+ "tmux-unavailable": "tmux 不可用",
32
+ };
33
+
34
+ /** 一份健康报告里确知坏掉的层;没报的层不算。 */
35
+ export function degradedLayers(health: NodeHealth | null | undefined): DegradedLayer[] {
36
+ if (!health) return [];
37
+ const out: DegradedLayer[] = [];
38
+ if (health.app_server && health.app_server.ok === false) {
39
+ out.push({
40
+ layer: "app_server",
41
+ reason: (health.app_server.last_error || "unreachable").slice(0, 200),
42
+ label: "App Server 断开",
43
+ hint: "节点的 codex app-server 端口没有响应:在节点所在机器上重启该节点(anet node restart <别名>)",
44
+ });
45
+ }
46
+ if (health.tui && health.tui.ok === false) {
47
+ out.push({
48
+ layer: "tui",
49
+ reason: health.tui.reason,
50
+ label: TUI_LABEL[health.tui.reason] ?? "TUI 不可用",
51
+ hint: "共存 TUI 不在运行:在节点所在机器上重新拉起该节点的 TUI(anet node restart <别名>)",
52
+ });
53
+ }
54
+ if (health.model_auth === "revoked" || health.model_auth === "expired") {
55
+ out.push({
56
+ layer: "model_auth",
57
+ reason: health.model_auth,
58
+ label: health.model_auth === "revoked" ? "需要重新登录" : "登录已过期",
59
+ hint: "该节点自己的 CODEX_HOME 需要重新登录(在那台机器上:CODEX_HOME=<节点目录>/codex-home codex login);不要拷别的节点的 auth.json",
60
+ });
61
+ }
62
+ return out;
63
+ }
64
+
65
+ export type HealthGuardResult =
66
+ | { ok: true; forced?: true }
67
+ | {
68
+ ok: false;
69
+ error: "node_degraded";
70
+ alias: string;
71
+ layers: DegradedLayer[];
72
+ message: string;
73
+ hint: string;
74
+ health_observed_ms_ago: number;
75
+ /** 能不能用 force=true 绕过(只有用户令牌能)。 */
76
+ force_allowed: boolean;
77
+ };
78
+
79
+ /**
80
+ * 派发前的健康门。networkId 用与 report_status 写入时同一个键(sessionNetId = 网络 id ?? "default")。
81
+ * force 只在 forceAllowed(用户令牌)时生效。
82
+ */
83
+ export function assertNodeHealthy(
84
+ alias: string,
85
+ networkId: string | null | undefined,
86
+ opts: { force?: boolean; forceAllowed: boolean; now?: number },
87
+ ): HealthGuardResult {
88
+ if (!alias) return { ok: true };
89
+ const hit = readNodeHealth(networkId ?? "default", alias, opts.now);
90
+ if (!hit) return { ok: true };
91
+ const layers = degradedLayers(hit.health);
92
+ if (!layers.length) return { ok: true };
93
+ if (opts.force && opts.forceAllowed) return { ok: true, forced: true };
94
+ const labels = layers.map(l => l.label).join("、");
95
+ return {
96
+ ok: false,
97
+ error: "node_degraded",
98
+ alias,
99
+ layers,
100
+ message: `节点 ${alias} 处于降级状态(${labels}),任务不会被执行,已拒绝派发。`,
101
+ hint: layers[0].hint + (opts.forceAllowed ? "。确需强制派发可带 force=true。" : ""),
102
+ health_observed_ms_ago: hit.observed_ms_ago,
103
+ force_allowed: opts.forceAllowed,
104
+ };
105
+ }
@@ -0,0 +1,115 @@
1
+ // #448 — report_status.health passthrough (layered node health, report-only).
2
+ //
3
+ // Hub-side contract verified here:
4
+ // 1. A well-formed `health` from the node's own token is kept (memory-only) and readable.
5
+ // 2. Malformed `health` never rejects the report — the node stays online, health reads as null.
6
+ // 3. Another node / a user login cannot speak for this node's health.
7
+ // 4. Stale (> TTL) health reads as null, never as "healthy".
8
+ //
9
+ // 跑法:cd server && COMMHUB_DB=/tmp/node-health.db bun test src/node-health-passthrough.test.ts
10
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
11
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { db } from "./db.js";
15
+ import { registerTools } from "./tools.js";
16
+ import { clearNodeHealthStore, NODE_HEALTH_TTL_MS, readNodeHealth, recordNodeHealth } from "./node-health-store.js";
17
+
18
+ const NET = "net_health_t";
19
+ const OWNER = "u_health_owner";
20
+ const NODE_A = "health-node-a";
21
+ const NODE_B = "health-node-b";
22
+ const NODE_A_ID = "node_health_a";
23
+ const NODE_B_ID = "node_health_b";
24
+ const T_A = "tok_health_node_a";
25
+ const T_B = "tok_health_node_b";
26
+
27
+ function cleanup() {
28
+ for (const t of ["sessions", "nodes", "api_tokens", "network_members", "networks"]) {
29
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
30
+ }
31
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [OWNER]); } catch {}
32
+ clearNodeHealthStore();
33
+ }
34
+ function seed() {
35
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [OWNER, OWNER]);
36
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, OWNER]);
37
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [OWNER, NET]);
38
+ for (const [id, alias, tok] of [[NODE_A_ID, NODE_A, T_A], [NODE_B_ID, NODE_B, T_B]]) {
39
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`, [id, alias, alias, NET, OWNER]);
40
+ db.run(`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`, [tok, OWNER, NET, `node:${alias}`, `hash_${tok}`, id]);
41
+ }
42
+ }
43
+
44
+ async function connectAs(alias: string, tokenId: string) {
45
+ const server = new McpServer({ name: "health-test", version: "1" });
46
+ registerTools(server, undefined, NET, OWNER, alias, true, tokenId);
47
+ const client = new Client({ name: "health-client", version: "1" });
48
+ const [ct, st] = InMemoryTransport.createLinkedPair();
49
+ await server.connect(st);
50
+ await client.connect(ct);
51
+ const call = async (args: Record<string, unknown>) => {
52
+ const r: any = await client.callTool({ name: "report_status", arguments: args });
53
+ return { isError: r.isError === true, body: JSON.parse(r.content?.[0]?.text ?? "null") };
54
+ };
55
+ return { call, close: async () => { await client.close(); await server.close(); } };
56
+ }
57
+
58
+ const HEALTH = {
59
+ bridge: "ok",
60
+ app_server: { ok: false, rtt_ms: null, last_error: "connection refused" },
61
+ tui: { ok: false, reason: "sleep-placeholder" },
62
+ model_auth: "revoked",
63
+ };
64
+
65
+ beforeEach(() => { cleanup(); seed(); });
66
+ afterAll(cleanup);
67
+
68
+ describe("#448 report_status.health passthrough", () => {
69
+ test("node's own token: stored and readable as reported", async () => {
70
+ const a = await connectAs(NODE_A, T_A);
71
+ try {
72
+ const r = await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: HEALTH });
73
+ expect(r.isError).toBe(false);
74
+ expect(r.body.ok).not.toBe(false);
75
+ } finally { await a.close(); }
76
+ expect(readNodeHealth(NET, NODE_A)?.health).toEqual(HEALTH as any);
77
+ });
78
+
79
+ test("malformed health never rejects the report (node stays online); it reads as not reported", async () => {
80
+ const a = await connectAs(NODE_A, T_A);
81
+ try {
82
+ for (const bad of [42, "ok", { app_server: { ok: "yes" }, model_auth: "weird", tui: [] }, { app_server: { ok: true, rtt_ms: NaN, last_error: 5 } }]) {
83
+ const r = await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: bad });
84
+ expect(r.isError).toBe(false);
85
+ }
86
+ } finally { await a.close(); }
87
+ expect(db.get<{ s: string }>("SELECT status AS s FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_A, NET)?.s).toBe("idle");
88
+ // 最后一份里 app_server.ok 合法、其余两格降级成 null —— 留下的是能证实的部分,不是编出来的。
89
+ expect(readNodeHealth(NET, NODE_A)?.health.app_server).toEqual({ ok: true, rtt_ms: null, last_error: null });
90
+ });
91
+
92
+ test("another node's token cannot speak for this node", async () => {
93
+ const b = await connectAs(NODE_B, T_B);
94
+ try {
95
+ await b.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: HEALTH });
96
+ } finally { await b.close(); }
97
+ expect(readNodeHealth(NET, NODE_A)).toBeNull();
98
+ });
99
+
100
+ test("stale health reads as null, never as healthy", () => {
101
+ const t0 = 1_000_000;
102
+ recordNodeHealth(NET, NODE_A, { bridge: "ok", model_auth: "ok" }, t0);
103
+ expect(readNodeHealth(NET, NODE_A, t0 + 1_000)?.observed_ms_ago).toBe(1_000);
104
+ expect(readNodeHealth(NET, NODE_A, t0 + NODE_HEALTH_TTL_MS + 1)).toBeNull();
105
+ });
106
+
107
+ test("a report without health leaves the last one (heartbeats from older code paths do not erase it)", async () => {
108
+ const a = await connectAs(NODE_A, T_A);
109
+ try {
110
+ await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: HEALTH });
111
+ await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "working", node_id: NODE_A_ID });
112
+ } finally { await a.close(); }
113
+ expect(readNodeHealth(NET, NODE_A)?.health.model_auth).toBe("revoked");
114
+ });
115
+ });
@@ -0,0 +1,72 @@
1
+ // #448 —— agent-node 上报的分层健康(`report_status.health`)的 Hub 侧透传。
2
+ //
3
+ // 「在线」只说明桥进程还活着:现场 ~40 台 codex 共存节点里,有的在 Hub 上 idle / in_flight=0,
4
+ // 实际 app-server 端口没人监听、TUI pane 变成 `sleep infinity`、或登录态 "refresh token revoked"。
5
+ // 节点把这几层分开报上来;这里只负责**收下、按节点存最新一份、在 /api/status 和 status_update 里带出去**。
6
+ //
7
+ // 刻意只放内存,不加列:它是**几秒钟一变的观测值**,不是需要跨重启保留的状态 —— Hub 重启后下一次
8
+ // 心跳(≤3 分钟,翻转时立即)就会补上;持久化一份旧观测值反而会在节点死掉后继续说「app_server ok」。
9
+ // 同理,超过 TTL 的那份按「没报」处理(null),不按「健康」处理。
10
+
11
+ import { z } from "zod/v4";
12
+
13
+ export const NODE_HEALTH_TTL_MS = 10 * 60_000;
14
+
15
+ // 🔴 `.catch(undefined)`:这一格是纯诊断信息,**任何形状都不许拒掉整份 report_status**
16
+ // (zod 对象里一个已知字段验证失败 = 整份被拒 = 节点在 Hub 上失联,#1225 的形状)。
17
+ // 合法的透传,其余一律当没报。子对象不 strict:新节点多报的键丢掉即可。
18
+ export const nodeHealthSchema = z.object({
19
+ bridge: z.literal("ok").optional(),
20
+ app_server: z.object({
21
+ ok: z.boolean(),
22
+ rtt_ms: z.number().nullable().catch(null),
23
+ last_error: z.string().max(200).nullable().catch(null),
24
+ }).optional().catch(undefined),
25
+ tui: z.object({
26
+ ok: z.boolean(),
27
+ reason: z.string().max(40),
28
+ }).optional().catch(undefined),
29
+ model_auth: z.enum(["ok", "revoked", "expired", "unknown"]).optional().catch(undefined),
30
+ }).optional().catch(undefined);
31
+
32
+ export type NodeHealth = NonNullable<z.infer<typeof nodeHealthSchema>>;
33
+
34
+ const store = new Map<string, { health: NodeHealth; at: number }>();
35
+ const keyOf = (networkId: string | null | undefined, alias: string) => `${networkId ?? "default"}\0${alias}`;
36
+
37
+ // #431 —— /api/status 的记忆化(status-read-cache.ts)按这两样判断正文还对不对:任何一次上报 → 版本 +1;
38
+ // 任何一份报告过 TTL 的那一刻(degraded / health 字段会消失)→ 由 nodeHealthNextExpiryAt 给出的时刻失效。
39
+ let version = 0;
40
+ export function nodeHealthVersion(): number { return version; }
41
+ /** 最早一份还没过期的报告的过期时刻(ms);一份都没有 → Infinity。 */
42
+ export function nodeHealthNextExpiryAt(now = Date.now()): number {
43
+ let next = Infinity;
44
+ for (const v of store.values()) {
45
+ const at = v.at + NODE_HEALTH_TTL_MS;
46
+ if (at >= now && at < next) next = at;
47
+ }
48
+ return next;
49
+ }
50
+
51
+ export function recordNodeHealth(networkId: string | null | undefined, alias: string, health: NodeHealth, now = Date.now()): void {
52
+ version++;
53
+ store.set(keyOf(networkId, alias), { health, at: now });
54
+ // 有界:按 TTL 顺手清掉过期的,别让改过名/删掉的节点在内存里攒着。
55
+ if (store.size > 256) {
56
+ for (const [k, v] of store) if (now - v.at > NODE_HEALTH_TTL_MS) store.delete(k);
57
+ }
58
+ }
59
+
60
+ /** 最新一份;没有或已过 TTL → null(读的人必须把它当「不知道」,而不是「健康」)。 */
61
+ export function readNodeHealth(
62
+ networkId: string | null | undefined, alias: string, now = Date.now(),
63
+ ): { health: NodeHealth; observed_ms_ago: number } | null {
64
+ const hit = store.get(keyOf(networkId, alias));
65
+ if (!hit) return null;
66
+ const age = now - hit.at;
67
+ if (age > NODE_HEALTH_TTL_MS) { store.delete(keyOf(networkId, alias)); return null; }
68
+ return { health: hit.health, observed_ms_ago: Math.max(0, age) };
69
+ }
70
+
71
+ /** 测试用。 */
72
+ export function clearNodeHealthStore(): void { store.clear(); version++; }
@@ -0,0 +1,188 @@
1
+ // #1281 — 子节点生命周期工具的参数名统一(node_id ⇄ child_node_id)。
2
+ //
3
+ // 分叉现状(origin/main 2976adb2):stop_node / start_node / delete_node 用
4
+ // `child_node_id`,restart_node / update_node_config 用 `node_id`,指向同一个
5
+ // 子节点。调用方按一个工具的习惯给另一个传就吃 -32602。修复:5 个工具都同时
6
+ // 接受两个名字(canonical = node_id,child_node_id 兼容 alias),解析收敛到一个
7
+ // 模块级 helper resolveNodeIdArg。
8
+ //
9
+ // 两向 witnessed-red(本文件即判据):
10
+ // · schema 层:每个工具的**历史用名**始终 parse 成功(证明没破已发布契约),
11
+ // **新增别名**在修复前 parse 失败(缺必填字段)、修复后成功。
12
+ // · handler 层:resolveNodeIdArg 纯函数 —— 二选一至少一必填、都传须相等。
13
+ // · 真实 handler 路径:stop_node 用 node_id / child_node_id 都能穿到
14
+ // resolveDaemonForChild(不再被 node_id_required 挡),都传冲突 → node_id_conflict。
15
+ //
16
+ // 对 pre-fix 见红的证明方式(评审可复现):把 tools.ts 里任一工具的
17
+ // `...NODE_ID_ALIAS_FIELDS` 还原成原来的单名字段(如 stop_node 的
18
+ // `child_node_id: z.string()...regex(...)`),本文件对应工具的「新增别名 parse 成功」
19
+ // 断言立即变红 —— 证明该断言有判别力,不是恒真。
20
+
21
+ import { describe, expect, test, beforeAll } from "bun:test";
22
+ import { mkdtempSync } from "fs";
23
+ import { join } from "path";
24
+ import { tmpdir } from "os";
25
+
26
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || (mkdtempSync(join(tmpdir(), "anet-1281-")) + "/commhub.db");
27
+
28
+ import { z } from "zod/v4";
29
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
30
+ import { registerTools, resolveNodeIdArg } from "./tools.js";
31
+
32
+ type ToolHandler = (args: any) => Promise<{ content: { type: "text"; text: string }[] }>;
33
+
34
+ // Capture BOTH the raw zod shape and the handler for every tool, so the
35
+ // schema-layer assertions run against the exact object server.tool received.
36
+ function buildCaptured() {
37
+ const schemas: Record<string, any> = {};
38
+ const handlers: Record<string, ToolHandler> = {};
39
+ const server = new McpServer({ name: "t1281", version: "0" }) as any;
40
+ const origTool = server.tool.bind(server);
41
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
42
+ schemas[name] = schema; handlers[name] = handler;
43
+ return origTool(name, _desc, schema, handler);
44
+ };
45
+ const origRegisterTool = server.registerTool?.bind(server);
46
+ if (origRegisterTool) {
47
+ server.registerTool = (name: string, cfg: any, handler: ToolHandler) => {
48
+ schemas[name] = cfg?.inputSchema ?? cfg; handlers[name] = handler;
49
+ return origRegisterTool(name, cfg, handler);
50
+ };
51
+ }
52
+ registerTools(server, undefined, null, "user_1281", null, false, null);
53
+ return { schemas, handlers };
54
+ }
55
+
56
+ let SCHEMAS: Record<string, any> = {};
57
+ let HANDLERS: Record<string, ToolHandler> = {};
58
+ beforeAll(() => { const c = buildCaptured(); SCHEMAS = c.schemas; HANDLERS = c.handlers; });
59
+
60
+ async function call(name: string, args: any) {
61
+ const r = await HANDLERS[name](args);
62
+ return JSON.parse(r.content[0].text);
63
+ }
64
+
65
+ // Per tool: the historically-required id name, the newly-added alias, and
66
+ // the OTHER required fields the shape needs so a safeParse isolates the id
67
+ // field rather than tripping on an unrelated missing field.
68
+ const TOOLS = [
69
+ { name: "stop_node", historic: "child_node_id", added: "node_id", extra: {} },
70
+ { name: "start_node", historic: "child_node_id", added: "node_id", extra: {} },
71
+ { name: "delete_node", historic: "child_node_id", added: "node_id", extra: { confirm_alias: "some-alias" } },
72
+ { name: "restart_node", historic: "node_id", added: "child_node_id", extra: {} },
73
+ { name: "update_node_config", historic: "node_id", added: "child_node_id", extra: { base_revision: 0, patch: {} } },
74
+ ];
75
+
76
+ const VALID_ID = "node_test_1281";
77
+
78
+ describe("#1281 schema layer — every lifecycle tool accepts BOTH node_id and child_node_id", () => {
79
+ for (const t of TOOLS) {
80
+ test(`${t.name}: historic name '${t.historic}' still parses (published contract intact)`, () => {
81
+ const shape = z.object(SCHEMAS[t.name]);
82
+ const r = shape.safeParse({ [t.historic]: VALID_ID, ...t.extra });
83
+ expect(r.success).toBe(true);
84
+ });
85
+
86
+ test(`${t.name}: added alias '${t.added}' parses (was -32602 pre-#1281 → RED without the fix)`, () => {
87
+ const shape = z.object(SCHEMAS[t.name]);
88
+ const r = shape.safeParse({ [t.added]: VALID_ID, ...t.extra });
89
+ expect(r.success).toBe(true);
90
+ });
91
+
92
+ test(`${t.name}: both names present + equal parses (schema layer permissive; conflict caught in handler)`, () => {
93
+ const shape = z.object(SCHEMAS[t.name]);
94
+ const r = shape.safeParse({ node_id: VALID_ID, child_node_id: VALID_ID, ...t.extra });
95
+ expect(r.success).toBe(true);
96
+ });
97
+ }
98
+ });
99
+
100
+ describe("#1281 resolveNodeIdArg — the single resolution helper", () => {
101
+ test("node_id only → resolves to node_id", () => {
102
+ expect(resolveNodeIdArg({ node_id: "node_a" })).toEqual({ ok: true, node_id: "node_a" });
103
+ });
104
+ test("child_node_id only → resolves to it (back-compat)", () => {
105
+ expect(resolveNodeIdArg({ child_node_id: "node_b" })).toEqual({ ok: true, node_id: "node_b" });
106
+ });
107
+ test("both present + EQUAL → resolves (no conflict)", () => {
108
+ expect(resolveNodeIdArg({ node_id: "node_c", child_node_id: "node_c" })).toEqual({ ok: true, node_id: "node_c" });
109
+ });
110
+ test("both present + DIFFERENT → node_id_conflict (catches A/B fat-finger)", () => {
111
+ const r = resolveNodeIdArg({ node_id: "node_a", child_node_id: "node_b" });
112
+ expect(r.ok).toBe(false);
113
+ expect((r as any).error).toBe("node_id_conflict");
114
+ });
115
+ test("neither present → node_id_required", () => {
116
+ const r = resolveNodeIdArg({});
117
+ expect(r.ok).toBe(false);
118
+ expect((r as any).error).toBe("node_id_required");
119
+ });
120
+ });
121
+
122
+ describe("#1281 real handler path — stop_node routes both names to the same place", () => {
123
+ // stop_node calls resolveNodeIdArg FIRST, then resolveDaemonForChild. A
124
+ // fabricated id has no create-request row → daemon_not_resolvable. The
125
+ // point is that BOTH names get PAST the id gate to that same downstream
126
+ // error (proving the alias flows through), and the two error branches fire.
127
+ test("node_id (new name) flows through → daemon_not_resolvable, NOT node_id_required", async () => {
128
+ const r = await call("stop_node", { node_id: VALID_ID });
129
+ expect(r.ok).toBe(false);
130
+ expect(r.error).toBe("daemon_not_resolvable");
131
+ });
132
+ test("child_node_id (old name) flows through → daemon_not_resolvable (back-compat intact)", async () => {
133
+ const r = await call("stop_node", { child_node_id: VALID_ID });
134
+ expect(r.ok).toBe(false);
135
+ expect(r.error).toBe("daemon_not_resolvable");
136
+ });
137
+ test("neither → node_id_required (before any daemon resolution)", async () => {
138
+ const r = await call("stop_node", { force: true });
139
+ expect(r.ok).toBe(false);
140
+ expect(r.error).toBe("node_id_required");
141
+ });
142
+ test("both differ → node_id_conflict (before any daemon resolution)", async () => {
143
+ const r = await call("stop_node", { node_id: "node_a", child_node_id: "node_b" });
144
+ expect(r.ok).toBe(false);
145
+ expect(r.error).toBe("node_id_conflict");
146
+ });
147
+ });
148
+
149
+ describe("app#196 —— 「解析不到 daemon」的两种情况必须给不同的话", () => {
150
+ // 🔴 生产实测(2026-08-28):218 个节点里 207 个是 `n_…`(有人直接
151
+ // `anet node start` 起的),它们**根本没有 daemon**。原先这两种情况
152
+ // 打印同一句 `pass daemon_node_id explicitly` —— 对那 207 个来说,
153
+ // **这句建议的前提是假的**,照着做只会走进死路。
154
+ const HAND_STARTED = "n_73687f17"; // 手工起的节点,真实形状
155
+ const DAEMON_CHILD = "node_test_1281"; // daemon 建的子节点
156
+
157
+ test("手工起的节点 → not_daemon_managed,并且**不**建议传 daemon_node_id", async () => {
158
+ const r = await call("stop_node", { node_id: HAND_STARTED });
159
+ expect(r.ok).toBe(false);
160
+ expect(r.error).toBe("not_daemon_managed");
161
+ // 🔴 这一条是本组的核心:旧文案的那句建议**不能**再出现在这条路径上。
162
+ expect(r.message).not.toContain("pass daemon_node_id explicitly");
163
+ // 而且要告诉他真正能走的那条路。
164
+ expect(r.message).toContain("anet node stop");
165
+ });
166
+
167
+ test("daemon 建的子节点(无 create-request 行) → 仍是 daemon_not_resolvable", async () => {
168
+ const r = await call("stop_node", { node_id: DAEMON_CHILD });
169
+ expect(r.ok).toBe(false);
170
+ expect(r.error).toBe("daemon_not_resolvable");
171
+ expect(r.message).toContain("pass daemon_node_id explicitly");
172
+ });
173
+
174
+ test("delete_node 走同一条判别(两个调用点不能只改一个)", async () => {
175
+ const r = await call("delete_node", { node_id: HAND_STARTED, confirm_alias: "x" });
176
+ expect(r.ok).toBe(false);
177
+ expect(r.error).toBe("not_daemon_managed");
178
+ });
179
+
180
+ // 🔴 反向见证:两种情况必须**真的不同**。若有人把判别改成恒定一支,
181
+ // 上面三条里至少一条会红 —— 但那还不够明显,所以这里直接断言两者不等。
182
+ test("两种情况的 error 与 message 都不相同", async () => {
183
+ const a = await call("stop_node", { node_id: HAND_STARTED });
184
+ const b = await call("stop_node", { node_id: DAEMON_CHILD });
185
+ expect(a.error).not.toBe(b.error);
186
+ expect(a.message).not.toBe(b.message);
187
+ });
188
+ });