@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,482 @@
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 { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
5
+ import { registerTools } from "./tools.js";
6
+
7
+ const NET = "net_task_consumption";
8
+ const USER = "user_task_consumption";
9
+ const NODE_A = "node_consumption_a";
10
+ const NODE_B = "node_consumption_b";
11
+
12
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
13
+
14
+ function cleanup() {
15
+ for (const table of ["scheduled_task_runs", "scheduled_tasks", "tasks", "inbox", "task_events", "sessions", "nodes"]) {
16
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
17
+ }
18
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
19
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
20
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
21
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
22
+ }
23
+
24
+ function seed() {
25
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))", [USER]);
26
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
27
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
28
+ for (const alias of [NODE_A, NODE_B]) {
29
+ db.run(
30
+ "INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', datetime('now'), datetime('now'), 'active')",
31
+ [`id_${alias}`, alias, NET],
32
+ );
33
+ db.run(
34
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, bound_node_id) VALUES (?1, ?1, ?2, ?3, ?4, ?5)",
35
+ [`token_${alias}`, USER, NET, alias, `id_${alias}`],
36
+ );
37
+ db.run(
38
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))",
39
+ [`resume_${alias}`, alias, `id_${alias}`, NET],
40
+ );
41
+ }
42
+ }
43
+
44
+ function toolsFor(opts: { alias?: string; nodeToken?: boolean } = {}) {
45
+ const server = new McpServer({ name: "task-consumption-test", version: "0" }) as any;
46
+ const tools: Record<string, ToolHandler> = {};
47
+ const original = server.tool.bind(server);
48
+ server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
49
+ tools[name] = handler;
50
+ return original(name, description, schema, handler);
51
+ };
52
+ registerTools(
53
+ server,
54
+ undefined,
55
+ NET,
56
+ USER,
57
+ opts.alias ?? USER,
58
+ opts.nodeToken ?? false,
59
+ opts.nodeToken ? `token_${opts.alias}` : null,
60
+ );
61
+ return tools;
62
+ }
63
+
64
+ async function call(handler: ToolHandler, args: any) {
65
+ const result = await handler(args);
66
+ return JSON.parse(result.content[0].text);
67
+ }
68
+
69
+ async function createTask(content: string, target = NODE_A): Promise<string> {
70
+ // The test calls the registered handler directly, bypassing MCP/Zod's
71
+ // default-value application, so spell out the production default.
72
+ const sent = await call(toolsFor()["send_task"], {
73
+ alias: target,
74
+ task: content,
75
+ priority: "normal",
76
+ });
77
+ expect(sent.ok).toBe(true);
78
+ return sent.task_id ?? sent.message_id;
79
+ }
80
+
81
+ function consumedAt(taskId: string): string | null {
82
+ return db.get<{ consumed_at: string | null }>(
83
+ "SELECT consumed_at FROM tasks WHERE task_id = ?1",
84
+ taskId,
85
+ )?.consumed_at ?? null;
86
+ }
87
+
88
+ function runtimeSubmittedAt(taskId: string): string | null {
89
+ return db.get<{ runtime_submitted_at: string | null }>(
90
+ "SELECT runtime_submitted_at FROM tasks WHERE task_id = ?1",
91
+ taskId,
92
+ )?.runtime_submitted_at ?? null;
93
+ }
94
+
95
+ function runtimeContext(taskId: string): { thread_id: string | null; turn_id: string | null } {
96
+ return db.get<{ thread_id: string | null; turn_id: string | null }>(
97
+ "SELECT thread_id, turn_id FROM tasks WHERE task_id = ?1",
98
+ taskId,
99
+ )!;
100
+ }
101
+
102
+ beforeEach(() => { cleanup(); seed(); });
103
+ afterAll(cleanup);
104
+
105
+ describe("task consumed_at identity and lifecycle", () => {
106
+ test("immutable node cursor paginates beyond 100 without alias or cross-node leakage", async () => {
107
+ for (let i = 0; i < 125; i++) {
108
+ db.run(
109
+ `INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id, created_at)
110
+ VALUES (?1, ?2, ?3, ?4, 'replied', 'x', ?5, ?6)`,
111
+ [`page-${String(i).padStart(3, "0")}`, `id_${NODE_A}`, i < 60 ? "old-alias" : NODE_A, NODE_B, NET, `2026-01-01 00:${String(Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}`],
112
+ );
113
+ }
114
+ db.run(
115
+ `INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id)
116
+ VALUES ('foreign-node-row', ?1, ?2, ?3, 'replied', 'x', ?4)`,
117
+ [`id_${NODE_B}`, NODE_A, NODE_A, NET],
118
+ );
119
+ const handler = toolsFor({ alias: NODE_A, nodeToken: true }).list_tasks;
120
+ const first = await call(handler, { from_node_id: `id_${NODE_A}`, durable_cursor: true, limit: 100 });
121
+ expect(first.capability).toBe("list_tasks.immutable-node-cursor.v1");
122
+ expect(first.tasks).toHaveLength(100);
123
+ const second = await call(handler, { from_node_id: `id_${NODE_A}`, durable_cursor: true, limit: 100, ...first.next_cursor });
124
+ expect(second.tasks).toHaveLength(25);
125
+ expect(new Set([...first.tasks, ...second.tasks].map((task: any) => task.task_id)).size).toBe(125);
126
+ expect([...first.tasks, ...second.tasks].some((task: any) => task.task_id === "foreign-node-row")).toBe(false);
127
+ expect((await call(handler, { from_node_id: `id_${NODE_B}`, durable_cursor: true, limit: 100 })).error).toBe("from_node_id_identity_mismatch");
128
+ });
129
+ test("a legacy unbound node token is refused the durable cursor even for its own node", async () => {
130
+ // Pre-RFC-036 tokens carry no immutable node binding. The durable outbox
131
+ // cursor must stay closed to them (no bind-on-first-use); agent-node
132
+ // degrades to inbox-only compensation on this exact error code.
133
+ db.run("UPDATE api_tokens SET bound_node_id = NULL WHERE token_id = ?1", [`token_${NODE_A}`]);
134
+ const handler = toolsFor({ alias: NODE_A, nodeToken: true }).list_tasks;
135
+ expect((await call(handler, { from_node_id: `id_${NODE_A}`, durable_cursor: true, limit: 10 })).error).toBe("from_node_id_identity_mismatch");
136
+ expect((await call(handler, { from_node_id: `id_${NODE_B}`, durable_cursor: true, limit: 10 })).error).toBe("from_node_id_identity_mismatch");
137
+ const bound = db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", `token_${NODE_A}`);
138
+ expect(bound?.bound_node_id).toBeNull();
139
+ });
140
+ test("terminal sequence paginates beyond 2000 and admits late completion of an old task", async () => {
141
+ db.run(
142
+ `INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id, created_at)
143
+ VALUES ('old-open-task', ?1, 'renamed-sender', ?2, 'running', 'late', ?3, '2020-01-01 00:00:00')`,
144
+ [`id_${NODE_A}`, NODE_B, NET],
145
+ );
146
+ for (let i = 0; i < 2_001; i++) {
147
+ db.run(
148
+ `INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id, created_at, completed_at)
149
+ VALUES (?1, ?2, ?3, ?4, 'replied', 'x', ?5, ?6, datetime('now'))`,
150
+ [`terminal-page-${String(i).padStart(4, "0")}`, `id_${NODE_A}`, i < 1_000 ? "old-alias" : NODE_A, NODE_B, NET, `2026-01-01 00:00:${String(i % 60).padStart(2, "0")}`],
151
+ );
152
+ }
153
+ db.run(
154
+ `INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id)
155
+ VALUES ('terminal-cross-network', ?1, ?2, ?3, 'replied', 'x', 'other-network')`,
156
+ [`id_${NODE_A}`, NODE_A, NODE_B],
157
+ );
158
+ const handler = toolsFor({ alias: NODE_A, nodeToken: true }).list_tasks;
159
+ let after = 0;
160
+ const ids: string[] = [];
161
+ for (let page = 0; page < 30; page++) {
162
+ const response = await call(handler, {
163
+ from_node_id: `id_${NODE_A}`, durable_cursor: true, durable_terminal_cursor: true,
164
+ after_terminal_seq: after, limit: 100,
165
+ });
166
+ expect(response.capability).toBe("list_tasks.immutable-terminal-sequence.v2");
167
+ ids.push(...response.tasks.map((task: any) => task.task_id));
168
+ after = response.next_terminal_seq;
169
+ if (!response.has_more) break;
170
+ }
171
+ expect(ids).toHaveLength(2_001);
172
+ expect(new Set(ids).size).toBe(2_001);
173
+ expect(ids).not.toContain("terminal-cross-network");
174
+ db.run("UPDATE tasks SET status = 'failed', completed_at = datetime('now') WHERE task_id = 'old-open-task'");
175
+ const late = await call(handler, {
176
+ from_node_id: `id_${NODE_A}`, durable_cursor: true, durable_terminal_cursor: true,
177
+ after_terminal_seq: after, limit: 100,
178
+ });
179
+ expect(late.tasks.map((task: any) => task.task_id)).toEqual(["old-open-task"]);
180
+ }, 60_000); // #1627 —— 原 20_000。历史基线 4196ms;2026-08-31 实测 21018ms(5.0×),撞上限。
181
+ test("enqueue and process-level ack keep consumed_at null", async () => {
182
+ const content = "connected process has not started a model turn";
183
+ const before = db.get<{ last_seen_at: string; task: string | null }>(
184
+ "SELECT last_seen_at, task FROM sessions WHERE network_id = ?1 AND alias = ?2",
185
+ NET,
186
+ NODE_A,
187
+ );
188
+ const taskId = await createTask(content);
189
+ const nodeTools = toolsFor({ alias: NODE_A, nodeToken: true });
190
+ const pending = await call(nodeTools.get_inbox, { alias: NODE_A, limit: 20 });
191
+ expect(pending.messages.find((row: any) => row.id === taskId)?.task_id).toBe(taskId);
192
+ expect(runtimeSubmittedAt(taskId)).toBeNull();
193
+ expect(consumedAt(taskId)).toBeNull();
194
+ const queued = db.get<{ last_seen_at: string; task: string | null }>(
195
+ "SELECT last_seen_at, task FROM sessions WHERE network_id = ?1 AND alias = ?2",
196
+ NET,
197
+ NODE_A,
198
+ );
199
+ // Preserve the issue's witnessed-red shape: dispatch echoes the sender's
200
+ // own text into the legacy session.task field while the receiver's
201
+ // heartbeat remains frozen. consumed_at must not inherit that false cue.
202
+ expect(queued?.task).toBe(content);
203
+ expect(queued?.last_seen_at).toBe(before?.last_seen_at);
204
+
205
+ expect((await call(nodeTools.ack_inbox, { alias: NODE_A, message_id: taskId })).ok).toBe(true);
206
+ expect(runtimeSubmittedAt(taskId)).toBeNull();
207
+ expect(consumedAt(taskId)).toBeNull();
208
+ });
209
+
210
+ test("runtime submission is visible without claiming authoritative consumption", async () => {
211
+ const taskId = await createTask("vendor request accepted but no turn event yet");
212
+ const handler = toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_runtime_submitted;
213
+ expect((await call(handler, { task_ids: [taskId] })).ok).toBe(true);
214
+ expect(typeof runtimeSubmittedAt(taskId)).toBe("string");
215
+ expect(consumedAt(taskId)).toBeNull();
216
+ });
217
+
218
+ test("token-bound target marks exact task idempotently", async () => {
219
+ const taskId = await createTask("runtime emitted an attributable event");
220
+ const handler = toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed;
221
+
222
+ const first = await call(handler, { task_ids: [taskId] });
223
+ expect(first.ok).toBe(true);
224
+ expect(first.tasks).toHaveLength(1);
225
+ expect(typeof runtimeSubmittedAt(taskId)).toBe("string");
226
+ expect(typeof consumedAt(taskId)).toBe("string");
227
+ const timestamp = consumedAt(taskId);
228
+
229
+ const replay = await call(handler, { task_ids: [taskId] });
230
+ expect(replay.ok).toBe(true);
231
+ expect(consumedAt(taskId)).toBe(timestamp);
232
+
233
+ const listed = await call(toolsFor().list_tasks, { alias: NODE_A, limit: 20 });
234
+ const listedTask = listed.tasks.find((task: any) => task.task_id === taskId);
235
+ expect(listedTask?.runtime_submitted_at).toBe(runtimeSubmittedAt(taskId));
236
+ expect(listedTask?.consumed_at).toBe(timestamp);
237
+ });
238
+
239
+ test("token-bound consumed evidence persists and exposes an exact BTW boundary", async () => {
240
+ const taskId = await createTask("runtime emitted exact Codex context");
241
+ const handler = toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed;
242
+ const context = { task_id: taskId, thread_id: "thread_btw_exact", turn_id: "turn_btw_exact" };
243
+
244
+ const first = await call(handler, { task_ids: [taskId], task_contexts: [context] });
245
+ expect(first.ok).toBe(true);
246
+ expect(first.tasks[0]).toMatchObject(context);
247
+ expect(runtimeContext(taskId)).toEqual({
248
+ thread_id: context.thread_id,
249
+ turn_id: context.turn_id,
250
+ });
251
+
252
+ const listed = await call(toolsFor().list_tasks, { alias: NODE_A, limit: 20 });
253
+ expect(listed.tasks.find((task: any) => task.task_id === taskId)).toMatchObject(context);
254
+
255
+ const replay = await call(handler, { task_ids: [taskId], task_contexts: [context] });
256
+ expect(replay.ok).toBe(true);
257
+ const conflict = await call(handler, {
258
+ task_ids: [taskId],
259
+ task_contexts: [{ ...context, turn_id: "turn_sibling" }],
260
+ });
261
+ expect(conflict).toEqual({
262
+ ok: false,
263
+ error: "task_runtime_context_conflict",
264
+ task_id: taskId,
265
+ });
266
+ expect(runtimeContext(taskId)).toEqual({
267
+ thread_id: context.thread_id,
268
+ turn_id: context.turn_id,
269
+ });
270
+ });
271
+
272
+ test("runtime context rejects user tokens, foreign tasks, and unrequested ids without partial writes", async () => {
273
+ const ownTask = await createTask("owned context", NODE_A);
274
+ const foreignTask = await createTask("foreign context", NODE_B);
275
+ const ownContext = { task_id: ownTask, thread_id: "thread_owned", turn_id: "turn_owned" };
276
+ const foreignContext = { task_id: foreignTask, thread_id: "thread_foreign", turn_id: "turn_foreign" };
277
+
278
+ expect(await call(toolsFor().mark_tasks_consumed, {
279
+ task_ids: [ownTask], task_contexts: [ownContext],
280
+ })).toEqual({ ok: false, error: "node_token_required" });
281
+ expect(await call(toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed, {
282
+ task_ids: [ownTask, foreignTask], task_contexts: [ownContext, foreignContext],
283
+ })).toEqual({ ok: false, error: "task_not_owned", task_id: foreignTask });
284
+ expect(await call(toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed, {
285
+ task_ids: [ownTask], task_contexts: [foreignContext],
286
+ })).toEqual({ ok: false, error: "task_context_not_requested", task_id: foreignTask });
287
+ expect(runtimeContext(ownTask)).toEqual({ thread_id: null, turn_id: null });
288
+ expect(runtimeContext(foreignTask)).toEqual({ thread_id: null, turn_id: null });
289
+ });
290
+
291
+ test("submitted evidence cannot smuggle a runtime context", async () => {
292
+ const taskId = await createTask("submission has no authoritative turn yet");
293
+ const result = await call(
294
+ toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_runtime_submitted,
295
+ {
296
+ task_ids: [taskId],
297
+ task_contexts: [{ task_id: taskId, thread_id: "thread_early", turn_id: "turn_early" }],
298
+ },
299
+ );
300
+ expect(result).toEqual({ ok: false, error: "runtime_context_requires_consumed" });
301
+ expect(runtimeSubmittedAt(taskId)).toBeNull();
302
+ expect(runtimeContext(taskId)).toEqual({ thread_id: null, turn_id: null });
303
+ });
304
+
305
+ test("one runtime wake can mark an exact batch of owned tasks", async () => {
306
+ const firstTask = await createTask("batched wake first");
307
+ const secondTask = await createTask("batched wake second");
308
+ const result = await call(
309
+ toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed,
310
+ { task_ids: [firstTask, secondTask] },
311
+ );
312
+ expect(result.ok).toBe(true);
313
+ expect(new Set(result.tasks.map((row: any) => row.task_id)))
314
+ .toEqual(new Set([firstTask, secondTask]));
315
+ expect(typeof consumedAt(firstTask)).toBe("string");
316
+ expect(typeof consumedAt(secondTask)).toBe("string");
317
+ });
318
+
319
+ test("foreign or missing row rejects the whole batch with zero partial writes", async () => {
320
+ const ownTask = await createTask("owned", NODE_A);
321
+ const foreignTask = await createTask("foreign", NODE_B);
322
+ const handler = toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed;
323
+
324
+ expect(await call(handler, { task_ids: [ownTask, foreignTask] })).toEqual({
325
+ ok: false,
326
+ error: "task_not_owned",
327
+ task_id: foreignTask,
328
+ });
329
+ expect(consumedAt(ownTask)).toBeNull();
330
+ expect(consumedAt(foreignTask)).toBeNull();
331
+ expect(runtimeSubmittedAt(ownTask)).toBeNull();
332
+ expect(runtimeSubmittedAt(foreignTask)).toBeNull();
333
+ });
334
+
335
+ test("user token cannot forge runtime consumption", async () => {
336
+ const taskId = await createTask("user token must not stamp node evidence");
337
+ const result = await call(toolsFor().mark_tasks_consumed, { task_ids: [taskId] });
338
+ expect(result).toEqual({ ok: false, error: "node_token_required" });
339
+ expect(runtimeSubmittedAt(taskId)).toBeNull();
340
+ expect(consumedAt(taskId)).toBeNull();
341
+ });
342
+
343
+ test("token-bound canonical alias is the fail-closed fallback only for tasks without node_id", async () => {
344
+ const taskId = await createTask("legacy direct session has no immutable node id");
345
+ db.run("UPDATE tasks SET to_node_id = NULL WHERE task_id = ?1", [taskId]);
346
+ db.run("UPDATE sessions SET node_id = NULL WHERE network_id = ?1 AND alias = ?2", [NET, NODE_A]);
347
+
348
+ const ownerResult = await call(
349
+ toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed,
350
+ { task_ids: [taskId] },
351
+ );
352
+ expect(ownerResult.ok).toBe(true);
353
+ expect(typeof consumedAt(taskId)).toBe("string");
354
+
355
+ const foreignTask = await createTask("alias fallback must not cross target aliases", NODE_A);
356
+ db.run("UPDATE tasks SET to_node_id = NULL WHERE task_id = ?1", [foreignTask]);
357
+ const foreignResult = await call(
358
+ toolsFor({ alias: NODE_B, nodeToken: true }).mark_tasks_consumed,
359
+ { task_ids: [foreignTask] },
360
+ );
361
+ expect(foreignResult).toEqual({ ok: false, error: "task_not_owned", task_id: foreignTask });
362
+ expect(consumedAt(foreignTask)).toBeNull();
363
+ });
364
+
365
+ test("retry keeps task-lifetime evidence and exposes the logical task id on the new inbox row", async () => {
366
+ const taskId = await createTask("task-lifetime evidence");
367
+ const nodeTools = toolsFor({ alias: NODE_A, nodeToken: true });
368
+ expect((await call(nodeTools.ack_inbox, { alias: NODE_A, message_id: taskId })).ok).toBe(true);
369
+ expect((await call(nodeTools.mark_tasks_consumed, { task_ids: [taskId] })).ok).toBe(true);
370
+ const submitted = runtimeSubmittedAt(taskId);
371
+ const consumed = consumedAt(taskId);
372
+ expect(typeof submitted).toBe("string");
373
+ expect(typeof consumed).toBe("string");
374
+
375
+ db.run("UPDATE tasks SET status = 'failed' WHERE task_id = ?1", [taskId]);
376
+ expect((await call(toolsFor().retry_task, { task_id: taskId })).ok).toBe(true);
377
+ expect(runtimeSubmittedAt(taskId)).toBe(submitted);
378
+ expect(consumedAt(taskId)).toBe(consumed);
379
+
380
+ const retriedInbox = await call(nodeTools.get_inbox, { alias: NODE_A, limit: 20 });
381
+ const retryRow = retriedInbox.messages.find((row: any) => row.id !== taskId && row.task_id === taskId);
382
+ expect(retryRow).toBeDefined();
383
+ expect((await call(nodeTools.ack_inbox, { alias: NODE_A, message_id: retryRow.task_id })).ok).toBe(true);
384
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", retryRow.id)?.acked).toBe(1);
385
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", taskId)?.status).toBe("acked");
386
+ });
387
+
388
+ test("an unconsumed first attempt can report against the linked task after retry", async () => {
389
+ const taskId = await createTask("retry must not lose logical task identity");
390
+ const nodeTools = toolsFor({ alias: NODE_A, nodeToken: true });
391
+ expect((await call(nodeTools.ack_inbox, { alias: NODE_A, message_id: taskId })).ok).toBe(true);
392
+ db.run("UPDATE tasks SET status = 'failed' WHERE task_id = ?1", [taskId]);
393
+ expect((await call(toolsFor().retry_task, { task_id: taskId })).ok).toBe(true);
394
+
395
+ const retriedInbox = await call(nodeTools.get_inbox, { alias: NODE_A, limit: 20 });
396
+ const retryRow = retriedInbox.messages.find((row: any) => row.id !== taskId && row.task_id === taskId);
397
+ expect(retryRow).toBeDefined();
398
+ // Backward compatibility: an older consumer may still ACK the transport
399
+ // inbox.id even though all task lifecycle operations use logical task_id.
400
+ expect((await call(nodeTools.ack_inbox, { alias: NODE_A, message_id: retryRow.id })).ok).toBe(true);
401
+ expect((await call(nodeTools.mark_tasks_consumed, { task_ids: [retryRow.task_id] })).ok).toBe(true);
402
+ expect(typeof runtimeSubmittedAt(taskId)).toBe("string");
403
+ expect(typeof consumedAt(taskId)).toBe("string");
404
+ });
405
+
406
+ test("reassign preserves task-lifetime evidence and binds the new inbox row to the same task", async () => {
407
+ const taskId = await createTask("reassign keeps logical task evidence");
408
+ const oldOwnerTools = toolsFor({ alias: NODE_A, nodeToken: true });
409
+ expect((await call(oldOwnerTools.ack_inbox, { alias: NODE_A, message_id: taskId })).ok).toBe(true);
410
+ expect((await call(oldOwnerTools.mark_tasks_consumed, { task_ids: [taskId] })).ok).toBe(true);
411
+ const submitted = runtimeSubmittedAt(taskId);
412
+ const consumed = consumedAt(taskId);
413
+
414
+ expect((await call(toolsFor().reassign_task, { task_id: taskId, new_alias: NODE_B })).ok).toBe(true);
415
+ expect(runtimeSubmittedAt(taskId)).toBe(submitted);
416
+ expect(consumedAt(taskId)).toBe(consumed);
417
+
418
+ const newOwnerTools = toolsFor({ alias: NODE_B, nodeToken: true });
419
+ const reassignedInbox = await call(newOwnerTools.get_inbox, { alias: NODE_B, limit: 20 });
420
+ const row = reassignedInbox.messages.find((message: any) => message.task_id === taskId);
421
+ expect(row).toBeDefined();
422
+ expect(row.id).not.toBe(taskId);
423
+ expect((await call(newOwnerTools.ack_inbox, { alias: NODE_B, message_id: row.task_id })).ok).toBe(true);
424
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", row.id)?.acked).toBe(1);
425
+ expect(await call(oldOwnerTools.mark_tasks_consumed, { task_ids: [taskId] })).toEqual({
426
+ ok: false,
427
+ error: "task_not_owned",
428
+ task_id: taskId,
429
+ });
430
+ expect((await call(newOwnerTools.mark_tasks_consumed, { task_ids: [taskId] })).ok).toBe(true);
431
+ });
432
+
433
+ test("scheduler delivery writes and exposes the same logical task id", async () => {
434
+ const scheduleId = `sched_${crypto.randomUUID()}`;
435
+ const scheduledFor = new Date(Date.now() + 60_000).toISOString();
436
+ const row = {
437
+ schedule_id: scheduleId,
438
+ network_id: NET,
439
+ created_by: USER,
440
+ name: "task linkage schedule",
441
+ target_node_id: `id_${NODE_A}`,
442
+ target_alias: NODE_A,
443
+ task_content: "scheduler must preserve logical task identity",
444
+ priority: "normal",
445
+ schedule_type: "once",
446
+ schedule_json: JSON.stringify({ type: "once", run_at: scheduledFor }),
447
+ timezone: "UTC",
448
+ overlap_policy: "skip",
449
+ misfire_policy: "catch_up_once",
450
+ status: "active",
451
+ next_run_at: scheduledFor,
452
+ last_run_at: null,
453
+ revision: 1,
454
+ created_at: new Date().toISOString(),
455
+ updated_at: new Date().toISOString(),
456
+ } as any;
457
+ db.run(
458
+ `INSERT INTO scheduled_tasks
459
+ (schedule_id, network_id, created_by, name, target_node_id, target_alias,
460
+ task_content, priority, schedule_type, schedule_json, timezone,
461
+ overlap_policy, misfire_policy, status, next_run_at)
462
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)`,
463
+ [scheduleId, NET, USER, row.name, row.target_node_id, NODE_A, row.task_content,
464
+ row.priority, row.schedule_type, row.schedule_json, row.timezone,
465
+ row.overlap_policy, row.misfire_policy, row.status, row.next_run_at],
466
+ );
467
+
468
+ const dispatched = dispatchScheduledOccurrence(row, scheduledFor, false);
469
+ expect(dispatched.taskId).toBeTruthy();
470
+ const inbox = db.get<{ id: string; task_id: string }>(
471
+ "SELECT id, task_id FROM inbox WHERE id = ?1",
472
+ [dispatched.taskId],
473
+ );
474
+ expect(inbox).toEqual({ id: dispatched.taskId!, task_id: dispatched.taskId! });
475
+ const pending = await call(
476
+ toolsFor({ alias: NODE_A, nodeToken: true }).get_inbox,
477
+ { alias: NODE_A, limit: 20 },
478
+ );
479
+ expect(pending.messages.find((message: any) => message.id === dispatched.taskId)?.task_id)
480
+ .toBe(dispatched.taskId);
481
+ });
482
+ });
@@ -0,0 +1,149 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-task-diagnostic-"));
7
+ process.env.COMMHUB_DB = join(PRIVATE_DB_DIR, "hub.db");
8
+
9
+ let db: typeof import("./db.js").db;
10
+ let server: ReturnType<typeof Bun.serve>;
11
+ let base = "";
12
+ let tokenA = "";
13
+ let tokenB = "";
14
+ let networkA = "";
15
+ let networkB = "";
16
+
17
+ const ALIAS = "diagnostic-worker";
18
+
19
+ async function detail(token: string, taskId: string): Promise<{ status: number; body: any }> {
20
+ const response = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}`, {
21
+ headers: { Authorization: `Bearer ${token}` },
22
+ });
23
+ return { status: response.status, body: await response.json() };
24
+ }
25
+
26
+ function insertTask(taskId: string, patch: {
27
+ status?: string;
28
+ runtimeSubmittedAt?: string | null;
29
+ consumedAt?: string | null;
30
+ } = {}) {
31
+ db.run(
32
+ `INSERT INTO tasks
33
+ (task_id, from_name, to_node_id, to_name, status, content, priority,
34
+ network_id, runtime_submitted_at, consumed_at)
35
+ VALUES (?1, 'diagnostic-sender', 'node-diagnostic-a', ?2, ?3,
36
+ 'diagnose me', 'normal', ?4, ?5, ?6)`,
37
+ [taskId, ALIAS, patch.status ?? "delivered", networkA, patch.runtimeSubmittedAt ?? null, patch.consumedAt ?? null],
38
+ );
39
+ }
40
+
41
+ async function openSse(token: string, networkId: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {
42
+ const response = await fetch(`${base}/events/${encodeURIComponent(ALIAS)}?network_id=${encodeURIComponent(networkId)}`, {
43
+ headers: { Authorization: `Bearer ${token}` },
44
+ });
45
+ expect(response.status).toBe(200);
46
+ const reader = response.body!.getReader();
47
+ const first = await reader.read();
48
+ expect(new TextDecoder().decode(first.value)).toContain('"type":"connected"');
49
+ return reader;
50
+ }
51
+
52
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
53
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
54
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
55
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
56
+ beforeAll(async () => {
57
+ ({ db } = await import("./db.js"));
58
+ const { register } = await import("./auth.js");
59
+ const authA = register(`diagnostic_a_${Date.now()}`, "Diagnostic-A-Strong-1!", undefined, "diagnostic-a");
60
+ const authB = register(`diagnostic_b_${Date.now()}`, "Diagnostic-B-Strong-1!", undefined, "diagnostic-b");
61
+ expect(authA.ok).toBe(true);
62
+ expect(authB.ok).toBe(true);
63
+ tokenA = authA.token!;
64
+ tokenB = authB.token!;
65
+ networkA = authA.network_id!;
66
+ networkB = authB.network_id!;
67
+
68
+ for (const [networkId, suffix] of [[networkA, "a"], [networkB, "b"]] as const) {
69
+ db.run(
70
+ `INSERT INTO nodes
71
+ (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
72
+ VALUES (?1, ?2, ?2, ?3, 'diagnostic-host', datetime('now'), datetime('now'), 'active')`,
73
+ [`node-diagnostic-${suffix}`, ALIAS, networkId],
74
+ );
75
+ db.run(
76
+ `INSERT INTO sessions
77
+ (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
78
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
79
+ [`resume-diagnostic-${suffix}`, ALIAS, `node-diagnostic-${suffix}`, networkId],
80
+ );
81
+ }
82
+
83
+ const mod = await import("./server.js");
84
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
85
+ base = `http://127.0.0.1:${server.port}`;
86
+ }, 30_000);
87
+
88
+ afterAll(() => {
89
+ try { server?.stop(true); } catch {}
90
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
91
+ });
92
+
93
+ describe("#166 scoped single-task diagnostics", () => {
94
+ test("does not count another network's same-alias SSE connection", async () => {
95
+ insertTask("diag-cross-network");
96
+ const foreignReader = await openSse(tokenB, networkB);
97
+ try {
98
+ const own = await detail(tokenA, "diag-cross-network");
99
+ expect(own.status).toBe(200);
100
+ expect(own.body.diagnostic).toMatchObject({
101
+ code: "target_no_live_sse",
102
+ evidence: { live_sse_connections: 0 },
103
+ });
104
+ const foreign = await detail(tokenB, "diag-cross-network");
105
+ expect(foreign.status).toBe(404);
106
+ expect(foreign.body).toMatchObject({ ok: false, error: "task_not_found" });
107
+ } finally {
108
+ await foreignReader.cancel();
109
+ }
110
+ });
111
+
112
+ test("reports a live same-network SSE without claiming runtime consumption", async () => {
113
+ insertTask("diag-live-sse");
114
+ const reader = await openSse(tokenA, networkA);
115
+ try {
116
+ const result = await detail(tokenA, "diag-live-sse");
117
+ expect(result.body.diagnostic).toMatchObject({
118
+ code: "delivered_waiting_for_agent",
119
+ evidence: {
120
+ target_session_status: "idle",
121
+ live_sse_connections: 1,
122
+ runtime_submitted: false,
123
+ runtime_consumed: false,
124
+ },
125
+ });
126
+ } finally {
127
+ await reader.cancel();
128
+ }
129
+ });
130
+
131
+ test("uses authoritative runtime evidence before current connectivity", async () => {
132
+ insertTask("diag-submitted", { runtimeSubmittedAt: "2026-08-10T00:00:00Z" });
133
+ insertTask("diag-consumed", {
134
+ runtimeSubmittedAt: "2026-08-10T00:00:00Z",
135
+ consumedAt: "2026-08-10T00:00:01Z",
136
+ });
137
+ db.run("UPDATE sessions SET status = 'offline' WHERE node_id = 'node-diagnostic-a' AND network_id = ?1", [networkA]);
138
+ expect((await detail(tokenA, "diag-submitted")).body.diagnostic.code).toBe("runtime_submitted_waiting_for_signal");
139
+ expect((await detail(tokenA, "diag-consumed")).body.diagnostic.code).toBe("runtime_consumed_nonterminal");
140
+ });
141
+
142
+ test("distinguishes offline and missing target sessions", async () => {
143
+ insertTask("diag-offline");
144
+ expect((await detail(tokenA, "diag-offline")).body.diagnostic.code).toBe("target_session_offline");
145
+ db.run("DELETE FROM sessions WHERE node_id = 'node-diagnostic-a' AND network_id = ?1", [networkA]);
146
+ insertTask("diag-missing");
147
+ expect((await detail(tokenA, "diag-missing")).body.diagnostic.code).toBe("target_session_missing");
148
+ });
149
+ });