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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +8 -7
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +487 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/agent-notice.ts +48 -0
  15. package/src/alias-filter.test.ts +72 -0
  16. package/src/alias-filter.ts +42 -0
  17. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  18. package/src/api-host-supervisors-fallback.test.ts +271 -0
  19. package/src/api-nodes-shape.test.ts +188 -0
  20. package/src/auth-sessions-http.test.ts +250 -0
  21. package/src/auth-validate.test.ts +5 -1
  22. package/src/auth.ts +493 -35
  23. package/src/avatar-validate.test.ts +163 -0
  24. package/src/avatar-validate.ts +103 -0
  25. package/src/blocked-exit-on-reply.test.ts +122 -0
  26. package/src/boot-side-effect.test.ts +124 -0
  27. package/src/commhub-bin-args.test.ts +85 -0
  28. package/src/config-apply-sec1.test.ts +270 -1
  29. package/src/config-apply-validate.test.ts +92 -0
  30. package/src/config-apply-validate.ts +81 -3
  31. package/src/create-node-blocked-gate.test.ts +356 -0
  32. package/src/create-node-tool-schema.test.ts +39 -0
  33. package/src/create-node-validate.test.ts +275 -0
  34. package/src/create-node-validate.ts +288 -0
  35. package/src/create-node-workdir.test.ts +255 -0
  36. package/src/create-node.test.ts +354 -0
  37. package/src/create-node.ts +349 -0
  38. package/src/dashboard-slash-routing-http.test.ts +107 -0
  39. package/src/db-adapter-guard.test.ts +148 -0
  40. package/src/db-adapter-translate.test.ts +128 -0
  41. package/src/db-adapter.ts +323 -87
  42. package/src/db-timestamp.test.ts +38 -0
  43. package/src/db-timestamp.ts +15 -0
  44. package/src/db.ts +965 -34
  45. package/src/delivered-without-push.test.ts +140 -0
  46. package/src/department-groups-http.test.ts +209 -0
  47. package/src/department-groups-sync-http.test.ts +328 -0
  48. package/src/department-groups.ts +434 -0
  49. package/src/department-heads-http.test.ts +329 -0
  50. package/src/department-heads.ts +226 -0
  51. package/src/departments-http.test.ts +185 -0
  52. package/src/departments.ts +260 -0
  53. package/src/desktop-message-seam.test.ts +150 -0
  54. package/src/desktop-message.test.ts +368 -0
  55. package/src/dm-file-scope-http.test.ts +178 -0
  56. package/src/ext-token-shared.test.ts +60 -0
  57. package/src/ext-validation-regression.test.ts +131 -0
  58. package/src/external-schedule-edits-http.test.ts +321 -0
  59. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  60. package/src/external-schedule-edits.ts +327 -0
  61. package/src/file-download-authz-dev-open.test.ts +135 -0
  62. package/src/file-download-authz.test.ts +404 -0
  63. package/src/file-download-date-bucket.test.ts +436 -0
  64. package/src/file-network-scope-dev-open.test.ts +97 -0
  65. package/src/file-network-scope.test.ts +732 -0
  66. package/src/group-membership-events-http.test.ts +264 -0
  67. package/src/group-messages-http.test.ts +482 -0
  68. package/src/group-messages.ts +297 -0
  69. package/src/health-redaction.test.ts +642 -0
  70. package/src/http-gzip.test.ts +94 -0
  71. package/src/http-gzip.ts +112 -0
  72. package/src/hub-timestamp.test.ts +38 -0
  73. package/src/hub-timestamp.ts +27 -0
  74. package/src/human-dm.ts +215 -0
  75. package/src/inbox-count-sse.test.ts +310 -0
  76. package/src/inbox-count.ts +29 -0
  77. package/src/index.ts +15 -2172
  78. package/src/lifecycle-guard.ts +50 -0
  79. package/src/list-host-supervisors.test.ts +314 -0
  80. package/src/mcp-context-cost-http.test.ts +119 -0
  81. package/src/mcp-projects-events-http.test.ts +162 -0
  82. package/src/mcp-route-label-http.test.ts +120 -0
  83. package/src/mcp-write-network-resolution.test.ts +429 -0
  84. package/src/member-audit-network-id-http.test.ts +93 -0
  85. package/src/member-presence-http.test.ts +206 -0
  86. package/src/messages-ack-by-agent-http.test.ts +142 -0
  87. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  88. package/src/migrate-sqlite-to-pg.ts +263 -0
  89. package/src/model-auth-notify-http.test.ts +181 -0
  90. package/src/model-auth-notify.ts +116 -0
  91. package/src/network-name-http.test.ts +75 -0
  92. package/src/network-scope.ts +224 -0
  93. package/src/network-token-membership-http.test.ts +139 -0
  94. package/src/node-attrs-validate.ts +88 -0
  95. package/src/node-attrs.test.ts +217 -0
  96. package/src/node-create-request-status-http.test.ts +92 -0
  97. package/src/node-files-transport.test.ts +344 -0
  98. package/src/node-health-dispatch-http.test.ts +279 -0
  99. package/src/node-health-guard.ts +105 -0
  100. package/src/node-health-passthrough.test.ts +115 -0
  101. package/src/node-health-store.ts +72 -0
  102. package/src/node-id-alias.test.ts +188 -0
  103. package/src/node-identity-conflict-http.test.ts +269 -0
  104. package/src/node-identity-conflict.ts +142 -0
  105. package/src/node-identity-mismatch.test.ts +74 -0
  106. package/src/node-lifecycle-controllable.test.ts +64 -0
  107. package/src/node-lifecycle-controllable.ts +51 -0
  108. package/src/node-logs-transport.test.ts +263 -0
  109. package/src/node-permissions-http.test.ts +276 -0
  110. package/src/node-permissions.ts +242 -0
  111. package/src/node-request-retention.test.ts +281 -0
  112. package/src/node-request-retention.ts +120 -0
  113. package/src/node-skills-transport.test.ts +209 -0
  114. package/src/node-token-privilege-http.test.ts +149 -0
  115. package/src/observer-avatar-http.test.ts +431 -0
  116. package/src/observer-push.test.ts +252 -0
  117. package/src/package-version-consistency.test.ts +25 -0
  118. package/src/peer-reply-atomic.test.ts +455 -0
  119. package/src/peer-reply-capability.test.ts +70 -0
  120. package/src/pg-worker.ts +87 -0
  121. package/src/probe-validate.test.ts +221 -0
  122. package/src/probe-validate.ts +104 -0
  123. package/src/probe.test.ts +80 -0
  124. package/src/probe.ts +200 -0
  125. package/src/project-viewer-can-http.test.ts +114 -0
  126. package/src/push.ts +429 -10
  127. package/src/redact-tokens.test.ts +65 -0
  128. package/src/redact-tokens.ts +40 -0
  129. package/src/rename.ts +7 -2
  130. package/src/reply-target-derivation.test.ts +96 -0
  131. package/src/report-status-host-schema.test.ts +211 -0
  132. package/src/report-status-resume-id-handover.test.ts +150 -0
  133. package/src/require-explicit-test-db.ts +9 -0
  134. package/src/requirement-due-reminders-http.test.ts +390 -0
  135. package/src/requirement-due-reminders.ts +310 -0
  136. package/src/requirement-events-http.test.ts +257 -0
  137. package/src/requirement-events.test.ts +45 -0
  138. package/src/requirement-events.ts +144 -0
  139. package/src/requirement-notify.ts +165 -0
  140. package/src/requirement-participant-notify-http.test.ts +309 -0
  141. package/src/requirement-tag-ops-http.test.ts +266 -0
  142. package/src/requirement-tags.ts +89 -0
  143. package/src/requirements-comment-http.test.ts +165 -0
  144. package/src/requirements-completed-at-migration.test.ts +70 -0
  145. package/src/requirements-errors-mcp-http.test.ts +164 -0
  146. package/src/requirements-errors.ts +143 -0
  147. package/src/requirements-etag-http.test.ts +91 -0
  148. package/src/requirements-http.test.ts +709 -0
  149. package/src/requirements-last-event-http.test.ts +344 -0
  150. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  151. package/src/requirements-list-slim-http.test.ts +319 -0
  152. package/src/requirements-mcp.test.ts +163 -0
  153. package/src/requirements-migrate.ts +225 -0
  154. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  155. package/src/requirements-people-http.test.ts +114 -0
  156. package/src/requirements-people-mcp-http.test.ts +225 -0
  157. package/src/requirements-people.ts +127 -0
  158. package/src/requirements-priority-lowest.test.ts +114 -0
  159. package/src/requirements-search-http.test.ts +182 -0
  160. package/src/requirements-search.test.ts +32 -0
  161. package/src/requirements-search.ts +145 -0
  162. package/src/requirements-seq-http.test.ts +193 -0
  163. package/src/requirements-seq-migration.test.ts +63 -0
  164. package/src/requirements-start-date.test.ts +125 -0
  165. package/src/requirements-stats-http.test.ts +288 -0
  166. package/src/requirements-stats.ts +164 -0
  167. package/src/requirements-status-participants-http.test.ts +220 -0
  168. package/src/requirements-upgrade-e2e.test.ts +126 -0
  169. package/src/requirements-upgrade-migration.test.ts +98 -0
  170. package/src/requirements.ts +1761 -0
  171. package/src/resolve-port.test.ts +57 -0
  172. package/src/resolve-port.ts +44 -0
  173. package/src/response-charset.test.ts +130 -0
  174. package/src/rest-explicit-columns-http.test.ts +222 -0
  175. package/src/rest-identity.test.ts +129 -0
  176. package/src/rest-identity.ts +100 -0
  177. package/src/rest-projections.ts +68 -0
  178. package/src/rest-write-network-resolution.test.ts +133 -0
  179. package/src/restricted-files.ts +73 -0
  180. package/src/retention.ts +15 -3
  181. package/src/route-callers-http.test.ts +120 -0
  182. package/src/route-timing.test.ts +211 -0
  183. package/src/route-timing.ts +258 -0
  184. package/src/rules-file-duplicate-alias.test.ts +234 -0
  185. package/src/rules-file-session-target.test.ts +207 -0
  186. package/src/rules-file-transport.test.ts +237 -0
  187. package/src/runtime-label.test.ts +22 -0
  188. package/src/runtime-label.ts +21 -0
  189. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  190. package/src/scheduled-reply-unread-http.test.ts +212 -0
  191. package/src/scheduled-run-terminal.test.ts +268 -0
  192. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  193. package/src/scheduled-stuck-http.test.ts +238 -0
  194. package/src/scheduled-stuck.ts +153 -0
  195. package/src/scheduled-tasks-http.test.ts +638 -0
  196. package/src/scheduled-tasks.ts +641 -0
  197. package/src/send-dedup-request-id.test.ts +229 -0
  198. package/src/send-reply-agent-warning.test.ts +202 -0
  199. package/src/send-reply-alias-optional.test.ts +131 -0
  200. package/src/send-reply-attachments.test.ts +458 -0
  201. package/src/send_dedup.test.ts +54 -0
  202. package/src/send_dedup.ts +52 -6
  203. package/src/serve-error.test.ts +38 -0
  204. package/src/serve-error.ts +27 -0
  205. package/src/server.ts +5038 -0
  206. package/src/shared/external-schedule-contract.ts +60 -0
  207. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  208. package/src/shared/probe-host-allowlist.ts +128 -0
  209. package/src/shared/reserved-env-drift.test.ts +43 -0
  210. package/src/shared/reserved-env.test.ts +35 -0
  211. package/src/shared/reserved-env.ts +31 -0
  212. package/src/side-thread-command-transport.test.ts +312 -0
  213. package/src/side-thread-command-transport.ts +318 -0
  214. package/src/side-thread-http-integration.test.ts +233 -0
  215. package/src/side-thread-http.ts +504 -0
  216. package/src/side-thread-production.test.ts +72 -0
  217. package/src/side-thread-production.ts +118 -0
  218. package/src/side-thread.test.ts +1067 -0
  219. package/src/side-thread.ts +2499 -0
  220. package/src/skillhub-http.test.ts +108 -0
  221. package/src/sqlite-close-checkpoint.test.ts +78 -0
  222. package/src/start-node.test.ts +109 -0
  223. package/src/status-alias-filter-http.test.ts +106 -0
  224. package/src/status-alias-resolver-http.test.ts +163 -0
  225. package/src/status-alias-resolver.ts +19 -0
  226. package/src/status-node-id-filter-http.test.ts +161 -0
  227. package/src/status-read-cache-http.test.ts +229 -0
  228. package/src/status-read-cache.ts +122 -0
  229. package/src/stop-delete-node.test.ts +1164 -0
  230. package/src/task-access-http.test.ts +379 -0
  231. package/src/task-access.ts +413 -0
  232. package/src/task-auth-origin-http.test.ts +91 -0
  233. package/src/task-auth-origin.test.ts +24 -0
  234. package/src/task-auth-origin.ts +7 -0
  235. package/src/task-consumption.test.ts +482 -0
  236. package/src/task-diagnostic-http.test.ts +149 -0
  237. package/src/task-diagnostic.test.ts +115 -0
  238. package/src/task-diagnostic.ts +118 -0
  239. package/src/task-expiry-notice-http.test.ts +287 -0
  240. package/src/task-expiry-notice.ts +313 -0
  241. package/src/task-idempotency-mcp.test.ts +142 -0
  242. package/src/task-idempotency.test.ts +60 -0
  243. package/src/task-idempotency.ts +75 -0
  244. package/src/task-lifecycle-watcher.test.ts +204 -0
  245. package/src/task-lifecycle-watcher.ts +49 -0
  246. package/src/task-network-resolution-http.test.ts +141 -0
  247. package/src/task-queue-ahead-http.test.ts +309 -0
  248. package/src/task-queue-ahead-scale.test.ts +211 -0
  249. package/src/task-queue-ahead.ts +186 -0
  250. package/src/task-stale-open-http.test.ts +290 -0
  251. package/src/task-stale-open.ts +103 -0
  252. package/src/tasks-pagination-http.test.ts +141 -0
  253. package/src/tool-audience-http.test.ts +195 -0
  254. package/src/tool-audience.ts +109 -0
  255. package/src/tools-list-json-schema.test.ts +67 -0
  256. package/src/tools.ts +4955 -300
  257. package/src/update-provider.test.ts +234 -0
  258. package/src/upload-early-reject-guard.test.ts +155 -0
  259. package/src/uploads-http.test.ts +97 -15
  260. package/src/uploads.test.ts +66 -0
  261. package/src/uploads.ts +138 -5
  262. package/src/user-inbox-by-agent-http.test.ts +161 -0
  263. package/src/user-inbox-netid-notnull.test.ts +122 -0
  264. package/src/user-inbox-read-http.test.ts +223 -0
  265. package/src/user-inbox-schema.test.ts +54 -0
  266. package/src/vault.test.ts +200 -0
  267. package/src/vault.ts +156 -0
@@ -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
+ });
@@ -0,0 +1,122 @@
1
+ // #431(app「连接较慢」)—— GET /api/status 的序列化结果记忆化 + 强 ETag。
2
+ //
3
+ // 现场(2026-10-02 生产 /api/stats/routes,15 分钟窗口):GET /api/status 2000 次 ≈ 2.2 次/秒,全部来自 UA「node」
4
+ // (旧版 agent-node 的别名解析器,.82 起给它 light+node_id 投影),线上平均 30 KB/次(已经是 gzip 后的字节:
5
+ // node 的 fetch 发 `Accept-Encoding: gzip, deflate`)。两次读之间表多半没变(report_status 约 0.75 次/秒),
6
+ // 但每次都重新 SELECT 300 行、拼对象、JSON.stringify、再 gzip 一遍。
7
+ //
8
+ // 这里只做「同一份正文不重复算」,**不改任何正文**:
9
+ // 键 = 最终 SQL + 参数(里面已经编码了调用者能看到哪些节点)+ 投影开关;
10
+ // 失效 = 任何写 sessions 表的语句(db.ts 里统一挂钩,SQLite / PG 同一处)、任何健康上报、
11
+ // 任何一份健康报告过 TTL(那一刻 degraded / health 字段会变)。
12
+ // 全量投影里 `health_observed_ms_ago` 随时间走:只要有一行带健康报告,这份正文就是时间的函数,不缓存。
13
+ // 命中时正文和当场重算逐字节相同(status-read-cache-http.test.ts 用随机写序列钉住)。
14
+ //
15
+ // ETag:正文的哈希,`If-None-Match` 命中回 304 空体。🔴 旧 agent-node 不发 If-None-Match(fetch 没有 HTTP 缓存),
16
+ // 所以 ETag 只帮得上会带它的客户端(app / dashboard 以后接上);隧道上省字节靠的是同一份 gzip 只压一次
17
+ // 且用更高的压缩级别(http-gzip.ts:可复用的正文压一次、存起来,级别 9)。
18
+
19
+ import type { DbAdapter } from "./db-adapter";
20
+ import { nodeHealthNextExpiryAt, nodeHealthVersion } from "./node-health-store.js";
21
+
22
+ let sessionsWriteGen = 0;
23
+ // #500 step 2 —— 全量投影带 queue_depth(tasks 表的开着任务数):写 tasks 的语句也要让**全量**正文失效。
24
+ // light / 别名解析器投影不读 tasks,不看这个代数(写任务不打掉它们的缓存)。
25
+ let tasksWriteGen = 0;
26
+
27
+ const WRITE_START = /^\s*(?:insert|update|delete|replace|upsert|merge|alter|drop|create|truncate|with)\b/i;
28
+ const SESSIONS_WORD = /\bsessions\b/i;
29
+ // `\b` 不在 `_` 与字母之间:scheduled_tasks / task_events 不命中,只有 tasks 表本身。
30
+ const TASKS_WORD = /\btasks\b/i;
31
+
32
+ /**
33
+ * 每条经过 db 的语句都过一遍。写语句(含带 RETURNING 的、DDL)里点名了 sessions → 代数 +1。读语句不动。
34
+ * 不会有「没点名 sessions 却改了它」的写:两种方言的 schema 里都没有指向 sessions 的外键 / 级联删除
35
+ * (status-read-cache.test.ts 钉住这一点)。
36
+ */
37
+ export function noteStatement(sql: string): void {
38
+ if (!WRITE_START.test(sql)) return;
39
+ if (SESSIONS_WORD.test(sql)) sessionsWriteGen++;
40
+ if (TASKS_WORD.test(sql)) tasksWriteGen++;
41
+ }
42
+
43
+ /** 原生 exec(迁移 / DDL / 多语句):不逐句判断,一律失效。 */
44
+ export function noteExec(): void { sessionsWriteGen++; tasksWriteGen++; }
45
+
46
+ export function statusWriteGeneration(): number { return sessionsWriteGen; }
47
+ export function statusTasksWriteGeneration(): number { return tasksWriteGen; }
48
+
49
+ /**
50
+ * db.ts 用它包住唯一的那个适配器:每条语句先报给上面两个函数。挂在这一个出口上,SQLite / PG 一样,
51
+ * 不靠每个写 sessions 的地方各自记得。直接换掉实例上的四个方法(不用 Proxy):有测试按 `db.all = …`
52
+ * 临时打桩数查询次数,方法得是普通可写属性。
53
+ */
54
+ export function withStatementHook(inner: DbAdapter): DbAdapter {
55
+ const run = inner.run.bind(inner), get = inner.get.bind(inner), all = inner.all.bind(inner), exec = inner.exec.bind(inner);
56
+ inner.run = (sql: string, params?: any[]) => { noteStatement(sql); return run(sql, params); };
57
+ inner.get = ((sql: string, ...params: any[]) => { noteStatement(sql); return get(sql, ...params); }) as DbAdapter["get"];
58
+ inner.all = ((sql: string, ...params: any[]) => { noteStatement(sql); return all(sql, ...params); }) as DbAdapter["all"];
59
+ inner.exec = (sql: string) => { noteExec(); return exec(sql); };
60
+ return inner;
61
+ }
62
+
63
+ type Entry = { gen: number; taskGen: number | null; healthVer: number; validUntil: number; body: string; etag: string };
64
+ const MAX_ENTRIES = 64;
65
+ const memo = new Map<string, Entry>();
66
+ export const statusCacheStats = { hits: 0, misses: 0, uncacheable: 0 };
67
+
68
+ function etagOf(body: string): string {
69
+ return `"s-${Bun.hash(body).toString(36)}-${body.length.toString(36)}"`;
70
+ }
71
+
72
+ /**
73
+ * 取或算。compute 返回正文和它能不能缓存(全量投影里有健康报告 = 正文随时间变 = 不能)。
74
+ * 返回的 etag 总是这份正文的哈希(不缓存的也给,便于 304)。
75
+ */
76
+ let bypass = false;
77
+ /** 测试用:临时绕过缓存(既不读也不写),拿「当场重算」的那一份来对比,而不清掉缓存里已有的条目。 */
78
+ export function __setStatusCacheBypassForTest(on: boolean): void { bypass = on; }
79
+
80
+ export function memoStatusBody(
81
+ key: string,
82
+ compute: () => { body: string; cacheable: boolean },
83
+ now = Date.now(),
84
+ opts: { dependsOnTasks?: boolean } = {},
85
+ ): { body: string; etag: string; hit: boolean } {
86
+ if (bypass) { const { body } = compute(); return { body, etag: etagOf(body), hit: false }; }
87
+ const gen = sessionsWriteGen;
88
+ const taskGen = opts.dependsOnTasks ? tasksWriteGen : null;
89
+ const healthVer = nodeHealthVersion();
90
+ const hit = memo.get(key);
91
+ if (hit && hit.gen === gen && hit.taskGen === taskGen && hit.healthVer === healthVer && now < hit.validUntil) {
92
+ statusCacheStats.hits++;
93
+ memo.delete(key);
94
+ memo.set(key, hit); // LRU
95
+ return { body: hit.body, etag: hit.etag, hit: true };
96
+ }
97
+ const { body, cacheable } = compute();
98
+ const etag = etagOf(body);
99
+ // 以算之前的代数入账:算的过程中若有写(改名清理之类),下一次比较就对不上、重算 —— 宁可多算一次也不发旧正文。
100
+ if (!cacheable) {
101
+ statusCacheStats.uncacheable++;
102
+ memo.delete(key);
103
+ return { body, etag, hit: false };
104
+ }
105
+ statusCacheStats.misses++;
106
+ memo.set(key, { gen, taskGen, healthVer, validUntil: nodeHealthNextExpiryAt(now), body, etag });
107
+ while (memo.size > MAX_ENTRIES) memo.delete(memo.keys().next().value as string);
108
+ return { body, etag, hit: false };
109
+ }
110
+
111
+ /** If-None-Match 里有这个 ETag(或 `*`)。弱比较:去掉 W/ 前缀。 */
112
+ export function ifNoneMatchHits(header: string | null, etag: string): boolean {
113
+ if (!header) return false;
114
+ const bare = (t: string) => t.trim().replace(/^W\//, "");
115
+ return header.split(",").some((t) => t.trim() === "*" || bare(t) === bare(etag));
116
+ }
117
+
118
+ /** 测试用。 */
119
+ export function __resetStatusCacheForTest(): void {
120
+ memo.clear();
121
+ statusCacheStats.hits = statusCacheStats.misses = statusCacheStats.uncacheable = 0;
122
+ }