@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,620 @@
1
+ // #491 / #492 / #494 —— 任务到期提醒 + 列表到期筛选。HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ // test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
3
+ //
4
+ // 假时钟:runDueReminders({ now }) 注入时刻;时区 = 默认 Asia/Shanghai。
5
+ // 网络 NET(boss)。owen = 负责人;pat、pia = 参与人;节点 NODE = 负责 Agent。
6
+ // 覆盖:三档各一次(收件人 = 负责人 + 参与人,发信别名 = 负责 Agent;节点收到一条 type=message 的消息,不是任务);
7
+ // 同一时刻跑两次 / 同一天跑两次不重发;逾期按天再提醒一次;完成 / 归档 / 清空 due 立即停;due 往后挪重新上膛;
8
+ // 时刻型 due;逾期上限;节点停了不发给节点;MCP / REST 的 overdue、due_within_days 筛选与严格参数。
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-due-rem-"));
16
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
17
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
18
+ process.env.HOST = "127.0.0.1";
19
+ delete process.env.COMMHUB_DUE_REMINDER_TZ;
20
+ delete process.env.COMMHUB_DUE_OVERDUE_MAX_DAYS;
21
+ delete process.env.COMMHUB_DUE_REMINDER_NETWORKS;
22
+ delete process.env.COMMHUB_DUE_REMINDERS;
23
+ delete process.env.COMMHUB_DUE_REMINDERS_NETWORKS;
24
+ delete process.env.COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS;
25
+ delete process.env.COMMHUB_DUE_REMINDERS_OWNERS;
26
+ // 节点提醒默认关;这个文件的大部分用例要看节点那一路,先打开(「默认关」单独一个用例)。
27
+ process.env.COMMHUB_DUE_REMINDER_NODES = "1";
28
+ const PW = "DueRemindPassw0rd!x";
29
+
30
+ type U = { token: string; id: string; username: string };
31
+ let BASE = "";
32
+ let hub: any = null;
33
+ let NET = "";
34
+ let db: any;
35
+ let due: typeof import("./requirement-due-reminders.js");
36
+ let boss: U, owen: U, pat: U, pia: U;
37
+ const stamp = Date.now();
38
+ const NODE_ID = `node_due_${stamp}`;
39
+ const ALIAS = `due-agent-${stamp}`;
40
+
41
+ // 2026-10-10 10:00 东八(02:00Z)
42
+ const T0 = Date.UTC(2026, 9, 10, 2, 0, 0);
43
+ const DAY = 86_400_000;
44
+
45
+ type R = { status: number; body: any };
46
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
47
+ const res = await fetch(`${BASE}${path}`, {
48
+ method,
49
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
50
+ body: payload === undefined ? undefined : JSON.stringify(payload),
51
+ });
52
+ const text = await res.text();
53
+ let body: any = null;
54
+ try { body = JSON.parse(text); } catch {}
55
+ return { status: res.status, body };
56
+ }
57
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
58
+ const res = await fetch(`${BASE}/mcp`, {
59
+ method: "POST",
60
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
61
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
62
+ });
63
+ const raw = await res.text();
64
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
65
+ const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
66
+ if (payload.error) return { rpcError: payload.error };
67
+ const item = payload.result.content[0];
68
+ try { return JSON.parse(item.text); } catch { return { isError: payload.result.isError, text: item.text }; }
69
+ }
70
+ const userRef = (id: string) => ({ kind: "user", id });
71
+
72
+ async function card(name: string, dueValue: string, extra: Record<string, unknown> = {}): Promise<string> {
73
+ const r = await send(boss.token, "POST", "/api/requirements", {
74
+ network_id: NET, name, due: dueValue, owner: userRef(owen.id), participants: [userRef(pat.id), userRef(pia.id), userRef(owen.id)],
75
+ agent_owner: { kind: "node", id: NODE_ID }, ...extra,
76
+ });
77
+ expect(r.status).toBe(201);
78
+ return r.body.requirement.id as string;
79
+ }
80
+ const patch = (id: string, body: Record<string, unknown>) => send(boss.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, body);
81
+
82
+ /** 这张卡发给人的提醒:收件人 → [{content, from, kind}]。 */
83
+ function userNotices(reqId: string): Record<string, Array<{ content: string; from: string; reminder: string }>> {
84
+ const rows = db.all("SELECT user_id, from_session, content, meta_json FROM user_inbox WHERE network_id = ?1 AND kind = 'task_due' ORDER BY created_at, message_id", NET) as any[];
85
+ const out: Record<string, any[]> = {};
86
+ for (const r of rows) {
87
+ const meta = JSON.parse(r.meta_json);
88
+ if (meta?.task_notice?.requirement_id !== reqId) continue;
89
+ (out[r.user_id] ||= []).push({ content: r.content, from: r.from_session, reminder: meta.task_notice.due_reminder });
90
+ }
91
+ return out;
92
+ }
93
+ function nodeMessages(reqId: string): Array<{ type: string; content: string; from: string }> {
94
+ return (db.all("SELECT type, content, from_session FROM inbox WHERE session_name = ?1 AND network_id = ?2 ORDER BY created_at", ALIAS, NET) as any[])
95
+ .filter(r => String(r.content).includes(reqId)).map(r => ({ type: r.type, content: r.content, from: r.from_session }));
96
+ }
97
+ const sentFor = (out: Array<{ requirement_id: string; kind: string }>, id: string) => out.filter(x => x.requirement_id === id).map(x => x.kind);
98
+
99
+ beforeAll(async () => {
100
+ ({ db } = await import("./db.js"));
101
+ const { addNetworkMember, register } = await import("./auth.js");
102
+ const mk = (name: string): U => {
103
+ const r = register(`dr_${name}_${stamp}`, PW);
104
+ expect(r.ok).toBe(true);
105
+ return { token: r.token!, id: r.user!.user_id, username: r.user!.username };
106
+ };
107
+ const b = register(`dr_boss_${stamp}`, PW);
108
+ boss = { token: b.token!, id: b.user!.user_id, username: b.user!.username };
109
+ NET = b.network_id!;
110
+ owen = mk("owen"); pat = mk("pat"); pia = mk("pia");
111
+ // pat 是受限成员(看不见任何 Agent):他的提醒发信别名不能泄露负责 Agent → 「任务提醒」。
112
+ addNetworkMember(NET, owen.id, "member", boss.id, { taskAccess: "all", agentAccess: "all" });
113
+ addNetworkMember(NET, pia.id, "member", boss.id, { taskAccess: "all", agentAccess: "all" });
114
+ addNetworkMember(NET, pat.id, "member", boss.id, { taskAccess: "all" });
115
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)", [NODE_ID, ALIAS, NET]);
116
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_due_${stamp}`, ALIAS, NODE_ID, NET]);
117
+ due = await import("./requirement-due-reminders.js");
118
+ const mod: any = await import("./server.js");
119
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
120
+ BASE = `http://127.0.0.1:${hub.port}`;
121
+ // 基线:假装功能在 T0 之前 30 天就开着了(否则 T0 第一次扫描会把 NET 里所有逾期卡当「开通前就逾期」不发)。
122
+ due.runDueReminders({ now: T0 - 30 * DAY });
123
+ }, 30_000);
124
+
125
+ afterAll(() => {
126
+ try { hub?.stop?.(true); } catch {}
127
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
128
+ });
129
+
130
+ describe("时区小工具", () => {
131
+ test("东八的今天 / 零点", () => {
132
+ expect(due.localDate(T0, "Asia/Shanghai")).toBe("2026-10-10");
133
+ expect(due.localDate(Date.UTC(2026, 9, 9, 16, 0), "Asia/Shanghai")).toBe("2026-10-10"); // 00:00 东八
134
+ expect(due.localDate(Date.UTC(2026, 9, 9, 15, 59), "Asia/Shanghai")).toBe("2026-10-09");
135
+ expect(new Date(due.localMidnightMs("2026-10-10", "Asia/Shanghai")).toISOString()).toBe("2026-10-09T16:00:00.000Z");
136
+ expect(new Date(due.localMidnightMs("2026-03-08", "America/New_York")).toISOString()).toBe("2026-03-08T05:00:00.000Z");
137
+ expect(due.addDays("2026-10-31", 1)).toBe("2026-11-01");
138
+ expect(due.dayDiff("2026-10-10", "2026-10-07")).toBe(3);
139
+ });
140
+ test("分档", () => {
141
+ const tz = "Asia/Shanghai";
142
+ expect(due.dueReminderFor("2026-10-11", T0, tz)?.kind).toBe("due_soon");
143
+ expect(due.dueReminderFor("2026-10-10", T0, tz)?.kind).toBe("due_today");
144
+ expect(due.dueReminderFor("2026-10-12", T0, tz)).toBeNull();
145
+ expect(due.dueReminderFor("2026-10-07", T0, tz)).toMatchObject({ kind: "overdue", overdueDays: 3, day: "2026-10-10" });
146
+ expect(due.dueReminderFor("2026-10-02", T0, tz)).toBeNull(); // 8 天 > 默认上限 7
147
+ expect(due.dueReminderFor("2026-10-10T08:00:00Z", T0, tz)?.kind).toBe("due_today"); // 16:00 东八,今天还没到
148
+ expect(due.dueReminderFor("2026-10-11T01:00:00Z", T0, tz)?.kind).toBe("due_soon"); // 明天 09:00,23 小时内
149
+ expect(due.dueReminderFor("2026-10-11T03:00:00Z", T0, tz)).toBeNull(); // 25 小时后
150
+ expect(due.dueReminderFor("2026-10-10T01:00:00Z", T0, tz)).toMatchObject({ kind: "overdue", overdueDays: 0 });
151
+ });
152
+ });
153
+
154
+ describe("提醒", () => {
155
+ test("即将到期:负责人 + 参与人各一条(发信 = 负责 Agent),节点收到一条不需回复的消息", async () => {
156
+ const id = await card("dr-明天", "2026-10-11");
157
+ const out = due.runDueReminders({ now: T0 });
158
+ expect(sentFor(out, id)).toEqual(["due_soon"]);
159
+ const n = userNotices(id);
160
+ expect(Object.keys(n).sort()).toEqual([owen.id, pat.id, pia.id].sort());
161
+ expect(n[pat.id][0].from).toBe(due.DUE_REMINDER_SENDER);
162
+ for (const uid of [owen.id, pat.id, pia.id]) {
163
+ expect(n[uid]).toHaveLength(1);
164
+ if (uid !== pat.id) expect(n[uid][0].from).toBe(ALIAS);
165
+ expect(n[uid][0].reminder).toBe("due_soon");
166
+ expect(n[uid][0].content).toContain("「dr-明天」即将到期");
167
+ }
168
+ expect(n[boss.id]).toBeUndefined(); // 创建者不是负责人 / 参与人:不提醒
169
+ const m = nodeMessages(id);
170
+ expect(m).toHaveLength(1);
171
+ expect(m[0].type).toBe("message");
172
+ expect(m[0].from).toBe(due.DUE_REMINDER_SENDER);
173
+ // 不是任务:tasks 表里没有这张卡的提醒
174
+ expect((db.all("SELECT task_id FROM tasks WHERE network_id = ?1 AND content LIKE ?2", NET, `%${id}%`) as any[]).length).toBe(0);
175
+ });
176
+
177
+ test("今天到期 + 同一时刻 / 同一天再跑不重发(去重表)", async () => {
178
+ const id = await card("dr-今天", "2026-10-10");
179
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual(["due_today"]);
180
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual([]);
181
+ expect(sentFor(due.runDueReminders({ now: T0 + 6 * 3600_000 }), id)).toEqual([]);
182
+ expect(userNotices(id)[owen.id]).toHaveLength(1);
183
+ expect(nodeMessages(id)).toHaveLength(1);
184
+ const rows = db.all("SELECT kind, due_on, day FROM requirement_due_reminders WHERE requirement_id = ?1", id) as any[];
185
+ expect(rows).toEqual([{ kind: "due_today", due_on: "2026-10-10", day: "2026-10-10" }]);
186
+ });
187
+
188
+ test("已逾期 N 天:每天最多一次,第二天再一次(N+1)", async () => {
189
+ const id = await card("dr-逾期", "2026-10-07");
190
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual(["overdue"]);
191
+ expect(sentFor(due.runDueReminders({ now: T0 + 3600_000 }), id)).toEqual([]);
192
+ expect(sentFor(due.runDueReminders({ now: T0 + DAY }), id)).toEqual(["overdue"]);
193
+ const texts = userNotices(id)[pat.id].map(x => x.content).sort();
194
+ expect(texts).toHaveLength(2);
195
+ expect(texts[0]).toContain("已逾期 3 天");
196
+ expect(texts[1]).toContain("已逾期 4 天");
197
+ });
198
+
199
+ test("完成 / 归档 / 清空 due → 立即停", async () => {
200
+ const done = await card("dr-完成", "2026-10-08");
201
+ const arch = await card("dr-归档", "2026-10-08");
202
+ const cleared = await card("dr-清空", "2026-10-08");
203
+ expect((await patch(done, { column: "done" })).status).toBe(200);
204
+ expect((await patch(arch, { archived: true })).status).toBe(200);
205
+ expect((await patch(cleared, { due: "" })).status).toBe(200);
206
+ const out = due.runDueReminders({ now: T0 });
207
+ for (const id of [done, arch, cleared]) {
208
+ expect(sentFor(out, id)).toEqual([]);
209
+ expect(userNotices(id)).toEqual({});
210
+ }
211
+ });
212
+
213
+ test("逾期提醒发过后改完成:第二天不再提醒", async () => {
214
+ const id = await card("dr-逾期后完成", "2026-10-09");
215
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual(["overdue"]);
216
+ expect((await patch(id, { column: "done" })).status).toBe(200);
217
+ expect(sentFor(due.runDueReminders({ now: T0 + DAY }), id)).toEqual([]);
218
+ expect(userNotices(id)[owen.id]).toHaveLength(1);
219
+ });
220
+
221
+ test("due 往后挪:旧档不再成立,新 due 重新上膛", async () => {
222
+ const id = await card("dr-挪", "2026-10-10");
223
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual(["due_today"]);
224
+ expect((await patch(id, { due: "2026-10-20" })).status).toBe(200);
225
+ expect(sentFor(due.runDueReminders({ now: T0 + 3600_000 }), id)).toEqual([]); // 太远:不发
226
+ expect((await patch(id, { due: "2026-10-11" })).status).toBe(200);
227
+ expect(sentFor(due.runDueReminders({ now: T0 + 2 * 3600_000 }), id)).toEqual(["due_soon"]);
228
+ expect(sentFor(due.runDueReminders({ now: T0 + DAY }), id)).toEqual(["due_today"]);
229
+ });
230
+
231
+ test("时刻型 due:今天稍后 = 今天到期;23 小时后 = 即将到期;已过 = 已过预计完成时间", async () => {
232
+ const later = await card("dr-时刻今天", "2026-10-10T16:00:00+08:00");
233
+ const soon = await card("dr-时刻明早", "2026-10-11T09:00:00+08:00");
234
+ const past = await card("dr-时刻已过", "2026-10-10T09:00:00+08:00");
235
+ const out = due.runDueReminders({ now: T0 });
236
+ expect(sentFor(out, later)).toEqual(["due_today"]);
237
+ expect(sentFor(out, soon)).toEqual(["due_soon"]);
238
+ expect(sentFor(out, past)).toEqual(["overdue"]);
239
+ expect(userNotices(later)[owen.id][0].content).toContain("2026-10-10 16:00");
240
+ expect(userNotices(past)[owen.id][0].content).toContain("已过预计完成时间");
241
+ });
242
+
243
+ test("逾期超过上限不提醒;上限可配", async () => {
244
+ const id = await card("dr-老卡", "2026-09-30");
245
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual([]);
246
+ process.env.COMMHUB_DUE_OVERDUE_MAX_DAYS = "30";
247
+ try { expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual(["overdue"]); }
248
+ finally { delete process.env.COMMHUB_DUE_OVERDUE_MAX_DAYS; }
249
+ });
250
+
251
+ test("没有负责 Agent:发信 = 任务提醒;节点停了:节点不收,人照收", async () => {
252
+ const noAgent = await card("dr-无Agent", "2026-10-11", { agent_owner: null });
253
+ db.run("UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1", [NODE_ID]);
254
+ try {
255
+ const stopped = await card("dr-节点停", "2026-10-11");
256
+ const out = due.runDueReminders({ now: T0 + 3 * 3600_000 });
257
+ expect(out.find(x => x.requirement_id === noAgent)?.node).toBeNull();
258
+ expect(userNotices(noAgent)[owen.id][0].from).toBe(due.DUE_REMINDER_SENDER);
259
+ expect(out.find(x => x.requirement_id === stopped)?.node).toBeNull();
260
+ expect(nodeMessages(stopped)).toEqual([]);
261
+ expect(userNotices(stopped)[pia.id]).toHaveLength(1);
262
+ } finally {
263
+ db.run("UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1", [NODE_ID]);
264
+ }
265
+ });
266
+ });
267
+
268
+ describe("上线护栏", () => {
269
+ const mkNet = (tag: string) => {
270
+ const r = (globalThis as any).__register(`dr_${tag}_${stamp}`, PW);
271
+ return { token: r.token as string, id: r.user.user_id as string, net: r.network_id as string };
272
+ };
273
+ async function cardIn(o: { token: string; id: string; net: string }, name: string, dueValue: string, extra: Record<string, unknown> = {}) {
274
+ const r = await send(o.token, "POST", "/api/requirements", { network_id: o.net, name, due: dueValue, owner: userRef(o.id), ...extra });
275
+ expect(r.status).toBe(201);
276
+ return r.body.requirement.id as string;
277
+ }
278
+ beforeAll(async () => { (globalThis as any).__register = (await import("./auth.js")).register; });
279
+
280
+ test("网络白名单:设了只发白名单里的网络;不设 = 所有网络", async () => {
281
+ const other = mkNet("other");
282
+ due.runDueReminders({ now: T0 - 30 * DAY }); // other 的基线
283
+ const mine = await card("dr-白名单内", "2026-10-11");
284
+ const theirs = await cardIn(other, "dr-白名单外", "2026-10-11");
285
+ process.env.COMMHUB_DUE_REMINDER_NETWORKS = ` ${NET} , `;
286
+ try {
287
+ const out = due.runDueReminders({ now: T0 });
288
+ expect(sentFor(out, mine)).toEqual(["due_soon"]);
289
+ expect(sentFor(out, theirs)).toEqual([]);
290
+ expect(db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE requirement_id = ?1", theirs).n).toBe(0);
291
+ } finally { delete process.env.COMMHUB_DUE_REMINDER_NETWORKS; }
292
+ expect(sentFor(due.runDueReminders({ now: T0 }), theirs)).toEqual(["due_soon"]);
293
+ });
294
+
295
+ test("节点提醒默认关:人照收,节点 inbox 没有", async () => {
296
+ delete process.env.COMMHUB_DUE_REMINDER_NODES;
297
+ try {
298
+ const id = await card("dr-节点默认关", "2026-10-11");
299
+ const out = due.runDueReminders({ now: T0 + 4 * 3600_000 });
300
+ expect(sentFor(out, id)).toEqual(["due_soon"]);
301
+ expect(out.find(x => x.requirement_id === id)?.node).toBeNull();
302
+ expect(nodeMessages(id)).toEqual([]);
303
+ expect(userNotices(id)[owen.id]).toHaveLength(1);
304
+ } finally { process.env.COMMHUB_DUE_REMINDER_NODES = "1"; }
305
+ });
306
+
307
+ test("开通时不补发:基线前就逾期的卡不发逾期提醒(第二天也不发);基线后才逾期的照发;今天 / 明天到期照发", async () => {
308
+ const fresh = mkNet("fresh");
309
+ const old = await cardIn(fresh, "dr-开通前逾期", "2026-10-07");
310
+ const oldTime = await cardIn(fresh, "dr-开通前逾期时刻", "2026-10-10T09:00:00+08:00");
311
+ const today = await cardIn(fresh, "dr-开通当天到期", "2026-10-10");
312
+ const tomorrow = await cardIn(fresh, "dr-开通次日到期", "2026-10-11");
313
+ // fresh 网络第一次被扫到 = T0(10:00 东八)
314
+ const first = due.runDueReminders({ now: T0 });
315
+ expect(sentFor(first, old)).toEqual([]);
316
+ expect(sentFor(first, oldTime)).toEqual([]);
317
+ expect(sentFor(first, today)).toEqual(["due_today"]);
318
+ const base = db.get("SELECT sent_at FROM requirement_due_reminders WHERE kind = 'baseline' AND network_id = ?1", fresh.net);
319
+ expect(base.sent_at).toBe(new Date(T0).toISOString());
320
+ // 第二天:旧卡仍不发;「开通当天到期」是在基线之后才逾期的 → 发「已逾期 1 天」;明天到期 → 今天到期
321
+ const next = due.runDueReminders({ now: T0 + DAY });
322
+ expect(sentFor(next, old)).toEqual([]);
323
+ expect(sentFor(next, oldTime)).toEqual([]);
324
+ expect(sentFor(next, today)).toEqual(["overdue"]);
325
+ expect(sentFor(next, tomorrow)).toEqual(["due_today"]);
326
+ // 基线行不被保留期清掉
327
+ due.runDueReminders({ now: T0 + 90 * DAY });
328
+ expect(db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE kind = 'baseline' AND network_id = ?1", fresh.net).n).toBe(1);
329
+ });
330
+
331
+ test("白名单后加进来的网络:以加进来那一刻为基线,不补发", async () => {
332
+ const late = mkNet("late");
333
+ process.env.COMMHUB_DUE_REMINDER_NETWORKS = NET;
334
+ try {
335
+ const old = await cardIn(late, "dr-后加网络旧卡", "2026-10-08");
336
+ due.runDueReminders({ now: T0 });
337
+ expect(db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE kind = 'baseline' AND network_id = ?1", late.net).n).toBe(0);
338
+ process.env.COMMHUB_DUE_REMINDER_NETWORKS = `${NET},${late.net}`;
339
+ expect(sentFor(due.runDueReminders({ now: T0 + 3600_000 }), old)).toEqual([]);
340
+ } finally { delete process.env.COMMHUB_DUE_REMINDER_NETWORKS; }
341
+ });
342
+
343
+ // ── #524 按网络开关 ────────────────────────────────────
344
+ const SCOPE_VARS = ["COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDER_NETWORKS", "COMMHUB_DUE_REMINDERS_OWNERS"];
345
+ async function withEnv<T>(vars: Record<string, string>, fn: () => T | Promise<T>): Promise<T> {
346
+ const saved = Object.fromEntries(SCOPE_VARS.map((k) => [k, process.env[k]]));
347
+ for (const k of SCOPE_VARS) delete process.env[k];
348
+ Object.assign(process.env, vars);
349
+ try { return await fn(); } finally {
350
+ for (const k of SCOPE_VARS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]!; }
351
+ }
352
+ }
353
+ const baselineCount = (net: string) => db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE kind = 'baseline' AND network_id = ?1", net).n;
354
+ const rowCount = (id: string) => db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE requirement_id = ?1", id).n;
355
+
356
+ test("#524 范围解析:各种组合 / 空白值 / 只绑本分支用到的参数", async () => {
357
+ const S = (vars: Record<string, string>) => withEnv(vars, () => due.dueReminderScope());
358
+ // 都不设 = 原样:所有网络
359
+ expect(await S({})).toEqual({ mode: "all", exclude: [] });
360
+ expect(await S({ COMMHUB_DUE_REMINDERS: "1" })).toEqual({ mode: "all", exclude: [] });
361
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0" })).toEqual({ mode: "off" });
362
+ // 旧名白名单不能在 =0 时打开(行为不变)
363
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDER_NETWORKS: "net_a" })).toEqual({ mode: "off" });
364
+ expect(await S({ COMMHUB_DUE_REMINDER_NETWORKS: "net_a" })).toEqual({ mode: "only", include: ["net_a"], exclude: [] });
365
+ // 新名白名单:=0 时也打开,只给名单里的
366
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: " net_a , net_b,net_a, " })).toEqual({ mode: "only", include: ["net_a", "net_b"], exclude: [] });
367
+ expect(await S({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_NETWORKS: "net_a" })).toEqual({ mode: "only", include: ["net_a"], exclude: [] });
368
+ // 黑名单
369
+ expect(await S({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x" })).toEqual({ mode: "all", exclude: ["net_x"] });
370
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x" })).toEqual({ mode: "off" });
371
+ expect(await S({ COMMHUB_DUE_REMINDERS_NETWORKS: "net_a,net_x", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x" })).toEqual({ mode: "only", include: ["net_a"], exclude: ["net_x"] });
372
+ // 空 / 空白 / 只有逗号 = 没设
373
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: "" })).toEqual({ mode: "off" });
374
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: " , , " })).toEqual({ mode: "off" });
375
+ expect(await S({ COMMHUB_DUE_REMINDERS_NETWORKS: " ", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: " , " })).toEqual({ mode: "all", exclude: [] });
376
+ // SQL:每个分支只 push 自己用到的参数
377
+ const p1: unknown[] = ["lo", "hi"];
378
+ expect(due.dueScopeSql({ mode: "all", exclude: [] }, p1)).toBe("");
379
+ expect(p1).toEqual(["lo", "hi"]);
380
+ const p2: unknown[] = ["lo", "hi"];
381
+ expect(due.dueScopeSql({ mode: "all", exclude: ["net_x"] }, p2)).toBe(" AND network_id NOT IN (?3)");
382
+ expect(p2).toEqual(["lo", "hi", "net_x"]);
383
+ const p3: unknown[] = ["lo", "hi"];
384
+ expect(due.dueScopeSql({ mode: "only", include: ["net_a", "net_b"], exclude: [] }, p3)).toBe(" AND network_id IN (?3, ?4)");
385
+ expect(p3).toEqual(["lo", "hi", "net_a", "net_b"]);
386
+ const p4: unknown[] = [];
387
+ expect(due.dueScopeSql({ mode: "only", include: [], exclude: ["net_x"] }, p4)).toBeNull();
388
+ expect(due.dueScopeSql({ mode: "off" }, p4)).toBeNull();
389
+ expect(p4).toEqual([]);
390
+ });
391
+
392
+ test("#524 COMMHUB_DUE_REMINDERS=0 + 白名单:只有白名单网络收到;别的网络的行连基线都不写", async () => {
393
+ const other = mkNet("allow_other");
394
+ const mine = await card("dr-524-白名单内", "2026-10-11");
395
+ const theirs = await cardIn(other, "dr-524-白名单外", "2026-10-11");
396
+ const out = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: ` ${NET} ,` }, () => due.runDueReminders({ now: T0 }));
397
+ expect(sentFor(out, mine)).toEqual(["due_soon"]);
398
+ expect(userNotices(mine)[owen.id]).toHaveLength(1);
399
+ expect(sentFor(out, theirs)).toEqual([]);
400
+ expect(rowCount(theirs)).toBe(0);
401
+ expect(baselineCount(other.net)).toBe(0);
402
+ // 对照:=0 不带白名单 → 整个关掉,谁都不发
403
+ const fresh = await card("dr-524-全关", "2026-10-11");
404
+ const off = await withEnv({ COMMHUB_DUE_REMINDERS: "0" }, () => due.runDueReminders({ now: T0 }));
405
+ expect(off).toEqual([]);
406
+ expect(rowCount(fresh)).toBe(0);
407
+ // 白名单空白 = 没设 → 仍然关
408
+ expect(await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: " , " }, () => due.runDueReminders({ now: T0 }))).toEqual([]);
409
+ });
410
+
411
+ test("#524 黑名单:功能开着时被排除的网络不发、不写基线;其余照发", async () => {
412
+ const other = mkNet("excl_other");
413
+ const mine = await card("dr-524-未排除", "2026-10-11");
414
+ const theirs = await cardIn(other, "dr-524-被排除", "2026-10-11");
415
+ const out = await withEnv({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: other.net }, () => due.runDueReminders({ now: T0 }));
416
+ expect(sentFor(out, mine)).toEqual(["due_soon"]);
417
+ expect(sentFor(out, theirs)).toEqual([]);
418
+ expect(rowCount(theirs)).toBe(0);
419
+ expect(baselineCount(other.net)).toBe(0);
420
+ // 白名单里也点了名 → 排除优先;白名单只剩它 → 什么都不扫
421
+ const both = mkNet("excl_both");
422
+ const c = await cardIn(both, "dr-524-白名单又被排除", "2026-10-11");
423
+ const out2 = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: both.net, COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: both.net }, () => due.runDueReminders({ now: T0 }));
424
+ expect(out2).toEqual([]);
425
+ expect(rowCount(c)).toBe(0);
426
+ expect(baselineCount(both.net)).toBe(0);
427
+ });
428
+
429
+ test("#524 默认不变:两个新变量都不设 → 所有网络照发;黑名单空白 = 没设", async () => {
430
+ const other = mkNet("dflt_other");
431
+ const mine = await card("dr-524-默认", "2026-10-11");
432
+ const theirs = await cardIn(other, "dr-524-默认别的网络", "2026-10-11");
433
+ const out = await withEnv({}, () => due.runDueReminders({ now: T0 }));
434
+ expect(sentFor(out, mine)).toEqual(["due_soon"]);
435
+ expect(sentFor(out, theirs)).toEqual(["due_soon"]);
436
+ const other2 = mkNet("dflt_blank");
437
+ const theirs2 = await cardIn(other2, "dr-524-黑名单空白", "2026-10-11");
438
+ const out2 = await withEnv({ COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: " " }, () => due.runDueReminders({ now: T0 }));
439
+ expect(sentFor(out2, theirs2)).toEqual(["due_soon"]);
440
+ });
441
+
442
+ test("#524 启动:=0 不起定时器;=0 + 白名单起;各打一行范围日志(只有网络 id)", async () => {
443
+ const logs: string[] = [];
444
+ const g = globalThis as any;
445
+ const orig = { log: console.log, setTimeout: g.setTimeout, setInterval: g.setInterval };
446
+ // 不起真定时器(否则 30 秒后会用真时钟扫一遍,干扰后面的用例):只记下调了几次。
447
+ let timers = 0;
448
+ console.log = (...a: unknown[]) => { logs.push(a.map(String).join(" ")); };
449
+ g.setTimeout = () => { timers++; return { unref() {} }; };
450
+ g.setInterval = () => { timers++; return { fake: true }; };
451
+ try {
452
+ const off = await withEnv({ COMMHUB_DUE_REMINDERS: "0" }, () => due.startDueReminderTimer());
453
+ expect(off).toBeNull();
454
+ expect(timers).toBe(0);
455
+ const on = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: "net_placeholder_a" }, () => due.startDueReminderTimer());
456
+ expect(on).toEqual({ fake: true } as any);
457
+ expect(timers).toBe(2);
458
+ await withEnv({ COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_placeholder_x" }, () => due.startDueReminderTimer());
459
+ } finally { console.log = orig.log; g.setTimeout = orig.setTimeout; g.setInterval = orig.setInterval; }
460
+ const scopeLogs = logs.filter((l) => l.startsWith("[due-reminders] scope:"));
461
+ expect(scopeLogs).toEqual([
462
+ "[due-reminders] scope: off",
463
+ "[due-reminders] scope: only networks net_placeholder_a",
464
+ "[due-reminders] scope: all networks except net_placeholder_x",
465
+ ]);
466
+ });
467
+
468
+ // ── #524 按负责人开关(两个团队共用一个网络,网络名单分不开)────────
469
+ test("#524 负责人名单:范围解析 / 空白 = 没设 / 只在用到时绑参数", async () => {
470
+ const S = (vars: Record<string, string>) => withEnv(vars, () => due.dueReminderScope());
471
+ // =0 + 负责人名单:与网络白名单同一语义 —— 单独就能打开,范围 = 所有网络里名单里的人负责的卡
472
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: " u_a , u_b,u_a, " })).toEqual({ mode: "all", exclude: [], owners: ["u_a", "u_b"] });
473
+ expect(await S({ COMMHUB_DUE_REMINDERS: "1", COMMHUB_DUE_REMINDERS_OWNERS: "u_a" })).toEqual({ mode: "all", exclude: [], owners: ["u_a"] });
474
+ // 与网络白名单 / 黑名单叠加(且)
475
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: "net_a", COMMHUB_DUE_REMINDERS_OWNERS: "u_a" })).toEqual({ mode: "only", include: ["net_a"], exclude: [], owners: ["u_a"] });
476
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS: "net_x", COMMHUB_DUE_REMINDERS_OWNERS: "u_a" })).toEqual({ mode: "all", exclude: ["net_x"], owners: ["u_a"] });
477
+ // 空 / 空白 / 只有逗号 = 没设:=0 时仍然关;不设 =0 时与原来完全一样(没有 owners 键)
478
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: "" })).toEqual({ mode: "off" });
479
+ expect(await S({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: " , ," })).toEqual({ mode: "off" });
480
+ expect(await S({ COMMHUB_DUE_REMINDERS_OWNERS: " " })).toStrictEqual({ mode: "all", exclude: [] });
481
+ // SQL:没名单不 push;有名单精确匹配存储形状,占位号接在已有参数后面
482
+ const p0: unknown[] = ["lo", "hi"];
483
+ expect(due.dueOwnerSql({ mode: "all", exclude: [] }, p0)).toBe("");
484
+ expect(due.dueOwnerSql({ mode: "off" }, p0)).toBe("");
485
+ expect(p0).toEqual(["lo", "hi"]);
486
+ const p1: unknown[] = ["lo", "hi", "net_x"];
487
+ expect(due.dueOwnerSql({ mode: "all", exclude: ["net_x"], owners: ["u_a", "u_b"] }, p1)).toBe(" AND owner_json IN (?4, ?5)");
488
+ expect(p1).toEqual(["lo", "hi", "net_x", JSON.stringify(userRef("u_a")), JSON.stringify(userRef("u_b"))]);
489
+ // 日志
490
+ expect(due.describeDueReminderScope({ mode: "all", exclude: [], owners: ["u_a"] })).toBe("all networks; only cards owned by u_a");
491
+ expect(due.describeDueReminderScope({ mode: "only", include: ["net_a"], exclude: [], owners: ["u_a", "u_b"] })).toBe("only networks net_a; only cards owned by u_a,u_b");
492
+ });
493
+
494
+ test("#524 COMMHUB_DUE_REMINDERS=0 + 负责人名单:同一网络里只有名单里的人负责的卡发(人 + 负责 Agent);别人负责的一条不发", async () => {
495
+ const listed = await card("dr-524-名单内负责人", "2026-10-11");
496
+ const unlisted = await card("dr-524-名单外负责人", "2026-10-11", { owner: userRef(pia.id), participants: [userRef(pat.id)] });
497
+ const noOwner = await card("dr-524-没有负责人", "2026-10-11", { owner: null, participants: [userRef(owen.id)] });
498
+ const out = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: ` ${owen.id} ,` }, () => due.runDueReminders({ now: T0 }));
499
+ // 名单里的人负责的卡:恰好一档,收件人 = 负责人 + 参与人,负责 Agent 也收到一条
500
+ expect(sentFor(out, listed)).toEqual(["due_soon"]);
501
+ expect(out.find((x) => x.requirement_id === listed)?.users.sort()).toEqual([owen.id, pat.id, pia.id].sort());
502
+ expect(out.find((x) => x.requirement_id === listed)?.node).toBe(ALIAS);
503
+ const n = userNotices(listed);
504
+ expect(Object.keys(n).sort()).toEqual([owen.id, pat.id, pia.id].sort());
505
+ for (const id of [owen.id, pat.id, pia.id]) expect(n[id].map((x) => x.reminder)).toEqual(["due_soon"]);
506
+ expect(nodeMessages(listed)).toHaveLength(1);
507
+ // 名单外的人负责的卡 / 没有负责人的卡:什么都不发,连去重行都不写(以后把人加进来,照样按时发)
508
+ for (const id of [unlisted, noOwner]) {
509
+ expect(sentFor(out, id)).toEqual([]);
510
+ expect(userNotices(id)).toEqual({});
511
+ expect(nodeMessages(id)).toEqual([]);
512
+ expect(rowCount(id)).toBe(0);
513
+ }
514
+ // 返回值里也只有名单里的人负责的卡
515
+ for (const x of out) {
516
+ const row = db.get("SELECT owner_json FROM requirements WHERE requirement_id = ?1", x.requirement_id);
517
+ expect(JSON.parse(row.owner_json)).toEqual(userRef(owen.id));
518
+ }
519
+ // 同一时刻再跑:不重发
520
+ expect(sentFor(await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: owen.id }, () => due.runDueReminders({ now: T0 })), listed)).toEqual([]);
521
+ // 与网络白名单是「且」:人在名单里、网络不在 → 不发
522
+ const other = mkNet("own_and");
523
+ const c = await card("dr-524-人对网络不对", "2026-10-11");
524
+ const out2 = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_NETWORKS: other.net, COMMHUB_DUE_REMINDERS_OWNERS: owen.id }, () => due.runDueReminders({ now: T0 }));
525
+ expect(sentFor(out2, c)).toEqual([]);
526
+ expect(rowCount(c)).toBe(0);
527
+ // 名单撤掉(=1、不设名单)→ 名单外负责人的卡照常发:证明上面是名单挡的,不是卡本身不该发
528
+ expect(sentFor(await withEnv({ COMMHUB_DUE_REMINDERS: "1" }, () => due.runDueReminders({ now: T0 })), unlisted)).toEqual(["due_soon"]);
529
+ });
530
+
531
+ test("#524 负责人名单不补发:网络早就开着,人后加进名单 —— 他加进来之前就逾期的卡不发,之后才逾期的照发", async () => {
532
+ const f = mkNet("own_late");
533
+ // 网络基线很早(T0 - 30 天),人还不在任何名单里
534
+ await withEnv({ COMMHUB_DUE_REMINDERS_NETWORKS: f.net }, () => due.runDueReminders({ now: T0 - 30 * DAY }));
535
+ expect(baselineCount(f.net)).toBe(1);
536
+ const old = await cardIn(f, "dr-524-进名单前逾期", "2026-10-07");
537
+ const today = await cardIn(f, "dr-524-进名单当天到期", "2026-10-10");
538
+ const env = { COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: f.id };
539
+ const first = await withEnv(env, () => due.runDueReminders({ now: T0 }));
540
+ // 网络基线比逾期早 —— 只看网络基线就会补发;负责人基线挡住
541
+ expect(sentFor(first, old)).toEqual([]);
542
+ expect(sentFor(first, today)).toEqual(["due_today"]);
543
+ const ob = db.all("SELECT network_id, due_on, sent_at FROM requirement_due_reminders WHERE kind = 'baseline_owner' AND network_id = ?1", f.net);
544
+ expect(ob).toEqual([{ network_id: f.net, due_on: f.id, sent_at: new Date(T0).toISOString() }]);
545
+ const next = await withEnv(env, () => due.runDueReminders({ now: T0 + DAY }));
546
+ expect(sentFor(next, old)).toEqual([]);
547
+ expect(sentFor(next, today)).toEqual(["overdue"]);
548
+ // 负责人基线不被保留期清掉
549
+ await withEnv(env, () => due.runDueReminders({ now: T0 + 90 * DAY }));
550
+ expect(db.get("SELECT COUNT(*) AS n FROM requirement_due_reminders WHERE kind = 'baseline_owner' AND network_id = ?1", f.net).n).toBe(1);
551
+ });
552
+
553
+ test("#524 启动:=0 + 负责人名单起定时器,日志只有 id", async () => {
554
+ const logs: string[] = [];
555
+ const g = globalThis as any;
556
+ const orig = { log: console.log, setTimeout: g.setTimeout, setInterval: g.setInterval };
557
+ let timers = 0;
558
+ console.log = (...a: unknown[]) => { logs.push(a.map(String).join(" ")); };
559
+ g.setTimeout = () => { timers++; return { unref() {} }; };
560
+ g.setInterval = () => { timers++; return { fake: true }; };
561
+ try {
562
+ const on = await withEnv({ COMMHUB_DUE_REMINDERS: "0", COMMHUB_DUE_REMINDERS_OWNERS: "u_placeholder_a" }, () => due.startDueReminderTimer());
563
+ expect(on).toEqual({ fake: true } as any);
564
+ expect(timers).toBe(2);
565
+ } finally { console.log = orig.log; g.setTimeout = orig.setTimeout; g.setInterval = orig.setInterval; }
566
+ expect(logs.filter((l) => l.startsWith("[due-reminders] scope:"))).toEqual(["[due-reminders] scope: all networks; only cards owned by u_placeholder_a"]);
567
+ });
568
+ });
569
+
570
+ describe("#494 列表筛选", () => {
571
+ // 筛选用真时钟:due 按此刻算出来。
572
+ const tz = "Asia/Shanghai";
573
+ let today = "", ids: Record<string, string> = {};
574
+ beforeAll(async () => {
575
+ const now = Date.now();
576
+ today = due.localDate(now, tz);
577
+ ids.over = await card("df-逾期", due.addDays(today, -2));
578
+ ids.overTime = await card("df-逾期时刻", new Date(now - 3600_000).toISOString());
579
+ ids.today = await card("df-今天", today);
580
+ ids.in3 = await card("df-三天后", due.addDays(today, 3));
581
+ ids.in10 = await card("df-十天后", due.addDays(today, 10));
582
+ ids.doneOver = await card("df-完成逾期", due.addDays(today, -2), { column: "done" });
583
+ ids.none = await card("df-无due", "");
584
+ });
585
+ const names = (rows: any[]) => rows.map((r: any) => r.name).filter((n: string) => n.startsWith("df-")).sort();
586
+
587
+ test("MCP overdue=true:只要没完成且过期的(日期型 + 时刻型)", async () => {
588
+ const r = await mcp(owen.token, "requirements_list", { network_id: NET, overdue: true, limit: 200 });
589
+ expect(r.ok).toBe(true);
590
+ expect(names(r.requirements)).toEqual(["df-逾期", "df-逾期时刻"].sort());
591
+ });
592
+ test("MCP overdue=false:其余的", async () => {
593
+ const r = await mcp(owen.token, "requirements_list", { network_id: NET, overdue: false, limit: 500 });
594
+ expect(names(r.requirements)).toEqual(["df-三天后", "df-今天", "df-十天后", "df-完成逾期", "df-无due"].sort());
595
+ });
596
+ test("MCP due_within_days:0 = 今天;3 = 到三天后;不含逾期 / 完成 / 无 due", async () => {
597
+ expect(names((await mcp(owen.token, "requirements_list", { network_id: NET, due_within_days: 0, limit: 200 })).requirements)).toEqual(["df-今天"]);
598
+ expect(names((await mcp(owen.token, "requirements_list", { network_id: NET, due_within_days: 3, limit: 200 })).requirements)).toEqual(["df-三天后", "df-今天"].sort());
599
+ });
600
+ test("MCP 严格参数:负数 / 非整数 / 字符串被拒", async () => {
601
+ for (const bad of [{ due_within_days: -1 }, { due_within_days: 1.5 }, { overdue: "yes" }]) {
602
+ const r = await mcp(owen.token, "requirements_list", { network_id: NET, ...bad });
603
+ expect(r.ok).not.toBe(true);
604
+ }
605
+ });
606
+ test("REST 同一组参数;不合法 → 400 + field", async () => {
607
+ const ok = await send(owen.token, "GET", `/api/requirements?network_id=${NET}&overdue=1`);
608
+ expect(ok.status).toBe(200);
609
+ expect(names(ok.body.requirements)).toEqual(["df-逾期", "df-逾期时刻"].sort());
610
+ const w = await send(owen.token, "GET", `/api/requirements?network_id=${NET}&due_within_days=10`);
611
+ expect(names(w.body.requirements)).toEqual(["df-三天后", "df-今天", "df-十天后"].sort());
612
+ const bad = await send(owen.token, "GET", `/api/requirements?network_id=${NET}&overdue=maybe`);
613
+ expect(bad.status).toBe(400);
614
+ expect(bad.body.error).toBe("invalid_overdue");
615
+ expect(bad.body.field).toBe("overdue");
616
+ const bad2 = await send(owen.token, "GET", `/api/requirements?network_id=${NET}&due_within_days=999`);
617
+ expect(bad2.status).toBe(400);
618
+ expect(bad2.body.error).toBe("invalid_due_within_days");
619
+ });
620
+ });