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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +8 -7
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +487 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/agent-notice.ts +48 -0
  15. package/src/alias-filter.test.ts +72 -0
  16. package/src/alias-filter.ts +42 -0
  17. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  18. package/src/api-host-supervisors-fallback.test.ts +271 -0
  19. package/src/api-nodes-shape.test.ts +188 -0
  20. package/src/auth-kdf-migration.test.ts +378 -0
  21. package/src/auth-sessions-http.test.ts +250 -0
  22. package/src/auth-tokens.test.ts +18 -9
  23. package/src/auth-validate.test.ts +5 -1
  24. package/src/auth.ts +554 -38
  25. package/src/avatar-validate.test.ts +163 -0
  26. package/src/avatar-validate.ts +103 -0
  27. package/src/blocked-exit-on-reply.test.ts +122 -0
  28. package/src/boot-side-effect.test.ts +124 -0
  29. package/src/commhub-bin-args.test.ts +85 -0
  30. package/src/config-apply-sec1.test.ts +830 -0
  31. package/src/config-apply-validate.test.ts +352 -0
  32. package/src/config-apply-validate.ts +249 -0
  33. package/src/create-node-blocked-gate.test.ts +356 -0
  34. package/src/create-node-tool-schema.test.ts +39 -0
  35. package/src/create-node-validate.test.ts +275 -0
  36. package/src/create-node-validate.ts +288 -0
  37. package/src/create-node-workdir.test.ts +255 -0
  38. package/src/create-node.test.ts +354 -0
  39. package/src/create-node.ts +349 -0
  40. package/src/cross-tenant-injection.test.ts +110 -0
  41. package/src/dashboard-slash-routing-http.test.ts +107 -0
  42. package/src/db-adapter-guard.test.ts +148 -0
  43. package/src/db-adapter-translate.test.ts +128 -0
  44. package/src/db-adapter.ts +323 -87
  45. package/src/db-timestamp.test.ts +38 -0
  46. package/src/db-timestamp.ts +15 -0
  47. package/src/db.ts +1268 -42
  48. package/src/delivered-without-push.test.ts +140 -0
  49. package/src/department-groups-http.test.ts +209 -0
  50. package/src/department-groups-sync-http.test.ts +328 -0
  51. package/src/department-groups.ts +434 -0
  52. package/src/department-heads-http.test.ts +329 -0
  53. package/src/department-heads.ts +226 -0
  54. package/src/departments-http.test.ts +185 -0
  55. package/src/departments.ts +260 -0
  56. package/src/desktop-message-seam.test.ts +150 -0
  57. package/src/desktop-message.test.ts +368 -0
  58. package/src/dm-file-scope-http.test.ts +178 -0
  59. package/src/ext-token-shared.test.ts +60 -0
  60. package/src/ext-validation-regression.test.ts +131 -0
  61. package/src/external-schedule-edits-http.test.ts +321 -0
  62. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  63. package/src/external-schedule-edits.ts +327 -0
  64. package/src/file-download-authz-dev-open.test.ts +135 -0
  65. package/src/file-download-authz.test.ts +404 -0
  66. package/src/file-download-date-bucket.test.ts +436 -0
  67. package/src/file-network-scope-dev-open.test.ts +97 -0
  68. package/src/file-network-scope.test.ts +732 -0
  69. package/src/group-membership-events-http.test.ts +264 -0
  70. package/src/group-messages-http.test.ts +482 -0
  71. package/src/group-messages.ts +297 -0
  72. package/src/health-redaction.test.ts +642 -0
  73. package/src/http-gzip.test.ts +94 -0
  74. package/src/http-gzip.ts +112 -0
  75. package/src/hub-timestamp.test.ts +38 -0
  76. package/src/hub-timestamp.ts +27 -0
  77. package/src/human-dm.ts +215 -0
  78. package/src/inbox-count-sse.test.ts +310 -0
  79. package/src/inbox-count.ts +29 -0
  80. package/src/index.ts +15 -2128
  81. package/src/lifecycle-guard.ts +50 -0
  82. package/src/list-host-supervisors.test.ts +314 -0
  83. package/src/mcp-context-cost-http.test.ts +119 -0
  84. package/src/mcp-projects-events-http.test.ts +162 -0
  85. package/src/mcp-route-label-http.test.ts +120 -0
  86. package/src/mcp-write-network-resolution.test.ts +429 -0
  87. package/src/member-audit-network-id-http.test.ts +93 -0
  88. package/src/member-presence-http.test.ts +206 -0
  89. package/src/messages-ack-by-agent-http.test.ts +142 -0
  90. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  91. package/src/migrate-sqlite-to-pg.ts +263 -0
  92. package/src/model-auth-notify-http.test.ts +181 -0
  93. package/src/model-auth-notify.ts +116 -0
  94. package/src/network-name-http.test.ts +75 -0
  95. package/src/network-scope.ts +224 -0
  96. package/src/network-token-membership-http.test.ts +139 -0
  97. package/src/node-attrs-validate.ts +88 -0
  98. package/src/node-attrs.test.ts +217 -0
  99. package/src/node-create-request-status-http.test.ts +92 -0
  100. package/src/node-files-transport.test.ts +344 -0
  101. package/src/node-health-dispatch-http.test.ts +279 -0
  102. package/src/node-health-guard.ts +105 -0
  103. package/src/node-health-passthrough.test.ts +115 -0
  104. package/src/node-health-store.ts +72 -0
  105. package/src/node-id-alias.test.ts +188 -0
  106. package/src/node-identity-conflict-http.test.ts +269 -0
  107. package/src/node-identity-conflict.ts +142 -0
  108. package/src/node-identity-mismatch.test.ts +74 -0
  109. package/src/node-lifecycle-controllable.test.ts +64 -0
  110. package/src/node-lifecycle-controllable.ts +51 -0
  111. package/src/node-logs-transport.test.ts +263 -0
  112. package/src/node-permissions-http.test.ts +276 -0
  113. package/src/node-permissions.ts +242 -0
  114. package/src/node-request-retention.test.ts +281 -0
  115. package/src/node-request-retention.ts +120 -0
  116. package/src/node-skills-transport.test.ts +209 -0
  117. package/src/node-token-privilege-http.test.ts +149 -0
  118. package/src/observer-avatar-http.test.ts +431 -0
  119. package/src/observer-push.test.ts +252 -0
  120. package/src/package-version-consistency.test.ts +25 -0
  121. package/src/peer-reply-atomic.test.ts +455 -0
  122. package/src/peer-reply-capability.test.ts +70 -0
  123. package/src/pg-worker.ts +87 -0
  124. package/src/probe-validate.test.ts +221 -0
  125. package/src/probe-validate.ts +104 -0
  126. package/src/probe.test.ts +80 -0
  127. package/src/probe.ts +200 -0
  128. package/src/project-viewer-can-http.test.ts +114 -0
  129. package/src/push.ts +429 -10
  130. package/src/redact-tokens.test.ts +65 -0
  131. package/src/redact-tokens.ts +40 -0
  132. package/src/rename.ts +7 -2
  133. package/src/reply-target-derivation.test.ts +96 -0
  134. package/src/report-status-host-schema.test.ts +211 -0
  135. package/src/report-status-resume-id-handover.test.ts +150 -0
  136. package/src/require-explicit-test-db.ts +9 -0
  137. package/src/requirement-due-reminders-http.test.ts +390 -0
  138. package/src/requirement-due-reminders.ts +310 -0
  139. package/src/requirement-events-http.test.ts +257 -0
  140. package/src/requirement-events.test.ts +45 -0
  141. package/src/requirement-events.ts +144 -0
  142. package/src/requirement-notify.ts +165 -0
  143. package/src/requirement-participant-notify-http.test.ts +309 -0
  144. package/src/requirement-tag-ops-http.test.ts +266 -0
  145. package/src/requirement-tags.ts +89 -0
  146. package/src/requirements-comment-http.test.ts +165 -0
  147. package/src/requirements-completed-at-migration.test.ts +70 -0
  148. package/src/requirements-errors-mcp-http.test.ts +164 -0
  149. package/src/requirements-errors.ts +143 -0
  150. package/src/requirements-etag-http.test.ts +91 -0
  151. package/src/requirements-http.test.ts +709 -0
  152. package/src/requirements-last-event-http.test.ts +344 -0
  153. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  154. package/src/requirements-list-slim-http.test.ts +319 -0
  155. package/src/requirements-mcp.test.ts +163 -0
  156. package/src/requirements-migrate.ts +225 -0
  157. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  158. package/src/requirements-people-http.test.ts +114 -0
  159. package/src/requirements-people-mcp-http.test.ts +225 -0
  160. package/src/requirements-people.ts +127 -0
  161. package/src/requirements-priority-lowest.test.ts +114 -0
  162. package/src/requirements-search-http.test.ts +182 -0
  163. package/src/requirements-search.test.ts +32 -0
  164. package/src/requirements-search.ts +145 -0
  165. package/src/requirements-seq-http.test.ts +193 -0
  166. package/src/requirements-seq-migration.test.ts +63 -0
  167. package/src/requirements-start-date.test.ts +125 -0
  168. package/src/requirements-stats-http.test.ts +288 -0
  169. package/src/requirements-stats.ts +164 -0
  170. package/src/requirements-status-participants-http.test.ts +220 -0
  171. package/src/requirements-upgrade-e2e.test.ts +126 -0
  172. package/src/requirements-upgrade-migration.test.ts +98 -0
  173. package/src/requirements.ts +1761 -0
  174. package/src/resolve-port.test.ts +57 -0
  175. package/src/resolve-port.ts +44 -0
  176. package/src/response-charset.test.ts +130 -0
  177. package/src/rest-explicit-columns-http.test.ts +222 -0
  178. package/src/rest-identity.test.ts +129 -0
  179. package/src/rest-identity.ts +100 -0
  180. package/src/rest-projections.ts +68 -0
  181. package/src/rest-write-network-resolution.test.ts +133 -0
  182. package/src/restricted-files.ts +73 -0
  183. package/src/retention.test.ts +450 -0
  184. package/src/retention.ts +281 -0
  185. package/src/route-callers-http.test.ts +120 -0
  186. package/src/route-timing.test.ts +211 -0
  187. package/src/route-timing.ts +258 -0
  188. package/src/rules-file-duplicate-alias.test.ts +234 -0
  189. package/src/rules-file-session-target.test.ts +207 -0
  190. package/src/rules-file-transport.test.ts +237 -0
  191. package/src/runtime-label.test.ts +22 -0
  192. package/src/runtime-label.ts +21 -0
  193. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  194. package/src/scheduled-reply-unread-http.test.ts +212 -0
  195. package/src/scheduled-run-terminal.test.ts +268 -0
  196. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  197. package/src/scheduled-stuck-http.test.ts +238 -0
  198. package/src/scheduled-stuck.ts +153 -0
  199. package/src/scheduled-tasks-http.test.ts +638 -0
  200. package/src/scheduled-tasks.ts +641 -0
  201. package/src/send-dedup-request-id.test.ts +229 -0
  202. package/src/send-reply-agent-warning.test.ts +202 -0
  203. package/src/send-reply-alias-optional.test.ts +131 -0
  204. package/src/send-reply-attachments.test.ts +458 -0
  205. package/src/send_dedup.test.ts +54 -0
  206. package/src/send_dedup.ts +52 -6
  207. package/src/serve-error.test.ts +38 -0
  208. package/src/serve-error.ts +27 -0
  209. package/src/server.ts +5038 -0
  210. package/src/shared/external-schedule-contract.ts +60 -0
  211. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  212. package/src/shared/probe-host-allowlist.ts +128 -0
  213. package/src/shared/reserved-env-drift.test.ts +43 -0
  214. package/src/shared/reserved-env.test.ts +35 -0
  215. package/src/shared/reserved-env.ts +31 -0
  216. package/src/side-thread-command-transport.test.ts +312 -0
  217. package/src/side-thread-command-transport.ts +318 -0
  218. package/src/side-thread-http-integration.test.ts +233 -0
  219. package/src/side-thread-http.ts +504 -0
  220. package/src/side-thread-production.test.ts +72 -0
  221. package/src/side-thread-production.ts +118 -0
  222. package/src/side-thread.test.ts +1067 -0
  223. package/src/side-thread.ts +2499 -0
  224. package/src/skillhub-http.test.ts +108 -0
  225. package/src/sqlite-close-checkpoint.test.ts +78 -0
  226. package/src/stale-sweeper.test.ts +181 -0
  227. package/src/stale-sweeper.ts +114 -0
  228. package/src/start-node.test.ts +109 -0
  229. package/src/status-alias-filter-http.test.ts +106 -0
  230. package/src/status-alias-resolver-http.test.ts +163 -0
  231. package/src/status-alias-resolver.ts +19 -0
  232. package/src/status-node-id-filter-http.test.ts +161 -0
  233. package/src/status-read-cache-http.test.ts +229 -0
  234. package/src/status-read-cache.ts +122 -0
  235. package/src/stop-delete-node.test.ts +1164 -0
  236. package/src/task-access-http.test.ts +379 -0
  237. package/src/task-access.ts +413 -0
  238. package/src/task-auth-origin-http.test.ts +91 -0
  239. package/src/task-auth-origin.test.ts +24 -0
  240. package/src/task-auth-origin.ts +7 -0
  241. package/src/task-consumption.test.ts +482 -0
  242. package/src/task-diagnostic-http.test.ts +149 -0
  243. package/src/task-diagnostic.test.ts +115 -0
  244. package/src/task-diagnostic.ts +118 -0
  245. package/src/task-expiry-notice-http.test.ts +287 -0
  246. package/src/task-expiry-notice.ts +313 -0
  247. package/src/task-idempotency-mcp.test.ts +142 -0
  248. package/src/task-idempotency.test.ts +60 -0
  249. package/src/task-idempotency.ts +75 -0
  250. package/src/task-lifecycle-watcher.test.ts +204 -0
  251. package/src/task-lifecycle-watcher.ts +49 -0
  252. package/src/task-network-resolution-http.test.ts +141 -0
  253. package/src/task-queue-ahead-http.test.ts +309 -0
  254. package/src/task-queue-ahead-scale.test.ts +211 -0
  255. package/src/task-queue-ahead.ts +186 -0
  256. package/src/task-stale-open-http.test.ts +290 -0
  257. package/src/task-stale-open.ts +103 -0
  258. package/src/tasks-pagination-http.test.ts +141 -0
  259. package/src/tool-audience-http.test.ts +195 -0
  260. package/src/tool-audience.ts +109 -0
  261. package/src/tools-list-json-schema.test.ts +67 -0
  262. package/src/tools.ts +5426 -275
  263. package/src/update-provider.test.ts +234 -0
  264. package/src/upload-early-reject-guard.test.ts +155 -0
  265. package/src/uploads-http.test.ts +97 -15
  266. package/src/uploads.test.ts +66 -0
  267. package/src/uploads.ts +138 -5
  268. package/src/user-inbox-by-agent-http.test.ts +161 -0
  269. package/src/user-inbox-netid-notnull.test.ts +122 -0
  270. package/src/user-inbox-read-http.test.ts +223 -0
  271. package/src/user-inbox-schema.test.ts +54 -0
  272. package/src/vault.test.ts +200 -0
  273. package/src/vault.ts +156 -0
@@ -0,0 +1,108 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { createNetworkTokenForNode, register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+
8
+ const dir = mkdtempSync(join(tmpdir(), "anet-skillhub-"));
9
+ let server: any;
10
+ let base = "";
11
+ let ownerToken = "";
12
+ let nodeToken = "";
13
+ let foreignToken = "";
14
+ let networkId = "";
15
+
16
+ async function tool(token: string, name: string, args: Record<string, unknown>) {
17
+ const res = await fetch(`${base}/mcp`, {
18
+ method: "POST",
19
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
20
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
21
+ });
22
+ expect(res.status).toBe(200);
23
+ const raw = await res.text();
24
+ const dataLines = raw.split("\n").filter(x => x.startsWith("data:"));
25
+ const payload = dataLines.length
26
+ ? JSON.parse(dataLines.at(-1)!.slice(5).trim())
27
+ : JSON.parse(raw);
28
+ return JSON.parse(payload.result.content[0].text);
29
+ }
30
+
31
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
32
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
33
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
34
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
35
+ beforeAll(async () => {
36
+ process.env.COMMHUB_DB ||= join(dir, "hub.db");
37
+ const owner = register(`skill_owner_${Date.now()}`, "SkillHubOwner123!", undefined, "seed");
38
+ expect(owner.ok).toBe(true);
39
+ ownerToken = owner.token!; networkId = owner.network_id!;
40
+ const ownerId = db.get<{ user_id: string }>("SELECT owner_id AS user_id FROM networks WHERE network_id = ?1", networkId)!.user_id;
41
+ const node = createNetworkTokenForNode(ownerId, networkId, "skill-writer");
42
+ expect(node.ok).toBe(true); nodeToken = node.token!;
43
+ const foreign = register(`skill_foreign_${Date.now()}`, "SkillHubForeign123!", undefined, "seed");
44
+ expect(foreign.ok).toBe(true); foreignToken = foreign.token!;
45
+ const mod: any = await import("./server.js");
46
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
47
+ base = `http://127.0.0.1:${server.port}`;
48
+ }, 30_000);
49
+
50
+ afterAll(() => { try { server?.stop?.(true); } catch {} try { rmSync(dir, { recursive: true, force: true }); } catch {} });
51
+
52
+ describe("SkillHub real Hub + SQLite", () => {
53
+ let skillId = "";
54
+ test("node submit is token-attributed, pending and immutable/idempotent", async () => {
55
+ const input = { network_id: networkId, slug: "incident-handoff", name: "Incident handoff", version: "1.0.0", description: "handoff", content: "# Incident handoff\n\nVerify facts." };
56
+ const first = await tool(nodeToken, "submit_skill", { ...input, source_alias: "forged-admin" });
57
+ expect(first.ok).toBe(true); expect(first.status).toBe("pending"); expect(first.source_alias).toBe("skill-writer"); skillId = first.skill_id;
58
+ const retry = await tool(nodeToken, "submit_skill", input);
59
+ expect(retry.idempotent).toBe(true); expect(retry.skill_id).toBe(skillId);
60
+ const conflict = await tool(nodeToken, "submit_skill", { ...input, content: "changed" });
61
+ expect(conflict.error).toBe("skill_version_conflict");
62
+ const raceBase = { ...input, slug: "concurrent-version", version: "2.0.0" };
63
+ const raced = await Promise.all([
64
+ tool(nodeToken, "submit_skill", { ...raceBase, content: "winner-a" }),
65
+ tool(nodeToken, "submit_skill", { ...raceBase, content: "winner-b" }),
66
+ ]);
67
+ expect(raced.filter(x => x.ok).length).toBe(1);
68
+ expect(raced.filter(x => x.error === "skill_version_conflict").length).toBe(1);
69
+ });
70
+
71
+ test("pending is reviewer-only; owner publishes; published becomes visible", async () => {
72
+ const nodeList = await tool(nodeToken, "list_skills", { network_id: networkId, include_pending: true });
73
+ expect(nodeList.skills).toHaveLength(0);
74
+ expect((await tool(nodeToken, "get_skill", { network_id: networkId, skill_id: skillId })).error).toBe("skill_not_found");
75
+ const ownerList = await tool(ownerToken, "list_skills", { network_id: networkId, include_pending: true });
76
+ expect(ownerList.reviewer).toBe(true); expect(ownerList.skills.map((x: any) => x.skill_id)).toContain(skillId);
77
+ expect((await tool(ownerToken, "get_skill", { network_id: networkId, skill_id: skillId })).skill.content).toContain("Verify facts");
78
+ const reviewed = await tool(ownerToken, "review_skill", { network_id: networkId, skill_id: skillId, decision: "published" });
79
+ expect(reviewed.status).toBe("published");
80
+ const published = await tool(nodeToken, "list_skills", { network_id: networkId });
81
+ expect(published.skills.map((x: any) => x.skill_id)).toContain(skillId);
82
+ expect((await tool(nodeToken, "get_skill", { network_id: networkId, skill_id: skillId })).skill.skill_id).toBe(skillId);
83
+ const detail = (await tool(nodeToken, "get_skill", { network_id: networkId, skill_id: skillId })).skill;
84
+ expect(detail.created_by_user).toBeUndefined(); expect(detail.reviewed_by_user).toBeUndefined(); expect(detail.content_hash).toBeUndefined();
85
+ });
86
+
87
+ test("query matches regardless of letter case (SQLite LIKE semantics, kept on PostgreSQL)", async () => {
88
+ for (const query of ["incident", "INCIDENT", "Handoff"]) {
89
+ const found = await tool(nodeToken, "list_skills", { network_id: networkId, query });
90
+ expect(found.skills.map((x: any) => x.skill_id)).toContain(skillId);
91
+ }
92
+ expect((await tool(nodeToken, "list_skills", { network_id: networkId, query: "no-such-skill" })).skills).toHaveLength(0);
93
+ });
94
+
95
+ test("% and _ in the query are literal characters, not wildcards (#2141)", async () => {
96
+ const ids = async (query: string) => (await tool(nodeToken, "list_skills", { network_id: networkId, query })).skills.map((x: any) => x.skill_id);
97
+ expect(await ids("_")).not.toContain(skillId); // was: matched everything
98
+ expect(await ids("%")).not.toContain(skillId);
99
+ expect(await ids("incident_handoff")).not.toContain(skillId); // `_` must not stand for the "-" in the slug
100
+ expect(await ids("incident-handoff")).toContain(skillId);
101
+ });
102
+
103
+ test("foreign user cannot read or review another network", async () => {
104
+ expect((await tool(foreignToken, "list_skills", { network_id: networkId })).ok).toBe(false);
105
+ const review = await tool(foreignToken, "review_skill", { network_id: networkId, skill_id: skillId, decision: "rejected" });
106
+ expect(review.ok).toBe(false);
107
+ });
108
+ });
@@ -0,0 +1,78 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { SQLiteAdapter } from "./db-adapter";
8
+
9
+ // #2151 — a cleanly stopped Hub must leave a complete .db: the -wal is absent
10
+ // or empty, and a copy of the .db file alone still has the data.
11
+ const dir = mkdtempSync(join(tmpdir(), "anet-wal-close-"));
12
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
13
+
14
+ const walBytes = (db: string) => (existsSync(`${db}-wal`) ? statSync(`${db}-wal`).size : 0);
15
+
16
+ function copyOnlyDb(db: string): Database {
17
+ const copy = join(dirname(db), `copy-of-${db.split("/").pop()}`);
18
+ copyFileSync(db, copy); // deliberately without -wal / -shm
19
+ return new Database(copy, { readonly: true });
20
+ }
21
+
22
+ describe("#2151 SQLite WAL is checkpointed on close", () => {
23
+ test("SQLiteAdapter.close() after >20 distinct statements leaves a complete .db", () => {
24
+ const path = join(dir, "adapter.db");
25
+ const raw = new Database(path);
26
+ raw.exec("PRAGMA journal_mode=WAL");
27
+ const db = new SQLiteAdapter(raw);
28
+ db.exec("CREATE TABLE t (x TEXT)");
29
+ db.run("INSERT INTO t VALUES (?1)", ["kept"]);
30
+ // bun:sqlite stops being able to close after more than 20 distinct query() strings.
31
+ for (let i = 0; i < 30; i++) db.get(`SELECT ${i} AS v`);
32
+ db.close();
33
+ expect(walBytes(path)).toBe(0);
34
+ const copy = copyOnlyDb(path);
35
+ expect(copy.query("SELECT x FROM t").all()).toEqual([{ x: "kept" }]);
36
+ copy.close();
37
+ });
38
+
39
+ test("a real Hub that served traffic and got SIGTERM leaves a complete .db", async () => {
40
+ const bin = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
41
+ const home = join(dir, "home");
42
+ const path = join(dir, "hub.db");
43
+ const port = String(27000 + Math.floor(Math.random() * 2000));
44
+ const base = `http://127.0.0.1:${port}`;
45
+ const hub = Bun.spawn(["bun", bin, "--port", port, "--host", "127.0.0.1", "--db", path], {
46
+ env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
47
+ });
48
+ try {
49
+ let up = false;
50
+ for (let i = 0; i < 150 && !up; i++) {
51
+ up = await fetch(`${base}/health`).then((r) => r.ok, () => false);
52
+ if (!up) await Bun.sleep(100);
53
+ }
54
+ expect(up).toBe(true);
55
+ const post = (p: string, body: unknown, token?: string) => fetch(`${base}${p}`, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
58
+ body: JSON.stringify(body),
59
+ }).then((r) => r.json() as Promise<any>);
60
+ const reg = await post("/api/auth/register", { username: "walcheck", password: "StrongPassw0rd" });
61
+ expect(reg.ok).toBe(true);
62
+ const login = await post("/api/auth/login", { username: "walcheck", password: "StrongPassw0rd" });
63
+ const net = await post("/api/networks", { name: "wal-net" }, login.token);
64
+ expect(net.ok).toBe(true);
65
+ const ntok = await post("/api/auth/node-token", { network_id: net.network_id, node_name: "wal-agent" }, login.token);
66
+ expect(ntok.ok).toBe(true);
67
+ expect(walBytes(path)).toBeGreaterThan(0); // the writes really are in the WAL before the stop
68
+ } finally {
69
+ hub.kill("SIGTERM");
70
+ await hub.exited;
71
+ }
72
+ expect(walBytes(path)).toBe(0);
73
+ const copy = copyOnlyDb(path);
74
+ expect(copy.query("SELECT COUNT(*) AS n FROM users WHERE username = 'walcheck'").get()).toEqual({ n: 1 });
75
+ expect(copy.query("SELECT COUNT(*) AS n FROM networks WHERE network_name = 'wal-net'").get()).toEqual({ n: 1 });
76
+ copy.close();
77
+ }, 40_000);
78
+ });
@@ -0,0 +1,181 @@
1
+ // Round-2/4 review ③ — stale session sweeper tests.
2
+ //
3
+ // Drives the real sweepStaleSessions against isolated in-process
4
+ // SQLite (COMMHUB_DB=/tmp/...). Pins behaviour the read-path
5
+ // regression (per-request UPDATE) would re-introduce if anyone
6
+ // undoes the fix:
7
+ //
8
+ // 1. fresh sessions stay non-offline regardless of how many
9
+ // times the sweeper runs;
10
+ // 2. stale sessions get marked offline exactly once (idempotent);
11
+ // 3. global sweep crosses tenant boundaries — a stale agent in
12
+ // network B is marked offline even when no one in net B is
13
+ // polling /api/status;
14
+ // 4. env-var cutoff is respected and bad values fall back silently.
15
+ //
16
+ // Run with:
17
+ // COMMHUB_DB=/tmp/stale-test.db bun test src/stale-sweeper.test.ts
18
+
19
+ import { describe, expect, test, beforeEach } from "bun:test";
20
+ import { db } from "./db.js";
21
+ import { sweepStaleSessions, staleCutoffMinutes } from "./stale-sweeper.js";
22
+
23
+ function insertSession(opts: {
24
+ alias: string;
25
+ status?: string;
26
+ minutesAgo: number;
27
+ network_id?: string;
28
+ }): void {
29
+ const id = `r_${opts.alias}`;
30
+ db.run(
31
+ `INSERT OR REPLACE INTO sessions
32
+ (resume_id, alias, status, network_id, updated_at, registered_at)
33
+ VALUES (?1, ?2, ?3, ?4, datetime('now', ?5), datetime('now', ?5))`,
34
+ [id, opts.alias, opts.status ?? "idle", opts.network_id ?? "default", `-${opts.minutesAgo} minutes`]
35
+ );
36
+ }
37
+
38
+ function getStatus(alias: string): string | null {
39
+ const row = db.get<{ status: string }>(
40
+ "SELECT status FROM sessions WHERE alias = ?1",
41
+ alias
42
+ );
43
+ return row?.status ?? null;
44
+ }
45
+
46
+ beforeEach(() => {
47
+ db.run("DELETE FROM sessions");
48
+ });
49
+
50
+ describe("sweepStaleSessions — fresh agents not touched", () => {
51
+ test("agent updated < cutoff stays at its current status", () => {
52
+ insertSession({ alias: "live-1", status: "idle", minutesAgo: 1 });
53
+ insertSession({ alias: "live-2", status: "working", minutesAgo: 5 });
54
+
55
+ sweepStaleSessions(10);
56
+
57
+ expect(getStatus("live-1")).toBe("idle");
58
+ expect(getStatus("live-2")).toBe("working");
59
+ });
60
+
61
+ test("multiple sweeps on quiet hub are pure no-ops (no write fired)", () => {
62
+ insertSession({ alias: "live", status: "idle", minutesAgo: 1 });
63
+
64
+ const r1 = sweepStaleSessions(10);
65
+ const r2 = sweepStaleSessions(10);
66
+ const r3 = sweepStaleSessions(10);
67
+
68
+ expect(r1.markedOffline).toBe(0);
69
+ expect(r2.markedOffline).toBe(0);
70
+ expect(r3.markedOffline).toBe(0);
71
+ expect(getStatus("live")).toBe("idle");
72
+ });
73
+ });
74
+
75
+ describe("sweepStaleSessions — stale agents marked offline", () => {
76
+ test("agent updated > cutoff gets marked offline exactly once", () => {
77
+ insertSession({ alias: "stale", status: "idle", minutesAgo: 30 });
78
+
79
+ const r1 = sweepStaleSessions(10);
80
+ expect(r1.markedOffline).toBe(1);
81
+ expect(getStatus("stale")).toBe("offline");
82
+
83
+ // Second sweep does nothing because the row is already offline
84
+ // (the predicate is `status != 'offline'`).
85
+ const r2 = sweepStaleSessions(10);
86
+ expect(r2.markedOffline).toBe(0);
87
+ expect(getStatus("stale")).toBe("offline");
88
+ });
89
+
90
+ test("already-offline stale row not re-marked (write avoided)", () => {
91
+ insertSession({ alias: "old-offline", status: "offline", minutesAgo: 60 });
92
+
93
+ const r = sweepStaleSessions(10);
94
+
95
+ expect(r.markedOffline).toBe(0);
96
+ expect(getStatus("old-offline")).toBe("offline");
97
+ });
98
+ });
99
+
100
+ describe("sweepStaleSessions — global across tenants (the read-path fix)", () => {
101
+ test("stale row in network B is marked offline even with no net-B polling", () => {
102
+ // Pre-fix: GET /api/status from net-A would only UPDATE rows in
103
+ // net-A's scope. A stale agent in net-B would stay non-offline
104
+ // until someone polled net-B. After the fix, the global sweeper
105
+ // is tenant-blind on the maintenance write.
106
+ insertSession({ alias: "stale-a", status: "idle", minutesAgo: 30, network_id: "net-A" });
107
+ insertSession({ alias: "stale-b", status: "idle", minutesAgo: 30, network_id: "net-B" });
108
+ insertSession({ alias: "stale-c", status: "idle", minutesAgo: 30, network_id: null as any });
109
+
110
+ const r = sweepStaleSessions(10);
111
+
112
+ expect(r.markedOffline).toBe(3);
113
+ expect(getStatus("stale-a")).toBe("offline");
114
+ expect(getStatus("stale-b")).toBe("offline");
115
+ expect(getStatus("stale-c")).toBe("offline");
116
+ });
117
+ });
118
+
119
+ describe("sweepStaleSessions — cutoff boundary + env", () => {
120
+ test("exactly-at-cutoff agent treated as stale (< strict, not <=)", () => {
121
+ // Just past the cutoff window.
122
+ insertSession({ alias: "just-stale", status: "idle", minutesAgo: 11 });
123
+ sweepStaleSessions(10);
124
+ expect(getStatus("just-stale")).toBe("offline");
125
+ });
126
+
127
+ test("just-under-cutoff agent treated as live", () => {
128
+ insertSession({ alias: "just-live", status: "working", minutesAgo: 9 });
129
+ sweepStaleSessions(10);
130
+ expect(getStatus("just-live")).toBe("working");
131
+ });
132
+
133
+ test("env COMMHUB_STALE_CUTOFF_MINUTES overrides default", () => {
134
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "3";
135
+ expect(staleCutoffMinutes()).toBe(3);
136
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
137
+ });
138
+
139
+ test("env invalid value falls back to default 10 (no operator footgun)", () => {
140
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "not-a-number";
141
+ expect(staleCutoffMinutes()).toBe(10);
142
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
143
+
144
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "0";
145
+ expect(staleCutoffMinutes()).toBe(10);
146
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
147
+
148
+ process.env.COMMHUB_STALE_CUTOFF_MINUTES = "-5";
149
+ expect(staleCutoffMinutes()).toBe(10);
150
+ delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
151
+ });
152
+ });
153
+
154
+ describe("sweepStaleSessions — read-path regression guard (pin the fix)", () => {
155
+ // This is the load-bearing pin: if anyone adds back a per-request
156
+ // UPDATE in /api/status / /api/servers / /api/server-detail-* /
157
+ // get_all_status, this test plus the absence of those UPDATEs in
158
+ // the source should make the regression visible. Since this is a
159
+ // unit-level test of the sweeper itself, the regression-prevention
160
+ // role is mostly social — the comments in this file + the comments
161
+ // in index.ts and tools.ts at the former UPDATE sites tell future
162
+ // editors "do not put the UPDATE back; use the sweeper."
163
+ test("after sweep, sessions row has the offline status persisted (single source of truth)", () => {
164
+ insertSession({ alias: "x", status: "working", minutesAgo: 30 });
165
+ const before = sweepStaleSessions(10);
166
+ expect(before.markedOffline).toBe(1);
167
+
168
+ // Re-read directly — no extra UPDATE needed by a caller.
169
+ const status = getStatus("x");
170
+ expect(status).toBe("offline");
171
+
172
+ // 100 simulated read-path lookups don't fire any maintenance writes.
173
+ for (let i = 0; i < 100; i++) {
174
+ const row = db.get<{ status: string }>(
175
+ "SELECT status FROM sessions WHERE alias = ?1",
176
+ "x"
177
+ );
178
+ expect(row?.status).toBe("offline");
179
+ }
180
+ });
181
+ });
@@ -0,0 +1,114 @@
1
+ // Round-2/4 review ③ — read-path write amplification fix.
2
+ //
3
+ // Before this module: GET /api/status, GET /api/servers, GET
4
+ // /api/server-health/:host, and the MCP `get_all_status` tool each
5
+ // fired:
6
+ //
7
+ // UPDATE sessions SET status = 'offline'
8
+ // WHERE updated_at < ?1 AND status != 'offline'
9
+ //
10
+ // on every call. With the dashboard polling /api/status every few
11
+ // seconds × N tabs × M users, that's a high-frequency WRITE on a
12
+ // heavily-read table just to maintain a derived field. Wait events
13
+ // stack up (WAL fsync, page locks) and the writes are 99% no-ops.
14
+ //
15
+ // Fix: move the stale-marking off the read path into a single
16
+ // background timer. Reads now SELECT only; the timer runs every 60s
17
+ // and applies the same UPDATE GLOBALLY (no per-network scope — stale
18
+ // is stale regardless of which tenant is asking). Worst-case delay
19
+ // between an agent crashing and going `offline` in the dashboard:
20
+ //
21
+ // 10min stale cutoff + 60s sweep = up to ~11min
22
+ //
23
+ // (unchanged from before the fix for any reader that wasn't actively
24
+ // polling).
25
+ //
26
+ // Same pattern as `src/retention.ts` for shutdown (clearInterval in
27
+ // the graceful shutdown handler).
28
+
29
+ import { db } from "./db.js";
30
+
31
+ export type StaleSweepResult = {
32
+ startedAt: string;
33
+ durationMs: number;
34
+ markedOffline: number;
35
+ staleCutoffMinutes: number;
36
+ };
37
+
38
+ // Stale window: an agent that hasn't reported in this many minutes is
39
+ // considered offline. Matches the pre-fix per-request cutoff so the
40
+ // observable behaviour from the dashboard's perspective is unchanged.
41
+ const DEFAULT_STALE_MINUTES = 10;
42
+ const DEFAULT_SWEEP_MS = 60 * 1000;
43
+
44
+ export function staleCutoffMinutes(): number {
45
+ const raw = process.env.COMMHUB_STALE_CUTOFF_MINUTES;
46
+ if (raw === undefined || raw === "") return DEFAULT_STALE_MINUTES;
47
+ const n = Number(raw);
48
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_STALE_MINUTES;
49
+ return n;
50
+ }
51
+
52
+ function sqliteCutoff(minutes: number): string {
53
+ return new Date(Date.now() - minutes * 60 * 1000)
54
+ .toISOString()
55
+ .replace("T", " ")
56
+ .slice(0, 19);
57
+ }
58
+
59
+ // Single GLOBAL UPDATE — no per-network filtering. The pre-fix per-
60
+ // request stale marker WAS scoped (each REST call only saw its own
61
+ // network's stale rows), but the semantics of "this row hasn't been
62
+ // heard from in 10min" is global. A network that no one's looking at
63
+ // still wants its agents marked offline so an admin scanning all
64
+ // networks gets consistent state.
65
+ export function sweepStaleSessions(cutoffMinutes = staleCutoffMinutes()): StaleSweepResult {
66
+ const startedAt = new Date().toISOString();
67
+ const t0 = Date.now();
68
+ let markedOffline = 0;
69
+ try {
70
+ const cutoff = sqliteCutoff(cutoffMinutes);
71
+ const res = db.run(
72
+ "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'",
73
+ [cutoff],
74
+ );
75
+ markedOffline = res.changes ?? 0;
76
+ } catch (e: any) {
77
+ console.log(`[commhub stale-sweep] failed: ${e?.message ?? e}`);
78
+ }
79
+ return {
80
+ startedAt,
81
+ durationMs: Date.now() - t0,
82
+ markedOffline,
83
+ staleCutoffMinutes: cutoffMinutes,
84
+ };
85
+ }
86
+
87
+ export function startStaleSessionSweeper(
88
+ intervalMs?: number,
89
+ ): ReturnType<typeof setInterval> {
90
+ const env = process.env.COMMHUB_STALE_SWEEP_SECONDS;
91
+ const envMs = env && Number.isFinite(Number(env)) && Number(env) > 0
92
+ ? Number(env) * 1000
93
+ : undefined;
94
+ const ms = intervalMs ?? envMs ?? DEFAULT_SWEEP_MS;
95
+ const sweep = () => {
96
+ try {
97
+ const r = sweepStaleSessions();
98
+ // Log only when something actually changed — quiet hubs stay quiet.
99
+ if (r.markedOffline > 0) {
100
+ console.log(
101
+ `[commhub stale-sweep] marked ${r.markedOffline} session(s) offline in ${r.durationMs}ms ` +
102
+ `(cutoff=${r.staleCutoffMinutes}min)`,
103
+ );
104
+ }
105
+ } catch (e: any) {
106
+ console.log(`[commhub stale-sweep] top-level failed: ${e?.message ?? e}`);
107
+ }
108
+ };
109
+ // Fire once at boot via setImmediate so the very-first /api/status
110
+ // after server start gets fresh stale state without blocking
111
+ // startup.
112
+ setImmediate(sweep);
113
+ return setInterval(sweep, ms);
114
+ }
@@ -0,0 +1,109 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db } from "./db.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ const NET = "net_start_node"; const USER = "u_start_node";
7
+ const DAEMON = "node_start_daemon"; const DAEMON_ALIAS = "start-daemon";
8
+ const CHILD = "node_start_child"; const CHILD_ALIAS = "start-child";
9
+
10
+ function cleanup() {
11
+ for (const table of ["node_start_requests", "node_create_requests", "audit_log", "nodes", "api_tokens", "network_members", "networks"]) {
12
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
13
+ }
14
+ try { db.run("DELETE FROM users WHERE user_id=?1", USER); } catch {}
15
+ }
16
+ beforeEach(() => { cleanup();
17
+ db.run(`INSERT INTO users(user_id,username,password_hash,role,created_at) VALUES(?1,?1,'x','user',datetime('now'))`, USER);
18
+ db.run(`INSERT INTO networks(network_id,network_name,owner_id,created_at) VALUES(?1,?1,?2,datetime('now'))`, [NET, USER]);
19
+ db.run(`INSERT INTO network_members(user_id,network_id,role,joined_at) VALUES(?1,?2,'owner',datetime('now'))`, [USER, NET]);
20
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,config_snapshot,hostname,created_at,updated_at,lifecycle_state) VALUES(?1,?2,?2,?3,'{}','h',datetime('now'),datetime('now'),'active')`, [DAEMON, DAEMON_ALIAS, NET]);
21
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,config_snapshot,hostname,created_at,updated_at,lifecycle_state) VALUES(?1,?2,?2,?3,'{}','h',datetime('now'),datetime('now'),'stopped')`, [CHILD, CHILD_ALIAS, NET]);
22
+ db.run(`INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('tok_start_daemon',?1,?2,'network',?3,'h')`, [USER, NET, `node:${DAEMON_ALIAS}`]);
23
+ db.run(`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id) VALUES('cr_start_child',?1,?2,?3,'codex-sdk','x','{}','[]','succeeded',1,'t',?4)`, [DAEMON, CHILD_ALIAS, NET, CHILD]);
24
+ });
25
+ afterAll(cleanup);
26
+
27
+ function handlers(user: string | null, daemon = false) {
28
+ const s = new McpServer({ name: "t", version: "0" }) as any; const out: any = {};
29
+ const orig = s.tool.bind(s); s.tool = (n: string, d: string, schema: any, h: any) => { out[n] = h; return orig(n,d,schema,h); };
30
+ registerTools(s, undefined, daemon ? NET : null, user, null, daemon, daemon ? "tok_start_daemon" : null); return out;
31
+ }
32
+ async function call(h: any, args: any) { return JSON.parse((await h(args)).content[0].text); }
33
+
34
+ describe("start_node Hub -> daemon lifecycle", () => {
35
+ test("dispatch, authenticated pull, ack transitions stopped -> starting -> active", async () => {
36
+ const u = handlers(USER); const dispatch = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
37
+ expect(dispatch.ok).toBe(true); expect(dispatch.lifecycle_state).toBe("starting");
38
+ const d = handlers(null, true); const pulled = await call(d.get_start_request, { request_id: dispatch.request_id });
39
+ expect(pulled).toMatchObject({ ok: true, child_node_id: CHILD, child_alias: CHILD_ALIAS });
40
+ const ack = await call(d.ack_start_request, { request_id: dispatch.request_id, status: "started", child_pid: 4321 });
41
+ expect(ack.ok).toBe(true);
42
+ expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe("active");
43
+ expect(db.all<any>(`SELECT action FROM audit_log WHERE target_id=?1 ORDER BY id`, dispatch.request_id).map((x:any)=>x.action)).toEqual(["start_node_dispatched", "start_node_completed"]);
44
+ });
45
+ test("active child and caller-supplied wrong daemon fail closed", async () => {
46
+ const u = handlers(USER);
47
+ db.run(`UPDATE nodes SET lifecycle_state='active' WHERE node_id=?1`, CHILD);
48
+ expect((await call(u.start_node, { child_node_id: CHILD, network_id: NET })).error).toBe("node_not_stopped");
49
+ db.run(`UPDATE nodes SET lifecycle_state='stopped' WHERE node_id=?1`, CHILD);
50
+ expect((await call(u.start_node, { child_node_id: CHILD, daemon_node_id: "node_wrong", network_id: NET })).error).toBe("daemon_child_mismatch");
51
+ });
52
+ });
53
+
54
+ // #1448 finding-2 — stale-starting reaper(对齐 update_node_config/restart_node 的 60s
55
+ // single-flight stale-supersede)。start 只受理 stopped;卡在 starting 的节点(门铃丢 /
56
+ // daemon 在 UPDATE starting 后死)改前永远 start 不了(gate 返 node_not_stopped)、也无
57
+ // reaper 拉回 → 永久卡死。改后:in-flight start 请求晾过 60s ⇒ 标 timeout 超越 + 放行
58
+ // 重派;未过阈值 ⇒ 拒 node_already_starting。
59
+ describe("start_node — stale-starting reaper (#1448 finding-2)", () => {
60
+ function seedStartReq(reqId: string, status: string, ageMs: number) {
61
+ db.run(
62
+ `INSERT INTO node_start_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,created_by_token,status,created_at)
63
+ VALUES(?1,?2,?3,?4,?5,'t',?6,?7)`,
64
+ [reqId, NET, DAEMON, CHILD, CHILD_ALIAS, status, Date.now() - ageMs],
65
+ );
66
+ }
67
+
68
+ test("starting + FRESH in-flight start (< 60s) → refused node_already_starting (not superseded)", async () => {
69
+ db.run(`UPDATE nodes SET lifecycle_state='starting' WHERE node_id=?1`, CHILD);
70
+ seedStartReq("str_fresh", "delivered", 5_000); // 5s old — within threshold
71
+ const u = handlers(USER);
72
+ const r = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
73
+ expect(r.ok).toBe(false);
74
+ expect(r.error).toBe("node_already_starting");
75
+ expect(r.existing_request_id).toBe("str_fresh");
76
+ expect(typeof r.age_ms).toBe("number");
77
+ // old request untouched, no new dispatch
78
+ expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id='str_fresh'`)?.status).toBe("delivered");
79
+ });
80
+
81
+ // witnessed-red:改前 gate 直接 `!== 'stopped' → node_not_stopped`,一个 stale 的
82
+ // 'starting' 节点返回 node_not_stopped、永远卡死。改后应超越重派(ok:true)。
83
+ test("starting + STALE in-flight start (> 60s) → supersede old + re-dispatch (converges)", async () => {
84
+ db.run(`UPDATE nodes SET lifecycle_state='starting' WHERE node_id=?1`, CHILD);
85
+ seedStartReq("str_stale", "delivered", 61_000); // 61s old — past 60s threshold
86
+ const u = handlers(USER);
87
+ const r = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
88
+ expect(r.ok).toBe(true);
89
+ expect(r.lifecycle_state).toBe("starting");
90
+ expect(typeof r.request_id).toBe("string");
91
+ expect(r.request_id).not.toBe("str_stale"); // fresh request
92
+ // old stale request superseded → terminal, unblocks the child-inflight unique index
93
+ expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id='str_stale'`)?.status).toBe("timeout");
94
+ // exactly one non-terminal start request now (the new one)
95
+ const live = db.all<any>(`SELECT request_id FROM node_start_requests WHERE child_node_id=?1 AND status IN ('pending','delivered')`, CHILD);
96
+ expect(live.map((x:any)=>x.request_id)).toEqual([r.request_id]);
97
+ // audit records the real prior state
98
+ const aud = db.get<any>(`SELECT detail FROM audit_log WHERE target_id=?1`, r.request_id);
99
+ expect(JSON.parse(aud.detail).lifecycle_state_before).toBe("starting");
100
+ });
101
+
102
+ test("starting + NO in-flight row (orphaned state) → self-heals by re-dispatching", async () => {
103
+ db.run(`UPDATE nodes SET lifecycle_state='starting' WHERE node_id=?1`, CHILD);
104
+ const u = handlers(USER);
105
+ const r = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
106
+ expect(r.ok).toBe(true);
107
+ expect(r.lifecycle_state).toBe("starting");
108
+ });
109
+ });