@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,416 @@
1
+ // #491 / #492 —— 任务到期提醒(Hub 侧)。
2
+ //
3
+ // 每 COMMHUB_DUE_REMINDER_TICK_MS(默认 10 分钟)扫一次「没完成、没归档、有预计完成时间」的卡,按下面三种发提醒:
4
+ // due_soon 即将到期:日期型 due = 明天;时刻型 due 在 24 小时内(且不是今天)。每个 due 值一次。
5
+ // due_today 今天到期:日期型 due = 今天;时刻型 due 落在今天且还没到。每个 due 值一次。
6
+ // overdue 已逾期 N 天:每个自然日最多一次;逾期超过 COMMHUB_DUE_OVERDUE_MAX_DAYS(默认 7)天后不再提醒
7
+ // (上线当天不会把几个月前的旧卡一次性刷一遍,也不会天天提醒一张早被遗忘的卡)。
8
+ // 「今天 / 明天 / 自然日」按 COMMHUB_DUE_REMINDER_TZ(默认 Asia/Shanghai)算。网络没有时区设置;due 的存法不变:
9
+ // 日期型原样「YYYY-MM-DD」(requirements.ts normalizeDue:全天,客户端按查看者本地理解),时刻型是 UTC 秒。
10
+ //
11
+ // 收件人:
12
+ // 人 —— 负责人(owner)+ 参与人里的人,去重。走 #462/#464 的 Hub 通知路(agent-notice.ts:user_inbox +
13
+ // /events/users/me desktop_message)。发信别名 = 负责 Agent 的别名(收件人看得见它时 → 出现在与它的会话里),
14
+ // 否则 DUE_REMINDER_SENDER「任务提醒」。
15
+ // 负责 Agent(agent_owner 节点)—— 一条不需要回复的消息:与 send_message 同一写法(inbox type='message' +
16
+ // new_message 推送),不是任务。节点不在 active 状态(停了 / 删除中)就不发。
17
+ // 停止:卡完成 / 归档 / 清空 due → 扫描查询本身就选不到它;due 往后挪 → 那一档不再成立,而新 due 值重新上膛。
18
+ // 去重:requirement_due_reminders 主键 (requirement_id, kind, due_on, day),先 INSERT … ON CONFLICT DO NOTHING
19
+ // 抢到这一行(changes = 1)才发 —— 重启、多次扫描、并发都不会重发。宁可漏一次(发送中途崩溃),不发两次。
20
+ // 只加表:旧 Hub 回滚后不认识这张表也不碰它。
21
+ //
22
+ // 上线护栏(生产上有别的团队的网络和节点):
23
+ // COMMHUB_DUE_REMINDER_NETWORKS=<id,id,…> 只给这些网络发;不设 = 所有网络(自建 Hub 默认就有这个功能)。
24
+ // #524 按网络开关(一个 Hub 上多个团队的网络,只想给自己的网络开):
25
+ // COMMHUB_DUE_REMINDERS_NETWORKS=<id,id,…> 白名单。COMMHUB_DUE_REMINDERS=0 时也生效:只给这些网络发。
26
+ // COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS=<id,id,…> 黑名单。功能开着时这些网络不发(白名单里也排除)。
27
+ // #524 按人开关(多个团队共用同一个网络,网络名单分不开时):
28
+ // COMMHUB_DUE_REMINDERS_OWNERS=<user id,…> 负责人白名单。设了就只扫负责人(owner)在名单里的卡;
29
+ // 这张卡的人提醒(负责人 + 参与人)和负责 Agent 消息照常发,别人负责的卡一条都不发。与网络白名单 / 黑名单是「且」。
30
+ // 和 COMMHUB_DUE_REMINDERS_NETWORKS 一样,COMMHUB_DUE_REMINDERS=0 时也生效(只给名单里的人负责的卡打开)。
31
+ // 开通不补发按「网络 × 负责人」各记一行基线(kind='baseline_owner',due_on = user id):以后把人加进名单,
32
+ // 从加进来那一刻算起,不会补发他过去几天的逾期卡。
33
+ // 这些新变量都不设 → 行为与之前完全相同。生效范围在 startHub 时打一行日志(只有网络 id)。
34
+ // 过滤写在扫描 SQL 的 WHERE 里(基线 INSERT 与候选 SELECT 同一个条件),范围外网络的行根本不会被读出来。
35
+ // COMMHUB_DUE_REMINDER_NODES=1 给负责 Agent 节点发消息;默认关(人照常提醒)。
36
+ // 开通时不补发:每个网络第一次被扫描到时记一行基线(kind='baseline',sent_at = 那一刻)。只有在基线**之后**才变成逾期的卡
37
+ // 会收到「已逾期」提醒;基线之前就已经逾期的卡永远不发逾期提醒(打开功能 / 把网络加进白名单都不会刷一批旧卡)。
38
+ // 选这个而不是「把旧卡逐张记成已发」:不用每卡每天写行,也不怕某张旧卡第一次没被扫到、第二天又冒出来。
39
+ // 即将到期 / 今天到期不受基线影响(它们本来就只在临近时发一次)。
40
+
41
+ import type { DbAdapter } from "./db-adapter.js";
42
+ import { db, uuidv4 } from "./db.js";
43
+ import { sendAgentNotice } from "./agent-notice.js";
44
+ import { canSeeAgent } from "./agent-access.js";
45
+ import { assertNodeActive } from "./lifecycle-guard.js";
46
+ import { pushEvent } from "./push.js";
47
+ import { pendingInboxCount } from "./inbox-count.js";
48
+ import { parseHubTimestamp } from "./hub-timestamp.js";
49
+
50
+ export const DUE_REMINDER_KIND = "task_due";
51
+ export const DUE_REMINDER_SENDER = "任务提醒";
52
+ export type DueKind = "due_soon" | "due_today" | "overdue";
53
+
54
+ const DAY_MS = 86_400_000;
55
+ const RETENTION_DAYS = 60;
56
+
57
+ /** IF NOT EXISTS:重复执行无副作用;SQLite / PostgreSQL 同一段。 */
58
+ export function ensureDueReminders(database: DbAdapter): void {
59
+ database.exec(`
60
+ CREATE TABLE IF NOT EXISTS requirement_due_reminders (
61
+ requirement_id TEXT NOT NULL,
62
+ kind TEXT NOT NULL,
63
+ due_on TEXT NOT NULL,
64
+ day TEXT NOT NULL,
65
+ network_id TEXT NOT NULL,
66
+ sent_at TEXT NOT NULL,
67
+ PRIMARY KEY (requirement_id, kind, due_on, day)
68
+ );
69
+ `);
70
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_due_reminders_sent ON requirement_due_reminders(sent_at)");
71
+ // 扫描查询按 due_on 取一段范围:只给有 due 的行建索引。
72
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_due_on ON requirements(due_on) WHERE due_on IS NOT NULL");
73
+ }
74
+
75
+ function envNumber(name: string, fallback: number, min: number): number {
76
+ const raw = process.env[name];
77
+ if (raw === undefined || raw.trim() === "") return fallback;
78
+ const n = Number(raw);
79
+ return Number.isFinite(n) && n >= min ? n : fallback;
80
+ }
81
+
82
+ export function dueReminderTimezone(): string {
83
+ const tz = process.env.COMMHUB_DUE_REMINDER_TZ?.trim() || "Asia/Shanghai";
84
+ try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); return tz; } catch { return "Asia/Shanghai"; }
85
+ }
86
+ /** 逗号分隔的网络 id;没设(或只有空白 / 逗号)→ null。去重,保持顺序。 */
87
+ function envIdList(name: string): string[] | null {
88
+ const raw = process.env[name];
89
+ if (raw === undefined) return null;
90
+ const ids = [...new Set(raw.split(",").map((x) => x.trim()).filter(Boolean))];
91
+ return ids.length ? ids : null;
92
+ }
93
+ /** COMMHUB_DUE_REMINDER_NETWORKS:逗号分隔的网络 id;没设(或只有空白)→ null = 所有网络。 */
94
+ export function reminderNetworks(): string[] | null {
95
+ return envIdList("COMMHUB_DUE_REMINDER_NETWORKS");
96
+ }
97
+
98
+ /**
99
+ * #524 生效范围。
100
+ * off —— 不扫描(COMMHUB_DUE_REMINDERS=0 且没设 COMMHUB_DUE_REMINDERS_NETWORKS)。
101
+ * all —— 所有网络,减去 exclude。
102
+ * only —— 只扫 include(已减去 exclude;可能为空 = 什么都不扫)。
103
+ * 白名单 = COMMHUB_DUE_REMINDERS_NETWORKS ∪ COMMHUB_DUE_REMINDER_NETWORKS(旧名);但只有新名能在 COMMHUB_DUE_REMINDERS=0 时打开。
104
+ * 白名单一旦设了就收窄(包括 COMMHUB_DUE_REMINDERS=1):宁可少发,不给没点名的网络发。
105
+ */
106
+ export type DueReminderScope =
107
+ | { mode: "off" }
108
+ | { mode: "all"; exclude: string[]; owners?: string[] }
109
+ | { mode: "only"; include: string[]; exclude: string[]; owners?: string[] };
110
+
111
+ /**
112
+ * owners(COMMHUB_DUE_REMINDERS_OWNERS)只在设了时出现:只扫负责人在名单里的卡(与网络条件是「且」)。
113
+ * 与 COMMHUB_DUE_REMINDERS_NETWORKS 同一语义:COMMHUB_DUE_REMINDERS=0 时也能单独把功能打开(只对名单里的人)。
114
+ */
115
+ export function dueReminderScope(): DueReminderScope {
116
+ const allow = envIdList("COMMHUB_DUE_REMINDERS_NETWORKS");
117
+ const legacy = envIdList("COMMHUB_DUE_REMINDER_NETWORKS");
118
+ const exclude = envIdList("COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS") ?? [];
119
+ const owners = envIdList("COMMHUB_DUE_REMINDERS_OWNERS");
120
+ if (process.env.COMMHUB_DUE_REMINDERS === "0" && !allow && !owners) return { mode: "off" };
121
+ const withOwners = owners ? { owners } : {};
122
+ if (!allow && !legacy) return { mode: "all", exclude, ...withOwners };
123
+ const include = [...new Set([...(allow ?? []), ...(legacy ?? [])])].filter((id) => !exclude.includes(id));
124
+ return { mode: "only", include, exclude, ...withOwners };
125
+ }
126
+
127
+ export function describeDueReminderScope(scope: DueReminderScope = dueReminderScope()): string {
128
+ if (scope.mode === "off") return "off";
129
+ const who = scope.owners ? `; only cards owned by ${scope.owners.join(",")}` : "";
130
+ if (scope.mode === "all") return (scope.exclude.length ? `all networks except ${scope.exclude.join(",")}` : "all networks") + who;
131
+ return scope.include.length ? `only networks ${scope.include.join(",")}${who}` : "no networks (allowlist empty after exclude)";
132
+ }
133
+
134
+ /**
135
+ * 负责人条件(只拼在 requirements 的候选 SELECT 上;networks 表没有负责人)。没设名单 → "",不 push 任何参数。
136
+ * 精确匹配存储形状 {"kind":"user","id":…}(与列表 ?owner=user:<id> 筛选同一写法):对不上就不发,宁可少发。
137
+ */
138
+ export function dueOwnerSql(scope: DueReminderScope, params: unknown[]): string {
139
+ if (scope.mode === "off" || !scope.owners?.length) return "";
140
+ return ` AND owner_json IN (${scope.owners.map((id) => `?${params.push(JSON.stringify({ kind: "user", id }))}`).join(", ")})`;
141
+ }
142
+
143
+ /**
144
+ * 范围条件(拼在 WHERE 后面)。只 push 这个分支真正用到的参数(PG 预编译拒绝绑了没用的参数)。
145
+ * null = 范围为空,调用方不该查询。
146
+ */
147
+ export function dueScopeSql(scope: DueReminderScope, params: unknown[]): string | null {
148
+ if (scope.mode === "off") return null;
149
+ if (scope.mode === "only") {
150
+ if (!scope.include.length) return null;
151
+ return ` AND network_id IN (${scope.include.map((id) => `?${params.push(id)}`).join(", ")})`;
152
+ }
153
+ if (!scope.exclude.length) return "";
154
+ return ` AND network_id NOT IN (${scope.exclude.map((id) => `?${params.push(id)}`).join(", ")})`;
155
+ }
156
+ export function nodeRemindersEnabled(): boolean { return process.env.COMMHUB_DUE_REMINDER_NODES === "1"; }
157
+ export const BASELINE_KIND = "baseline";
158
+ export const OWNER_BASELINE_KIND = "baseline_owner";
159
+
160
+ export function overdueMaxDays(): number { return Math.floor(envNumber("COMMHUB_DUE_OVERDUE_MAX_DAYS", 7, 0)); }
161
+
162
+ // ── 时区小工具 ─────────────────────────────────────────────
163
+ /** 某一时刻在 tz 的日期「YYYY-MM-DD」。 */
164
+ export function localDate(ms: number, tz: string): string {
165
+ return new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(ms));
166
+ }
167
+ /** 日期 + n 天(纯日历运算,与时区无关)。 */
168
+ export function addDays(date: string, n: number): string {
169
+ const [y, m, d] = date.split("-").map(Number);
170
+ return new Date(Date.UTC(y, m - 1, d) + n * DAY_MS).toISOString().slice(0, 10);
171
+ }
172
+ /** 两个日期相差几天(a - b)。 */
173
+ export function dayDiff(a: string, b: string): number {
174
+ const ms = (s: string) => { const [y, m, d] = s.split("-").map(Number); return Date.UTC(y, m - 1, d); };
175
+ return Math.round((ms(a) - ms(b)) / DAY_MS);
176
+ }
177
+ function tzOffsetMs(ms: number, tz: string): number {
178
+ const p = Object.fromEntries(new Intl.DateTimeFormat("en-US", { timeZone: tz, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
179
+ .formatToParts(new Date(ms)).filter((x) => x.type !== "literal").map((x) => [x.type, Number(x.value)]));
180
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000;
181
+ }
182
+ /** tz 里某天 00:00 对应的 UTC 时刻(ms)。 */
183
+ export function localMidnightMs(date: string, tz: string): number {
184
+ const [y, m, d] = date.split("-").map(Number);
185
+ const guess = Date.UTC(y, m - 1, d);
186
+ let t = guess - tzOffsetMs(guess, tz);
187
+ t = guess - tzOffsetMs(t, tz);
188
+ return t;
189
+ }
190
+
191
+ const isDateOnly = (due: string) => due.length === 10;
192
+
193
+ /** 从哪一刻起算逾期:日期型 = tz 里第二天零点;时刻型 = 那个时刻。 */
194
+ export function overdueSinceMs(due: string, tz: string): number {
195
+ return isDateOnly(due) ? localMidnightMs(addDays(due, 1), tz) : Date.parse(due);
196
+ }
197
+
198
+ /** 这张卡此刻该发哪一档(没有 → null)。day = 去重用的那一天。 */
199
+ export function dueReminderFor(due: string, nowMs: number, tz: string, maxOverdue = overdueMaxDays()): { kind: DueKind; day: string; overdueDays: number; dueDay: string } | null {
200
+ const today = localDate(nowMs, tz);
201
+ if (isDateOnly(due)) {
202
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(due)) return null;
203
+ if (due === today) return { kind: "due_today", day: due, overdueDays: 0, dueDay: due };
204
+ if (due === addDays(today, 1)) return { kind: "due_soon", day: due, overdueDays: 0, dueDay: due };
205
+ if (due < today) {
206
+ const n = dayDiff(today, due);
207
+ return n <= maxOverdue ? { kind: "overdue", day: today, overdueDays: n, dueDay: due } : null;
208
+ }
209
+ return null;
210
+ }
211
+ const t = Date.parse(due);
212
+ if (!Number.isFinite(t)) return null;
213
+ const dueDay = localDate(t, tz);
214
+ if (t <= nowMs) {
215
+ const n = dayDiff(today, dueDay);
216
+ return n <= maxOverdue ? { kind: "overdue", day: today, overdueDays: n, dueDay } : null;
217
+ }
218
+ if (dueDay === today) return { kind: "due_today", day: dueDay, overdueDays: 0, dueDay };
219
+ if (t - nowMs <= DAY_MS) return { kind: "due_soon", day: dueDay, overdueDays: 0, dueDay };
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * 列表筛选(requirements.ts listFilters)用的条件:overdue / due_within_days。只看没完成的卡。
225
+ * 日期型(长度 10)按 tz 的「今天」比日期;时刻型(UTC 字符串)按此刻比时刻。字符串比较:两种形状各自同长、同格式。
226
+ */
227
+ export function overdueSql(params: unknown[], nowMs: number, tz: string): string {
228
+ const today = localDate(nowMs, tz);
229
+ const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
230
+ return `(due_on IS NOT NULL AND column_name NOT IN ('done', 'abandoned') AND ((length(due_on) = 10 AND due_on < ?${params.push(today)}) OR (length(due_on) > 10 AND due_on <= ?${params.push(nowIso)})))`;
231
+ }
232
+ /** 没逾期、且在 tz 的「今天 + days」那天结束之前到期(0 = 今天内)。 */
233
+ export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz: string): string {
234
+ const today = localDate(nowMs, tz);
235
+ const lastDay = addDays(today, days);
236
+ const endIso = new Date(localMidnightMs(addDays(lastDay, 1), tz)).toISOString().replace(/\.\d{3}Z$/, "Z");
237
+ const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
238
+ return `(due_on IS NOT NULL AND column_name NOT IN ('done', 'abandoned') AND ((length(due_on) = 10 AND due_on >= ?${params.push(today)} AND due_on <= ?${params.push(lastDay)}) OR (length(due_on) > 10 AND due_on > ?${params.push(nowIso)} AND due_on < ?${params.push(endIso)})))`;
239
+ }
240
+
241
+ // ── 扫描 ───────────────────────────────────────────────────
242
+ type DueRow = {
243
+ requirement_id: string; network_id: string; seq: number | null; title: string; due_on: string;
244
+ owner_json: string | null; participants_json: string | null; agent_owner_json: string | null;
245
+ };
246
+ const parse = (raw: string | null) => { try { return raw ? JSON.parse(raw) : null; } catch { return null; } };
247
+ const userIdOf = (ref: unknown): string | null => {
248
+ const r = ref as { kind?: unknown; id?: unknown } | null;
249
+ return r && r.kind === "user" && typeof r.id === "string" ? r.id : null;
250
+ };
251
+
252
+ export function humanRecipients(row: Pick<DueRow, "owner_json" | "participants_json">): string[] {
253
+ const ids = new Set<string>();
254
+ const owner = userIdOf(parse(row.owner_json));
255
+ if (owner) ids.add(owner);
256
+ const participants = parse(row.participants_json);
257
+ if (Array.isArray(participants)) for (const p of participants) { const id = userIdOf(p); if (id) ids.add(id); }
258
+ return [...ids];
259
+ }
260
+
261
+ function fmtDue(due: string, tz: string): string {
262
+ if (isDateOnly(due)) return due;
263
+ const t = Date.parse(due);
264
+ const s = new Intl.DateTimeFormat("sv-SE", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(t));
265
+ return s;
266
+ }
267
+
268
+ export function dueReminderText(row: Pick<DueRow, "seq" | "title" | "due_on">, r: { kind: DueKind; overdueDays: number }, tz: string): { title: string; text: string } {
269
+ const card = `${row.seq ? `#${row.seq} ` : ""}「${row.title}」`;
270
+ const when = fmtDue(row.due_on, tz);
271
+ if (r.kind === "due_soon") return { title: "任务即将到期", text: `任务${card}即将到期(预计完成 ${when})。` };
272
+ if (r.kind === "due_today") return { title: "任务今天到期", text: `任务${card}今天到期(预计完成 ${when})。` };
273
+ const late = r.overdueDays >= 1 ? `已逾期 ${r.overdueDays} 天` : "已过预计完成时间";
274
+ return { title: "任务已逾期", text: `任务${card}${late}(预计完成 ${when}),还没有完成。` };
275
+ }
276
+
277
+ export type DueReminderSent = { requirement_id: string; kind: DueKind; users: string[]; node: string | null };
278
+
279
+ /**
280
+ * 跑一次。now 可注入(测试用假时钟)。返回这次真正发出的提醒。
281
+ * 一条按 due_on 范围走索引的查询取候选;每个候选先抢去重行,抢到才发。
282
+ */
283
+ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[] {
284
+ const nowMs = opts.now ?? Date.now();
285
+ const tz = dueReminderTimezone();
286
+ const maxOverdue = overdueMaxDays();
287
+ const today = localDate(nowMs, tz);
288
+ // 范围:最老 = 今天 - maxOverdue - 1(UTC 日期 ≤ 本地日期,多留一天);最新 < 今天 + 2(24 小时内的时刻型 UTC 日期 ≤ 明天)。
289
+ const lo = addDays(today, -maxOverdue - 1);
290
+ const hi = addDays(today, 2);
291
+ const scope = dueReminderScope();
292
+ // 范围为空(关闭 / 白名单全被排除)→ 什么都不读、不写基线。
293
+ if (dueScopeSql(scope, []) === null) return [];
294
+ const sentAt = new Date(nowMs).toISOString();
295
+ // 基线:范围内每个网络第一次被扫到的那一刻(已有就不动)。一条 INSERT … SELECT。
296
+ {
297
+ const bp: unknown[] = [BASELINE_KIND, sentAt];
298
+ // WHERE 必须有:SQLite 里「INSERT … SELECT … FROM t ON CONFLICT」会把 ON 读成 JOIN 约束(语法错),要一个 WHERE 隔开。
299
+ const where = ` WHERE 1=1${dueScopeSql(scope, bp) ?? ""}`;
300
+ db.run(
301
+ `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
302
+ SELECT '__baseline__:' || network_id, ?1, '', '', network_id, ?2 FROM networks${where}
303
+ ON CONFLICT DO NOTHING`,
304
+ bp,
305
+ );
306
+ // 负责人名单:每个(范围内网络 × 名单里的人)第一次被扫到的那一刻。due_on 列存 user id。
307
+ if (scope.mode !== "off") for (const owner of scope.owners ?? []) {
308
+ const op: unknown[] = [OWNER_BASELINE_KIND, sentAt, owner];
309
+ db.run(
310
+ `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
311
+ SELECT '__baseline__:' || network_id, ?1, ?3, '', network_id, ?2 FROM networks WHERE 1=1${dueScopeSql(scope, op) ?? ""}
312
+ ON CONFLICT DO NOTHING`,
313
+ op,
314
+ );
315
+ }
316
+ }
317
+ const ownerBaselines = new Map<string, number>();
318
+ if (scope.mode !== "off" && scope.owners?.length) {
319
+ for (const b of db.all<{ network_id: string; due_on: string; sent_at: string }>("SELECT network_id, due_on, sent_at FROM requirement_due_reminders WHERE kind = ?1", OWNER_BASELINE_KIND)) {
320
+ const t = parseHubTimestamp(b.sent_at);
321
+ if (t !== null) ownerBaselines.set(`${b.network_id}\u0000${b.due_on}`, t);
322
+ }
323
+ }
324
+ const baselines = new Map(
325
+ db.all<{ network_id: string; sent_at: string }>("SELECT network_id, sent_at FROM requirement_due_reminders WHERE kind = ?1", BASELINE_KIND)
326
+ .map((b) => [b.network_id, parseHubTimestamp(b.sent_at)] as const)
327
+ .filter((e): e is readonly [string, number] => e[1] !== null),
328
+ );
329
+ const params: unknown[] = [lo, hi];
330
+ const netFilter = dueScopeSql(scope, params) ?? "";
331
+ const ownerFilter = dueOwnerSql(scope, params);
332
+ const rows = db.all<DueRow>(
333
+ `SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
334
+ FROM requirements
335
+ WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name NOT IN ('done', 'abandoned') AND COALESCE(archived, 0) = 0${netFilter}${ownerFilter}`,
336
+ ...params,
337
+ );
338
+ const out: DueReminderSent[] = [];
339
+ for (const row of rows) {
340
+ const r = dueReminderFor(row.due_on, nowMs, tz, maxOverdue);
341
+ if (!r) continue;
342
+ if (r.kind === "overdue") {
343
+ // 基线之前就已经逾期 → 不发(开通时不补发)。没有基线(网络行不存在)= 当作此刻开通。
344
+ let base = baselines.get(row.network_id) ?? nowMs;
345
+ // 负责人名单:再取「这个人进名单」的基线,两者取晚的(把人加进名单也不补发)。
346
+ if (scope.mode !== "off" && scope.owners?.length) {
347
+ const owner = userIdOf(parse(row.owner_json));
348
+ const ownerBase = owner ? ownerBaselines.get(`${row.network_id}\u0000${owner}`) : undefined;
349
+ base = Math.max(base, ownerBase ?? nowMs);
350
+ }
351
+ if (overdueSinceMs(row.due_on, tz) < base) continue;
352
+ }
353
+ try {
354
+ const claimed = db.run(
355
+ `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
356
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT DO NOTHING`,
357
+ [row.requirement_id, r.kind, row.due_on, r.day, row.network_id, sentAt],
358
+ );
359
+ if (claimed.changes < 1) continue;
360
+ out.push(deliver(row, r, tz));
361
+ } catch (e: any) {
362
+ console.error(`[due-reminders] ${row.requirement_id} ${r.kind} failed: ${e?.message || e}`);
363
+ }
364
+ }
365
+ // 去重行保留 RETENTION_DAYS 天(比逾期上限长得多,删掉的行不会再被需要)。
366
+ try { db.run("DELETE FROM requirement_due_reminders WHERE sent_at < ?1 AND kind NOT IN (?2, ?3)", [new Date(nowMs - RETENTION_DAYS * DAY_MS).toISOString(), BASELINE_KIND, OWNER_BASELINE_KIND]); } catch {}
367
+ return out;
368
+ }
369
+
370
+ function deliver(row: DueRow, r: { kind: DueKind; overdueDays: number; dueDay: string }, tz: string): DueReminderSent {
371
+ const { title, text } = dueReminderText(row, r, tz);
372
+ const agent = parse(row.agent_owner_json) as { kind?: unknown; id?: unknown } | null;
373
+ const nodeId = agent?.kind === "node" && typeof agent.id === "string" ? agent.id : null;
374
+ const node = nodeId
375
+ ? db.get<{ alias: string | null }>("SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", nodeId, row.network_id)
376
+ : null;
377
+ const alias = node?.alias || null;
378
+ const meta = { task_notice: { requirement_id: row.requirement_id, seq: row.seq ?? null, network_id: row.network_id, due_reminder: r.kind, due: row.due_on, overdue_days: r.overdueDays } };
379
+ const users: string[] = [];
380
+ for (const userId of humanRecipients(row)) {
381
+ const fromAlias = alias && canSeeAgent(userId, row.network_id, { alias, nodeId }) ? alias : DUE_REMINDER_SENDER;
382
+ const id = sendAgentNotice({
383
+ networkId: row.network_id, userId, fromAlias, kind: DUE_REMINDER_KIND, title, text,
384
+ severity: r.kind === "overdue" ? "warning" : "info", meta, idPrefix: "dm_due_",
385
+ });
386
+ if (id) users.push(userId);
387
+ }
388
+ let notifiedNode: string | null = null;
389
+ if (alias && nodeRemindersEnabled() && assertNodeActive(alias, row.network_id).ok) {
390
+ const id = uuidv4();
391
+ const message = `[${title}] ${text}\n(这是提醒,不需要回复。requirement_id=${row.requirement_id})`;
392
+ db.run(
393
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
394
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, ?5, ?6)`,
395
+ [id, alias, nodeId, message, DUE_REMINDER_SENDER, row.network_id],
396
+ );
397
+ pushEvent(alias, { type: "new_message", inbox_count: pendingInboxCount(alias, row.network_id), from: DUE_REMINDER_SENDER, message_id: id }, row.network_id);
398
+ notifiedNode = alias;
399
+ }
400
+ return { requirement_id: row.requirement_id, kind: r.kind as DueKind, users, node: notifiedNode };
401
+ }
402
+
403
+ /**
404
+ * startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉(除非设了 COMMHUB_DUE_REMINDERS_NETWORKS / _OWNERS,见 dueReminderScope);
405
+ * bootServer(测试)不调用它。第一次扫描延后一个间隔外的 30 秒,不和启动抢。启动时打一行生效范围(只有网络 id)。
406
+ */
407
+ export function startDueReminderTimer(): ReturnType<typeof setInterval> | null {
408
+ const scope = dueReminderScope();
409
+ console.log(`[due-reminders] scope: ${describeDueReminderScope(scope)}`);
410
+ if (scope.mode === "off") return null;
411
+ const ms = envNumber("COMMHUB_DUE_REMINDER_TICK_MS", 10 * 60_000, 1_000);
412
+ const tick = () => { try { runDueReminders(); } catch (e: any) { console.error(`[due-reminders] tick failed: ${e?.message || e}`); } };
413
+ const first = setTimeout(tick, Math.min(ms, 30_000));
414
+ (first as any)?.unref?.();
415
+ return setInterval(tick, ms);
416
+ }