@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,211 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ __resetRouteTimingForTest, callerTokenKind, labelRoute, mcpRouteLabel, recordRouteTiming, routeKey, routeStats,
4
+ setRouteCallerMasterToken, setRouteCallerTokenReader, userAgentFamily, ROUTE_MAX_CALLERS_PER_ROUTE,
5
+ ROUTE_BUCKET_MS, ROUTE_BUCKET_RETENTION, ROUTE_MAX_KEYS_PER_BUCKET, ROUTE_OVERFLOW_KEY,
6
+ } from "./route-timing";
7
+
8
+ const u = (s: string) => new URL(`http://hub${s}`);
9
+ const req = (path: string, method = "GET") => new Request(`http://hub${path}`, { method });
10
+ const res = (status = 200, len?: number) => new Response("x", { status, headers: len === undefined ? {} : { "Content-Length": String(len) } });
11
+ const at = (ms: number) => performance.now() - ms;
12
+
13
+ test("routeKey collapses ids, aliases and file names, keeps payload-shaping params", () => {
14
+ expect(routeKey("GET", u("/api/requirements?network_id=net_1&limit=500"))).toBe("GET /api/requirements");
15
+ expect(routeKey("GET", u("/api/requirements?view=summary&network_id=n"))).toBe("GET /api/requirements?view=summary");
16
+ expect(routeKey("PATCH", u("/api/requirements/req_3f9a/checklist/c1"))).toBe("PATCH /api/requirements/:id/checklist/:id");
17
+ expect(routeKey("GET", u("/api/requirements/stats"))).toBe("GET /api/requirements/stats");
18
+ expect(routeKey("GET", u("/api/status?light=1"))).toBe("GET /api/status?light=1");
19
+ expect(routeKey("GET", u(`/api/nodes/${encodeURIComponent("通信牛")}/config`))).toBe("GET /api/nodes/:id/config");
20
+ expect(routeKey("GET", u("/api/messages?scope=user&limit=50"))).toBe("GET /api/messages?scope=user");
21
+ });
22
+
23
+ test("routeStats groups the window, sorts by total time, and counts every request (no sample cap)", () => {
24
+ __resetRouteTimingForTest();
25
+ const now = 1_000_000_000;
26
+ recordRouteTiming(req("/api/requirements"), at(30), res(200, 1000), now);
27
+ recordRouteTiming(req("/api/requirements"), at(10), res(200, 500), now);
28
+ recordRouteTiming(req("/api/status?light=1"), at(5), res(503), now);
29
+ recordRouteTiming(req("/api/status?light=1"), at(100), res(), now - 10 * 60_000); // 窗口外
30
+ expect(recordRouteTiming(req("/ws"), 0, undefined, now)).toBeUndefined();
31
+ const stats = routeStats(60_000, now);
32
+ expect(stats.samples).toBe(3);
33
+ expect(stats.routes.map(r => r.route)).toEqual(["GET /api/requirements", "GET /api/status?light=1"]);
34
+ const r0 = stats.routes[0];
35
+ expect(r0.count).toBe(2);
36
+ expect(r0.bytes).toBe(1500);
37
+ expect(r0.max_ms).toBeGreaterThanOrEqual(29);
38
+ expect(r0.total_ms).toBeGreaterThanOrEqual(39);
39
+ expect(r0.p95_ms).toBeLessThanOrEqual(r0.max_ms);
40
+ expect(stats.routes[1].errors).toBe(1);
41
+ // The old 4096-sample ring dropped everything past its size; the buckets count all of it.
42
+ for (let i = 0; i < 10_000; i++) recordRouteTiming(req("/api/tasks"), at(1), res(), now);
43
+ const after = routeStats(60_000, now);
44
+ expect(after.routes.find(r => r.route === "GET /api/tasks")!.count).toBe(10_000);
45
+ expect(after.samples).toBe(10_003);
46
+ });
47
+
48
+ test("?minutes=10 really covers 10 minutes: a steady 15 req/s stream is fully counted, older traffic is not", () => {
49
+ __resetRouteTimingForTest();
50
+ const t0 = 2_000_000_000 - (2_000_000_000 % ROUTE_BUCKET_MS); // bucket-aligned
51
+ // 20 minutes at 15 req/s — the old ring filled in ~4.5 minutes of this.
52
+ for (let s = 0; s < 20 * 60; s++) {
53
+ for (let k = 0; k < 15; k++) recordRouteTiming(req("/mcp", "POST"), at(1), res(), t0 + s * 1000);
54
+ }
55
+ const now = t0 + 20 * 60_000;
56
+ const ten = routeStats(10 * 60_000, now);
57
+ // window starts at t0+10min → the ten minute buckets t0+10 … t0+19
58
+ expect(ten.samples).toBe(10 * 60 * 15);
59
+ expect(ten.oldest_at).toBe(new Date(t0 + 10 * 60_000).toISOString());
60
+ expect(routeStats(20 * 60_000, now).samples).toBe(20 * 60 * 15);
61
+ expect(routeStats(60 * 60_000, now).samples).toBe(20 * 60 * 15);
62
+ });
63
+
64
+ test("buckets older than the retention are dropped", () => {
65
+ __resetRouteTimingForTest();
66
+ const now = 3_000_000_000;
67
+ const old = now - (ROUTE_BUCKET_RETENTION + 5) * ROUTE_BUCKET_MS;
68
+ recordRouteTiming(req("/api/old"), at(1), res(), old);
69
+ expect(routeStats(ROUTE_BUCKET_RETENTION * 2 * ROUTE_BUCKET_MS, old).samples).toBe(1);
70
+ recordRouteTiming(req("/api/new"), at(1), res(), now);
71
+ const s = routeStats(ROUTE_BUCKET_RETENTION * 2 * ROUTE_BUCKET_MS, now);
72
+ expect(s.routes.map(r => r.route)).toEqual(["GET /api/new"]);
73
+ });
74
+
75
+ test("p95 comes from the histogram: close to the true value and never above max", () => {
76
+ __resetRouteTimingForTest();
77
+ const now = 4_000_000_000;
78
+ // 96 fast (~1 ms) and 4 slow (~200 ms): p95 (the 96th of 100, same rank as the old ring's
79
+ // sorted[floor(n·0.95)]) must be in the fast band, max in the slow band.
80
+ for (let i = 0; i < 96; i++) recordRouteTiming(req("/api/p"), at(1), res(), now);
81
+ for (let i = 0; i < 4; i++) recordRouteTiming(req("/api/p"), at(200), res(), now);
82
+ const r = routeStats(60_000, now).routes[0];
83
+ expect(r.p95_ms).toBeGreaterThanOrEqual(1);
84
+ expect(r.p95_ms).toBeLessThan(5);
85
+ expect(r.max_ms).toBeGreaterThanOrEqual(200);
86
+ __resetRouteTimingForTest();
87
+ recordRouteTiming(req("/api/q"), at(3), res(), now);
88
+ const one = routeStats(60_000, now).routes[0];
89
+ expect(one.p95_ms).toBe(one.max_ms);
90
+ });
91
+
92
+ test("distinct routes per bucket are capped; the rest land in the overflow key", () => {
93
+ __resetRouteTimingForTest();
94
+ const now = 5_000_000_000;
95
+ for (let i = 0; i < ROUTE_MAX_KEYS_PER_BUCKET + 50; i++) {
96
+ const r = req("/mcp", "POST");
97
+ labelRoute(r, `tools/call t${String.fromCharCode(97 + (i % 26))}${Math.floor(i / 26)}x`);
98
+ recordRouteTiming(r, at(1), res(), now);
99
+ }
100
+ const s = routeStats(60_000, now);
101
+ expect(s.routes.length).toBe(ROUTE_MAX_KEYS_PER_BUCKET + 1);
102
+ expect(s.routes.find(r => r.route === ROUTE_OVERFLOW_KEY)!.count).toBe(50);
103
+ expect(s.samples).toBe(ROUTE_MAX_KEYS_PER_BUCKET + 50);
104
+ });
105
+
106
+ test("a label is appended to the route shape of that request only", () => {
107
+ __resetRouteTimingForTest();
108
+ const now = 6_000_000_000;
109
+ const a = req("/mcp", "POST");
110
+ labelRoute(a, "tools/call report_status");
111
+ recordRouteTiming(a, at(1), res(), now);
112
+ recordRouteTiming(req("/mcp", "POST"), at(1), res(), now);
113
+ expect(routeStats(60_000, now).routes.map(r => r.route).sort()).toEqual(["POST /mcp", "POST /mcp tools/call report_status"]);
114
+ });
115
+
116
+ test("mcpRouteLabel: method, tool name, and nothing else", () => {
117
+ expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "report_status", arguments: { task: "secret" } } })).toBe("tools/call report_status");
118
+ expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "tools/list" })).toBe("tools/list");
119
+ expect(mcpRouteLabel({ jsonrpc: "2.0", method: "notifications/initialized" })).toBe("notifications/initialized");
120
+ expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "initialize", params: { name: "x" } })).toBe("initialize");
121
+ // odd shapes collapse to "?"
122
+ expect(mcpRouteLabel([{ method: "tools/list" }])).toBe("batch");
123
+ expect(mcpRouteLabel(null)).toBe("?");
124
+ expect(mcpRouteLabel("tools/list")).toBe("?");
125
+ expect(mcpRouteLabel(42)).toBe("?");
126
+ expect(mcpRouteLabel({})).toBe("?");
127
+ expect(mcpRouteLabel({ method: 7 })).toBe("?");
128
+ expect(mcpRouteLabel({ method: "x".repeat(65) })).toBe("?");
129
+ expect(mcpRouteLabel({ method: "tools/call x" })).toBe("?");
130
+ expect(mcpRouteLabel({ method: "通信" })).toBe("?");
131
+ expect(mcpRouteLabel({ method: "tools/call" })).toBe("tools/call ?");
132
+ expect(mcpRouteLabel({ method: "tools/call", params: "report_status" })).toBe("tools/call ?");
133
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: { toString: () => "x" } } })).toBe("tools/call ?");
134
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "a b" } })).toBe("tools/call ?");
135
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "../../etc/passwd" } })).toBe("tools/call ?");
136
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "n".repeat(65) } })).toBe("tools/call ?");
137
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "" } })).toBe("tools/call ?");
138
+ });
139
+
140
+ // ── caller classes (?by=caller) ──
141
+ const reqAs = (path: string, headers: Record<string, string>) => new Request(`http://hub${path}`, { headers });
142
+
143
+ test("userAgentFamily keeps only allow-listed product names and a sanitized version", () => {
144
+ expect(userAgentFamily("agent-node/2.5.0-preview.88")).toBe("agent-node/2.5.0-preview.88");
145
+ expect(userAgentFamily("node")).toBe("node");
146
+ expect(userAgentFamily("Bun/1.2.19")).toBe("bun/1.2.19");
147
+ expect(userAgentFamily("okhttp/4.12.0")).toBe("okhttp/4.12.0");
148
+ expect(userAgentFamily("curl/8.5.0")).toBe("curl/8.5.0");
149
+ expect(userAgentFamily("agent-network-desktop/0.2.170")).toBe("agent-network-desktop/0.2.170");
150
+ expect(userAgentFamily("tauri-plugin-http/2.5.9")).toBe("tauri-plugin-http/2.5.9");
151
+ expect(userAgentFamily("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15")).toBe("browser");
152
+ expect(userAgentFamily("SomeApp/42 CFNetwork/1568.100.1 Darwin/24.0.0")).toBe("cfnetwork/1568.100.1");
153
+ expect(userAgentFamily(null)).toBe("none");
154
+ expect(userAgentFamily(" ")).toBe("none");
155
+ // Free text never comes through: unknown product names, and anything after a version.
156
+ expect(userAgentFamily("my-laptop-hostname/1.0")).toBe("other");
157
+ expect(userAgentFamily("curl/8.5.0;user=alice@example.com")).toBe("curl/8.5.0");
158
+ expect(userAgentFamily("node/abc-secret")).toBe("node");
159
+ });
160
+
161
+ test("callerTokenKind recognises token kinds without keeping the token", () => {
162
+ setRouteCallerMasterToken("master-secret-xyz");
163
+ // Header-only reader, as installed by default; another test file in the same process may have booted server.ts.
164
+ const previous = setRouteCallerTokenReader(r => r.headers.get("authorization")?.replace("Bearer ", "") || "");
165
+ try {
166
+ const kind = (headers: Record<string, string>) => callerTokenKind(reqAs("/api/status", headers));
167
+ expect(kind({ Authorization: "Bearer ntok_abc" })).toBe("node");
168
+ expect(kind({ Authorization: "Bearer utok_abc" })).toBe("user");
169
+ expect(kind({ Authorization: "Bearer master-secret-xyz" })).toBe("master");
170
+ expect(kind({ Authorization: "Bearer something-else" })).toBe("token");
171
+ expect(kind({})).toBe("anon");
172
+ // ?token= is read only through the reader server.ts installs (requestToken); a header-only reader ignores it.
173
+ expect(callerTokenKind(reqAs("/events/x?token=ntok_q", {}))).toBe("anon");
174
+ setRouteCallerTokenReader(r => new URL(r.url).searchParams.get("t") || "");
175
+ expect(callerTokenKind(reqAs("/events/x?t=ntok_q", {}))).toBe("node");
176
+ } finally {
177
+ setRouteCallerMasterToken(undefined);
178
+ setRouteCallerTokenReader(previous);
179
+ }
180
+ });
181
+
182
+ test("routeStats({byCaller}) counts per class; without it the rows have no callers field", () => {
183
+ __resetRouteTimingForTest();
184
+ const now = 3_000_000_000;
185
+ const status = (headers: Record<string, string>, len: number) => recordRouteTiming(reqAs("/api/status", headers), at(1), res(200, len), now);
186
+ for (let i = 0; i < 3; i++) status({ Authorization: "Bearer utok_secret1", "User-Agent": "node" }, 100);
187
+ status({ Authorization: "Bearer ntok_secret2", "User-Agent": "agent-node/2.5.0-preview.88" }, 10);
188
+ status({ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/130.0" }, 1);
189
+ const plain = routeStats(60_000, now);
190
+ expect(plain.routes[0]).not.toHaveProperty("callers");
191
+ const by = routeStats(60_000, now, { byCaller: true });
192
+ expect(by.routes[0].callers).toEqual([
193
+ { class: "user node", count: 3, bytes: 300 },
194
+ { class: "node agent-node/2.5.0-preview.88", count: 1, bytes: 10 },
195
+ { class: "anon browser", count: 1, bytes: 1 },
196
+ ]);
197
+ expect(JSON.stringify(by)).not.toMatch(/secret|Mozilla|Linux|Chrome/);
198
+ });
199
+
200
+ test("caller classes are capped per route: per bucket and again after merging buckets", () => {
201
+ __resetRouteTimingForTest();
202
+ const now = 4_000_000_000 - (4_000_000_000 % ROUTE_BUCKET_MS);
203
+ // Minute 1: 30 distinct versions → 20 stored, 10 into (other). Minute 2: 25 more distinct ones.
204
+ for (let i = 0; i < 30; i++) recordRouteTiming(reqAs("/api/status", { "User-Agent": `curl/${i}.0` }), at(1), res(200, 1), now - ROUTE_BUCKET_MS);
205
+ for (let i = 100; i < 125; i++) recordRouteTiming(reqAs("/api/status", { "User-Agent": `curl/${i}.0` }), at(1), res(200, 1), now);
206
+ const callers = routeStats(5 * 60_000, now, { byCaller: true }).routes[0].callers!;
207
+ expect(callers.length).toBe(ROUTE_MAX_CALLERS_PER_ROUTE);
208
+ expect(callers.at(-1)!.class).toBe(ROUTE_OVERFLOW_KEY);
209
+ expect(callers.reduce((n, c) => n + c.count, 0)).toBe(55);
210
+ expect(callers.reduce((n, c) => n + c.bytes, 0)).toBe(55);
211
+ });
@@ -0,0 +1,258 @@
1
+ // 每个路由的耗时统计(2026-09-30):生产 hub 平均 ~24% CPU,但没有任何按路由的计时,也没有访问日志,
2
+ // 说不清是谁在吃 CPU。这里在 fetch 入口按路由形状累计次数、耗时、状态、字节,
3
+ // GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query 值、不记请求体;调用者只记粗粒度类别(见下文 callerClass)。
4
+ //
5
+ // 2026-10-01 —— 原来是 4096 条样本的环:生产上 212 s 就写满,?minutes=10 实际只覆盖三分半钟。
6
+ // 改成按分钟分桶的累计器:每个桶里每个路由一行(次数 / 总耗时 / 最大值 / 字节 / 5xx / 耗时直方图),
7
+ // 保留 ROUTE_BUCKET_RETENTION 个桶(24 小时),窗口多长就真覆盖多长。p95 从直方图取(桶上沿,不超过 max)。
8
+ export const ROUTE_BUCKET_MS = 60_000;
9
+ export const ROUTE_BUCKET_RETENTION = 1440;
10
+ /** 每个分钟桶里最多这么多个不同的路由;再多的记到 ROUTE_OVERFLOW_KEY,防止怪路径 / 怪工具名把内存撑大。 */
11
+ export const ROUTE_MAX_KEYS_PER_BUCKET = 256;
12
+ export const ROUTE_OVERFLOW_KEY = "(other)";
13
+
14
+ // 耗时直方图:第 i 格的上沿 = HIST_BASE_MS · 2^(i/2),0.05 ms … ~52 s;最后一格收下更慢的。
15
+ const HIST_BASE_MS = 0.05;
16
+ const HIST_BINS = 41;
17
+ function histBin(ms: number): number {
18
+ if (!(ms > HIST_BASE_MS)) return 0;
19
+ return Math.min(HIST_BINS - 1, Math.ceil(2 * Math.log2(ms / HIST_BASE_MS)));
20
+ }
21
+ const histUpper = (bin: number) => HIST_BASE_MS * 2 ** (bin / 2);
22
+
23
+ type Agg = { count: number; totalMs: number; maxMs: number; bytes: number; errors: number; hist: Uint32Array; callers?: Map<string, CallerAgg> };
24
+ type CallerAgg = { count: number; bytes: number };
25
+ type Bucket = { minute: number; routes: Map<string, Agg> };
26
+ // 按 minute 升序;新的一分钟追加在尾部,超出保留期从头部丢。
27
+ const buckets: Bucket[] = [];
28
+
29
+ /** 路由的形状:id / 别名 / 文件名这类段换成 :id,几个会改变载荷形状的参数保留(light / view / scope / changes)。 */
30
+ const SHAPE_PARAMS = ["changes", "light", "scope", "view"];
31
+ export function routeKey(method: string, url: URL): string {
32
+ const segments = url.pathname.split("/").map((seg, i) => {
33
+ if (i < 2 || !seg) return seg;
34
+ return /[0-9%]/.test(seg) || /[^\x20-\x7e]/.test(seg) || seg.length > 24 ? ":id" : seg;
35
+ });
36
+ const shape = SHAPE_PARAMS.filter(k => url.searchParams.has(k)).map(k => `${k}=${url.searchParams.get(k)}`).join("&");
37
+ return `${method} ${segments.join("/")}${shape ? `?${shape}` : ""}`;
38
+ }
39
+
40
+ // 处理器给某个请求补一段标签(比如 POST /mcp 的 JSON-RPC 方法名),recordRouteTiming 拼到路由形状后面。
41
+ const labels = new WeakMap<Request, string>();
42
+ export function labelRoute(req: Request, label: string): void {
43
+ labels.set(req, label);
44
+ }
45
+
46
+ const MCP_METHOD_RE = /^[A-Za-z][A-Za-z0-9_./-]{0,63}$/;
47
+ const MCP_TOOL_RE = /^[A-Za-z0-9_.-]{1,64}$/;
48
+ /**
49
+ * POST /mcp 的标签:JSON-RPC 方法名,tools/call 再带工具名,如 "tools/call report_status"。
50
+ * 只读 method 和 params.name 两个字段,别的(尤其 arguments)一概不碰;形状不对的一律折成 "?",
51
+ * 批量请求记 "batch"。名字只放行短的 ASCII 标识符,防止拿请求体造出任意多、任意长的路由键。
52
+ */
53
+ export function mcpRouteLabel(body: unknown): string {
54
+ if (Array.isArray(body)) return "batch";
55
+ if (!body || typeof body !== "object") return "?";
56
+ const method = (body as { method?: unknown }).method;
57
+ if (typeof method !== "string" || !MCP_METHOD_RE.test(method)) return "?";
58
+ if (method !== "tools/call") return method;
59
+ const params = (body as { params?: unknown }).params;
60
+ const name = params && typeof params === "object" ? (params as { name?: unknown }).name : undefined;
61
+ return `tools/call ${typeof name === "string" && MCP_TOOL_RE.test(name) ? name : "?"}`;
62
+ }
63
+
64
+ // ── 调用方分类(2026-10-01)────────────────────────────────────────────────────────────
65
+ // 生产上全量 GET /api/status 每秒 ~2.8 次,但大多数连接经 frp 隧道进来,来源全是 127.0.0.1,也没有访问日志,
66
+ // 看不出是谁在拉。每个请求再记一个粗粒度的「调用方类别」= 令牌种类 + User-Agent 家族,
67
+ // GET /api/stats/routes?by=caller 按路由给出各类别的次数 / 字节。
68
+ // 🔴 只记类别:不记令牌、用户 / 节点 id、IP、完整 UA。UA 只认白名单里的产品名(其余一律 "other"),
69
+ // 版本号只放行数字 + 一段预发布标记,浏览器一律折成 "browser"。
70
+ /** 每个分钟桶里每个路由最多这么多个调用方类别;再多的记到 ROUTE_OVERFLOW_KEY。汇总输出也只给前这么多个。 */
71
+ export const ROUTE_MAX_CALLERS_PER_ROUTE = 20;
72
+
73
+ let masterToken = "";
74
+ /** 启动时把 COMMHUB_AUTH_TOKEN 交进来,用于把它认成 "master";只做相等比较,不输出。 */
75
+ export function setRouteCallerMasterToken(token: string | undefined): void {
76
+ masterToken = token || "";
77
+ }
78
+
79
+ // 取令牌走 server.ts 的 requestToken()(启动时交进来),与鉴权同一个优先级:先 Authorization,再 ?token=。
80
+ // 这里不自己读 ?token=(query-token-ratchet 只许减少直接读的地方)。没交进来时只看 Authorization 头。
81
+ let readToken: (req: Request) => string = req => req.headers.get("authorization")?.replace("Bearer ", "") || "";
82
+ /** 返回原来的读取函数(测试用来还原)。 */
83
+ export function setRouteCallerTokenReader(reader: (req: Request) => string): (req: Request) => string {
84
+ const previous = readToken;
85
+ readToken = reader;
86
+ return previous;
87
+ }
88
+
89
+ /** 令牌种类:ntok_ → node,utok_ → user,等于 master 令牌 → master,别的非空令牌 → token,没有 → anon。 */
90
+ export function callerTokenKind(req: Request): string {
91
+ const token = readToken(req);
92
+ if (!token) return "anon";
93
+ if (token.startsWith("ntok_")) return "node";
94
+ if (token.startsWith("utok_")) return "user";
95
+ if (masterToken && token === masterToken) return "master";
96
+ return "token";
97
+ }
98
+
99
+ // UA 的第一个产品名(小写)→ 输出用的名字。只有这里列出的名字会原样出现在统计里。
100
+ const UA_PRODUCTS = new Map([
101
+ "agent-node", "agent-network", "anet", "agent-network-desktop", "agent-network-dashboard", "commhub-server", "tauri-plugin-http",
102
+ "claude-code", "node", "undici", "node-fetch", "axios", "got", "bun", "deno", "okhttp", "cfnetwork", "dalvik",
103
+ "curl", "wget", "python-requests", "python-httpx", "python-urllib", "aiohttp", "go-http-client", "reqwest",
104
+ "postmanruntime", "httpie", "powershell",
105
+ ].map(n => [n, n]));
106
+ const UA_HEAD_RE = /^([A-Za-z][A-Za-z0-9._-]{0,40})(?:\/(\S{1,64}))?/;
107
+ const UA_VERSION_RE = /^\d{1,6}(?:\.\d{1,6}){0,2}(?:-[A-Za-z]{1,16}(?:\.\d{1,6})?)?/;
108
+
109
+ /**
110
+ * User-Agent 家族:"agent-node/2.5.0-preview.88"、"node"、"Bun/1.2.3" → "bun/1.2.3"、"okhttp/4.12.0"、
111
+ * "curl/8.5.0";Mozilla/… → "browser";没有 UA → "none";不在白名单 → "other"。
112
+ * iOS 的 RN fetch 形如 "<App>/<build> CFNetwork/… Darwin/…",第一个产品名是 app 自己的,认 CFNetwork 记成 "cfnetwork"。
113
+ */
114
+ export function userAgentFamily(ua: string | null): string {
115
+ const s = (ua || "").trim();
116
+ if (!s) return "none";
117
+ if (s.startsWith("Mozilla/")) return "browser";
118
+ const head = UA_HEAD_RE.exec(s);
119
+ let name = head ? UA_PRODUCTS.get(head[1].toLowerCase()) : undefined;
120
+ let rawVersion = head?.[2];
121
+ if (!name) {
122
+ if (!/\sCFNetwork\//.test(s)) return "other";
123
+ name = "cfnetwork";
124
+ rawVersion = /\sCFNetwork\/(\S{1,64})/.exec(s)?.[1];
125
+ }
126
+ const version = rawVersion ? UA_VERSION_RE.exec(rawVersion)?.[0] : undefined;
127
+ return version ? `${name}/${version}` : name;
128
+ }
129
+
130
+ /** 一个请求的调用方类别,如 "node agent-node/2.5.0-preview.88"、"user browser"、"anon none"。 */
131
+ export function callerClass(req: Request): string {
132
+ return `${callerTokenKind(req)} ${userAgentFamily(req.headers.get("user-agent"))}`;
133
+ }
134
+
135
+ /** 记一次请求;原样返回 res,方便套在 fetch 处理器的 return 上。WebSocket 升级(res 为空)不记。 */
136
+ export function recordRouteTiming<T extends Response | undefined>(req: Request, started: number, res: T, now: number = Date.now()): T {
137
+ if (!res) return res;
138
+ let url: URL;
139
+ try { url = new URL(req.url); } catch { return res; }
140
+ const label = labels.get(req);
141
+ const route = label ? `${routeKey(req.method, url)} ${label}` : routeKey(req.method, url);
142
+ const length = Number(res.headers.get("content-length"));
143
+ const bytes = Number.isFinite(length) && res.headers.has("content-length") ? length : 0;
144
+ addSample(route, performance.now() - started, res.status, bytes, now, callerClass(req));
145
+ return res;
146
+ }
147
+
148
+ function addSample(route: string, ms: number, status: number, bytes: number, now: number, caller: string): void {
149
+ const minute = Math.floor(now / ROUTE_BUCKET_MS);
150
+ let bucket = buckets[buckets.length - 1];
151
+ if (!bucket || minute > bucket.minute) {
152
+ buckets.push(bucket = { minute, routes: new Map() });
153
+ const oldest = minute - ROUTE_BUCKET_RETENTION + 1;
154
+ let drop = 0;
155
+ while (drop < buckets.length && buckets[drop].minute < oldest) drop++;
156
+ if (drop) buckets.splice(0, drop);
157
+ } else if (minute < bucket.minute) {
158
+ // 时间戳比最新的桶早(时钟回拨,或测试里乱序记):放回它自己的那一分钟;比保留期还早的丢掉。
159
+ if (minute < bucket.minute - ROUTE_BUCKET_RETENTION + 1) return;
160
+ let i = buckets.length - 1;
161
+ while (i > 0 && buckets[i - 1].minute >= minute) i--;
162
+ if (buckets[i].minute !== minute) buckets.splice(i, 0, { minute, routes: new Map() });
163
+ bucket = buckets[i];
164
+ }
165
+ let agg = bucket.routes.get(route);
166
+ if (!agg) {
167
+ if (bucket.routes.size >= ROUTE_MAX_KEYS_PER_BUCKET) route = ROUTE_OVERFLOW_KEY;
168
+ agg = bucket.routes.get(route);
169
+ if (!agg) bucket.routes.set(route, agg = { count: 0, totalMs: 0, maxMs: 0, bytes: 0, errors: 0, hist: new Uint32Array(HIST_BINS) });
170
+ }
171
+ agg.count++;
172
+ agg.totalMs += ms;
173
+ if (ms > agg.maxMs) agg.maxMs = ms;
174
+ agg.bytes += bytes;
175
+ if (status >= 500) agg.errors++;
176
+ agg.hist[histBin(ms)]++;
177
+ const callers = agg.callers ??= new Map();
178
+ let c = callers.get(caller);
179
+ if (!c) {
180
+ if (callers.size >= ROUTE_MAX_CALLERS_PER_ROUTE) caller = ROUTE_OVERFLOW_KEY;
181
+ c = callers.get(caller);
182
+ if (!c) callers.set(caller, c = { count: 0, bytes: 0 });
183
+ }
184
+ c.count++;
185
+ c.bytes += bytes;
186
+ }
187
+
188
+ export type RouteStat = { route: string; count: number; total_ms: number; avg_ms: number; p95_ms: number; max_ms: number; bytes: number; errors: number; callers?: CallerStat[] };
189
+ export type CallerStat = { class: string; count: number; bytes: number };
190
+
191
+ /**
192
+ * 最近 windowMs 内的请求按路由汇总,按总耗时降序。粒度是分钟桶:窗口起点落在哪个桶,就从那个桶算起。
193
+ * oldest_at 是窗口里最早一个有数据的桶的起点 —— 它比 now - window_ms 晚很多,说明 hub 是那之后才起来的。
194
+ */
195
+ export function routeStats(windowMs: number, now: number = Date.now(), opts: { byCaller?: boolean } = {}): { window_ms: number; samples: number; oldest_at: string | null; routes: RouteStat[] } {
196
+ const fromMinute = Math.floor((now - windowMs) / ROUTE_BUCKET_MS);
197
+ const merged = new Map<string, Agg>();
198
+ let oldest: number | null = null;
199
+ for (const bucket of buckets) {
200
+ if (bucket.minute < fromMinute) continue;
201
+ if (oldest === null) oldest = bucket.minute * ROUTE_BUCKET_MS;
202
+ for (const [route, a] of bucket.routes) {
203
+ let m = merged.get(route);
204
+ if (!m) merged.set(route, m = { count: 0, totalMs: 0, maxMs: 0, bytes: 0, errors: 0, hist: new Uint32Array(HIST_BINS) });
205
+ m.count += a.count;
206
+ m.totalMs += a.totalMs;
207
+ if (a.maxMs > m.maxMs) m.maxMs = a.maxMs;
208
+ m.bytes += a.bytes;
209
+ m.errors += a.errors;
210
+ for (let i = 0; i < HIST_BINS; i++) m.hist[i] += a.hist[i];
211
+ if (opts.byCaller && a.callers) {
212
+ const mc = m.callers ??= new Map();
213
+ for (const [cls, c] of a.callers) {
214
+ const t = mc.get(cls);
215
+ if (t) { t.count += c.count; t.bytes += c.bytes; } else mc.set(cls, { count: c.count, bytes: c.bytes });
216
+ }
217
+ }
218
+ }
219
+ }
220
+ const round = (n: number) => Math.round(n * 10) / 10;
221
+ const routes = [...merged].map(([route, a]): RouteStat => {
222
+ const rank = Math.min(a.count, Math.floor(a.count * 0.95) + 1);
223
+ let seen = 0, bin = 0;
224
+ for (; bin < HIST_BINS - 1; bin++) { seen += a.hist[bin]; if (seen >= rank) break; }
225
+ return {
226
+ route,
227
+ count: a.count,
228
+ total_ms: round(a.totalMs),
229
+ avg_ms: round(a.totalMs / a.count),
230
+ p95_ms: round(Math.min(histUpper(bin), a.maxMs)),
231
+ max_ms: round(a.maxMs),
232
+ bytes: a.bytes,
233
+ errors: a.errors,
234
+ ...(opts.byCaller ? { callers: callerStats(a.callers) } : {}),
235
+ };
236
+ }).sort((a, b) => b.total_ms - a.total_ms);
237
+ return { window_ms: windowMs, samples: routes.reduce((n, r) => n + r.count, 0), oldest_at: oldest === null ? null : new Date(oldest).toISOString(), routes };
238
+ }
239
+
240
+ /** 按次数降序;各分钟桶的类别合起来可能超过上限,超出的并进 ROUTE_OVERFLOW_KEY。 */
241
+ function callerStats(callers: Map<string, CallerAgg> | undefined): CallerStat[] {
242
+ let other: CallerStat | undefined;
243
+ const named: CallerStat[] = [];
244
+ for (const [cls, c] of callers ?? []) {
245
+ if (cls === ROUTE_OVERFLOW_KEY) other = { class: cls, count: c.count, bytes: c.bytes };
246
+ else named.push({ class: cls, count: c.count, bytes: c.bytes });
247
+ }
248
+ named.sort((a, b) => b.count - a.count || b.bytes - a.bytes || (a.class < b.class ? -1 : 1));
249
+ if (!other && named.length <= ROUTE_MAX_CALLERS_PER_ROUTE) return named;
250
+ other ??= { class: ROUTE_OVERFLOW_KEY, count: 0, bytes: 0 };
251
+ for (const r of named.splice(ROUTE_MAX_CALLERS_PER_ROUTE - 1)) { other.count += r.count; other.bytes += r.bytes; }
252
+ return [...named, other];
253
+ }
254
+
255
+ /** Test-only. */
256
+ export function __resetRouteTimingForTest(): void {
257
+ buckets.length = 0;
258
+ }