@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,315 @@
1
+ // #500 —— 任务过期时告诉派活的人。
2
+ //
3
+ // 以前 TTL 巡检(server.ts patrolExpiredTasks)只把任务改成 expired、写一条 task_events,
4
+ // 派活的一方什么都收不到:14 天 109 条过期,0 条被告知;agent 收不到回音就原样重发,队列越排越长。
5
+ //
6
+ // 这里在巡检提交之后,按「派活的一方是谁」走它平时收回复的那条路:
7
+ // - agent 节点:inbox 写一行 type='reply'(requires_response='none')+ SSE new_reply。
8
+ // 与 send_reply 同形:agent-node(peer-reply-inbox.ts)和 claude-code channel(channel-inbox-wake.ts)
9
+ // 都把 new_reply 当门铃拉 inbox。agent-node 按 inbox-message-policy.ts 把 type=reply 当「终态结果」
10
+ // 交给模型、不回复(不会被当成新任务);claude-code channel 像别的回复一样注入会话。from_session = 'hub':自派(A→A)的任务不会被节点当成自己发的而跳过。
11
+ // - 人(Dashboard / App 的用户):agent-notice.ts(user_inbox + /events/users/me 的 desktop_message),
12
+ // from_session = 目标节点别名 —— 与 #462/#464 同一条路,出现在**与该节点的会话**里 + 未读 + 通知。
13
+ // - scheduler:不发。scheduled_task_runs 那一行已经记了 expired(syncScheduledRunForTask)。
14
+ // - hub / api / 认不出来的发送方:不发(没有可达的收件人)。
15
+ // 限流:同一次巡检里,同一个 (网络, 发送方, 目标) 只发**一条**,多条过期合并成一条摘要。
16
+ // 父任务:子任务过期时通知父任务的发送方一条(同样按父任务合并),**不**改父任务的状态 / result ——
17
+ // chainReplyToParent 会把父任务改成 replied/failed,这里刻意不走它:父任务的执行者可能还在干活。
18
+ // 全部在巡检事务提交之后做,每一步各自吞异常:通知失败不能让巡检失败,也不能回滚已经过期的任务。
19
+
20
+ import { db, uuidv4 } from "./db.js";
21
+ import { parseDbTimestampMs } from "./db-timestamp.js";
22
+ import { sendAgentNotice } from "./agent-notice.js";
23
+ import { pushEvent, pushNetworkObserverEvent } from "./push.js";
24
+ import { pendingInboxCount } from "./inbox-count.js";
25
+ import { QUEUE_HORIZON_SQL, QUEUE_OPEN_STATUSES_SQL } from "./task-queue-ahead.js";
26
+
27
+ export const TASK_EXPIRED_NOTICE_KIND = "task_expired";
28
+
29
+ /** 巡检刚刚结束为 expired 的一行(巡检 SELECT 出来的列)。 */
30
+ export type ExpiredTaskRow = {
31
+ task_id: string;
32
+ network_id: string | null;
33
+ from_name: string | null;
34
+ from_node_id: string | null;
35
+ to_name: string;
36
+ content: string | null;
37
+ created_at: string | null;
38
+ expires_at: string | null;
39
+ parent_task_id: string | null;
40
+ meta_json: string | null;
41
+ };
42
+
43
+ export type ExpirySenderRoute =
44
+ | { kind: "agent"; alias: string }
45
+ | { kind: "user"; userId: string; username: string }
46
+ | { kind: "none"; reason: "scheduler" | "system" | "unknown" };
47
+
48
+ /** 一条发出去的通知(测试 / 日志用)。 */
49
+ export type ExpiryNoticeSent = {
50
+ route: "agent" | "user";
51
+ recipient: string;
52
+ target: string;
53
+ task_ids: string[];
54
+ message_id: string;
55
+ about: "task" | "child";
56
+ };
57
+
58
+ const SYSTEM_SENDERS = new Set(["", "hub", "api"]);
59
+ const MAX_LISTED = 10;
60
+
61
+ function authOrigin(metaJson: string | null): string | null {
62
+ if (!metaJson) return null;
63
+ try {
64
+ const m = JSON.parse(metaJson);
65
+ return m && typeof m === "object" && typeof m.auth_origin === "string" ? m.auth_origin : null;
66
+ } catch { return null; }
67
+ }
68
+
69
+ function isScheduledTask(taskId: string): boolean {
70
+ return !!db.get("SELECT 1 AS hit FROM scheduled_task_runs WHERE task_id = ?1", taskId);
71
+ }
72
+
73
+ function memberUser(username: string, networkId: string | null): { userId: string } | null {
74
+ if (!networkId) return null; // user_inbox 按网络存:没有网络就没有可达的人
75
+ const u = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", username);
76
+ if (!u) return null;
77
+ const m = db.get("SELECT 1 AS hit FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, u.user_id);
78
+ return m ? { userId: u.user_id } : null;
79
+ }
80
+
81
+ function agentSession(alias: string, networkId: string | null): { node_id: string | null } | null {
82
+ return networkId
83
+ ? db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1", alias, networkId)
84
+ : db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id IS NULL ORDER BY updated_at DESC LIMIT 1", alias);
85
+ }
86
+
87
+ /**
88
+ * 派活的一方是谁 → 走哪条路。
89
+ * 节点派的(auth_origin=node 或带 from_node_id)先按节点认;否则先按人认(Dashboard 用用户令牌派活,
90
+ * from_name 是用户名、from_node_id 为空)。两种都认不出 → 不发。
91
+ */
92
+ export function classifyExpirySender(row: Pick<ExpiredTaskRow, "task_id" | "network_id" | "from_name" | "from_node_id" | "meta_json">): ExpirySenderRoute {
93
+ const from = (row.from_name ?? "").trim();
94
+ const origin = authOrigin(row.meta_json);
95
+ if (from === "scheduler" || origin === "hub_scheduler" || isScheduledTask(row.task_id)) return { kind: "none", reason: "scheduler" };
96
+ if (SYSTEM_SENDERS.has(from)) return { kind: "none", reason: "system" };
97
+ const nodeFirst = origin === "node" || (!!row.from_node_id && origin !== "user");
98
+ const asAgent = (): ExpirySenderRoute | null => (agentSession(from, row.network_id) ? { kind: "agent", alias: from } : null);
99
+ const asUser = (): ExpirySenderRoute | null => {
100
+ const u = memberUser(from, row.network_id);
101
+ return u ? { kind: "user", userId: u.userId, username: from } : null;
102
+ };
103
+ return (nodeFirst ? (asAgent() ?? asUser()) : (asUser() ?? asAgent())) ?? { kind: "none", reason: "unknown" };
104
+ }
105
+
106
+ /**
107
+ * 过期那一刻,目标节点上比它早、还没结束的任务数(正在跑的也算)。
108
+ * #519 —— 与 task-queue-ahead.ts 的 queue_ahead 同一口径:只数最近 24 小时(QUEUE_HORIZON_SQL)派出的。
109
+ * 以前没有下界,几个月前被遗弃在 acked / running 的行全算「排在前面」,通知会说前面还有几千个。
110
+ * 走 idx_tasks_to_created(to_name, created_at)的区间 [horizon, createdAt)。
111
+ */
112
+ export function openTasksAhead(target: string, networkId: string | null, createdAt: string | null): number | null {
113
+ if (!createdAt) return null;
114
+ try {
115
+ const row = networkId
116
+ ? db.get<{ n: number }>(
117
+ `SELECT COUNT(*) AS n FROM tasks WHERE to_name = ?1 AND network_id = ?2
118
+ AND status IN ${QUEUE_OPEN_STATUSES_SQL} AND created_at < ?3 AND created_at >= ${QUEUE_HORIZON_SQL}`,
119
+ target, networkId, createdAt)
120
+ : db.get<{ n: number }>(
121
+ `SELECT COUNT(*) AS n FROM tasks WHERE to_name = ?1 AND network_id IS NULL
122
+ AND status IN ${QUEUE_OPEN_STATUSES_SQL} AND created_at < ?2 AND created_at >= ${QUEUE_HORIZON_SQL}`,
123
+ target, createdAt);
124
+ return Number(row?.n ?? 0);
125
+ } catch { return null; }
126
+ }
127
+
128
+ function minutesBetween(fromTs: string | null, toMs: number): number | null {
129
+ if (!fromTs) return null;
130
+ const ms = parseDbTimestampMs(fromTs);
131
+ return Number.isFinite(ms) ? Math.max(0, Math.round((toMs - ms) / 60_000)) : null;
132
+ }
133
+
134
+ function preview(content: string | null, max = 60): string {
135
+ const s = (content ?? "").replace(/\s+/g, " ").trim();
136
+ return s.length > max ? `${s.slice(0, max)}…` : s;
137
+ }
138
+
139
+ export const EXPIRY_GUIDANCE_ZH = "建议:换一个空闲的节点重新派(已过期的任务不能 reassign_task),或稍后用 retry_task 让它在原节点重新排队;不要原样重发一条新任务 —— 重发只会让它的队列更长。";
140
+ export const EXPIRY_GUIDANCE_EN = "Consider sending it to another node or retry_task later; do not blindly resend.";
141
+
142
+ export type ExpiryNoticeInput = {
143
+ target: string;
144
+ tasks: Array<{ task_id: string; content: string | null; waited_min: number | null; ttl_min: number | null }>;
145
+ queued_ahead: number | null;
146
+ };
147
+
148
+ const fmtMin = (m: number | null) => (m === null ? "?" : String(m));
149
+
150
+ /** 给派活一方的正文。单条与多条两种形状;人和 agent 读同一份。 */
151
+ export function expiryNoticeText(n: ExpiryNoticeInput): { title: string; text: string } {
152
+ const count = n.tasks.length;
153
+ const ahead = n.queued_ahead === null ? "" : `过期时 ${n.target} 上还有 ${n.queued_ahead} 个比它早、没结束的任务。\n`;
154
+ const aheadEn = n.queued_ahead === null ? "" : `, ${n.queued_ahead} open task(s) ahead`;
155
+ if (count === 1) {
156
+ const t = n.tasks[0];
157
+ const ttl = t.ttl_min === null ? "" : `(期限 ${t.ttl_min} 分钟)`;
158
+ return {
159
+ title: "任务已过期",
160
+ text: `[Hub] 任务已过期:你派给 ${n.target} 的任务 ${t.task_id} 等了 ${fmtMin(t.waited_min)} 分钟仍没被取走${ttl},Hub 已把它结束为 expired,${n.target} 不会再执行它。\n`
161
+ + ahead
162
+ + `内容:${preview(t.content)}\n`
163
+ + `${EXPIRY_GUIDANCE_ZH}\n`
164
+ + `Task ${t.task_id} to ${n.target} expired unconsumed after ${fmtMin(t.waited_min)} min${aheadEn}. ${EXPIRY_GUIDANCE_EN}`,
165
+ };
166
+ }
167
+ const waited = n.tasks.map((t) => t.waited_min).filter((m): m is number => m !== null);
168
+ const longest = waited.length ? Math.max(...waited) : null;
169
+ const lines = n.tasks.slice(0, MAX_LISTED).map((t) => `- ${t.task_id} 等了 ${fmtMin(t.waited_min)} 分钟:${preview(t.content, 40)}`);
170
+ if (count > MAX_LISTED) lines.push(`- …另外 ${count - MAX_LISTED} 个`);
171
+ return {
172
+ title: `${count} 个任务已过期`,
173
+ text: `[Hub] ${count} 个任务已过期:你派给 ${n.target} 的 ${count} 个任务都没被取走(最长等了 ${fmtMin(longest)} 分钟),Hub 已把它们结束为 expired,${n.target} 不会再执行。\n`
174
+ + ahead
175
+ + `${lines.join("\n")}\n`
176
+ + `${EXPIRY_GUIDANCE_ZH}\n`
177
+ + `${count} tasks to ${n.target} expired unconsumed (longest wait ${fmtMin(longest)} min${aheadEn}). ${EXPIRY_GUIDANCE_EN}`,
178
+ };
179
+ }
180
+
181
+ /** 父任务发送方收到的正文:子任务过期了,父任务**没有**被结束。 */
182
+ export function childExpiryNoticeText(p: { parent_task_id: string; parent_target: string; child_target: string; child_task_ids: string[] }): { title: string; text: string } {
183
+ const n = p.child_task_ids.length;
184
+ return {
185
+ title: "子任务已过期",
186
+ text: `[Hub] 子任务已过期:你派给 ${p.parent_target} 的任务 ${p.parent_task_id} 拆出的 ${n} 个子任务(派给 ${p.child_target}:${p.child_task_ids.join(", ")})没被取走,已结束为 expired。`
187
+ + `父任务没有被结束,仍由 ${p.parent_target} 处理;它也收到了过期通知。\n`
188
+ + `Sub-task(s) of ${p.parent_task_id} sent by ${p.parent_target} to ${p.child_target} expired unconsumed; the parent task is still open.`,
189
+ };
190
+ }
191
+
192
+ /** 审计:task_events 记一笔「通知过谁」。不用 logTaskEvent:它按 to_status 派生 event_type,
193
+ * 写 expired 会多出一条 task.expired,让按事件数过期的统计翻倍。 */
194
+ function noteEvent(taskId: string, status: string, detail: string): void {
195
+ try {
196
+ db.run(
197
+ `INSERT INTO task_events (task_id, from_status, to_status, event_type, actor, detail, network_id)
198
+ VALUES (?1, ?2, ?2, 'task.expiry_notice', 'patrol', ?3, (SELECT network_id FROM tasks WHERE task_id = ?1))`,
199
+ [taskId, status, detail],
200
+ );
201
+ } catch {}
202
+ }
203
+
204
+ function deliverToAgent(alias: string, networkId: string | null, inReplyTo: string, text: string, meta: Record<string, unknown>, status = "expired", prefix = "exp_"): string {
205
+ const id = `${prefix}${uuidv4().replace(/-/g, "").slice(0, 20)}`;
206
+ const nodeId = agentSession(alias, networkId)?.node_id ?? null;
207
+ db.run(
208
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
209
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, 'hub', ?5, 'none', ?6, ?7)`,
210
+ [id, alias, nodeId, text, inReplyTo, networkId, JSON.stringify(meta)],
211
+ );
212
+ pushEvent(alias, { type: "new_reply", inbox_count: pendingInboxCount(alias, networkId), from: "hub", message_id: id, in_reply_to: inReplyTo, status }, networkId);
213
+ return id;
214
+ }
215
+
216
+ /** 按 route 投递一条通知。#758 孤儿任务通知复用它(kind / status / id 前缀不同)。 */
217
+ export function deliver(
218
+ route: Extract<ExpirySenderRoute, { kind: "agent" | "user" }>,
219
+ networkId: string | null,
220
+ fromAlias: string,
221
+ inReplyTo: string,
222
+ notice: { title: string; text: string },
223
+ meta: Record<string, unknown>,
224
+ as: { kind: string; status: string; agentPrefix: string; userPrefix: string } = { kind: TASK_EXPIRED_NOTICE_KIND, status: "expired", agentPrefix: "exp_", userPrefix: "dm_expired_" },
225
+ ): string | null {
226
+ if (route.kind === "agent") return deliverToAgent(route.alias, networkId, inReplyTo, notice.text, meta, as.status, as.agentPrefix);
227
+ if (!networkId) return null;
228
+ return sendAgentNotice({
229
+ networkId, userId: route.userId, fromAlias, kind: as.kind,
230
+ title: notice.title, text: notice.text, severity: "warning", meta, idPrefix: as.userPrefix,
231
+ });
232
+ }
233
+
234
+ /**
235
+ * 巡检提交之后调用。返回发出去的通知(测试用)。任何一组失败只记日志,不影响别的组。
236
+ */
237
+ export function notifyExpiredTasks(rows: ExpiredTaskRow[], nowMs = Date.now()): ExpiryNoticeSent[] {
238
+ const sent: ExpiryNoticeSent[] = [];
239
+ if (rows.length === 0) return sent;
240
+
241
+ // 网络观察者(Dashboard 任务页):与 send_reply 同一个摘要事件,只带路由元数据,让任务行刷新成 expired。
242
+ for (const r of rows) {
243
+ try { pushNetworkObserverEvent(r.network_id, { type: "new_reply", task_id: r.task_id, message_id: null, from: "hub", to: r.from_name ?? null, status: "expired" }); } catch {}
244
+ }
245
+
246
+ // 1. 给派活的一方:按 (网络, 发送方, 目标) 合并。
247
+ const groups = new Map<string, ExpiredTaskRow[]>();
248
+ for (const r of rows) {
249
+ const key = JSON.stringify([r.network_id ?? null, r.from_name ?? "", r.to_name]);
250
+ const g = groups.get(key);
251
+ if (g) g.push(r); else groups.set(key, [r]);
252
+ }
253
+ for (const group of groups.values()) {
254
+ try {
255
+ group.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
256
+ const head = group[0];
257
+ const route = classifyExpirySender(head);
258
+ if (route.kind === "none") continue;
259
+ const newest = group[group.length - 1];
260
+ const input: ExpiryNoticeInput = {
261
+ target: head.to_name,
262
+ tasks: group.map((t) => ({
263
+ task_id: t.task_id,
264
+ content: t.content,
265
+ waited_min: minutesBetween(t.created_at, nowMs),
266
+ ttl_min: t.created_at && t.expires_at
267
+ ? Math.round((parseDbTimestampMs(t.expires_at) - parseDbTimestampMs(t.created_at)) / 60_000)
268
+ : null,
269
+ })),
270
+ queued_ahead: openTasksAhead(head.to_name, head.network_id, newest.created_at),
271
+ };
272
+ const notice = expiryNoticeText(input);
273
+ const taskIds = group.map((t) => t.task_id);
274
+ const meta = { task_expired: { target: head.to_name, task_ids: taskIds, queued_ahead: input.queued_ahead } };
275
+ const id = deliver(route, head.network_id, head.to_name, newest.task_id, notice, meta);
276
+ if (id) {
277
+ sent.push({ route: route.kind, recipient: route.kind === "agent" ? route.alias : route.userId, target: head.to_name, task_ids: taskIds, message_id: id, about: "task" });
278
+ for (const t of group) noteEvent(t.task_id, "expired", `notified ${route.kind} ${route.kind === "agent" ? route.alias : route.username}`);
279
+ }
280
+ } catch (e: any) {
281
+ console.error(`[patrol] expiry notice failed: ${e?.message || e}`);
282
+ }
283
+ }
284
+
285
+ // 2. 父任务:按父任务合并,只通知,不改父任务的状态 / result。跨网络的父任务不碰。
286
+ const byParent = new Map<string, ExpiredTaskRow[]>();
287
+ for (const r of rows) {
288
+ if (!r.parent_task_id) continue;
289
+ const g = byParent.get(r.parent_task_id);
290
+ if (g) g.push(r); else byParent.set(r.parent_task_id, [r]);
291
+ }
292
+ for (const [parentId, children] of byParent) {
293
+ try {
294
+ const parent = db.get<{ task_id: string; from_name: string | null; from_node_id: string | null; to_name: string; status: string; network_id: string | null; meta_json: string | null }>(
295
+ "SELECT task_id, from_name, from_node_id, to_name, status, network_id, meta_json FROM tasks WHERE task_id = ?1",
296
+ parentId,
297
+ );
298
+ if (!parent) continue;
299
+ const childNet = children[0].network_id ?? null;
300
+ if ((parent.network_id ?? null) !== childNet) continue;
301
+ const childIds = children.map((c) => c.task_id);
302
+ const childTargets = [...new Set(children.map((c) => c.to_name))].join(", ");
303
+ noteEvent(parent.task_id, parent.status, `child expired: ${childIds.join(",")} → ${childTargets}`);
304
+ const route = classifyExpirySender(parent);
305
+ if (route.kind === "none") continue;
306
+ const notice = childExpiryNoticeText({ parent_task_id: parent.task_id, parent_target: parent.to_name, child_target: childTargets, child_task_ids: childIds });
307
+ const meta = { task_expired: { parent_task_id: parent.task_id, target: childTargets, task_ids: childIds } };
308
+ const id = deliver(route, parent.network_id, parent.to_name, parent.task_id, notice, meta);
309
+ if (id) sent.push({ route: route.kind, recipient: route.kind === "agent" ? route.alias : route.userId, target: childTargets, task_ids: childIds, message_id: id, about: "child" });
310
+ } catch (e: any) {
311
+ console.error(`[patrol] child-expiry notice failed parent=${parentId}: ${e?.message || e}`);
312
+ }
313
+ }
314
+ return sent;
315
+ }
@@ -0,0 +1,142 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db } from "./db.js";
4
+ import { registerTools } from "./tools.js";
5
+ import { sharedSendDedup } from "./send_dedup.js";
6
+
7
+ const NET = "net_chat_idem";
8
+ const USER = "user_chat_idem";
9
+ const TARGET = "node_chat_idem";
10
+
11
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
12
+
13
+ function cleanup() {
14
+ for (const table of ["tasks", "inbox", "task_events"]) {
15
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
16
+ }
17
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
18
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
19
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
20
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
21
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
22
+ sharedSendDedup.clear();
23
+ }
24
+
25
+ function seed() {
26
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))", [USER]);
27
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
28
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
29
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', datetime('now'), datetime('now'), 'active')", [`id_${TARGET}`, TARGET, NET]);
30
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))", [`resume_${TARGET}`, TARGET, `id_${TARGET}`, NET]);
31
+ }
32
+
33
+ function sendTaskHandler(auth?: { callerAlias: string; nodeToken: boolean }): ToolHandler {
34
+ const server = new McpServer({ name: "idempotency-test", version: "0" }) as any;
35
+ let handler: ToolHandler | undefined;
36
+ const original = server.tool.bind(server);
37
+ server.tool = (name: string, description: string, schema: any, candidate: ToolHandler) => {
38
+ if (name === "send_task") handler = candidate;
39
+ return original(name, description, schema, candidate);
40
+ };
41
+ registerTools(
42
+ server,
43
+ undefined,
44
+ NET,
45
+ USER,
46
+ auth?.callerAlias ?? USER,
47
+ auth?.nodeToken ?? false,
48
+ auth?.nodeToken ? "token_node_chat_idem" : null,
49
+ );
50
+ if (!handler) throw new Error("send_task handler missing");
51
+ return handler;
52
+ }
53
+
54
+ async function call(handler: ToolHandler, args: any) {
55
+ const result = await handler(args);
56
+ return JSON.parse(result.content[0].text);
57
+ }
58
+
59
+ beforeEach(() => { cleanup(); seed(); });
60
+ afterAll(cleanup);
61
+
62
+ describe("send_task durable idempotency", () => {
63
+ test("Hub stamps authenticated user origin and overrides client spoofing", async () => {
64
+ const handler = sendTaskHandler();
65
+ const sent = await call(handler, {
66
+ alias: TARGET,
67
+ task: "authenticated dashboard payload",
68
+ priority: "normal",
69
+ meta: {
70
+ source: "dashboard-chat",
71
+ client_request_id: "dreq_0123456789abcdef0123456789abcdef",
72
+ auth_origin: "node",
73
+ },
74
+ });
75
+ expect(sent.ok).toBe(true);
76
+ const task = db.get<{ meta_json: string }>("SELECT meta_json FROM tasks WHERE task_id = ?1", [sent.message_id]);
77
+ const inbox = db.get<{ meta_json: string }>("SELECT meta_json FROM inbox WHERE id = ?1", [sent.message_id]);
78
+ expect(JSON.parse(task!.meta_json).auth_origin).toBe("user");
79
+ expect(JSON.parse(inbox!.meta_json).auth_origin).toBe("user");
80
+ });
81
+
82
+ test("network-token caller cannot forge Dashboard user origin through MCP", async () => {
83
+ const handler = sendTaskHandler({ callerAlias: "node-origin-sender", nodeToken: true });
84
+ const sent = await call(handler, {
85
+ alias: TARGET,
86
+ task: "node attempts user-origin spoof",
87
+ priority: "normal",
88
+ meta: {
89
+ source: "dashboard-chat",
90
+ client_request_id: "dreq_fedcba9876543210fedcba9876543210",
91
+ auth_origin: "user",
92
+ },
93
+ });
94
+ expect(sent.ok).toBe(true);
95
+ const inbox = db.get<{ meta_json: string }>("SELECT meta_json FROM inbox WHERE id = ?1", [sent.message_id]);
96
+ expect(JSON.parse(inbox!.meta_json).auth_origin).toBe("node");
97
+ });
98
+
99
+ test("lost-response retry returns the original task and does not enqueue twice", async () => {
100
+ const handler = sendTaskHandler();
101
+ const args = {
102
+ alias: TARGET,
103
+ task: "chat idempotent payload",
104
+ priority: "normal",
105
+ meta: { client_request_id: "dreq_0123456789abcdef" },
106
+ };
107
+ const first = await call(handler, args);
108
+ const replay = await call(handler, args);
109
+
110
+ expect(first.ok).toBe(true);
111
+ expect(replay).toEqual({
112
+ ok: true,
113
+ message_id: first.message_id,
114
+ task_id: first.message_id,
115
+ task_status: "delivered",
116
+ idempotent_replay: true,
117
+ actual_to: {
118
+ alias: TARGET,
119
+ to_node_id: "id_node_chat_idem",
120
+ network_id: NET,
121
+ },
122
+ });
123
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE network_id = ?1", [NET])?.count).toBe(1);
124
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE network_id = ?1", [NET])?.count).toBe(1);
125
+ });
126
+
127
+ test("request-id reuse with changed content fails closed without a second row", async () => {
128
+ const handler = sendTaskHandler();
129
+ const base = {
130
+ alias: TARGET,
131
+ priority: "normal",
132
+ meta: { client_request_id: "dreq_0123456789abcdef" },
133
+ };
134
+ expect((await call(handler, { ...base, task: "first payload" })).ok).toBe(true);
135
+ expect(await call(handler, { ...base, task: "changed payload" })).toEqual({
136
+ ok: false,
137
+ error: "idempotency_conflict",
138
+ message: "client_request_id was already used with a different task payload",
139
+ });
140
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE network_id = ?1", [NET])?.count).toBe(1);
141
+ });
142
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ clientRequestIdFromMeta,
4
+ idempotentTaskId,
5
+ idempotentTaskMatches,
6
+ type StoredIdempotentTask,
7
+ } from "./task-idempotency.js";
8
+
9
+ describe("dashboard task idempotency", () => {
10
+ test("accepts only bounded dashboard request ids", () => {
11
+ expect(clientRequestIdFromMeta({ client_request_id: "dreq_0123456789abcdef" })).toBe("dreq_0123456789abcdef");
12
+ expect(clientRequestIdFromMeta({ client_request_id: "short" })).toBeNull();
13
+ expect(clientRequestIdFromMeta({ client_request_id: "dreq_bad space 0123456789" })).toBeNull();
14
+ expect(clientRequestIdFromMeta(null)).toBeNull();
15
+ });
16
+
17
+ test("same caller scope and request id derive the same task id", () => {
18
+ const a = idempotentTaskId("net_a", "admin", "dreq_0123456789abcdef");
19
+ expect(idempotentTaskId("net_a", "admin", "dreq_0123456789abcdef")).toBe(a);
20
+ expect(idempotentTaskId("net_b", "admin", "dreq_0123456789abcdef")).not.toBe(a);
21
+ expect(idempotentTaskId("net_a", "other", "dreq_0123456789abcdef")).not.toBe(a);
22
+ expect(a).toMatch(/^idem_[0-9a-f]{40}$/);
23
+ });
24
+
25
+ test("replay accepts semantically identical metadata regardless of key order", () => {
26
+ const row: StoredIdempotentTask = {
27
+ task_id: "idem_x", from_name: "admin", to_name: "worker", priority: "normal",
28
+ content: "hello", network_id: "net_a", status: "delivered",
29
+ meta_json: JSON.stringify({ client_request_id: "dreq_0123456789abcdef", attachments: [{ name: "a", file_id: "f1" }] }),
30
+ };
31
+ expect(idempotentTaskMatches(row, {
32
+ fromName: "admin", toName: "worker", priority: "normal", content: "hello", networkId: "net_a",
33
+ metaJson: JSON.stringify({ attachments: [{ file_id: "f1", name: "a" }], client_request_id: "dreq_0123456789abcdef" }),
34
+ })).toBe(true);
35
+ });
36
+
37
+ test("rolling upgrade ignores the new server-only auth stamp for replay equality", () => {
38
+ const row: StoredIdempotentTask = {
39
+ task_id: "idem_x", from_name: "admin", to_name: "worker", priority: "normal",
40
+ content: "hello", network_id: "net_a", status: "delivered",
41
+ meta_json: JSON.stringify({ client_request_id: "dreq_0123456789abcdef", source: "dashboard-chat" }),
42
+ };
43
+ expect(idempotentTaskMatches(row, {
44
+ fromName: "admin", toName: "worker", priority: "normal", content: "hello", networkId: "net_a",
45
+ metaJson: JSON.stringify({ client_request_id: "dreq_0123456789abcdef", source: "dashboard-chat", auth_origin: "user" }),
46
+ })).toBe(true);
47
+ });
48
+
49
+ test("same request id with changed payload is a conflict", () => {
50
+ const row: StoredIdempotentTask = {
51
+ task_id: "idem_x", from_name: "admin", to_name: "worker", priority: "normal",
52
+ content: "hello", network_id: "net_a", status: "delivered",
53
+ meta_json: JSON.stringify({ client_request_id: "dreq_0123456789abcdef" }),
54
+ };
55
+ expect(idempotentTaskMatches(row, {
56
+ fromName: "admin", toName: "worker", priority: "normal", content: "changed", networkId: "net_a",
57
+ metaJson: row.meta_json,
58
+ })).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1,75 @@
1
+ import { createHash } from "crypto";
2
+
3
+ const CLIENT_REQUEST_ID_RE = /^dreq_[A-Za-z0-9_-]{16,96}$/;
4
+
5
+ export interface StoredIdempotentTask {
6
+ task_id: string;
7
+ from_name: string;
8
+ to_name: string;
9
+ priority: string;
10
+ content: string;
11
+ network_id: string | null;
12
+ meta_json: string | null;
13
+ status: string;
14
+ }
15
+
16
+ export interface ExpectedIdempotentTask {
17
+ fromName: string;
18
+ toName: string;
19
+ priority: string;
20
+ content: string;
21
+ networkId: string | null;
22
+ metaJson: string | null;
23
+ }
24
+
25
+ export function clientRequestIdFromMeta(meta: unknown): string | null {
26
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null;
27
+ const value = (meta as Record<string, unknown>).client_request_id;
28
+ return typeof value === "string" && CLIENT_REQUEST_ID_RE.test(value) ? value : null;
29
+ }
30
+
31
+ export function idempotentTaskId(networkId: string | null, fromName: string, requestId: string): string {
32
+ const digest = createHash("sha256")
33
+ .update(networkId ?? "<legacy-global>")
34
+ .update("\0")
35
+ .update(fromName)
36
+ .update("\0")
37
+ .update(requestId)
38
+ .digest("hex");
39
+ return `idem_${digest.slice(0, 40)}`;
40
+ }
41
+
42
+ function canonicalJson(value: unknown): string {
43
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
44
+ if (value && typeof value === "object") {
45
+ return `{${Object.entries(value as Record<string, unknown>)
46
+ .sort(([a], [b]) => a.localeCompare(b))
47
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
48
+ .join(",")}}`;
49
+ }
50
+ return JSON.stringify(value) ?? "null";
51
+ }
52
+
53
+ function parsedMeta(value: string | null): unknown {
54
+ if (!value) return null;
55
+ try { return JSON.parse(value); } catch { return value; }
56
+ }
57
+
58
+ function logicalRequestMeta(value: string | null): unknown {
59
+ const parsed = parsedMeta(value);
60
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
61
+ // auth_origin is a Hub-derived audit fact, not part of the client request.
62
+ // Ignore it for replay equality so a request first accepted by the old Hub
63
+ // (without the stamp) remains idempotent across a rolling upgrade.
64
+ const { auth_origin: _authOrigin, ...logical } = parsed as Record<string, unknown>;
65
+ return logical;
66
+ }
67
+
68
+ export function idempotentTaskMatches(row: StoredIdempotentTask, expected: ExpectedIdempotentTask): boolean {
69
+ return row.from_name === expected.fromName
70
+ && row.to_name === expected.toName
71
+ && row.priority === expected.priority
72
+ && row.content === expected.content
73
+ && (row.network_id ?? null) === (expected.networkId ?? null)
74
+ && canonicalJson(logicalRequestMeta(row.meta_json)) === canonicalJson(logicalRequestMeta(expected.metaJson));
75
+ }
@@ -0,0 +1,54 @@
1
+ import type { DbAdapter } from "./db-adapter";
2
+
3
+ type StoredLateReply = {
4
+ late_reply_id: string;
5
+ task_id: string;
6
+ from_node_id: string;
7
+ thread_id: string;
8
+ turn_id: string;
9
+ status: "replied" | "failed" | "cancelled";
10
+ result: string;
11
+ meta_json: string | null;
12
+ created_at: string;
13
+ };
14
+
15
+ function parsedMeta(raw: string | null): Record<string, unknown> | null {
16
+ if (!raw) return null;
17
+ try {
18
+ const value = JSON.parse(raw);
19
+ return value && typeof value === "object" && !Array.isArray(value)
20
+ ? value as Record<string, unknown>
21
+ : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /** Stable detail projection. Storage routing ids stay private. */
28
+ export function listTaskLateReplies(database: DbAdapter, taskId: string, networkId: string | null) {
29
+ const params: unknown[] = [taskId];
30
+ let sql = `SELECT late_reply_id,task_id,from_node_id,thread_id,turn_id,status,result,meta_json,created_at
31
+ FROM task_late_replies WHERE task_id=?1`;
32
+ if (networkId) {
33
+ params.push(networkId);
34
+ sql += " AND network_id=?2";
35
+ } else {
36
+ sql += " AND network_id IS NULL";
37
+ }
38
+ sql += " ORDER BY created_at,late_reply_id";
39
+ return database.all<StoredLateReply>(sql, ...params).map((row) => {
40
+ const meta = parsedMeta(row.meta_json);
41
+ return {
42
+ late_reply_id: row.late_reply_id,
43
+ task_id: row.task_id,
44
+ from_node_id: row.from_node_id,
45
+ thread_id: row.thread_id,
46
+ turn_id: row.turn_id,
47
+ status: row.status,
48
+ result: row.result,
49
+ created_at: row.created_at,
50
+ late: true as const,
51
+ ...(meta ? { meta } : {}),
52
+ };
53
+ });
54
+ }