@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,186 @@
1
+ // #500 step 2 —— 派活之前 / 派活那一刻,让发送方看见目标节点的队列。
2
+ //
3
+ // 第一步(#2322)在任务过期时通知发送方;这一步在 send_task / POST /api/task 的响应里直接告诉它
4
+ // 「前面还有几个、目标正在忙没有、大概要等多久」,并在 /api/status(全量)和 get_all_status 的每一行
5
+ // 带上 queue_depth。只是建议:**从不拒绝派活**,老客户端不认识的字段直接忽略。
6
+ //
7
+ // 定义(与 task-expiry-notice.ts 的 openTasksAhead 同一组状态):
8
+ // 开着的任务 = tasks 行,status ∈ {created, delivered, acked, running},且 created_at 在最近 24 小时内。
9
+ // - created / delivered:还没被消费的,以及已经被运行时取走(consumed_at)但状态还没前移的;
10
+ // - acked / running:已经开工、还没出终态的。
11
+ // - 消息(send_message / broadcast)只写 inbox、不写 tasks,天然不算。终态(replied / failed / cancelled /
12
+ // expired)不算。
13
+ // - 24 小时窗口:TTL 上限就是 24 小时,超过 24 小时还没终态的行几乎都是被遗弃的(节点重启丢了、
14
+ // running 从来没回来;#2322 起 consumed 的 delivered 行也不再被巡检过期),不算进队列,否则队列只增不减。
15
+ // queue_ahead = 派这一条**之前**目标上开着的任务数(按网络;没有网络 = 不加网络条件,与 pending 计数同口径)。
16
+ // target_busy = 目标上有一条已开工(acked / running,或带 started_at / consumed_at)的开着的任务,或者会话状态是
17
+ // working / busy / running。
18
+ // est_wait_minutes = 目标最近 24 小时 replied 任务的耗时中位数 × queue_ahead,四舍五入、上限 1440;
19
+ // 样本 < 3 条 → null(不知道);queue_ahead = 0 → 0(前面没人)。
20
+ // 耗时 = completed_at − (started_at ?? 不晚于 completed_at 的 consumed_at ?? delivered_at ?? created_at)。
21
+ // 开工时间戳很稀疏,回落到派出时间就把排队也算进去了 —— 所以只是**粗估**,偏大。
22
+ // warning = queue_ahead ≥ 3 或 est_wait_minutes > 30 时给一句人话,建议换个空闲节点 / 合并请求 / 加长 ttl_seconds。
23
+ //
24
+ // 查询:派活时一条(计数)+ 必要时一条(耗时样本,LIMIT 200,走 idx_tasks_to_created);状态读一条 GROUP BY。
25
+ // 条件分支各自拼占位符,PostgreSQL 上不会有「绑了没用」的参数。
26
+
27
+ import { db } from "./db.js";
28
+ import { parseDbTimestampMs } from "./db-timestamp.js";
29
+
30
+ export const QUEUE_OPEN_STATUSES_SQL = "('created', 'delivered', 'acked', 'running')";
31
+ /** 开着的任务只看最近这么久派出的(= TTL 上限)。 */
32
+ export const QUEUE_HORIZON_SQL = "datetime('now', '-86400 seconds')";
33
+ export const QUEUE_WARN_AHEAD = 3;
34
+ export const QUEUE_WARN_WAIT_MINUTES = 30;
35
+ export const EST_WAIT_MIN_SAMPLES = 3;
36
+ export const EST_WAIT_CAP_MINUTES = 1440;
37
+ const EST_WAIT_SAMPLE_LIMIT = 200;
38
+ const BUSY_SESSION_STATUSES = new Set(["working", "busy", "running"]);
39
+
40
+ export type DispatchQueueInfo = {
41
+ queue_ahead: number;
42
+ target_busy: boolean;
43
+ est_wait_minutes: number | null;
44
+ warning?: string;
45
+ };
46
+
47
+ function scoped(sql: string, params: any[], networkId: string | null | undefined): string {
48
+ if (!networkId) return sql;
49
+ params.push(networkId);
50
+ return `${sql} AND network_id = ?${params.length}`;
51
+ }
52
+
53
+ /** 目标上开着的任务数 + 其中已开工的数。一条查询。 */
54
+ export function openTaskCounts(alias: string, networkId: string | null | undefined): { open: number; started: number } {
55
+ const q = openTaskCountsQuery(alias, networkId);
56
+ const row = db.get<{ open_n: number | string | null; started_n: number | string | null }>(q.sql, ...q.params);
57
+ return { open: Number(row?.open_n ?? 0) || 0, started: Number(row?.started_n ?? 0) || 0 };
58
+ }
59
+
60
+ /**
61
+ * The statement openTaskCounts runs (exported for the plan assertion). Unlike queueDepthQuery below it needs no
62
+ * index hint: the to_name equality + created_at range always wins idx_tasks_to_created, even for a node with tens of
63
+ * thousands of stuck `acked` rows (task-queue-ahead-scale.test.ts pins that plan).
64
+ */
65
+ export function openTaskCountsQuery(alias: string, networkId: string | null | undefined): { sql: string; params: any[] } {
66
+ const params: any[] = [alias];
67
+ let sql = `SELECT COUNT(*) AS open_n,
68
+ SUM(CASE WHEN status IN ('acked', 'running') OR started_at IS NOT NULL OR consumed_at IS NOT NULL THEN 1 ELSE 0 END) AS started_n
69
+ FROM tasks WHERE to_name = ?1 AND status IN ${QUEUE_OPEN_STATUSES_SQL} AND created_at >= ${QUEUE_HORIZON_SQL}`;
70
+ sql = scoped(sql, params, networkId);
71
+ return { sql, params };
72
+ }
73
+
74
+ /** 目标最近 24 小时 replied 任务的耗时中位数(分钟);样本不够 → null。一条查询。 */
75
+ export function medianTaskMinutes(alias: string, networkId: string | null | undefined): number | null {
76
+ const params: any[] = [alias];
77
+ let sql = `SELECT created_at, delivered_at, started_at, consumed_at, completed_at FROM tasks
78
+ WHERE to_name = ?1 AND status = 'replied' AND completed_at IS NOT NULL AND created_at >= ${QUEUE_HORIZON_SQL}`;
79
+ sql = scoped(sql, params, networkId);
80
+ sql += ` ORDER BY created_at DESC LIMIT ${EST_WAIT_SAMPLE_LIMIT}`;
81
+ const rows = db.all<{ created_at: string | null; delivered_at: string | null; started_at: string | null; consumed_at: string | null; completed_at: string | null }>(sql, ...params);
82
+ const mins: number[] = [];
83
+ for (const r of rows) {
84
+ const end = r.completed_at ? parseDbTimestampMs(r.completed_at) : NaN;
85
+ if (!Number.isFinite(end)) continue;
86
+ const consumed = r.consumed_at ? parseDbTimestampMs(r.consumed_at) : NaN;
87
+ const startText = r.started_at ?? (Number.isFinite(consumed) && consumed <= end ? r.consumed_at : null) ?? r.delivered_at ?? r.created_at;
88
+ const start = startText ? parseDbTimestampMs(startText) : NaN;
89
+ if (!Number.isFinite(start) || end < start) continue;
90
+ mins.push((end - start) / 60_000);
91
+ }
92
+ if (mins.length < EST_WAIT_MIN_SAMPLES) return null;
93
+ mins.sort((a, b) => a - b);
94
+ const mid = mins.length >> 1;
95
+ return mins.length % 2 ? mins[mid] : (mins[mid - 1] + mins[mid]) / 2;
96
+ }
97
+
98
+ export function queueWarning(alias: string, info: Omit<DispatchQueueInfo, "warning">, ttlSeconds: number): string | undefined {
99
+ const waitHigh = info.est_wait_minutes != null && info.est_wait_minutes > QUEUE_WARN_WAIT_MINUTES;
100
+ if (info.queue_ahead < QUEUE_WARN_AHEAD && !waitHigh) return undefined;
101
+ const ttlMin = Math.round(ttlSeconds / 60);
102
+ const wait = info.est_wait_minutes == null ? "unknown wait" : `rough wait ~${info.est_wait_minutes} min`;
103
+ const expiry = info.est_wait_minutes != null && info.est_wait_minutes >= ttlMin
104
+ ? ` It may expire before it starts (ttl ${ttlMin} min).`
105
+ : ` It expires if not started within ${ttlMin} min.`;
106
+ return `${alias} already has ${info.queue_ahead} open task(s) ahead of this one (${wait}${info.target_busy ? ", busy now" : ""}). `
107
+ + `Your task is queued, not refused.${expiry} Consider an idle node instead (get_all_status: status=idle, queue_depth=0), `
108
+ + `merging your asks, or a longer ttl_seconds. Do not resend the same task.`;
109
+ }
110
+
111
+ /**
112
+ * 派活那一刻的队列信息。在写 inbox / tasks **之前**调:那时开着的就是排在它前面的。
113
+ * 任何异常都吞掉、返回 null —— 这些字段只是建议,不能让派活失败。
114
+ */
115
+ export function dispatchQueueInfo(alias: string, networkId: string | null | undefined, sessionStatus: string | null | undefined, ttlSeconds: number): DispatchQueueInfo | null {
116
+ try {
117
+ const { open, started } = openTaskCounts(alias, networkId);
118
+ const target_busy = started > 0 || BUSY_SESSION_STATUSES.has(String(sessionStatus ?? "").toLowerCase());
119
+ let est_wait_minutes: number | null = 0;
120
+ if (open > 0) {
121
+ const median = medianTaskMinutes(alias, networkId);
122
+ est_wait_minutes = median == null ? null : Math.min(EST_WAIT_CAP_MINUTES, Math.round(median * open));
123
+ }
124
+ const base = { queue_ahead: open, target_busy, est_wait_minutes };
125
+ const warning = queueWarning(alias, base, ttlSeconds);
126
+ return warning ? { ...base, warning } : base;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ let queueDepthOffForTest = false;
133
+ /**
134
+ * Test-only: queueDepthByNode answers an empty map without querying (every row's queue_depth = 0), i.e. a full
135
+ * /api/status read does exactly the pre-#2325 work. The scale test alternates reads with it on and off against the
136
+ * same server so the queue_depth share is measured A/B in the same time window (robust to a busy CI runner).
137
+ */
138
+ export function __setQueueDepthOffForTest(on: boolean): void { queueDepthOffForTest = on; }
139
+
140
+ export const queueDepthKey = (networkId: string | null | undefined, alias: string) => `${networkId ?? ""}\u0000${alias}`;
141
+
142
+ /** 别名不多于这个数时按别名查(走 idx_tasks_to_created);多了就扫最近 24 小时派出的任务(走覆盖索引 idx_tasks_created_queue)。 */
143
+ export const QUEUE_DEPTH_ALIAS_LIST_MAX = 50;
144
+
145
+ /**
146
+ * 每个 (网络, 别名) 上开着的任务数。一条 GROUP BY;没有开着任务的节点不在表里(= 0)。
147
+ * `aliases` = 这次要标注的行:App 的 `?alias=` / `?node_id=` 单行读只查那一两个别名,不扫全库的开着任务。
148
+ * 返回的计数按 (network_id, to_name) 分组,调用方按行自己的 network_id 取,所以别的网络的同名节点不会串。
149
+ */
150
+ export function queueDepthByNode(aliases?: Array<string | null | undefined>): Map<string, number> {
151
+ const out = new Map<string, number>();
152
+ if (queueDepthOffForTest) return out;
153
+ const q = queueDepthQuery(aliases);
154
+ if (!q) return out;
155
+ const rows = db.all<{ network_id: string | null; to_name: string; n: number | string }>(q.sql, ...q.params);
156
+ for (const r of rows) out.set(queueDepthKey(r.network_id, r.to_name), Number(r.n) || 0);
157
+ return out;
158
+ }
159
+
160
+ /** The statement queueDepthByNode runs (exported so the scale test can EXPLAIN exactly this SQL). null = nothing to ask. */
161
+ export function queueDepthQuery(aliases?: Array<string | null | undefined>): { sql: string; params: any[] } | null {
162
+ const wanted = aliases ? [...new Set(aliases.filter((a): a is string => typeof a === "string" && a.length > 0))] : null;
163
+ if (wanted && wanted.length === 0) return null;
164
+ const params: any[] = [];
165
+ // 🔴 The `|| ''` wrappers are deliberate — they are index hints, not data transforms (#500 follow-up to #2325).
166
+ // On a production-shaped DB (57k tasks, 31k stuck in `acked` for weeks, ~1.9k created in the last 24 h) the
167
+ // planner picked idx_tasks_status for `status IN (…)` and walked every open-status row ever written (~32k) to
168
+ // keep the 24 h window: full /api/status went from ~8 ms to ~70 ms p50. Wrapping `status` makes that index
169
+ // unusable, so the scan is driven by the 24 h range on the covering idx_tasks_created_queue (created_at, status,
170
+ // network_id, to_name) (EXPLAIN: `SEARCH tasks USING COVERING INDEX idx_tasks_created_queue (created_at>?)`, plus a
171
+ // temp b-tree for the GROUP BY; no task row is read) — or, for the alias list, by idx_tasks_to_created. Without the
172
+ // covering index it falls back to idx_tasks_created + one row lookup per task: ~3 ms on the production copy, which
173
+ // was still +50 % on a full /api/status read; covering, ~0.3 ms. The GROUP BY must be wrapped too: grouping on the bare columns lets SQLite pick
174
+ // idx_tasks_network to avoid the sort, which is another full walk. `|| ''` is portable (SQLite and PostgreSQL;
175
+ // a CTE/subquery gets flattened back, `+status` / `LIMIT -1` are SQLite-only). NULL || '' stays NULL on both, and
176
+ // the selected expressions are the grouped ones (PostgreSQL requires that). task-queue-ahead-scale.test.ts pins
177
+ // the plan and a latency budget.
178
+ let sql = `SELECT (network_id || '') AS network_id, (to_name || '') AS to_name, COUNT(*) AS n FROM tasks
179
+ WHERE (status || '') IN ${QUEUE_OPEN_STATUSES_SQL} AND created_at >= ${QUEUE_HORIZON_SQL}`;
180
+ if (wanted && wanted.length <= QUEUE_DEPTH_ALIAS_LIST_MAX) {
181
+ sql += ` AND to_name IN (${wanted.map((_, i) => `?${i + 1}`).join(", ")})`;
182
+ params.push(...wanted);
183
+ }
184
+ sql += " GROUP BY (network_id || ''), (to_name || '')";
185
+ return { sql, params };
186
+ }
@@ -0,0 +1,160 @@
1
+ // #552 — every rejected REST POST /api/task leaves exactly one hub log line.
2
+ //
3
+ // Incident: a restricted member's app sends were refused and the hub log
4
+ // showed nothing, so "never arrived" and "rejected" were indistinguishable.
5
+ // Real Bun.serve on a private port + temp DB; console.log is captured around
6
+ // each request. The line must carry status + error + user + requested
7
+ // alias/network/client_request_id, and must NOT carry the bearer token or the
8
+ // task text.
9
+
10
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "fs";
12
+ import { join } from "path";
13
+ import { tmpdir } from "os";
14
+ import { register } from "./auth.js";
15
+ import { db } from "./db.js";
16
+ import { formatTaskRejection } from "./task-rejection-log.js";
17
+
18
+ const DIR = mkdtempSync(join(tmpdir(), "anet-task-reject-log-"));
19
+ let BASE = "";
20
+ let hub: any = null;
21
+ const PW = "RejectLogPassw0rd!x";
22
+ let adminToken = "";
23
+ let adminName = "";
24
+ let NET = "";
25
+ let aliceToken = "";
26
+ const AGENT = { alias: "rejlog-agent", node: "node_rejlog" };
27
+ const SECRET_TEXT = "TOP-SECRET-TASK-BODY-552";
28
+
29
+ async function postTask(token: string | null, body: string): Promise<{ status: number; json: any; lines: string[] }> {
30
+ const lines: string[] = [];
31
+ const orig = console.log;
32
+ console.log = (...args: any[]) => { lines.push(args.map(String).join(" ")); };
33
+ try {
34
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
35
+ if (token) headers.Authorization = `Bearer ${token}`;
36
+ const res = await fetch(`${BASE}/api/task`, { method: "POST", headers, body });
37
+ const text = await res.text();
38
+ let json: any = text;
39
+ try { json = JSON.parse(text); } catch {}
40
+ return { status: res.status, json, lines };
41
+ } finally {
42
+ console.log = orig;
43
+ }
44
+ }
45
+ const rejectedLines = (lines: string[]) => lines.filter(l => l.includes("→ /api/task →") && l.includes("REJECTED"));
46
+
47
+ beforeAll(async () => {
48
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
49
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
50
+ process.env.HOST = "127.0.0.1";
51
+
52
+ const admin = register(`rejlog_admin_${Date.now()}`, PW, undefined, "Admin");
53
+ expect(admin.ok).toBe(true);
54
+ adminToken = admin.token!;
55
+ adminName = admin.user!.username;
56
+ NET = admin.network_id!;
57
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.user!.user_id]);
58
+ db.run(
59
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
60
+ [AGENT.node, AGENT.alias, NET, admin.user!.user_id],
61
+ );
62
+ db.run(
63
+ `INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at)
64
+ VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`,
65
+ [`resume_${AGENT.node}`, AGENT.alias, AGENT.node, NET],
66
+ );
67
+
68
+ const mod: any = await import("./server.js");
69
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
70
+ BASE = `http://127.0.0.1:${hub.port}`;
71
+
72
+ // A restricted member (default agent_access='granted', zero grants) who also
73
+ // owns a personal network → two memberships, like the #552 app user.
74
+ const created = await fetch(`${BASE}/api/admin/users`, {
75
+ method: "POST",
76
+ headers: { Authorization: `Bearer ${adminToken}`, "Content-Type": "application/json" },
77
+ body: JSON.stringify({ username: "rejlog_alice", password: PW, network_id: NET, role: "member" }),
78
+ });
79
+ expect(created.status).toBe(200);
80
+ const login = await fetch(`${BASE}/api/auth/login`, {
81
+ method: "POST", headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({ username: "rejlog_alice", password: PW }),
83
+ });
84
+ expect(login.status).toBe(200);
85
+ aliceToken = (await login.json() as any).token;
86
+ }, 30_000);
87
+
88
+ afterAll(() => {
89
+ try { hub?.stop?.(true); } catch {}
90
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
91
+ });
92
+
93
+ function assertNoSecrets(line: string, token: string) {
94
+ expect(line).not.toContain(token);
95
+ expect(line).not.toContain(SECRET_TEXT);
96
+ expect(line).not.toContain("file_");
97
+ }
98
+
99
+ describe("#552 REST POST /api/task rejections are logged", () => {
100
+ test("alias_not_found (404): one line with status, error, user, alias, network, client_request_id", async () => {
101
+ const r = await postTask(adminToken, JSON.stringify({ alias: "no-such-agent", task: SECRET_TEXT, network_id: NET, meta: { client_request_id: "crid-552-a" } }));
102
+ expect(r.status).toBe(404);
103
+ expect(r.json.error).toBe("alias_not_found");
104
+ const lines = rejectedLines(r.lines);
105
+ expect(lines.length).toBe(1);
106
+ expect(lines[0]).toMatch(/^\[\d\d:\d\d:\d\d\] /);
107
+ expect(lines[0]).toContain(`${adminName} → /api/task → no-such-agent: REJECTED 404 alias_not_found`);
108
+ expect(lines[0]).toContain(`net=${NET}`);
109
+ expect(lines[0]).toContain("crid=crid-552-a");
110
+ assertNoSecrets(lines[0], adminToken);
111
+ });
112
+
113
+ test("network_id_required (400) for a member of two networks who omits network_id", async () => {
114
+ const r = await postTask(aliceToken, JSON.stringify({ alias: AGENT.alias, task: SECRET_TEXT }));
115
+ expect(r.status).toBe(400);
116
+ expect(r.json.error).toBe("network_id_required");
117
+ const lines = rejectedLines(r.lines);
118
+ expect(lines.length).toBe(1);
119
+ expect(lines[0]).toContain(`rejlog_alice → /api/task → ${AGENT.alias}: REJECTED 400 network_id_required (net=-)`);
120
+ assertNoSecrets(lines[0], aliceToken);
121
+ });
122
+
123
+ test("agent_not_granted (403) — the restricted-member case from #552", async () => {
124
+ const r = await postTask(aliceToken, JSON.stringify({ alias: AGENT.alias, task: SECRET_TEXT, network_id: NET, attachments: [{ file_id: "file_deadbeef" }] }));
125
+ expect(r.status).toBe(403);
126
+ expect(r.json.error).toBe("agent_not_granted");
127
+ const lines = rejectedLines(r.lines);
128
+ expect(lines.length).toBe(1);
129
+ expect(lines[0]).toContain(`rejlog_alice → /api/task → ${AGENT.alias}: REJECTED 403 agent_not_granted (net=${NET})`);
130
+ assertNoSecrets(lines[0], aliceToken);
131
+ });
132
+
133
+ test("invalid JSON (400) is logged with the user and '-' placeholders", async () => {
134
+ const r = await postTask(aliceToken, "{not json");
135
+ expect(r.status).toBe(400);
136
+ const lines = rejectedLines(r.lines);
137
+ expect(lines.length).toBe(1);
138
+ expect(lines[0]).toContain("rejlog_alice → /api/task → -: REJECTED 400 invalid JSON (net=-)");
139
+ });
140
+
141
+ test("a successful send logs no REJECTED line", async () => {
142
+ const r = await postTask(adminToken, JSON.stringify({ alias: AGENT.alias, task: "hello", network_id: NET }));
143
+ expect(r.status).toBe(200);
144
+ expect(rejectedLines(r.lines).length).toBe(0);
145
+ });
146
+ });
147
+
148
+ describe("formatTaskRejection", () => {
149
+ test("request-supplied values cannot inject extra log lines and are clipped", () => {
150
+ const line = formatTaskRejection({
151
+ status: 404, error: "alias_not_found", username: null,
152
+ body: { alias: "evil\n[00:00:00] admin → forged", network_id: "n".repeat(200), meta: { client_request_id: "x\r\ny" } },
153
+ });
154
+ expect(line).not.toContain("\n");
155
+ expect(line).not.toContain("\r");
156
+ expect(line).toContain("anon → /api/task → evil [00:00:00] admin → forged: REJECTED 404 alias_not_found");
157
+ expect(line).toContain(`net=${"n".repeat(64)}…`);
158
+ expect(line).toContain("crid=x y");
159
+ });
160
+ });
@@ -0,0 +1,49 @@
1
+ // #552 — one log line for every non-2xx answer from REST POST /api/task.
2
+ //
3
+ // A restricted member's app sends were rejected and the hub log showed
4
+ // nothing, so "the request never arrived" and "the hub refused it" looked
5
+ // identical from the server side. Every rejection branch now goes through
6
+ // logTaskRejection().
7
+ //
8
+ // What is logged is deliberately narrow: status, error code, the
9
+ // authenticated username, and the *requested* alias / network_id /
10
+ // client_request_id. Never the bearer token, the task text, or attachment
11
+ // ids. Request-supplied values are clipped and stripped of control
12
+ // characters so a client cannot forge extra log lines.
13
+
14
+ function ts(): string {
15
+ return new Date().toTimeString().slice(0, 8);
16
+ }
17
+
18
+ function clip(value: unknown, max = 64): string {
19
+ if (typeof value !== "string" || !value) return "-";
20
+ const clean = value.replace(/[\u0000-\u001f\u007f\s]+/g, " ").trim();
21
+ if (!clean) return "-";
22
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
23
+ }
24
+
25
+ export interface TaskRejectionContext {
26
+ status: number;
27
+ error: unknown;
28
+ username: string | null | undefined;
29
+ /** The raw request body (or null when it did not parse). Only alias, network_id and meta.client_request_id are read. */
30
+ body: unknown;
31
+ }
32
+
33
+ export function formatTaskRejection(ctx: TaskRejectionContext): string {
34
+ const b = ctx.body && typeof ctx.body === "object" ? (ctx.body as Record<string, any>) : {};
35
+ const meta = b.meta && typeof b.meta === "object" ? b.meta : {};
36
+ const crid = typeof meta.client_request_id === "string" ? ` crid=${clip(meta.client_request_id)}` : "";
37
+ return `[${ts()}] ${clip(ctx.username ?? "anon")} → /api/task → ${clip(b.alias)}: REJECTED ${ctx.status} ${clip(ctx.error)} (net=${clip(b.network_id)}${crid})`;
38
+ }
39
+
40
+ /** Logs one line for a non-2xx response and returns the response unchanged. */
41
+ export async function logTaskRejection(res: Response, username: string | null | undefined, body: unknown): Promise<Response> {
42
+ if (res.status >= 200 && res.status < 300) return res;
43
+ let error: unknown = null;
44
+ try {
45
+ error = ((await res.clone().json()) as any)?.error;
46
+ } catch {}
47
+ console.log(formatTaskRejection({ status: res.status, error: error ?? "unknown", username: username || "anon", body }));
48
+ return res;
49
+ }