@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130

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 (317) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,163 @@
1
+ // 旧版 agent-node(< 2.5.0-preview.93)找自己别名的全量 /api/status 读法 → light 投影 + node_id。
2
+ //
3
+ // 背景(2026-10-01,app「连接较慢」#431):生产上约 2.3 次/秒的全量状态读(~93 KB gzip)几乎都来自旧节点的
4
+ // CurrentAliasResolver,它只读 sessions[].node_id / .alias,且 2.5 s 硬超时。经 RELAY frp 隧道到国内机器时常常
5
+ // 读不完就中止,Hub 已经推进隧道的字节被丢掉(约占隧道 60%)。这里钉住:
6
+ // - 签名只认「节点令牌 + Accept 恰好 application/json + 只有 network_id」(statusAliasResolverRead);
7
+ // - 命中时回 light + node_id,旧解析器照旧解析出别名(含改名之后);
8
+ // - 不命中的(用户令牌 / CLI 不带 Accept / 浏览器 */* / light / node_id / alias / full=1 / 多值 Accept)字节不变;
9
+ // - 量体积:同一批行,完整投影 vs 这条读法的 gzip 字节。
10
+
11
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
12
+ import { mkdtempSync, rmSync } from "fs";
13
+ import { join } from "path";
14
+ import { tmpdir } from "os";
15
+ import { gzipSync } from "zlib";
16
+ import { register, createNetworkTokenForNode } from "./auth.js";
17
+ import { db } from "./db.js";
18
+ import { statusAliasResolverRead } from "./status-alias-resolver.js";
19
+
20
+ const DIR = mkdtempSync(join(tmpdir(), "anet-status-alias-resolver-"));
21
+ let BASE = "";
22
+ let hub: any = null;
23
+ const PW = "StatusAliasResolverPassw0rd!";
24
+ let userToken = "", userId = "", NET = "";
25
+ let nodeToken = "";
26
+ const ROWS = 120;
27
+
28
+ function seed(i: number) {
29
+ const node = `node_sar_${i}`, alias = `sar-${i}`;
30
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)`, [node, alias, NET, userId]);
31
+ db.run(
32
+ `INSERT INTO sessions (resume_id, alias, node_id, status, network_id, task, output, project_dir, config_path, hostname, ip, server, agent, version,
33
+ cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb,
34
+ process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, updated_at, last_seen_at, registered_at)
35
+ VALUES (?1, ?2, ?3, 'idle', ?4, ?5, ?6, ?7, ?8, ?9, '10.0.0.' || ?10, ?9, 'agent-node:codex-app-server', '2.5.0-preview.88',
36
+ '1.5', '8', '61.4', '26.9', '34.5', '491.8', '442.2', '29.4', 121868288, 116.2, 0.2, 292866, 0,
37
+ datetime('now', '-' || ?10 || ' seconds'), datetime('now'), datetime('now', '-30 days'))`,
38
+ [`r_${node}`, alias, node, NET, `示例任务 ${i}:`.repeat(12), `示例输出 ${i} `.repeat(20), `/home/user/project-${i}`,
39
+ `/home/user/project-${i}/.anet/nodes/${alias}/config.json`, `sample-host-${i % 4}`, i],
40
+ );
41
+ }
42
+
43
+ async function req(path: string, headers: Record<string, string>) {
44
+ const res = await fetch(`${BASE}${path}`, { headers: { "Accept-Encoding": "gzip", ...headers } });
45
+ const text = await res.text();
46
+ return { status: res.status, text, body: JSON.parse(text) as any, projection: res.headers.get("x-status-projection") };
47
+ }
48
+ const asNode = (accept?: string) => ({ Authorization: `Bearer ${nodeToken}`, ...(accept !== undefined ? { Accept: accept } : {}) });
49
+ const gz = (s: string) => gzipSync(Buffer.from(s)).length;
50
+
51
+ // The exact call agent-node 2.5.0-preview.32 … .90 makes (dist/cli.js CurrentAliasResolver.fetchCanonicalAlias).
52
+ async function oldResolver(nodeId: string, token: string, networkId: string): Promise<string | null> {
53
+ try {
54
+ const headers: Record<string, string> = { Accept: "application/json" };
55
+ if (token) headers.Authorization = `Bearer ${token}`;
56
+ const url = `${BASE}/api/status${networkId ? `?network_id=${encodeURIComponent(networkId)}` : ""}`;
57
+ const ctl = new AbortController();
58
+ const timer = setTimeout(() => ctl.abort(), 2500);
59
+ try {
60
+ const res = await fetch(url, { headers, signal: ctl.signal });
61
+ if (!res.ok) return null;
62
+ const body = (await res.json()) as { sessions?: Array<{ node_id?: string; alias?: string }> };
63
+ return body.sessions?.find((s) => s.node_id === nodeId)?.alias ?? null;
64
+ } finally { clearTimeout(timer); }
65
+ } catch { return null; }
66
+ }
67
+
68
+ beforeAll(async () => {
69
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
70
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
71
+ process.env.HOST = "127.0.0.1";
72
+ const a = register(`sar_owner_${Date.now()}`, PW);
73
+ userToken = a.token!; userId = a.user!.user_id; NET = a.network_id!;
74
+ for (let i = 0; i < ROWS; i++) seed(i);
75
+ nodeToken = createNetworkTokenForNode(userId, NET, "sar-7", "node_sar_7").token!;
76
+ const mod: any = await import("./server.js");
77
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
78
+ BASE = `http://127.0.0.1:${hub.port}`;
79
+ }, 30_000);
80
+
81
+ afterAll(() => {
82
+ try { hub?.stop?.(true); } catch {}
83
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
84
+ });
85
+
86
+ describe("statusAliasResolverRead (signature)", () => {
87
+ const p = (q: string) => new URLSearchParams(q);
88
+ test("matches only node token + Accept exactly application/json + at most network_id", () => {
89
+ expect(statusAliasResolverRead(p(""), "ntok_x", "application/json")).toBe(true);
90
+ expect(statusAliasResolverRead(p("network_id=net_1"), "ntok_x", "application/json")).toBe(true);
91
+ expect(statusAliasResolverRead(p("network_id=net_1"), "ntok_x", " Application/JSON ")).toBe(true);
92
+ expect(statusAliasResolverRead(p("network_id=net_1"), "utok_x", "application/json")).toBe(false);
93
+ expect(statusAliasResolverRead(p("network_id=net_1"), "legacy-global-token", "application/json")).toBe(false);
94
+ expect(statusAliasResolverRead(p("network_id=net_1"), "ntok_x", null)).toBe(false);
95
+ expect(statusAliasResolverRead(p("network_id=net_1"), "ntok_x", "*/*")).toBe(false);
96
+ expect(statusAliasResolverRead(p("network_id=net_1"), "ntok_x", "application/json, text/plain, */*")).toBe(false);
97
+ for (const q of ["light=1", "node_id=n", "alias=a", "full=1", "network_id=n&full=1", "network_id=n&light=0"]) {
98
+ expect(statusAliasResolverRead(p(q), "ntok_x", "application/json")).toBe(false);
99
+ }
100
+ });
101
+ });
102
+
103
+ describe("GET /api/status for the old alias resolver", () => {
104
+ test("old resolver still resolves its alias from the light body", async () => {
105
+ expect(await oldResolver("node_sar_7", nodeToken, NET)).toBe("sar-7");
106
+ expect(await oldResolver("node_sar_7", nodeToken, "")).toBe("sar-7");
107
+ // other rows are resolvable too (the resolver can be asked about a renamed peer id) and an unknown id is null
108
+ expect(await oldResolver("node_sar_42", nodeToken, NET)).toBe("sar-42");
109
+ expect(await oldResolver("node_nobody", nodeToken, NET)).toBeNull();
110
+ });
111
+
112
+ test("…and follows a rename (the reason the resolver exists)", async () => {
113
+ db.run("UPDATE sessions SET alias = 'sar-7-renamed' WHERE node_id = 'node_sar_7'");
114
+ try {
115
+ expect(await oldResolver("node_sar_7", nodeToken, NET)).toBe("sar-7-renamed");
116
+ } finally {
117
+ db.run("UPDATE sessions SET alias = 'sar-7' WHERE node_id = 'node_sar_7'");
118
+ }
119
+ });
120
+
121
+ test("the body is the light projection plus node_id, marked by a header", async () => {
122
+ const r = await req(`/api/status?network_id=${NET}`, asNode("application/json"));
123
+ expect(r.status).toBe(200);
124
+ expect(r.projection).toBe("alias-resolver");
125
+ expect(r.body.sessions).toHaveLength(ROWS);
126
+ expect(Object.keys(r.body.sessions[0]).sort()).toEqual(["agent", "alias", "network_id", "node_id", "runtime", "server", "status", "task", "updated_at"]);
127
+ expect(r.body.summary.total).toBe(ROWS);
128
+ });
129
+
130
+ test("everything else is byte-identical to the full projection", async () => {
131
+ const full = await req(`/api/status?network_id=${NET}&full=1`, asNode("application/json"));
132
+ expect(full.projection).toBeNull();
133
+ expect(full.body.sessions[0].host).toBeDefined();
134
+ const same = [
135
+ await req(`/api/status?network_id=${NET}`, { Authorization: `Bearer ${userToken}`, Accept: "application/json" }), // user token (app / dashboard)
136
+ await req(`/api/status?network_id=${NET}`, asNode()), // anet CLI: authHeaders() only
137
+ await req(`/api/status?network_id=${NET}`, asNode("*/*")), // browser / undici / bun default
138
+ await req(`/api/status?network_id=${NET}`, asNode("application/json, text/plain, */*")),
139
+ ];
140
+ for (const r of same) {
141
+ expect(r.projection).toBeNull();
142
+ expect(r.body.sessions[0].host).toBeDefined();
143
+ expect(r.text).toBe(full.text);
144
+ }
145
+ });
146
+
147
+ test("light / node_id reads keep their own exact shape", async () => {
148
+ const light = await req(`/api/status?network_id=${NET}&light=1`, asNode("application/json"));
149
+ expect(light.projection).toBeNull();
150
+ expect(light.body.sessions[0].node_id).toBeUndefined();
151
+ const one = await req(`/api/status?network_id=${NET}&node_id=node_sar_7&light=1`, asNode("application/json"));
152
+ expect(one.body.sessions).toHaveLength(1);
153
+ expect(one.body.sessions[0].node_id).toBe("node_sar_7");
154
+ });
155
+
156
+ test("size: full projection vs the alias-resolver body (gzip)", async () => {
157
+ const full = await req(`/api/status?network_id=${NET}&full=1`, asNode("application/json"));
158
+ const slim = await req(`/api/status?network_id=${NET}`, asNode("application/json"));
159
+ const f = gz(full.text), s = gz(slim.text);
160
+ console.log(`[size] ${ROWS} rows: full ${full.text.length} B raw / ${f} B gzip → alias-resolver ${slim.text.length} B raw / ${s} B gzip (${Math.round(100 - (100 * s) / f)}% smaller)`);
161
+ expect(s).toBeLessThan(f / 2);
162
+ });
163
+ });
@@ -0,0 +1,19 @@
1
+ // GET /api/status 的「旧版 agent-node 找自己别名」读法识别(纯函数,可单测)。
2
+ //
3
+ // agent-node < 2.5.0-preview.93 的 CurrentAliasResolver 每 30 s 读一次全网完整 /api/status,只用
4
+ // sessions[].node_id / .alias,并且 2.5 s 硬超时。它的请求有一个别的客户端都不发的签名(2026-10-01 逐个核过
5
+ // 线上在用的 agent-node .32–.93 包、agent-network CLI 2.2.21 / 2.3.0-preview.76–.120 源码、dashboard、
6
+ // claude-code 节点的 .anet/node-server.js):
7
+ // - 节点令牌(ntok_);
8
+ // - Accept 恰好是 `application/json`(CLI 的 authHeaders 只带 Authorization;浏览器 / undici / bun 默认 `*/*`);
9
+ // - 查询串里除了 network_id 什么都没有(不带 light / node_id / alias / full)。
10
+ // 命中 ⇒ 回 light 投影 + node_id(它读的就这两列)。`?full=1` 永远拿完整投影。
11
+
12
+ const ALLOWED_PARAMS = new Set(["network_id"]);
13
+
14
+ export function statusAliasResolverRead(params: URLSearchParams, token: string, accept: string | null): boolean {
15
+ if (!token.startsWith("ntok_")) return false;
16
+ if ((accept ?? "").trim().toLowerCase() !== "application/json") return false;
17
+ for (const key of params.keys()) if (!ALLOWED_PARAMS.has(key)) return false;
18
+ return true;
19
+ }
@@ -0,0 +1,161 @@
1
+ // GET /api/status?node_id=<id> — one node's rows instead of the whole network.
2
+ // Every agent-node resolves its own current alias every 30 s (CurrentAliasResolver). Unfiltered that
3
+ // is the whole network's full projection (~91 KB gzip per call on production, ~90 nodes ⇒ ~3 req/s)
4
+ // to read one row. This suite pins that the filter narrows, never widens (it is exactly the caller's
5
+ // unfiltered rows with that node_id, for every kind of caller), that it combines with light=1 (which
6
+ // then carries node_id so the caller can check the row is its own), that an unknown id is an empty
7
+ // list rather than an error, that no/blank node_id is byte-identical to the old response, and that
8
+ // /health advertises it.
9
+
10
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "fs";
12
+ import { join } from "path";
13
+ import { tmpdir } from "os";
14
+ import { register, createNetworkTokenForNode } from "./auth.js";
15
+ import { db } from "./db.js";
16
+
17
+ const DIR = mkdtempSync(join(tmpdir(), "anet-status-node-id-"));
18
+ let BASE = "";
19
+ let hub: any = null;
20
+ const PW = "StatusNodeIdPassw0rd!";
21
+ let aToken = "", aId = "", NET_A = "";
22
+ let bToken = "", bId = "", NET_B = "";
23
+ let nodeToken = "";
24
+
25
+ async function raw(token: string | null, path: string) {
26
+ const res = await fetch(`${BASE}${path}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
27
+ return { status: res.status, text: await res.text() };
28
+ }
29
+ async function get(token: string, path: string) {
30
+ const r = await raw(token, path);
31
+ return { status: r.status, body: JSON.parse(r.text) as any };
32
+ }
33
+ function seed(alias: string, node: string, net: string, owner: string, minutesAgo: number) {
34
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)`, [node, alias, net, owner]);
35
+ db.run(
36
+ `INSERT INTO sessions (resume_id, alias, node_id, status, network_id, task, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'idle', ?4, ?5, datetime('now', ?6), datetime('now'))`,
37
+ [`r_${node}`, alias, node, net, `task of ${alias}`, `-${minutesAgo} minutes`],
38
+ );
39
+ }
40
+ const nodeIds = (rows: any[]) => rows.map(r => r.node_id);
41
+
42
+ beforeAll(async () => {
43
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
44
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
45
+ process.env.HOST = "127.0.0.1";
46
+ const a = register(`sn_owner_a_${Date.now()}`, PW);
47
+ aToken = a.token!; aId = a.user!.user_id; NET_A = a.network_id!;
48
+ const b = register(`sn_owner_b_${Date.now()}`, PW);
49
+ bToken = b.token!; bId = b.user!.user_id; NET_B = b.network_id!;
50
+ seed("sn-one", "node_sn_one", NET_A, aId, 1);
51
+ seed("sn-two", "node_sn_two", NET_A, aId, 2);
52
+ seed("sn-three", "node_sn_three", NET_A, aId, 3);
53
+ seed("sn-b-only", "node_sn_b_only", NET_B, bId, 0);
54
+ const minted = createNetworkTokenForNode(aId, NET_A, "sn-two", "node_sn_two");
55
+ nodeToken = minted.token!;
56
+ const mod: any = await import("./server.js");
57
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
58
+ BASE = `http://127.0.0.1:${hub.port}`;
59
+ }, 30_000);
60
+
61
+ afterAll(() => {
62
+ try { hub?.stop?.(true); } catch {}
63
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
64
+ });
65
+
66
+ describe("/api/status?node_id=", () => {
67
+ test("node_id narrows the full projection to that node", async () => {
68
+ const r = await get(aToken, `/api/status?network_id=${NET_A}&node_id=node_sn_two`);
69
+ expect(r.status).toBe(200);
70
+ expect(nodeIds(r.body.sessions)).toEqual(["node_sn_two"]);
71
+ expect(r.body.sessions[0].alias).toBe("sn-two");
72
+ expect(typeof r.body.sessions[0].rules_file_capable).toBe("boolean");
73
+ expect(r.body.sessions[0].host).toBeDefined();
74
+ expect(r.body.summary.total).toBe(1);
75
+ });
76
+
77
+ test("combines with light=1: light projection, one row, and it carries node_id", async () => {
78
+ const r = await get(aToken, `/api/status?light=1&network_id=${NET_A}&node_id=node_sn_three`);
79
+ expect(r.status).toBe(200);
80
+ expect(r.body.sessions).toHaveLength(1);
81
+ expect(r.body.sessions[0].alias).toBe("sn-three");
82
+ expect(r.body.sessions[0].node_id).toBe("node_sn_three");
83
+ expect(r.body.sessions[0].host).toBeUndefined();
84
+ expect(Object.keys(r.body.sessions[0]).sort()).toEqual(
85
+ ["agent", "alias", "network_id", "node_id", "runtime", "server", "status", "task", "updated_at"],
86
+ );
87
+ });
88
+
89
+ test("unknown id is an empty list with 200, not a 404", async () => {
90
+ for (const path of [`/api/status?network_id=${NET_A}&node_id=node_nobody`, `/api/status?light=1&node_id=node_nobody`]) {
91
+ const r = await get(aToken, path);
92
+ expect(r.status).toBe(200);
93
+ expect(r.body.ok).toBe(true);
94
+ expect(r.body.sessions).toEqual([]);
95
+ expect(r.body.summary.total).toBe(0);
96
+ }
97
+ });
98
+
99
+ test("absent or blank node_id: response bytes identical to the old read (full and light)", async () => {
100
+ for (const base of [`/api/status?network_id=${NET_A}`, `/api/status?light=1&network_id=${NET_A}`, `/api/status`]) {
101
+ const plain = await raw(aToken, base);
102
+ const blank = await raw(aToken, `${base}${base.includes("?") ? "&" : "?"}node_id=%20`);
103
+ expect(plain.status).toBe(200);
104
+ expect(blank.text).toBe(plain.text);
105
+ }
106
+ // the light rows without the param never gained a node_id field
107
+ const light = await get(aToken, `/api/status?light=1&network_id=${NET_A}`);
108
+ expect(light.body.sessions.length).toBe(3);
109
+ for (const s of light.body.sessions) expect("node_id" in s).toBe(false);
110
+ });
111
+
112
+ test("never widens: for every caller, filtered == that caller's unfiltered rows with that node_id", async () => {
113
+ const callers = [aToken, bToken, nodeToken];
114
+ const ids = ["node_sn_one", "node_sn_two", "node_sn_three", "node_sn_b_only", "node_nobody"];
115
+ for (const tok of callers) {
116
+ const full = await get(tok, `/api/status`);
117
+ expect(full.status).toBe(200);
118
+ for (const id of ids) {
119
+ const one = await get(tok, `/api/status?node_id=${id}`);
120
+ expect(one.status).toBe(full.status);
121
+ expect(one.body.sessions).toEqual(full.body.sessions.filter((s: any) => s.node_id === id));
122
+ }
123
+ }
124
+ });
125
+
126
+ test("a member of another network gets nothing for a node outside their network", async () => {
127
+ const r = await get(bToken, `/api/status?node_id=node_sn_one`);
128
+ expect(r.status).toBe(200);
129
+ expect(r.body.sessions).toEqual([]);
130
+ const own = await get(bToken, `/api/status?node_id=node_sn_b_only`);
131
+ expect(nodeIds(own.body.sessions)).toEqual(["node_sn_b_only"]);
132
+ });
133
+
134
+ test("a node token reads its own row (light) and nothing from another network", async () => {
135
+ const own = await get(nodeToken, `/api/status?light=1&node_id=node_sn_two`);
136
+ expect(own.status).toBe(200);
137
+ expect(own.body.sessions.map((s: any) => [s.node_id, s.alias])).toEqual([["node_sn_two", "sn-two"]]);
138
+ const foreign = await get(nodeToken, `/api/status?light=1&node_id=node_sn_b_only`);
139
+ expect(foreign.status).toBe(200);
140
+ expect(foreign.body.sessions).toEqual([]);
141
+ // explicit other network_id is refused exactly as without the filter
142
+ const cross = await raw(nodeToken, `/api/status?network_id=${NET_B}&node_id=node_sn_b_only`);
143
+ const crossNoFilter = await raw(nodeToken, `/api/status?network_id=${NET_B}`);
144
+ expect(cross.status).toBe(crossNoFilter.status);
145
+ if (cross.status !== 200) expect(cross.text).toBe(crossNoFilter.text);
146
+ else expect(JSON.parse(cross.text).sessions).toEqual([]);
147
+ });
148
+
149
+ test("unauthenticated read is refused exactly as without the filter", async () => {
150
+ const withFilter = await raw(null, `/api/status?node_id=node_sn_one`);
151
+ const without = await raw(null, `/api/status`);
152
+ expect(withFilter.status).toBe(without.status);
153
+ expect(withFilter.text).toBe(without.text);
154
+ });
155
+
156
+ test("/health advertises status_node_id", async () => {
157
+ const r = await raw(null, `/health`);
158
+ expect(r.status).toBe(200);
159
+ expect(JSON.parse(r.text).capabilities).toContain("status_node_id");
160
+ });
161
+ });
@@ -0,0 +1,229 @@
1
+ // #431 —— GET /api/status 记忆化 + 强 ETag(status-read-cache.ts)。钉住:
2
+ // - 正文逐字节不变:随机写序列(改 sessions、健康上报、健康过期、改名清理)之后,每种读法「可能命中缓存的那一份」
3
+ // 都等于「清空缓存当场重算的那一份」;而且确实命中过(不是全靠重算凑出来的绿);
4
+ // - JSON.stringify 与原来的 Response.json 对这些正文逐字节一致;Content-Type 不变;
5
+ // - 不同调用者(另一个网络的用户、节点令牌的别名解析读法)键不串;
6
+ // - 全量投影里有健康报告(health_observed_ms_ago 随时间走)就不缓存;
7
+ // - ETag / If-None-Match → 304;gzip 解开等于原文。
8
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+ import { gunzipSync } from "zlib";
13
+ import { register, createNetworkTokenForNode } from "./auth.js";
14
+ import { db } from "./db.js";
15
+ import { clearNodeHealthStore, recordNodeHealth, NODE_HEALTH_TTL_MS } from "./node-health-store.js";
16
+ import { __resetStatusCacheForTest, __setStatusCacheBypassForTest, noteStatement, statusCacheStats, statusWriteGeneration } from "./status-read-cache.js";
17
+
18
+ const DIR = mkdtempSync(join(tmpdir(), "anet-status-read-cache-"));
19
+ let BASE = "";
20
+ let hub: any = null;
21
+ const PW = "StatusReadCachePassw0rd!";
22
+ let userToken = "", userId = "", NET = "", nodeToken = "", otherToken = "", OTHER_NET = "";
23
+ const ROWS = 60;
24
+
25
+ // 时间戳在 JS 里算好再绑定(SQLite 的 datetime('now', …) 拼接在 PostgreSQL 上没有,这份测试两种库都跑)。
26
+ const ts = (offsetSec: number) => new Date(Date.now() + offsetSec * 1000).toISOString().replace("T", " ").slice(0, 19);
27
+ function seed(i: number) {
28
+ const node = `node_src_${i}`, alias = i % 2 ? `示例-${i}` : `src-${i}`;
29
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)`, [node, alias, NET, userId]);
30
+ db.run(
31
+ `INSERT INTO sessions (resume_id, alias, node_id, status, network_id, task, output, project_dir, hostname, server, agent, version,
32
+ cpu_load_1min, mem_total_gb, process_rss_bytes, external_schedules, updated_at, last_seen_at, registered_at)
33
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, ?10, '2.5.0-preview.88', '1.5', '61.4', 121868288, ?11,
34
+ ?12, ?13, '2026-09-01 00:00:00')`,
35
+ [`r_${node}`, alias, node, i % 3 ? "idle" : "offline", NET, `示例任务 ${i}:\n "引号" \\ 反斜杠 
 `.repeat(1 + (i % 5)),
36
+ i % 4 ? null : `示例输出 ${i}`, `/home/user/project-${i}`, `sample-host-${i % 4}`,
37
+ i % 2 ? "agent-node:codex-app-server" : "claude-code", i % 3 ? null : JSON.stringify([{ id: `s${i}`, every: 60 }]), ts(-i), ts(0)],
38
+ );
39
+ }
40
+
41
+ async function get(path: string, headers: Record<string, string>) {
42
+ const res = await fetch(`${BASE}${path}`, { headers });
43
+ const buf = new Uint8Array(await res.arrayBuffer());
44
+ return { status: res.status, buf, headers: res.headers };
45
+ }
46
+ const text = (b: Uint8Array) => new TextDecoder().decode(b);
47
+
48
+ type Variant = { name: string; path: () => string; headers: () => Record<string, string> };
49
+ const VARIANTS: Variant[] = [
50
+ { name: "user full", path: () => `/api/status?network_id=${NET}`, headers: () => ({ Authorization: `Bearer ${userToken}` }) },
51
+ { name: "user light", path: () => `/api/status?network_id=${NET}&light=1`, headers: () => ({ Authorization: `Bearer ${userToken}` }) },
52
+ { name: "user alias", path: () => `/api/status?network_id=${NET}&alias=${encodeURIComponent("示例-7")}`, headers: () => ({ Authorization: `Bearer ${userToken}` }) },
53
+ { name: "node light node_id", path: () => `/api/status?network_id=${NET}&light=1&node_id=node_src_9`, headers: () => ({ Authorization: `Bearer ${nodeToken}` }) },
54
+ { name: "old alias resolver", path: () => `/api/status?network_id=${NET}`, headers: () => ({ Authorization: `Bearer ${nodeToken}`, Accept: "application/json" }) },
55
+ { name: "node full=1", path: () => `/api/status?network_id=${NET}&full=1`, headers: () => ({ Authorization: `Bearer ${nodeToken}`, Accept: "application/json" }) },
56
+ { name: "other network user", path: () => `/api/status?network_id=${OTHER_NET}`, headers: () => ({ Authorization: `Bearer ${otherToken}` }) },
57
+ ];
58
+
59
+ beforeAll(async () => {
60
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
61
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
62
+ process.env.HOST = "127.0.0.1";
63
+ const a = register(`src_owner_${Date.now()}`, PW);
64
+ userToken = a.token!; userId = a.user!.user_id; NET = a.network_id!;
65
+ for (let i = 0; i < ROWS; i++) seed(i);
66
+ nodeToken = createNetworkTokenForNode(userId, NET, "src-8", "node_src_8").token!;
67
+ const b = register(`src_other_${Date.now()}`, PW);
68
+ otherToken = b.token!; OTHER_NET = b.network_id!;
69
+ const mod: any = await import("./server.js");
70
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
71
+ BASE = `http://127.0.0.1:${hub.port}`;
72
+ }, 30_000);
73
+
74
+ afterAll(() => {
75
+ try { hub?.stop?.(true); } catch {}
76
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
77
+ });
78
+
79
+ describe("write detection", () => {
80
+ test("writes that name sessions bump the generation; reads and other tables do not", () => {
81
+ const g = statusWriteGeneration();
82
+ noteStatement("SELECT * FROM sessions");
83
+ noteStatement("UPDATE tasks SET status = 'x'");
84
+ noteStatement("INSERT INTO inbox (id) VALUES (1)");
85
+ expect(statusWriteGeneration()).toBe(g);
86
+ noteStatement(" update sessions set status='idle'");
87
+ noteStatement("INSERT INTO sessions (resume_id) VALUES ('x') RETURNING resume_id");
88
+ noteStatement("DELETE FROM sessions WHERE alias = 'x'");
89
+ noteStatement("WITH t AS (SELECT 1) UPDATE sessions SET task = NULL");
90
+ expect(statusWriteGeneration()).toBe(g + 4);
91
+ });
92
+
93
+ test("the hook sits on the shared db adapter (every module's writes go through it)", () => {
94
+ const g = statusWriteGeneration();
95
+ db.run("UPDATE sessions SET progress = progress WHERE 1=0");
96
+ expect(statusWriteGeneration()).toBe(g + 1);
97
+ });
98
+
99
+ test("no foreign key / trigger can change sessions without naming it", () => {
100
+ if (db.dialect === "postgres") {
101
+ const fks = db.all<{ c: number }>(`SELECT COUNT(*) AS c FROM information_schema.referential_constraints rc
102
+ JOIN information_schema.table_constraints tc ON tc.constraint_name = rc.unique_constraint_name WHERE tc.table_name = 'sessions'`);
103
+ expect(Number(fks[0]?.c ?? 0)).toBe(0);
104
+ const trg = db.all<{ c: number }>(`SELECT COUNT(*) AS c FROM information_schema.triggers WHERE action_statement ILIKE '%sessions%' OR event_object_table = 'sessions'`);
105
+ expect(Number(trg[0]?.c ?? 0)).toBe(0);
106
+ return;
107
+ }
108
+ const fks = db.all<{ sql: string }>("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL").map(r => r.sql);
109
+ expect(fks.filter(s => /references\s+sessions\b/i.test(s))).toEqual([]);
110
+ expect(fks.filter(s => /create\s+trigger/i.test(s) && /\bsessions\b/i.test(s))).toEqual([]);
111
+ });
112
+ });
113
+
114
+ describe("GET /api/status bodies are byte-identical with the cache", () => {
115
+ test("random writes / health reports / expiry: cached == recomputed, and the cache is really used", async () => {
116
+ __resetStatusCacheForTest();
117
+ let rnd = 461;
118
+ const r = () => (rnd = (rnd * 1103515245 + 12345) % 2147483648) / 2147483648;
119
+ let compared = 0, hits = 0;
120
+ for (let step = 0; step < 60; step++) {
121
+ const op = Math.floor(r() * 6);
122
+ const i = Math.floor(r() * ROWS);
123
+ const alias = i % 2 ? `示例-${i}` : `src-${i}`;
124
+ if (op === 0) db.run(`UPDATE sessions SET status = ?1, updated_at = ?2 WHERE node_id = ?3`, [r() < 0.5 ? "idle" : "working", ts(step), `node_src_${i}`]);
125
+ if (op === 1) db.run(`UPDATE sessions SET task = ?1 WHERE node_id = ?2`, [`新任务 ${step}`, `node_src_${i}`]);
126
+ if (op === 2) recordNodeHealth(NET, alias, { bridge: "ok", app_server: { ok: r() < 0.5, rtt_ms: 3, last_error: null }, model_auth: "ok" });
127
+ if (op === 3) {
128
+ // A report that expires a few ms from now: the degraded field must disappear on time.
129
+ recordNodeHealth(NET, alias, { bridge: "ok", app_server: { ok: false, rtt_ms: null, last_error: "closed (code=1006)" }, model_auth: "ok" }, Date.now() - NODE_HEALTH_TTL_MS + 30);
130
+ await Bun.sleep(40);
131
+ }
132
+ if (op === 4) db.run(`UPDATE sessions SET alias = ?1 WHERE node_id = ?2`, [`${alias}-改`, `node_src_${i}`]);
133
+ // op 5: no write at all — the next reads must be cache hits for the cacheable variants.
134
+ for (const v of VARIANTS) {
135
+ const first = await get(v.path(), v.headers());
136
+ const before = statusCacheStats.hits;
137
+ const again = await get(v.path(), v.headers());
138
+ hits += statusCacheStats.hits - before;
139
+ // Fresh = recomputed right now, without dropping what the cache holds (stale entries must stay in play
140
+ // across steps — clearing here would hide a missed invalidation).
141
+ __setStatusCacheBypassForTest(true);
142
+ const fresh = await get(v.path(), v.headers()).finally(() => __setStatusCacheBypassForTest(false));
143
+ expect(first.status).toBe(200);
144
+ const norm = (b: Uint8Array) => text(b).replace(/"health_observed_ms_ago":\d+/g, '"health_observed_ms_ago":0');
145
+ expect(norm(first.buf)).toBe(norm(fresh.buf));
146
+ expect(norm(again.buf)).toBe(norm(fresh.buf));
147
+ if (!text(fresh.buf).includes('"health_observed_ms_ago":')) expect(text(again.buf)).toBe(text(fresh.buf));
148
+ compared++;
149
+ }
150
+ }
151
+ expect(compared).toBe(60 * VARIANTS.length);
152
+ // Cacheable variants (everything except a full body carrying a health report) read twice in a row must hit.
153
+ expect(hits).toBeGreaterThan(60 * 4);
154
+ }, 120_000);
155
+
156
+ test("JSON.stringify == Response.json for these bodies; headers keep Content-Type, add ETag", async () => {
157
+ for (const v of VARIANTS) {
158
+ const res = await get(v.path(), v.headers());
159
+ const body = text(res.buf);
160
+ expect(await Response.json(JSON.parse(body)).text()).toBe(body);
161
+ expect(res.headers.get("content-type")).toBe("application/json;charset=utf-8");
162
+ expect(res.headers.get("etag")).toMatch(/^"s-[0-9a-z]+-[0-9a-z]+"$/);
163
+ }
164
+ });
165
+
166
+ test("keys do not leak across callers", async () => {
167
+ const owner = JSON.parse(text((await get(`/api/status?network_id=${NET}`, { Authorization: `Bearer ${userToken}` })).buf));
168
+ const other = JSON.parse(text((await get(`/api/status?network_id=${OTHER_NET}`, { Authorization: `Bearer ${otherToken}` })).buf));
169
+ expect(owner.sessions.length).toBe(ROWS);
170
+ expect(other.sessions.length).toBe(0);
171
+ const resolver = await get(`/api/status?network_id=${NET}`, { Authorization: `Bearer ${nodeToken}`, Accept: "application/json" });
172
+ expect(resolver.headers.get("x-status-projection")).toBe("alias-resolver");
173
+ expect(Object.keys(JSON.parse(text(resolver.buf)).sessions[0])).not.toContain("host");
174
+ });
175
+
176
+ test("a health report expiring (no write at all) invalidates the cached body on time", async () => {
177
+ __resetStatusCacheForTest();
178
+ clearNodeHealthStore();
179
+ const v = VARIANTS[1]; // user light: carries `degraded` for a fresh down app_server
180
+ const [{ alias: target }] = db.all<{ alias: string }>("SELECT alias FROM sessions WHERE node_id = 'node_src_2'");
181
+ recordNodeHealth(NET, target, { bridge: "ok", app_server: { ok: false, rtt_ms: null, last_error: "closed (code=1006)" }, model_auth: "ok" }, Date.now() - NODE_HEALTH_TTL_MS + 1_500);
182
+ const during = text((await get(v.path(), v.headers())).buf);
183
+ expect(during).toContain('"degraded":[{"layer":"app_server"');
184
+ const hitsBefore = statusCacheStats.hits;
185
+ expect(text((await get(v.path(), v.headers())).buf)).toBe(during);
186
+ expect(statusCacheStats.hits).toBe(hitsBefore + 1); // it was cached
187
+ await Bun.sleep(1_600);
188
+ const after = text((await get(v.path(), v.headers())).buf);
189
+ expect(after).not.toContain('"degraded"');
190
+ __resetStatusCacheForTest();
191
+ expect(text((await get(v.path(), v.headers())).buf)).toBe(after);
192
+ });
193
+
194
+ test("full projection with a health report is not cached (its body moves with time)", async () => {
195
+ __resetStatusCacheForTest();
196
+ recordNodeHealth(NET, "src-0", { bridge: "ok", model_auth: "ok" });
197
+ const v = VARIANTS[0];
198
+ const a = text((await get(v.path(), v.headers())).buf);
199
+ await Bun.sleep(15);
200
+ const b = text((await get(v.path(), v.headers())).buf);
201
+ expect(statusCacheStats.uncacheable).toBeGreaterThanOrEqual(2);
202
+ expect(a).not.toBe(b); // health_observed_ms_ago moved — a cached copy would have frozen it
203
+ });
204
+ });
205
+
206
+ describe("ETag / 304 / gzip", () => {
207
+ test("If-None-Match with the current ETag → 304 empty; after a write → 200 with a new ETag", async () => {
208
+ const h = { Authorization: `Bearer ${nodeToken}`, Accept: "application/json" };
209
+ const first = await get(`/api/status?network_id=${NET}`, h);
210
+ const etag = first.headers.get("etag")!;
211
+ const not = await get(`/api/status?network_id=${NET}`, { ...h, "If-None-Match": etag });
212
+ expect(not.status).toBe(304);
213
+ expect(not.buf.byteLength).toBe(0);
214
+ expect(not.headers.get("x-status-projection")).toBe("alias-resolver");
215
+ db.run(`UPDATE sessions SET status = 'working', updated_at = ?1 WHERE node_id = 'node_src_3'`, [ts(120)]);
216
+ const changed = await get(`/api/status?network_id=${NET}`, { ...h, "If-None-Match": etag });
217
+ expect(changed.status).toBe(200);
218
+ expect(changed.headers.get("etag")).not.toBe(etag);
219
+ });
220
+
221
+ test("gzip (the old node's Accept-Encoding: gzip, deflate) decodes to the same bytes", async () => {
222
+ const h = { Authorization: `Bearer ${nodeToken}`, Accept: "application/json" };
223
+ const plain = await get(`/api/status?network_id=${NET}`, h);
224
+ const res = await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: { ...h, "Accept-Encoding": "gzip, deflate" }, decompress: false } as any);
225
+ expect(res.headers.get("content-encoding")).toBe("gzip");
226
+ const raw = new Uint8Array(await res.arrayBuffer());
227
+ expect(text(gunzipSync(raw))).toBe(text(plain.buf));
228
+ });
229
+ });