@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,215 @@
1
+ // 多用户账号:人与人私信(同一网络里的两个用户之间)。
2
+ //
3
+ // 投递复用 user_inbox(与 send_desktop_message 同一张表、同一条 /events/users/me 推送、同一套
4
+ // /api/messages?scope=user 未读与 ack),另外记下 sender_user_id —— 由 Hub 按发信人的用户令牌写入,
5
+ // 不是客户端声称的 from_session。会话记录按 (收件人, sender_user_id) 双向取,所以发信人看得到自己发的。
6
+ //
7
+ // 与 Agent 权限无关:受限成员(看不到任何 Agent 的成员)照样能和网络里的其他人私信。
8
+
9
+ import { db, uuidv4 } from "./db.js";
10
+ import { getUserNetworkRole } from "./auth.js";
11
+ import { isAgentRestricted } from "./agent-access.js";
12
+ import { pushUserEvent, hasUserSubscribers } from "./push.js";
13
+ import { existsSync, readFileSync } from "fs";
14
+ import { DM_FILE_SCOPE, FILE_ID_REGEX, indexEntryPath, validateAttachments, validateIndexEntry, type UploadIndexEntry } from "./uploads.js";
15
+ import { restrictedMemberCanUseFile } from "./restricted-files.js";
16
+ import { redactMessageRow } from "./redact-tokens.js";
17
+
18
+ export const HUMAN_DM_KIND = "human_dm";
19
+ export const MAX_DM_CHARS = 10_000;
20
+
21
+ export type HumanDmSendInput = {
22
+ networkId: string;
23
+ sender: { userId: string; username: string };
24
+ toUserId?: unknown;
25
+ toUsername?: unknown;
26
+ message?: unknown;
27
+ attachments?: unknown;
28
+ clientRequestId?: unknown;
29
+ };
30
+
31
+ export type HumanDmSendResult =
32
+ | { ok: true; message: Record<string, unknown>; delivered: boolean }
33
+ | { ok: false; error: string; status: number };
34
+
35
+ export function sendHumanDm(input: HumanDmSendInput): HumanDmSendResult {
36
+ const { networkId, sender } = input;
37
+ if (!getUserNetworkRole(sender.userId, networkId)) return { ok: false, error: "not a member of this network", status: 403 };
38
+ const text = typeof input.message === "string" ? input.message : "";
39
+ if (text.length > MAX_DM_CHARS) return { ok: false, error: "message_too_long", status: 400 };
40
+
41
+ const byId = typeof input.toUserId === "string" && input.toUserId
42
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", input.toUserId)
43
+ : null;
44
+ const byName = typeof input.toUsername === "string" && input.toUsername
45
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", input.toUsername)
46
+ : null;
47
+ if (byId && byName && byId.user_id !== byName.user_id) return { ok: false, error: "dm_target_mismatch", status: 400 };
48
+ const target = byId ?? byName;
49
+ // 不存在与不在本网络同一个错误,不给用户名探测留差异。
50
+ if (!target || !getUserNetworkRole(target.user_id, networkId)) return { ok: false, error: "dm_target_not_in_network", status: 404 };
51
+ if (target.user_id === sender.userId) return { ok: false, error: "dm_to_self", status: 400 };
52
+
53
+ const attachments = validateAttachments(input.attachments);
54
+ if (!attachments.ok) return { ok: false, error: "bad_attachments", status: 400 };
55
+ if (!text.trim() && attachments.attachments.length === 0) return { ok: false, error: "message_required", status: 400 };
56
+ const restricted = isAgentRestricted(sender.userId, networkId);
57
+ for (const a of attachments.attachments) {
58
+ const fileId = (a as { file_id?: string }).file_id;
59
+ if (!fileId) continue;
60
+ const entry = networkFileEntry(networkId, fileId);
61
+ const usable = restricted
62
+ ? restrictedMemberCanUseFile(sender.userId, sender.username, networkId, fileId)
63
+ : !!entry;
64
+ if (!usable) return { ok: false, error: "attachment_not_accessible", status: 403 };
65
+ // 私信文件只能由看得见它的人转发:否则任何成员把别人私信里的 file_id 塞进一条自己发出的私信,
66
+ // 就成了「私信参与者」而解锁它。上传者本人、以及已经在某段私信里收到/发出过它的人可以转。
67
+ if (entry?.scope === DM_FILE_SCOPE && entry.owner_id !== sender.userId && !dmParticipantSeesFile(sender.userId, networkId, fileId)) {
68
+ return { ok: false, error: "attachment_not_accessible", status: 403 };
69
+ }
70
+ }
71
+
72
+ const clientRequestId = typeof input.clientRequestId === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(input.clientRequestId)
73
+ ? input.clientRequestId : null;
74
+ // 同一个气泡重试:按 (发信人, client_request_id) 定出同一个 message_id,重投不产生第二条。
75
+ const messageId = clientRequestId
76
+ ? `dm_${sender.userId}_${clientRequestId}`.slice(0, 120)
77
+ : `dm_${uuidv4()}`;
78
+ const metaJson = attachments.attachments.length ? JSON.stringify({ attachments: attachments.attachments }) : null;
79
+
80
+ db.transaction(() => {
81
+ insertDmRow({ messageId, networkId, targetUserId: target.user_id, sender, title: null, text, metaJson });
82
+ db.run(
83
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
84
+ VALUES (?1, ?2, 'human_dm_sent', 'user', ?3, ?4, ?5)`,
85
+ [sender.userId, sender.username, target.user_id, JSON.stringify({ message_id: messageId, attachments: attachments.attachments.length }), networkId],
86
+ );
87
+ });
88
+ const row = db.get<Record<string, unknown>>(
89
+ `SELECT message_id, network_id, user_id, sender_user_id, from_session, kind, content, meta_json, acked, created_at
90
+ FROM user_inbox WHERE message_id = ?1`,
91
+ messageId,
92
+ )!;
93
+ // 推送在提交之后(与 send_desktop_message 同一条规矩:回滚的消息不能先被看见)。
94
+ const delivered = pushDmEvent({ messageId, networkId, targetUserId: target.user_id, sender, title: null, text, metaJson });
95
+ return { ok: true, message: redactMessageRow(row), delivered };
96
+ }
97
+
98
+ type DmRow = { messageId: string; networkId: string; targetUserId: string; sender: { userId: string; username: string }; title: string | null; text: string; metaJson: string | null };
99
+
100
+ function insertDmRow(m: DmRow): void {
101
+ db.run(
102
+ // created_at 带毫秒:同一秒里一来一回的两条私信要能排出先后(秒级的 datetime('now') 排不出)。
103
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id, created_at)
104
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'info', ?8, ?9, strftime('%Y-%m-%d %H:%M:%f', 'now'))
105
+ ON CONFLICT(message_id) DO NOTHING`,
106
+ [m.messageId, m.networkId, m.targetUserId, m.sender.username, HUMAN_DM_KIND, m.title, m.text, m.metaJson, m.sender.userId],
107
+ );
108
+ }
109
+
110
+ /** 推 desktop_message 到收件人的 /events/users/me。返回推送时他有没有在线的订阅。 */
111
+ function pushDmEvent(m: DmRow): boolean {
112
+ const delivered = hasUserSubscribers(m.networkId, m.targetUserId);
113
+ pushUserEvent(m.networkId, m.targetUserId, {
114
+ type: "desktop_message",
115
+ message_id: m.messageId,
116
+ kind: HUMAN_DM_KIND,
117
+ from: m.sender.username,
118
+ from_user_id: m.sender.userId,
119
+ title: m.title,
120
+ message: m.text,
121
+ severity: "info",
122
+ created_at: new Date().toISOString(),
123
+ ...(m.metaJson ? { meta: JSON.parse(m.metaJson) } : {}),
124
+ });
125
+ return delivered;
126
+ }
127
+
128
+ /**
129
+ * Hub 代某人发的一条私信(参与人改了任务的通知,见 requirement-notify.ts):与人发的私信同一张表、同一条推送,
130
+ * 所以 App 按私信展示(顶部提示 + 人员列表未读 + 与他的会话里能看到),不用新渠道。
131
+ * 不校验正文 / 附件(调用方生成),也不记 human_dm_sent 审计(这不是他亲手发的)。
132
+ * 收件人或发信人已不在网络里 → 不发。返回 false = 没发。
133
+ */
134
+ export function sendNoticeDm(m: DmRow): boolean {
135
+ if (m.targetUserId === m.sender.userId) return false;
136
+ if (!getUserNetworkRole(m.sender.userId, m.networkId) || !getUserNetworkRole(m.targetUserId, m.networkId)) return false;
137
+ insertDmRow(m);
138
+ pushDmEvent(m);
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * 把一条还没读的通知私信改写成合并后的正文(同一段时间里的连续改动并成一条)。
144
+ * 收件人已经读过(acked)或那行没了 → false,调用方另发一条新的。
145
+ * 只改库、再推一次同一个 message_id:App 按 message_id 去重,不会第二次弹;会话列表下次拉到的是新正文。
146
+ */
147
+ export function rewriteUnreadNoticeDm(m: DmRow): boolean {
148
+ const row = db.get<{ acked: number | boolean | null }>("SELECT acked FROM user_inbox WHERE message_id = ?1 AND user_id = ?2", m.messageId, m.targetUserId);
149
+ if (!row || row.acked) return false;
150
+ db.run("UPDATE user_inbox SET content = ?1, meta_json = ?2 WHERE message_id = ?3 AND user_id = ?4", [m.text, m.metaJson, m.messageId, m.targetUserId]);
151
+ pushDmEvent(m);
152
+ return true;
153
+ }
154
+
155
+ /** 带附件:文件必须属于这个网络(防把别的网络的 file_id 塞进来)。返回它的索引条目,不属于 → null。 */
156
+ export function networkFileEntry(networkId: string, fileId: string): UploadIndexEntry | null {
157
+ if (!FILE_ID_REGEX.test(fileId)) return null;
158
+ const path = indexEntryPath(fileId);
159
+ if (!path || !existsSync(path)) return null;
160
+ try {
161
+ const entry = JSON.parse(readFileSync(path, "utf8"));
162
+ return validateIndexEntry(entry) && entry.file_id === fileId && entry.network_id === networkId ? entry : null;
163
+ } catch { return null; }
164
+ }
165
+
166
+ /**
167
+ * 这个用户是不是某段带着这个文件的私信里的一方(收到的或发出的)。私信文件(scope=dm)的下载只认它
168
+ * 和上传者本人(server.ts authorizeFileDownload)。LIKE 只做预筛,真正的判定按 attachments 键解析。
169
+ */
170
+ export function dmParticipantSeesFile(userId: string, networkId: string, fileId: string): boolean {
171
+ if (!FILE_ID_REGEX.test(fileId)) return false;
172
+ const needle = `%"${fileId.replace(/_/g, "\\_")}"%`;
173
+ const rows = db.all<{ meta_json: string | null }>(
174
+ `SELECT meta_json FROM user_inbox
175
+ WHERE network_id = ?1 AND kind = '${HUMAN_DM_KIND}' AND (user_id = ?2 OR sender_user_id = ?2)
176
+ AND meta_json LIKE ?3 ESCAPE '\\' LIMIT 50`,
177
+ networkId, userId, needle,
178
+ );
179
+ return rows.some((row) => {
180
+ try {
181
+ const list = JSON.parse(row.meta_json ?? "null")?.attachments;
182
+ return Array.isArray(list) && list.some((a: any) => a && typeof a === "object" && a.file_id === fileId);
183
+ } catch { return false; }
184
+ });
185
+ }
186
+
187
+ /** 我和某人的私信记录(双向),新的在前。before = 上一页最后一条的 created_at。 */
188
+ export function listDmThread(networkId: string, meUserId: string, otherUserId: string, limit = 50, before?: string | null) {
189
+ const params: unknown[] = [networkId, meUserId, otherUserId];
190
+ let sql = `SELECT message_id, network_id, user_id, sender_user_id, from_session, kind, content, meta_json, acked, created_at
191
+ FROM user_inbox
192
+ WHERE network_id = ?1 AND kind = '${HUMAN_DM_KIND}'
193
+ AND ((user_id = ?2 AND sender_user_id = ?3) OR (user_id = ?3 AND sender_user_id = ?2))`;
194
+ if (before) { params.push(before); sql += ` AND created_at < ?${params.length}`; }
195
+ params.push(Math.max(1, Math.min(limit, 200)));
196
+ sql += ` ORDER BY created_at DESC, rowid DESC LIMIT ?${params.length}`;
197
+ return db.all<Record<string, unknown>>(sql, ...params).map((row) => ({
198
+ ...redactMessageRow(row),
199
+ direction: row.sender_user_id === meUserId ? "out" : "in",
200
+ }));
201
+ }
202
+
203
+ /** 我在这个网络里的私信会话:每个对方一行,最后一条时间 + 我这边的未读数。 */
204
+ export function listDmThreads(networkId: string, meUserId: string) {
205
+ return db.all<{ other_user_id: string; last_at: string; unread: number }>(
206
+ `SELECT other_user_id, MAX(created_at) AS last_at, SUM(unread) AS unread FROM (
207
+ SELECT sender_user_id AS other_user_id, created_at, CASE WHEN acked = 0 THEN 1 ELSE 0 END AS unread
208
+ FROM user_inbox WHERE network_id = ?1 AND user_id = ?2 AND kind = '${HUMAN_DM_KIND}' AND sender_user_id IS NOT NULL
209
+ UNION ALL
210
+ SELECT user_id AS other_user_id, created_at, 0 AS unread
211
+ FROM user_inbox WHERE network_id = ?1 AND sender_user_id = ?2 AND kind = '${HUMAN_DM_KIND}'
212
+ ) GROUP BY other_user_id ORDER BY last_at DESC`,
213
+ networkId, meUserId,
214
+ ).map((r) => ({ other_user_id: r.other_user_id, last_at: r.last_at, unread: Number(r.unread ?? 0) }));
215
+ }
@@ -0,0 +1,49 @@
1
+ import { db } from "./db.js";
2
+ import { getUserNetworkRole } from "./auth.js";
3
+ import { resolveNodeCaller } from "./create-node.js";
4
+ import { resolveCanonicalAlias } from "./rename.js";
5
+
6
+ type InboxCaller = {
7
+ tokenId?: string | null;
8
+ userId?: string | null;
9
+ networkId?: string | null;
10
+ nodeToken: boolean;
11
+ identityRejected: boolean;
12
+ };
13
+
14
+ /** Inbox reads and ACKs act as the recipient, not as a dispatch target. */
15
+ export function resolveInboxCaller(alias: string, caller: InboxCaller):
16
+ { ok: true; alias: string } | { ok: false; error: string } {
17
+ const denied = { ok: false as const, error: "inbox_identity_mismatch" };
18
+ if (caller.identityRejected) return denied;
19
+ const requested = alias.trim();
20
+ if (caller.nodeToken) {
21
+ const node = resolveNodeCaller(caller.tokenId);
22
+ if (node.ok) {
23
+ if (node.networkId !== caller.networkId) return denied;
24
+ const canonical = resolveCanonicalAlias(node.networkId, requested).alias;
25
+ return canonical === node.alias ? { ok: true, alias: node.alias } : denied;
26
+ }
27
+ // Registration tokens are network-scoped human tokens, not node tokens.
28
+ // Missing/foreign/ambiguous node identities never fall back to the issuer.
29
+ if (node.reason !== "not_a_node_token" || !caller.tokenId) return denied;
30
+ const token = db.get<{ name: string | null }>(
31
+ "SELECT name FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL", caller.tokenId,
32
+ );
33
+ if (!token || token.name?.startsWith("node:")) return denied;
34
+ }
35
+ // Preserve open-development and legacy global-token mode.
36
+ if (!caller.userId) return { ok: true, alias: requested };
37
+ const user = db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", caller.userId);
38
+ if (user?.username === requested) return { ok: true, alias: requested };
39
+ if (!caller.networkId) return { ok: false, error: "network_id_required" };
40
+ const role = getUserNetworkRole(caller.userId, caller.networkId);
41
+ if (!role) return denied;
42
+ const canonical = resolveCanonicalAlias(caller.networkId, requested).alias;
43
+ if (role === "owner" || role === "admin") return { ok: true, alias: canonical };
44
+ const owned = db.get<{ node_id: string }>(
45
+ "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2 AND owner_user_id = ?3 LIMIT 1",
46
+ canonical, caller.networkId, caller.userId,
47
+ );
48
+ return owned ? { ok: true, alias: canonical } : denied;
49
+ }
@@ -0,0 +1,310 @@
1
+ // SSE `inbox_count` contract for the message-shaped events.
2
+ //
3
+ // Background: `new_task` has shipped a real unacked count since #1304,
4
+ // but `new_message` / `new_reply` carried NO count at all and
5
+ // `broadcast` carried a hardcoded `inbox_count: 1`. A client that
6
+ // renders "N new" from the SSE payload therefore never moved for plain
7
+ // messages and replies, and showed a constant 1 for broadcasts.
8
+ //
9
+ // Gate discipline: every assertion below expects a count that is NOT 1
10
+ // (pre-seeded unacked rows push the expected value to 2 or 3). A
11
+ // hardcoded `1` — the exact defect being fixed — fails these tests
12
+ // rather than passing them. Asserting `=== 1` would have been a fake
13
+ // gate that the old code satisfied.
14
+ //
15
+ // Run: COMMHUB_DB=/tmp/inbox-count-sse.db bun test src/inbox-count-sse.test.ts
16
+
17
+ import { afterAll, beforeEach, afterEach, describe, expect, test } from "bun:test";
18
+ import { db, uuidv4 } from "./db.js";
19
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
+ import { registerTools } from "./tools.js";
21
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
22
+ import { pendingInboxCount } from "./inbox-count.js";
23
+
24
+ const NET = "net_inboxcount";
25
+ const OWNER = "u_inboxcount_owner";
26
+ const RECV = "peer-inboxcount-recv"; // receives messages/replies
27
+ const OTHER = "peer-inboxcount-other"; // second broadcast recipient
28
+ const SENDER = "peer-inboxcount-send";
29
+ // #1440 ① — a broadcast target the lifecycle guard skips.
30
+ const STOPPED = "peer-inboxcount-stopped";
31
+ const ALIASES = [RECV, OTHER, SENDER];
32
+
33
+ type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
34
+
35
+ function cleanup() {
36
+ for (const t of ["tasks", "inbox", "task_events"]) {
37
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
38
+ }
39
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
40
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
41
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
42
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
43
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [OWNER]); } catch {}
44
+ }
45
+
46
+ function seed() {
47
+ db.run(
48
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
49
+ VALUES (?1, ?1, 'x', 'user', datetime('now'))`,
50
+ [OWNER],
51
+ );
52
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET, OWNER]);
53
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [OWNER, NET]);
54
+ for (const alias of ALIASES) {
55
+ db.run(
56
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
57
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'), datetime('now'), 'active')`,
58
+ [`node_${alias}`, alias, NET, `host-${alias}`],
59
+ );
60
+ db.run(
61
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
62
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
63
+ [`res_${alias}`, alias, `node_${alias}`, NET],
64
+ );
65
+ }
66
+ // A session that broadcast will select as a target, but whose node is
67
+ // NOT active — assertNodeActive skips it, so it gets no inbox row.
68
+ db.run(
69
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
70
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'), datetime('now'), 'stopped')`,
71
+ [`node_${STOPPED}`, STOPPED, NET, `host-${STOPPED}`],
72
+ );
73
+ db.run(
74
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
75
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
76
+ [`res_${STOPPED}`, STOPPED, `node_${STOPPED}`, NET],
77
+ );
78
+ }
79
+
80
+ beforeEach(() => { cleanup(); seed(); });
81
+ afterEach(() => { __resetSSEClientsForTest(); });
82
+ afterAll(cleanup);
83
+
84
+ function buildHandlers(alias: string): Record<string, ToolHandler> {
85
+ const server = new McpServer({ name: "test", version: "0" }) as any;
86
+ const tools: Record<string, ToolHandler> = {};
87
+ const origTool = server.tool.bind(server);
88
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
89
+ tools[name] = handler;
90
+ return origTool(name, _desc, schema, handler);
91
+ };
92
+ registerTools(server, undefined, NET, OWNER, alias, true, null);
93
+ return tools;
94
+ }
95
+
96
+ async function call(handler: ToolHandler, args: any): Promise<Record<string, any>> {
97
+ const r = await handler(args);
98
+ return JSON.parse(r.content[0].text);
99
+ }
100
+
101
+ /** Read the next SSE data frame, with a hard timeout so a silent stream
102
+ * fails instead of hanging. Skips keepalive comment frames. */
103
+ async function readFrame(
104
+ reader: ReadableStreamDefaultReader<Uint8Array>,
105
+ timeoutMs = 2_000,
106
+ ): Promise<Record<string, any>> {
107
+ const decoder = new TextDecoder();
108
+ let buf = "";
109
+ const deadline = Date.now() + timeoutMs;
110
+ while (Date.now() < deadline) {
111
+ const result = await Promise.race([
112
+ reader.read(),
113
+ new Promise<never>((_, rej) =>
114
+ setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
115
+ ),
116
+ ]);
117
+ if (result.done) throw new Error("SSE stream ended unexpectedly");
118
+ buf += decoder.decode(result.value, { stream: true });
119
+ const sep = buf.indexOf("\n\n");
120
+ if (sep === -1) continue;
121
+ const rawFrame = buf.slice(0, sep);
122
+ buf = buf.slice(sep + 2);
123
+ const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
124
+ if (!dataLine) continue;
125
+ return JSON.parse(dataLine.slice(6));
126
+ }
127
+ throw new Error(`no SSE frame within ${timeoutMs}ms`);
128
+ }
129
+
130
+ /** Subscribe and swallow the initial `connected` frame. */
131
+ async function subscribe(alias: string) {
132
+ const reader = createSSEStream(alias, NET).body!.getReader();
133
+ await readFrame(reader);
134
+ return reader;
135
+ }
136
+
137
+ /** Pre-existing unacked inbox rows, so the expected count is never 1. */
138
+ function seedUnacked(alias: string, n: number, type = "message") {
139
+ for (let i = 0; i < n; i++) {
140
+ db.run(
141
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
142
+ VALUES (?1, ?2, ?3, ?4, 'normal', ?5, ?6, ?7)`,
143
+ [uuidv4(), alias, `node_${alias}`, type, `backlog ${i}`, SENDER, NET],
144
+ );
145
+ }
146
+ }
147
+
148
+ function makeTask(from: string, to: string, content: string, status = "delivered"): string {
149
+ const id = uuidv4();
150
+ db.run(
151
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
152
+ VALUES (?1, ?2, ?3, 'normal', ?4, ?5, 'reply', datetime('now'), ?6)`,
153
+ [id, from, to, status, content, NET],
154
+ );
155
+ return id;
156
+ }
157
+
158
+ describe("SSE inbox_count", () => {
159
+ test("new_message carries the recipient's real unacked count, not a hardcoded 1", async () => {
160
+ seedUnacked(RECV, 2);
161
+ const reader = await subscribe(RECV);
162
+
163
+ const tools = buildHandlers(SENDER);
164
+ const res = await call(tools.send_message, { alias: RECV, message: "hello", network_id: NET });
165
+ expect(res.ok ?? true).not.toBe(false);
166
+
167
+ const evt = await readFrame(reader);
168
+ expect(evt.type).toBe("new_message");
169
+ // 2 seeded + this one. A hardcoded 1 — or a missing field — fails here.
170
+ expect(evt.inbox_count).toBe(3);
171
+ expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
172
+ });
173
+
174
+ test("new_reply carries the reply target's real unacked count", async () => {
175
+ seedUnacked(RECV, 1);
176
+ const taskId = makeTask(RECV, SENDER, "please do the thing");
177
+ const reader = await subscribe(RECV);
178
+
179
+ const tools = buildHandlers(SENDER);
180
+ const res = await call(tools.send_reply, {
181
+ alias: RECV,
182
+ text: "done",
183
+ in_reply_to: taskId,
184
+ status: "completed",
185
+ network_id: NET,
186
+ });
187
+ expect(res.ok).toBe(true);
188
+
189
+ const evt = await readFrame(reader);
190
+ expect(evt.type).toBe("new_reply");
191
+ // 1 seeded + the reply row. Not 1, and not absent.
192
+ expect(evt.inbox_count).toBe(2);
193
+ expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
194
+ });
195
+
196
+ test("broadcast counts per recipient instead of shipping a constant 1", async () => {
197
+ // Asymmetric backlogs: if the payload were hardcoded, both
198
+ // recipients would report the same number. They must not.
199
+ seedUnacked(RECV, 2);
200
+ const recvReader = await subscribe(RECV);
201
+ const otherReader = await subscribe(OTHER);
202
+
203
+ const tools = buildHandlers(SENDER);
204
+ await call(tools.broadcast, { message: "all hands", network_id: NET });
205
+
206
+ const recvEvt = await readFrame(recvReader);
207
+ const otherEvt = await readFrame(otherReader);
208
+ expect(recvEvt.type).toBe("broadcast");
209
+ expect(otherEvt.type).toBe("broadcast");
210
+
211
+ // RECV: 2 seeded + the broadcast row. OTHER: only the broadcast row.
212
+ expect(recvEvt.inbox_count).toBe(3);
213
+ expect(otherEvt.inbox_count).toBe(1);
214
+ expect(recvEvt.inbox_count).not.toBe(otherEvt.inbox_count);
215
+ });
216
+
217
+ test("acked rows are excluded — the count tracks what is still unread", async () => {
218
+ seedUnacked(RECV, 3);
219
+ db.run("UPDATE inbox SET acked = 1 WHERE session_name = ?1 AND network_id = ?2", [RECV, NET]);
220
+ const reader = await subscribe(RECV);
221
+
222
+ const tools = buildHandlers(SENDER);
223
+ await call(tools.send_message, { alias: RECV, message: "after the catch-up", network_id: NET });
224
+
225
+ const evt = await readFrame(reader);
226
+ // 3 acked rows contribute nothing; only the new one is outstanding.
227
+ expect(evt.inbox_count).toBe(1);
228
+ });
229
+ });
230
+
231
+ // #1440 ② — `retry_task` and `reassign_task` re-queue a task and push
232
+ // `new_task`, but both shipped a hardcoded `inbox_count: 1` while the
233
+ // hub already knew the real number. Same defect family as the
234
+ // `broadcast` case above; same gate discipline — the expected counts
235
+ // here are 3, so a hardcoded 1 cannot satisfy them.
236
+ describe("#1440 new_task inbox_count on requeue paths", () => {
237
+ test("retry_task carries the real unacked count, not a hardcoded 1", async () => {
238
+ seedUnacked(RECV, 2);
239
+ const taskId = makeTask(SENDER, RECV, "flaky job", "failed");
240
+ const reader = await subscribe(RECV);
241
+
242
+ const tools = buildHandlers(SENDER);
243
+ const res = await call(tools.retry_task, { task_id: taskId, network_id: NET });
244
+ expect(res.ok).toBe(true);
245
+
246
+ const evt = await readFrame(reader);
247
+ expect(evt.type).toBe("new_task");
248
+ // 2 seeded + the re-queued task row.
249
+ expect(evt.inbox_count).toBe(3);
250
+ expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
251
+ });
252
+
253
+ test("reassign_task carries the NEW assignee's real unacked count", async () => {
254
+ seedUnacked(RECV, 2);
255
+ const taskId = makeTask(SENDER, OTHER, "moved job");
256
+ const reader = await subscribe(RECV);
257
+
258
+ const tools = buildHandlers(SENDER);
259
+ const res = await call(tools.reassign_task, { task_id: taskId, new_alias: RECV, network_id: NET });
260
+ expect(res.ok).toBe(true);
261
+ expect(res.reassigned_to).toBe(RECV);
262
+
263
+ const evt = await readFrame(reader);
264
+ expect(evt.type).toBe("new_task");
265
+ // 2 seeded on the new assignee + the row reassign just wrote.
266
+ expect(evt.inbox_count).toBe(3);
267
+ expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
268
+ });
269
+ });
270
+
271
+ // #1440 ① — the broadcast push loop walked every candidate target,
272
+ // including the ones assertNodeActive had just skipped. Those nodes
273
+ // were told "you have a broadcast" while their inbox got no row for it.
274
+ // Before #1439 the hardcoded `inbox_count: 1` hid this: everyone got
275
+ // the same digit, so a spurious event was indistinguishable from a real
276
+ // one.
277
+ describe("#1440 broadcast delivery set", () => {
278
+ test("a lifecycle-skipped node gets no inbox row AND no broadcast event", async () => {
279
+ const activeReader = await subscribe(OTHER);
280
+ const stoppedReader = await subscribe(STOPPED);
281
+
282
+ const tools = buildHandlers(SENDER);
283
+ const res = await call(tools.broadcast, { message: "all hands", network_id: NET });
284
+
285
+ // The active recipient is served — this proves the broadcast ran, so
286
+ // the absence checked below is "not sent", not "not yet sent"
287
+ // (pushEvent is synchronous inside the handler).
288
+ const activeEvt = await readFrame(activeReader);
289
+ expect(activeEvt.type).toBe("broadcast");
290
+
291
+ // The skipped node has no row...
292
+ const rows = db.get<{ cnt: number }>(
293
+ "SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND type = 'broadcast'",
294
+ STOPPED,
295
+ );
296
+ expect(rows?.cnt).toBe(0);
297
+ // ...and must therefore receive no event announcing one.
298
+ await expect(readFrame(stoppedReader, 300)).rejects.toThrow(/no SSE frame/);
299
+
300
+ // message_ids covers only the recipients that actually got a row:
301
+ // RECV + OTHER + SENDER, never STOPPED.
302
+ expect(res.message_ids).toHaveLength(3);
303
+ // #1440 ③ — `recipients` counted candidates, not deliveries, so it
304
+ // disagreed with the documented invariant
305
+ // ("message_ids.length === recipients", docs-site/docs/api/rest-data.md)
306
+ // exactly when a node was skipped. 4 sessions, 3 delivered.
307
+ expect(res.recipients).toBe(res.message_ids.length);
308
+ expect(res.recipients).toBe(3);
309
+ });
310
+ });
@@ -0,0 +1,29 @@
1
+ import { db } from "./db.js";
2
+
3
+ /**
4
+ * Unacked inbox rows for `alias` — the number a client should show as
5
+ * "new". This is the count `new_task` has shipped since #1304; it is
6
+ * lifted here verbatim so every SSE event that carries `inbox_count`
7
+ * derives it the same way instead of hardcoding a digit.
8
+ *
9
+ * The SQL and its network scoping are unchanged from the `new_task`
10
+ * sites (tools.ts / server.ts): falsy `networkId` means "no network
11
+ * filter", matching both `addScope()` and the inline `if (taskNetId)`
12
+ * guard those sites already use.
13
+ *
14
+ * The `?? 1` fallback only fires if the COUNT itself returns no row,
15
+ * which SQLite does not do. It is not a default count: it is the
16
+ * fail-closed direction for this gate. Every caller pushes right after
17
+ * inserting an inbox row, so the floor is genuinely 1, and under-
18
+ * reporting an unread (badge says 0, a message is sitting there) is the
19
+ * failure this event exists to prevent.
20
+ */
21
+ export function pendingInboxCount(alias: string, networkId?: string | null): number {
22
+ const params: any[] = [alias];
23
+ let sql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
24
+ if (networkId) {
25
+ sql += " AND network_id = ?2";
26
+ params.push(networkId);
27
+ }
28
+ return db.get<{ cnt: number }>(sql, ...params)?.cnt ?? 1;
29
+ }