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

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 (318) 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 +933 -36
  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 +130 -0
  205. package/src/rest-identity.ts +139 -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 +93 -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 +140 -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-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,140 @@
1
+ // f015d9d6 症状①:tasks.status 被置为 'delivered',而 SSE 推送根本没发出。
2
+ //
3
+ // send_task 无条件以字面量 'delivered' 插入 tasks 行,推送却另外被
4
+ // `if (target.state === "online")` 挡着(tools.ts:1432)。同一段代码里给
5
+ // network observer 的事件把同一件事标成 "queued" —— 也就是说代码**知道**
6
+ // 它没投出去,却仍然把 delivered 写进了 tasks.status。
7
+ //
8
+ // 而 "online" 的判据是 resolveDeliveryTarget 里的
9
+ // stale = now - (last_seen_at || updated_at) > 5 分钟
10
+ // 且 last_seen_at 只在 report_status 时刷新(tools.ts:674/702)。
11
+ // 全网「停发心跳/状态类消息」的省额度规则之下,一个安静的 MCP 会话必然变 stale。
12
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { db } from "./db.js";
15
+ import { registerTools } from "./tools.js";
16
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
17
+
18
+ const NET = "net_delivered_push_f015";
19
+ const USER = "user_delivered_push_f015";
20
+ const SENDER = "sender-f015b";
21
+ const TARGET = "quiet-mcp-f015b";
22
+
23
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
24
+
25
+ function cleanup() {
26
+ __resetSSEClientsForTest();
27
+ for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
28
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
29
+ }
30
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
31
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
32
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
33
+ }
34
+
35
+ /** minutesAgo 控制目标会话的 last_seen_at —— 这是本文件唯一要动的变量。 */
36
+ function seed(minutesAgo: number) {
37
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
38
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
39
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
40
+ for (const alias of [SENDER, TARGET]) {
41
+ db.run(
42
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, 'network')",
43
+ [`token-${alias}`, `hash-${alias}`, USER, NET, `node:${alias}`],
44
+ );
45
+ }
46
+ db.run(
47
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?1, 'idle', NULL, ?2, datetime('now'), datetime('now', ?3))",
48
+ [SENDER, NET, "-1 minutes"],
49
+ );
50
+ // 🔴 目标:MCP 挂载的 claude-code 会话形状(node_id NULL),
51
+ // status 是 'idle'(不是 offline),只是安静了 minutesAgo 分钟。
52
+ db.run(
53
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now', ?4), datetime('now', ?4))",
54
+ [`cc-n_${TARGET}`, TARGET, NET, `-${minutesAgo} minutes`],
55
+ );
56
+ }
57
+
58
+ function toolsFor(alias: string): Record<string, ToolHandler> {
59
+ const server = new McpServer({ name: "delivered-push-f015", version: "0" }) as any;
60
+ const tools: Record<string, ToolHandler> = {};
61
+ const original = server.tool.bind(server);
62
+ server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
63
+ tools[name] = handler; return original(name, description, schema, handler);
64
+ };
65
+ registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
66
+ return tools;
67
+ }
68
+
69
+ /** 从订阅者那一侧真正读出字节 —— 地面真相,不是连接计数。 */
70
+ async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, needle: string, ms = 1500): Promise<string | null> {
71
+ const dec = new TextDecoder();
72
+ let buf = "";
73
+ const deadline = Date.now() + ms;
74
+ while (Date.now() < deadline) {
75
+ const chunk = await Promise.race([
76
+ reader.read(),
77
+ new Promise<{ done: true; value: undefined }>((r) => setTimeout(() => r({ done: true, value: undefined }), 200)),
78
+ ]);
79
+ if (chunk.value) buf += dec.decode(chunk.value, { stream: true });
80
+ if (buf.includes(needle)) return buf;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ let sendSeq = 0;
86
+ /** minutesAgo = 目标安静了多久;withSubscriber = 是否真的有人连着。 */
87
+ async function send(minutesAgo: number, withSubscriber = true) {
88
+ cleanup(); seed(minutesAgo);
89
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
90
+ if (withSubscriber) {
91
+ const res = createSSEStream(TARGET, NET);
92
+ reader = res.body!.getReader();
93
+ }
94
+ const r = await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
95
+ const out = JSON.parse(r.content[0].text);
96
+ const row = db.get<{ status: string }>("SELECT status FROM tasks WHERE to_name = ?1 AND network_id = ?2", TARGET, NET);
97
+ const received = reader ? await readUntil(reader, "new_task") : null;
98
+ return { out, taskStatus: row?.status ?? null, received };
99
+ }
100
+
101
+ describe("f015d9d6 症状① — delivered 置位与实际推送不同步", () => {
102
+ beforeEach(() => cleanup());
103
+ afterAll(() => cleanup());
104
+
105
+ test("对照:目标 1 分钟前还活跃 ⇒ 订阅者收到 new_task", async () => {
106
+ const { taskStatus, received } = await send(1);
107
+ expect(taskStatus).toBe("delivered");
108
+ expect(received).toContain("new_task");
109
+ });
110
+
111
+ test("🔴 核心:目标安静 10 分钟但订阅者连着 ⇒ 仍然收得到(修复前这里收到 null)", async () => {
112
+ const { taskStatus, received } = await send(10);
113
+ expect(received).toContain("new_task");
114
+ expect(taskStatus).toBe("delivered"); // 现在这个 delivered 是真话了
115
+ });
116
+
117
+ test("🔴 可达性判据必须来自订阅者注册表,不是 last_seen_at 的时间戳猜测", async () => {
118
+ // 安静 10 分钟 = 时间戳判据会说 offline;而实际上有人在听。
119
+ const quiet = await send(10);
120
+ expect(quiet.received).toContain("new_task");
121
+ // 真没人听的时候,pushEvent 本来就是 no-op,不需要额外的闸来保护。
122
+ const nobody = await send(10, false);
123
+ expect(nobody.received).toBeNull();
124
+ });
125
+
126
+ test("真的没有订阅者时,tasks 行仍写 delivered —— 本 PR 刻意不改,记录在案", async () => {
127
+ const { taskStatus, received } = await send(10, false);
128
+ expect(received).toBeNull();
129
+ // 这一条钉的是**当前行为**,不是我认为对的行为:没人收到,行仍自称 delivered。
130
+ expect(taskStatus).toBe("delivered");
131
+ });
132
+
133
+ test("活性判据的输入 last_seen_at 只由 report_status 刷新 —— 发送方不刷新它", async () => {
134
+ cleanup(); seed(4);
135
+ const before = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
136
+ await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
137
+ const after = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
138
+ expect(after?.last_seen_at).toBe(before!.last_seen_at);
139
+ });
140
+ });
@@ -0,0 +1,209 @@
1
+ // RFC-042(看板 #457)部门群,第一个 PR:建群 / 查群 / 权限 / 删部门解除关联。
2
+ // HTTP 集成测试(真实 Bun.serve,私有端口,临时库;PostgreSQL 由 test2123 梯子用 COMMHUB_TEST_PG_URL 跑同一个文件)。
3
+ // 钉住:谁能建(owner / admin / Hub 管理员 / 部门(含上级)负责人)、谁能看(群成员 + 管理者,别人 404)、
4
+ // Agent(节点令牌)一律 403、建群时的成员 = 部门子树成员 ∪ 子树负责人、一个部门一个群、删部门只解除关联。
5
+
6
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "fs";
8
+ import { join } from "path";
9
+ import { tmpdir } from "os";
10
+ import { register, createNetworkTokenForNode } from "./auth.js";
11
+ import { db } from "./db.js";
12
+
13
+ const DIR = mkdtempSync(join(tmpdir(), "anet-dept-groups-"));
14
+ let BASE = "";
15
+ let hub: any = null;
16
+ const PW = "DeptGroupsPassw0rd!x";
17
+ let NET = "";
18
+ const U: Record<string, { token: string; id: string }> = {};
19
+ const DEPT: Record<string, string> = {};
20
+
21
+ async function send(token: string, method: string, path: string, body?: unknown) {
22
+ const res = await fetch(`${BASE}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
23
+ const text = await res.text();
24
+ let json: any = null;
25
+ try { json = JSON.parse(text); } catch {}
26
+ return { status: res.status, body: json };
27
+ }
28
+ const D = (suffix = "") => `/api/networks/${NET}/departments${suffix}`;
29
+ const G = (deptKey: string) => D(`/${DEPT[deptKey]}/group`);
30
+ const CG = (suffix = "") => `/api/networks/${NET}/chat-groups${suffix}`;
31
+ const ids = (members: Array<{ user_id: string }>) => members.map((m) => m.user_id).sort();
32
+
33
+ beforeAll(async () => {
34
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
35
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
36
+ process.env.HOST = "127.0.0.1";
37
+ const a = register(`dg_owner_${Date.now()}`, PW, undefined, "Owner");
38
+ expect(a.ok).toBe(true);
39
+ U.owner = { token: a.token!, id: a.user!.user_id };
40
+ NET = a.network_id!;
41
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
42
+ const mod: any = await import("./server.js");
43
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
44
+ BASE = `http://127.0.0.1:${hub.port}`;
45
+ const add = async (username: string, role: string) => {
46
+ expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
47
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
48
+ expect(login.status).toBe(200);
49
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
50
+ };
51
+ const stamp = Date.now();
52
+ U.admin = await add(`dg_admin_${stamp}`, "admin");
53
+ U.head = await add(`dg_head_${stamp}`, "member"); // 研发部负责人
54
+ U.alice = await add(`dg_alice_${stamp}`, "member"); // 研发部成员
55
+ U.bob = await add(`dg_bob_${stamp}`, "member"); // 后端组成员 + 后端组负责人
56
+ U.carol = await add(`dg_carol_${stamp}`, "member"); // 未分配
57
+ U.viewer = await add(`dg_viewer_${stamp}`, "viewer"); // 研发部成员,只读
58
+ U.node = { token: createNetworkTokenForNode(U.owner.id, NET, "dg-node", "node_dg").token!, id: "node_dg" };
59
+ const o = register(`dg_outsider_${stamp}`, PW);
60
+ U.outsider = { token: o.token!, id: o.user!.user_id };
61
+
62
+ const mk = async (name: string, extra: Record<string, unknown> = {}) => {
63
+ const r = await send(U.owner.token, "POST", D(), { name, ...extra });
64
+ expect(r.status).toBe(201);
65
+ return r.body.department.id as string;
66
+ };
67
+ DEPT.rd = await mk("研发部", { leader_user_id: U.head.id });
68
+ DEPT.backend = await mk("后端组", { parent_id: DEPT.rd, leader_user_id: U.bob.id });
69
+ DEPT.market = await mk("市场部");
70
+ const place = async (who: string, dept: string | null) =>
71
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[who].id}/department`, { department_id: dept })).status).toBe(200);
72
+ await place("head", DEPT.rd);
73
+ await place("alice", DEPT.rd);
74
+ await place("viewer", DEPT.rd);
75
+ await place("bob", DEPT.backend);
76
+ }, 30_000);
77
+
78
+ afterAll(() => {
79
+ try { hub?.stop?.(true); } catch {}
80
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
81
+ });
82
+
83
+ describe("who can create a department group", () => {
84
+ test("members, viewers, outsiders, nodes and unauthenticated callers cannot", async () => {
85
+ expect((await send(U.carol.token, "POST", G("rd"), {})).body.error).toBe("owner/admin required");
86
+ expect((await send(U.carol.token, "POST", G("rd"), {})).status).toBe(403);
87
+ expect((await send(U.alice.token, "POST", G("rd"), {})).status).toBe(403);
88
+ expect((await send(U.viewer.token, "POST", G("rd"), {})).status).toBe(403);
89
+ expect((await send(U.outsider.token, "POST", G("rd"), {})).status).toBe(403);
90
+ const node = await send(U.node.token, "POST", G("rd"), {});
91
+ expect(node.status).toBe(403);
92
+ expect(node.body.error).toBe("humans_only");
93
+ expect((await send("", "POST", G("rd"), {})).status).toBe(401);
94
+ expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM chat_groups WHERE network_id = ?1", NET)?.n ?? 0)).toBe(0);
95
+ });
96
+
97
+ test("a head cannot create one outside their subtree (sub-department head → parent or sibling)", async () => {
98
+ for (const key of ["rd", "market"]) {
99
+ const r = await send(U.bob.token, "POST", G(key), {});
100
+ expect(r.status).toBe(403);
101
+ expect(r.body.error).toBe("department_scope_denied");
102
+ }
103
+ });
104
+
105
+ test("owner creates the 研发部 group: name defaults to the department, members = subtree members ∪ subtree heads", async () => {
106
+ const r = await send(U.owner.token, "POST", G("rd"));
107
+ expect(r.status).toBe(201);
108
+ expect(r.body.group.name).toBe("研发部");
109
+ expect(r.body.group.department_id).toBe(DEPT.rd);
110
+ expect(r.body.group.id).toMatch(/^grp_[0-9a-f]{20}$/);
111
+ // 研发部:head / alice / viewer;后端组(下级):bob。未分配的 carol、不在部门里的 owner / admin 不进。
112
+ expect(ids(r.body.members)).toEqual([U.alice.id, U.bob.id, U.head.id, U.viewer.id].sort());
113
+ expect(r.body.group.member_count).toBe(4);
114
+ for (const m of r.body.members) expect(m.source).toBe("department");
115
+ });
116
+
117
+ test("one group per department: a second create → 409 with the existing group_id", async () => {
118
+ const existing = (await send(U.owner.token, "GET", G("rd"))).body.group.id;
119
+ for (const who of ["owner", "admin", "head"]) {
120
+ const r = await send(U[who].token, "POST", G("rd"), { name: "又一个" });
121
+ expect(r.status).toBe(409);
122
+ expect(r.body.error).toBe("department_group_exists");
123
+ expect(r.body.group_id).toBe(existing);
124
+ }
125
+ });
126
+
127
+ test("a head can create one for a department in their subtree, with a custom name", async () => {
128
+ const r = await send(U.head.token, "POST", G("backend"), { name: " 后端小群 " });
129
+ expect(r.status).toBe(201);
130
+ expect(r.body.group.name).toBe("后端小群");
131
+ // 后端组只有 bob(他同时是负责人);研发部负责人在上级部门,不在这个子树里。
132
+ expect(ids(r.body.members)).toEqual([U.bob.id]);
133
+ const audit = db.get<{ detail: string }>("SELECT detail FROM audit_log WHERE network_id = ?1 AND action = 'department_group_created' ORDER BY id DESC LIMIT 1", NET);
134
+ expect(JSON.parse(audit!.detail)).toMatchObject({ id: DEPT.backend, via: "leader" });
135
+ });
136
+
137
+ test("admin can create; bad names and unknown departments are rejected", async () => {
138
+ expect((await send(U.admin.token, "POST", G("market"), { name: 42 })).body.error).toBe("invalid_group_name");
139
+ expect((await send(U.admin.token, "POST", G("market"), { name: "x".repeat(41) })).status).toBe(400);
140
+ expect((await send(U.admin.token, "POST", G("market"), { name: " " })).status).toBe(400);
141
+ expect((await send(U.admin.token, "POST", D("/dept_nope/group"), {})).status).toBe(404);
142
+ const r = await send(U.admin.token, "POST", G("market"), { name: "市场部群" });
143
+ expect(r.status).toBe(201);
144
+ expect(r.body.members).toEqual([]);
145
+ });
146
+ });
147
+
148
+ describe("who can read", () => {
149
+ test("GET …/departments/:id/group: group members and managers see it; others get 404; nodes 403", async () => {
150
+ for (const who of ["owner", "admin", "head", "alice", "bob", "viewer"]) {
151
+ const r = await send(U[who].token, "GET", G("rd"));
152
+ expect(r.status).toBe(200);
153
+ expect(ids(r.body.members)).toHaveLength(4);
154
+ }
155
+ const carol = await send(U.carol.token, "GET", G("rd"));
156
+ expect(carol.status).toBe(404);
157
+ expect(carol.body.error).toBe("department_group_not_found");
158
+ expect((await send(U.node.token, "GET", G("rd"))).status).toBe(403);
159
+ expect((await send(U.outsider.token, "GET", G("rd"))).status).toBe(403);
160
+ });
161
+
162
+ test("GET …/chat-groups lists my groups; owner / admin see all", async () => {
163
+ const names = async (who: string) => (await send(U[who].token, "GET", CG())).body.groups.map((g: any) => g.name).sort();
164
+ expect(await names("alice")).toEqual(["研发部"]);
165
+ expect(await names("bob")).toEqual(["后端小群", "研发部"].sort());
166
+ expect(await names("carol")).toEqual([]);
167
+ expect(await names("owner")).toEqual(["后端小群", "市场部群", "研发部"].sort());
168
+ const owner = (await send(U.owner.token, "GET", CG())).body.groups;
169
+ expect(owner.every((g: any) => g.is_member === false)).toBe(true);
170
+ expect((await send(U.node.token, "GET", CG())).status).toBe(403);
171
+ expect((await send(U.outsider.token, "GET", CG())).status).toBe(403);
172
+ expect((await send(U.alice.token, "POST", CG(), {})).status).toBe(405);
173
+ });
174
+
175
+ test("GET …/chat-groups/:id: members see the roster; a non-member gets 404 (existence not leaked)", async () => {
176
+ const gid = (await send(U.owner.token, "GET", G("rd"))).body.group.id;
177
+ const alice = await send(U.alice.token, "GET", CG(`/${gid}`));
178
+ expect(alice.status).toBe(200);
179
+ expect(alice.body.is_member).toBe(true);
180
+ expect(ids(alice.body.members)).toContain(U.alice.id);
181
+ expect((await send(U.carol.token, "GET", CG(`/${gid}`))).status).toBe(404);
182
+ expect((await send(U.carol.token, "GET", CG("/grp_doesnotexist"))).status).toBe(404);
183
+ });
184
+ });
185
+
186
+ describe("deleting a department unlinks its group", () => {
187
+ test("group and members stay with department_id = null; the id can get a fresh group later", async () => {
188
+ // carol 当负责人但不放进部门:部门是空的(能删),而她在 roster 里。(第 2 个 PR 起调出部门会被同步移出群,
189
+ // 所以「删部门时群里还有人」只会是负责人或手动拉的人。)
190
+ const tmp = (await send(U.owner.token, "POST", D(), { name: "临时项目组", id: "dept_tmp_dg", leader_user_id: U.carol.id })).body.department.id as string;
191
+ const created = await send(U.owner.token, "POST", D(`/${tmp}/group`), {});
192
+ expect(created.status).toBe(201);
193
+ expect(ids(created.body.members)).toEqual([U.carol.id]);
194
+ expect((await send(U.owner.token, "DELETE", D(`/${tmp}`))).status).toBe(200);
195
+
196
+ const after = await send(U.owner.token, "GET", CG(`/${created.body.group.id}`));
197
+ expect(after.status).toBe(200);
198
+ expect(after.body.group.department_id).toBe(null);
199
+ expect(ids(after.body.members)).toEqual([U.carol.id]);
200
+ // 解除关联后不再对账:carol 仍是这个群的成员(她已不是任何部门的负责人)。
201
+ expect((await send(U.carol.token, "GET", CG(`/${created.body.group.id}`))).status).toBe(200);
202
+
203
+ // 同一个部门 id 再建:不被已解除关联的旧群挡住(唯一索引允许多个 NULL)。
204
+ expect((await send(U.owner.token, "POST", D(), { name: "临时项目组", id: "dept_tmp_dg" })).status).toBe(201);
205
+ const again = await send(U.owner.token, "POST", D(`/${tmp}/group`), {});
206
+ expect(again.status).toBe(201);
207
+ expect(again.body.group.id).not.toBe(created.body.group.id);
208
+ });
209
+ });