@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.101

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 (269) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +8 -7
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +487 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/agent-notice.ts +48 -0
  15. package/src/alias-filter.test.ts +72 -0
  16. package/src/alias-filter.ts +42 -0
  17. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  18. package/src/api-host-supervisors-fallback.test.ts +271 -0
  19. package/src/api-nodes-shape.test.ts +188 -0
  20. package/src/auth-sessions-http.test.ts +250 -0
  21. package/src/auth-validate.test.ts +5 -1
  22. package/src/auth.ts +493 -35
  23. package/src/avatar-validate.test.ts +163 -0
  24. package/src/avatar-validate.ts +103 -0
  25. package/src/blocked-exit-on-reply.test.ts +122 -0
  26. package/src/boot-side-effect.test.ts +124 -0
  27. package/src/commhub-bin-args.test.ts +85 -0
  28. package/src/config-apply-sec1.test.ts +270 -1
  29. package/src/config-apply-validate.test.ts +92 -0
  30. package/src/config-apply-validate.ts +81 -3
  31. package/src/create-node-blocked-gate.test.ts +356 -0
  32. package/src/create-node-tool-schema.test.ts +39 -0
  33. package/src/create-node-validate.test.ts +275 -0
  34. package/src/create-node-validate.ts +288 -0
  35. package/src/create-node-workdir.test.ts +255 -0
  36. package/src/create-node.test.ts +354 -0
  37. package/src/create-node.ts +349 -0
  38. package/src/dashboard-slash-routing-http.test.ts +107 -0
  39. package/src/db-adapter-guard.test.ts +148 -0
  40. package/src/db-adapter-translate.test.ts +128 -0
  41. package/src/db-adapter.ts +323 -87
  42. package/src/db-timestamp.test.ts +38 -0
  43. package/src/db-timestamp.ts +15 -0
  44. package/src/db.ts +984 -34
  45. package/src/delivered-without-push.test.ts +140 -0
  46. package/src/department-groups-http.test.ts +209 -0
  47. package/src/department-groups-sync-http.test.ts +328 -0
  48. package/src/department-groups.ts +434 -0
  49. package/src/department-heads-http.test.ts +329 -0
  50. package/src/department-heads.ts +226 -0
  51. package/src/departments-http.test.ts +185 -0
  52. package/src/departments.ts +260 -0
  53. package/src/desktop-message-seam.test.ts +150 -0
  54. package/src/desktop-message.test.ts +368 -0
  55. package/src/dm-file-scope-http.test.ts +178 -0
  56. package/src/ext-token-shared.test.ts +60 -0
  57. package/src/ext-validation-regression.test.ts +131 -0
  58. package/src/external-schedule-edits-http.test.ts +321 -0
  59. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  60. package/src/external-schedule-edits.ts +327 -0
  61. package/src/file-download-authz-dev-open.test.ts +135 -0
  62. package/src/file-download-authz.test.ts +404 -0
  63. package/src/file-download-date-bucket.test.ts +436 -0
  64. package/src/file-network-scope-dev-open.test.ts +97 -0
  65. package/src/file-network-scope.test.ts +732 -0
  66. package/src/group-membership-events-http.test.ts +264 -0
  67. package/src/group-messages-http.test.ts +482 -0
  68. package/src/group-messages.ts +297 -0
  69. package/src/health-redaction.test.ts +642 -0
  70. package/src/http-gzip.test.ts +94 -0
  71. package/src/http-gzip.ts +112 -0
  72. package/src/hub-timestamp.test.ts +38 -0
  73. package/src/hub-timestamp.ts +27 -0
  74. package/src/human-dm.ts +215 -0
  75. package/src/inbox-count-sse.test.ts +310 -0
  76. package/src/inbox-count.ts +29 -0
  77. package/src/index.ts +15 -2172
  78. package/src/lifecycle-guard.ts +50 -0
  79. package/src/list-host-supervisors.test.ts +314 -0
  80. package/src/mcp-context-cost-http.test.ts +119 -0
  81. package/src/mcp-projects-events-http.test.ts +162 -0
  82. package/src/mcp-route-label-http.test.ts +120 -0
  83. package/src/mcp-write-network-resolution.test.ts +429 -0
  84. package/src/member-audit-network-id-http.test.ts +93 -0
  85. package/src/member-presence-http.test.ts +206 -0
  86. package/src/messages-ack-by-agent-http.test.ts +142 -0
  87. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  88. package/src/migrate-sqlite-to-pg.ts +263 -0
  89. package/src/model-auth-notify-http.test.ts +181 -0
  90. package/src/model-auth-notify.ts +116 -0
  91. package/src/network-name-http.test.ts +75 -0
  92. package/src/network-scope.ts +224 -0
  93. package/src/network-token-membership-http.test.ts +139 -0
  94. package/src/node-attrs-validate.ts +88 -0
  95. package/src/node-attrs.test.ts +217 -0
  96. package/src/node-create-request-status-http.test.ts +92 -0
  97. package/src/node-files-transport.test.ts +344 -0
  98. package/src/node-health-dispatch-http.test.ts +279 -0
  99. package/src/node-health-guard.ts +105 -0
  100. package/src/node-health-passthrough.test.ts +115 -0
  101. package/src/node-health-store.ts +72 -0
  102. package/src/node-id-alias.test.ts +188 -0
  103. package/src/node-identity-conflict-http.test.ts +269 -0
  104. package/src/node-identity-conflict.ts +142 -0
  105. package/src/node-identity-mismatch.test.ts +74 -0
  106. package/src/node-lifecycle-controllable.test.ts +64 -0
  107. package/src/node-lifecycle-controllable.ts +51 -0
  108. package/src/node-logs-transport.test.ts +263 -0
  109. package/src/node-permissions-http.test.ts +276 -0
  110. package/src/node-permissions.ts +242 -0
  111. package/src/node-request-retention.test.ts +281 -0
  112. package/src/node-request-retention.ts +120 -0
  113. package/src/node-skills-transport.test.ts +209 -0
  114. package/src/node-token-privilege-http.test.ts +149 -0
  115. package/src/observer-avatar-http.test.ts +431 -0
  116. package/src/observer-push.test.ts +252 -0
  117. package/src/package-version-consistency.test.ts +25 -0
  118. package/src/peer-reply-atomic.test.ts +455 -0
  119. package/src/peer-reply-capability.test.ts +70 -0
  120. package/src/pg-worker.ts +87 -0
  121. package/src/probe-validate.test.ts +221 -0
  122. package/src/probe-validate.ts +104 -0
  123. package/src/probe.test.ts +80 -0
  124. package/src/probe.ts +200 -0
  125. package/src/project-viewer-can-http.test.ts +114 -0
  126. package/src/push.ts +429 -10
  127. package/src/redact-tokens.test.ts +65 -0
  128. package/src/redact-tokens.ts +40 -0
  129. package/src/rename.ts +7 -2
  130. package/src/reply-target-derivation.test.ts +96 -0
  131. package/src/report-status-host-schema.test.ts +211 -0
  132. package/src/report-status-resume-id-handover.test.ts +150 -0
  133. package/src/require-explicit-test-db.ts +9 -0
  134. package/src/requirement-due-reminders-http.test.ts +518 -0
  135. package/src/requirement-due-reminders.ts +369 -0
  136. package/src/requirement-events-http.test.ts +257 -0
  137. package/src/requirement-events.test.ts +45 -0
  138. package/src/requirement-events.ts +144 -0
  139. package/src/requirement-notify.ts +165 -0
  140. package/src/requirement-participant-notify-http.test.ts +309 -0
  141. package/src/requirement-tag-ops-http.test.ts +266 -0
  142. package/src/requirement-tags.ts +89 -0
  143. package/src/requirements-comment-http.test.ts +165 -0
  144. package/src/requirements-completed-at-migration.test.ts +70 -0
  145. package/src/requirements-errors-mcp-http.test.ts +164 -0
  146. package/src/requirements-errors.ts +143 -0
  147. package/src/requirements-etag-http.test.ts +91 -0
  148. package/src/requirements-http.test.ts +709 -0
  149. package/src/requirements-last-event-http.test.ts +344 -0
  150. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  151. package/src/requirements-list-slim-http.test.ts +319 -0
  152. package/src/requirements-mcp.test.ts +163 -0
  153. package/src/requirements-migrate.ts +225 -0
  154. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  155. package/src/requirements-people-http.test.ts +114 -0
  156. package/src/requirements-people-mcp-http.test.ts +225 -0
  157. package/src/requirements-people.ts +127 -0
  158. package/src/requirements-priority-lowest.test.ts +114 -0
  159. package/src/requirements-search-http.test.ts +182 -0
  160. package/src/requirements-search.test.ts +32 -0
  161. package/src/requirements-search.ts +145 -0
  162. package/src/requirements-seq-http.test.ts +193 -0
  163. package/src/requirements-seq-migration.test.ts +63 -0
  164. package/src/requirements-start-date.test.ts +125 -0
  165. package/src/requirements-stats-http.test.ts +288 -0
  166. package/src/requirements-stats.ts +164 -0
  167. package/src/requirements-status-participants-http.test.ts +220 -0
  168. package/src/requirements-upgrade-e2e.test.ts +126 -0
  169. package/src/requirements-upgrade-migration.test.ts +98 -0
  170. package/src/requirements.ts +1761 -0
  171. package/src/resolve-port.test.ts +57 -0
  172. package/src/resolve-port.ts +44 -0
  173. package/src/response-charset.test.ts +130 -0
  174. package/src/rest-explicit-columns-http.test.ts +222 -0
  175. package/src/rest-identity.test.ts +129 -0
  176. package/src/rest-identity.ts +100 -0
  177. package/src/rest-projections.ts +68 -0
  178. package/src/rest-write-network-resolution.test.ts +133 -0
  179. package/src/restricted-files.ts +73 -0
  180. package/src/retention.ts +15 -3
  181. package/src/route-callers-http.test.ts +120 -0
  182. package/src/route-timing.test.ts +211 -0
  183. package/src/route-timing.ts +258 -0
  184. package/src/rules-file-duplicate-alias.test.ts +234 -0
  185. package/src/rules-file-session-target.test.ts +207 -0
  186. package/src/rules-file-transport.test.ts +237 -0
  187. package/src/runtime-label.test.ts +22 -0
  188. package/src/runtime-label.ts +21 -0
  189. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  190. package/src/scheduled-failures-http.test.ts +233 -0
  191. package/src/scheduled-failures.ts +179 -0
  192. package/src/scheduled-reply-unread-http.test.ts +212 -0
  193. package/src/scheduled-run-terminal.test.ts +268 -0
  194. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  195. package/src/scheduled-stuck-http.test.ts +238 -0
  196. package/src/scheduled-stuck.ts +153 -0
  197. package/src/scheduled-tasks-http.test.ts +638 -0
  198. package/src/scheduled-tasks.ts +650 -0
  199. package/src/send-dedup-request-id.test.ts +229 -0
  200. package/src/send-reply-agent-warning.test.ts +202 -0
  201. package/src/send-reply-alias-optional.test.ts +131 -0
  202. package/src/send-reply-attachments.test.ts +458 -0
  203. package/src/send_dedup.test.ts +54 -0
  204. package/src/send_dedup.ts +52 -6
  205. package/src/serve-error.test.ts +38 -0
  206. package/src/serve-error.ts +27 -0
  207. package/src/server.ts +5038 -0
  208. package/src/shared/external-schedule-contract.ts +60 -0
  209. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  210. package/src/shared/probe-host-allowlist.ts +128 -0
  211. package/src/shared/reserved-env-drift.test.ts +43 -0
  212. package/src/shared/reserved-env.test.ts +35 -0
  213. package/src/shared/reserved-env.ts +31 -0
  214. package/src/side-thread-command-transport.test.ts +312 -0
  215. package/src/side-thread-command-transport.ts +318 -0
  216. package/src/side-thread-http-integration.test.ts +233 -0
  217. package/src/side-thread-http.ts +504 -0
  218. package/src/side-thread-production.test.ts +72 -0
  219. package/src/side-thread-production.ts +118 -0
  220. package/src/side-thread.test.ts +1067 -0
  221. package/src/side-thread.ts +2499 -0
  222. package/src/skillhub-http.test.ts +108 -0
  223. package/src/sqlite-close-checkpoint.test.ts +78 -0
  224. package/src/start-node.test.ts +109 -0
  225. package/src/status-alias-filter-http.test.ts +106 -0
  226. package/src/status-alias-resolver-http.test.ts +163 -0
  227. package/src/status-alias-resolver.ts +19 -0
  228. package/src/status-node-id-filter-http.test.ts +161 -0
  229. package/src/status-read-cache-http.test.ts +229 -0
  230. package/src/status-read-cache.ts +122 -0
  231. package/src/stop-delete-node.test.ts +1164 -0
  232. package/src/task-access-http.test.ts +379 -0
  233. package/src/task-access.ts +413 -0
  234. package/src/task-auth-origin-http.test.ts +91 -0
  235. package/src/task-auth-origin.test.ts +24 -0
  236. package/src/task-auth-origin.ts +7 -0
  237. package/src/task-consumption.test.ts +482 -0
  238. package/src/task-diagnostic-http.test.ts +149 -0
  239. package/src/task-diagnostic.test.ts +115 -0
  240. package/src/task-diagnostic.ts +118 -0
  241. package/src/task-expiry-notice-http.test.ts +287 -0
  242. package/src/task-expiry-notice.ts +313 -0
  243. package/src/task-idempotency-mcp.test.ts +142 -0
  244. package/src/task-idempotency.test.ts +60 -0
  245. package/src/task-idempotency.ts +75 -0
  246. package/src/task-lifecycle-watcher.test.ts +204 -0
  247. package/src/task-lifecycle-watcher.ts +49 -0
  248. package/src/task-network-resolution-http.test.ts +141 -0
  249. package/src/task-queue-ahead-http.test.ts +309 -0
  250. package/src/task-queue-ahead-scale.test.ts +211 -0
  251. package/src/task-queue-ahead.ts +186 -0
  252. package/src/task-stale-open-http.test.ts +290 -0
  253. package/src/task-stale-open.ts +103 -0
  254. package/src/tasks-pagination-http.test.ts +141 -0
  255. package/src/tool-audience-http.test.ts +195 -0
  256. package/src/tool-audience.ts +109 -0
  257. package/src/tools-list-json-schema.test.ts +67 -0
  258. package/src/tools.ts +4955 -300
  259. package/src/update-provider.test.ts +234 -0
  260. package/src/upload-early-reject-guard.test.ts +155 -0
  261. package/src/uploads-http.test.ts +97 -15
  262. package/src/uploads.test.ts +66 -0
  263. package/src/uploads.ts +138 -5
  264. package/src/user-inbox-by-agent-http.test.ts +161 -0
  265. package/src/user-inbox-netid-notnull.test.ts +122 -0
  266. package/src/user-inbox-read-http.test.ts +223 -0
  267. package/src/user-inbox-schema.test.ts +54 -0
  268. package/src/vault.test.ts +200 -0
  269. package/src/vault.ts +156 -0
@@ -0,0 +1,369 @@
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
+ // 两个新变量都不设 → 行为与之前完全相同。生效范围在 startHub 时打一行日志(只有网络 id)。
28
+ // 过滤写在扫描 SQL 的 WHERE 里(基线 INSERT 与候选 SELECT 同一个条件),范围外网络的行根本不会被读出来。
29
+ // COMMHUB_DUE_REMINDER_NODES=1 给负责 Agent 节点发消息;默认关(人照常提醒)。
30
+ // 开通时不补发:每个网络第一次被扫描到时记一行基线(kind='baseline',sent_at = 那一刻)。只有在基线**之后**才变成逾期的卡
31
+ // 会收到「已逾期」提醒;基线之前就已经逾期的卡永远不发逾期提醒(打开功能 / 把网络加进白名单都不会刷一批旧卡)。
32
+ // 选这个而不是「把旧卡逐张记成已发」:不用每卡每天写行,也不怕某张旧卡第一次没被扫到、第二天又冒出来。
33
+ // 即将到期 / 今天到期不受基线影响(它们本来就只在临近时发一次)。
34
+
35
+ import type { DbAdapter } from "./db-adapter.js";
36
+ import { db, uuidv4 } from "./db.js";
37
+ import { sendAgentNotice } from "./agent-notice.js";
38
+ import { canSeeAgent } from "./agent-access.js";
39
+ import { assertNodeActive } from "./lifecycle-guard.js";
40
+ import { pushEvent } from "./push.js";
41
+ import { pendingInboxCount } from "./inbox-count.js";
42
+ import { parseHubTimestamp } from "./hub-timestamp.js";
43
+
44
+ export const DUE_REMINDER_KIND = "task_due";
45
+ export const DUE_REMINDER_SENDER = "任务提醒";
46
+ export type DueKind = "due_soon" | "due_today" | "overdue";
47
+
48
+ const DAY_MS = 86_400_000;
49
+ const RETENTION_DAYS = 60;
50
+
51
+ /** IF NOT EXISTS:重复执行无副作用;SQLite / PostgreSQL 同一段。 */
52
+ export function ensureDueReminders(database: DbAdapter): void {
53
+ database.exec(`
54
+ CREATE TABLE IF NOT EXISTS requirement_due_reminders (
55
+ requirement_id TEXT NOT NULL,
56
+ kind TEXT NOT NULL,
57
+ due_on TEXT NOT NULL,
58
+ day TEXT NOT NULL,
59
+ network_id TEXT NOT NULL,
60
+ sent_at TEXT NOT NULL,
61
+ PRIMARY KEY (requirement_id, kind, due_on, day)
62
+ );
63
+ `);
64
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_due_reminders_sent ON requirement_due_reminders(sent_at)");
65
+ // 扫描查询按 due_on 取一段范围:只给有 due 的行建索引。
66
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_due_on ON requirements(due_on) WHERE due_on IS NOT NULL");
67
+ }
68
+
69
+ function envNumber(name: string, fallback: number, min: number): number {
70
+ const raw = process.env[name];
71
+ if (raw === undefined || raw.trim() === "") return fallback;
72
+ const n = Number(raw);
73
+ return Number.isFinite(n) && n >= min ? n : fallback;
74
+ }
75
+
76
+ export function dueReminderTimezone(): string {
77
+ const tz = process.env.COMMHUB_DUE_REMINDER_TZ?.trim() || "Asia/Shanghai";
78
+ try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); return tz; } catch { return "Asia/Shanghai"; }
79
+ }
80
+ /** 逗号分隔的网络 id;没设(或只有空白 / 逗号)→ null。去重,保持顺序。 */
81
+ function envIdList(name: string): string[] | null {
82
+ const raw = process.env[name];
83
+ if (raw === undefined) return null;
84
+ const ids = [...new Set(raw.split(",").map((x) => x.trim()).filter(Boolean))];
85
+ return ids.length ? ids : null;
86
+ }
87
+ /** COMMHUB_DUE_REMINDER_NETWORKS:逗号分隔的网络 id;没设(或只有空白)→ null = 所有网络。 */
88
+ export function reminderNetworks(): string[] | null {
89
+ return envIdList("COMMHUB_DUE_REMINDER_NETWORKS");
90
+ }
91
+
92
+ /**
93
+ * #524 生效范围。
94
+ * off —— 不扫描(COMMHUB_DUE_REMINDERS=0 且没设 COMMHUB_DUE_REMINDERS_NETWORKS)。
95
+ * all —— 所有网络,减去 exclude。
96
+ * only —— 只扫 include(已减去 exclude;可能为空 = 什么都不扫)。
97
+ * 白名单 = COMMHUB_DUE_REMINDERS_NETWORKS ∪ COMMHUB_DUE_REMINDER_NETWORKS(旧名);但只有新名能在 COMMHUB_DUE_REMINDERS=0 时打开。
98
+ * 白名单一旦设了就收窄(包括 COMMHUB_DUE_REMINDERS=1):宁可少发,不给没点名的网络发。
99
+ */
100
+ export type DueReminderScope =
101
+ | { mode: "off" }
102
+ | { mode: "all"; exclude: string[] }
103
+ | { mode: "only"; include: string[]; exclude: string[] };
104
+
105
+ export function dueReminderScope(): DueReminderScope {
106
+ const allow = envIdList("COMMHUB_DUE_REMINDERS_NETWORKS");
107
+ const legacy = envIdList("COMMHUB_DUE_REMINDER_NETWORKS");
108
+ const exclude = envIdList("COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS") ?? [];
109
+ if (process.env.COMMHUB_DUE_REMINDERS === "0" && !allow) return { mode: "off" };
110
+ if (!allow && !legacy) return { mode: "all", exclude };
111
+ const include = [...new Set([...(allow ?? []), ...(legacy ?? [])])].filter((id) => !exclude.includes(id));
112
+ return { mode: "only", include, exclude };
113
+ }
114
+
115
+ export function describeDueReminderScope(scope: DueReminderScope = dueReminderScope()): string {
116
+ if (scope.mode === "off") return "off";
117
+ if (scope.mode === "all") return scope.exclude.length ? `all networks except ${scope.exclude.join(",")}` : "all networks";
118
+ return scope.include.length ? `only networks ${scope.include.join(",")}` : "no networks (allowlist empty after exclude)";
119
+ }
120
+
121
+ /**
122
+ * 范围条件(拼在 WHERE 后面)。只 push 这个分支真正用到的参数(PG 预编译拒绝绑了没用的参数)。
123
+ * null = 范围为空,调用方不该查询。
124
+ */
125
+ export function dueScopeSql(scope: DueReminderScope, params: unknown[]): string | null {
126
+ if (scope.mode === "off") return null;
127
+ if (scope.mode === "only") {
128
+ if (!scope.include.length) return null;
129
+ return ` AND network_id IN (${scope.include.map((id) => `?${params.push(id)}`).join(", ")})`;
130
+ }
131
+ if (!scope.exclude.length) return "";
132
+ return ` AND network_id NOT IN (${scope.exclude.map((id) => `?${params.push(id)}`).join(", ")})`;
133
+ }
134
+ export function nodeRemindersEnabled(): boolean { return process.env.COMMHUB_DUE_REMINDER_NODES === "1"; }
135
+ export const BASELINE_KIND = "baseline";
136
+
137
+ export function overdueMaxDays(): number { return Math.floor(envNumber("COMMHUB_DUE_OVERDUE_MAX_DAYS", 7, 0)); }
138
+
139
+ // ── 时区小工具 ─────────────────────────────────────────────
140
+ /** 某一时刻在 tz 的日期「YYYY-MM-DD」。 */
141
+ export function localDate(ms: number, tz: string): string {
142
+ return new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(ms));
143
+ }
144
+ /** 日期 + n 天(纯日历运算,与时区无关)。 */
145
+ export function addDays(date: string, n: number): string {
146
+ const [y, m, d] = date.split("-").map(Number);
147
+ return new Date(Date.UTC(y, m - 1, d) + n * DAY_MS).toISOString().slice(0, 10);
148
+ }
149
+ /** 两个日期相差几天(a - b)。 */
150
+ export function dayDiff(a: string, b: string): number {
151
+ const ms = (s: string) => { const [y, m, d] = s.split("-").map(Number); return Date.UTC(y, m - 1, d); };
152
+ return Math.round((ms(a) - ms(b)) / DAY_MS);
153
+ }
154
+ function tzOffsetMs(ms: number, tz: string): number {
155
+ 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" })
156
+ .formatToParts(new Date(ms)).filter((x) => x.type !== "literal").map((x) => [x.type, Number(x.value)]));
157
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000;
158
+ }
159
+ /** tz 里某天 00:00 对应的 UTC 时刻(ms)。 */
160
+ export function localMidnightMs(date: string, tz: string): number {
161
+ const [y, m, d] = date.split("-").map(Number);
162
+ const guess = Date.UTC(y, m - 1, d);
163
+ let t = guess - tzOffsetMs(guess, tz);
164
+ t = guess - tzOffsetMs(t, tz);
165
+ return t;
166
+ }
167
+
168
+ const isDateOnly = (due: string) => due.length === 10;
169
+
170
+ /** 从哪一刻起算逾期:日期型 = tz 里第二天零点;时刻型 = 那个时刻。 */
171
+ export function overdueSinceMs(due: string, tz: string): number {
172
+ return isDateOnly(due) ? localMidnightMs(addDays(due, 1), tz) : Date.parse(due);
173
+ }
174
+
175
+ /** 这张卡此刻该发哪一档(没有 → null)。day = 去重用的那一天。 */
176
+ export function dueReminderFor(due: string, nowMs: number, tz: string, maxOverdue = overdueMaxDays()): { kind: DueKind; day: string; overdueDays: number; dueDay: string } | null {
177
+ const today = localDate(nowMs, tz);
178
+ if (isDateOnly(due)) {
179
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(due)) return null;
180
+ if (due === today) return { kind: "due_today", day: due, overdueDays: 0, dueDay: due };
181
+ if (due === addDays(today, 1)) return { kind: "due_soon", day: due, overdueDays: 0, dueDay: due };
182
+ if (due < today) {
183
+ const n = dayDiff(today, due);
184
+ return n <= maxOverdue ? { kind: "overdue", day: today, overdueDays: n, dueDay: due } : null;
185
+ }
186
+ return null;
187
+ }
188
+ const t = Date.parse(due);
189
+ if (!Number.isFinite(t)) return null;
190
+ const dueDay = localDate(t, tz);
191
+ if (t <= nowMs) {
192
+ const n = dayDiff(today, dueDay);
193
+ return n <= maxOverdue ? { kind: "overdue", day: today, overdueDays: n, dueDay } : null;
194
+ }
195
+ if (dueDay === today) return { kind: "due_today", day: dueDay, overdueDays: 0, dueDay };
196
+ if (t - nowMs <= DAY_MS) return { kind: "due_soon", day: dueDay, overdueDays: 0, dueDay };
197
+ return null;
198
+ }
199
+
200
+ /**
201
+ * 列表筛选(requirements.ts listFilters)用的条件:overdue / due_within_days。只看没完成的卡。
202
+ * 日期型(长度 10)按 tz 的「今天」比日期;时刻型(UTC 字符串)按此刻比时刻。字符串比较:两种形状各自同长、同格式。
203
+ */
204
+ export function overdueSql(params: unknown[], nowMs: number, tz: string): string {
205
+ const today = localDate(nowMs, tz);
206
+ const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
207
+ return `(due_on IS NOT NULL AND column_name <> 'done' AND ((length(due_on) = 10 AND due_on < ?${params.push(today)}) OR (length(due_on) > 10 AND due_on <= ?${params.push(nowIso)})))`;
208
+ }
209
+ /** 没逾期、且在 tz 的「今天 + days」那天结束之前到期(0 = 今天内)。 */
210
+ export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz: string): string {
211
+ const today = localDate(nowMs, tz);
212
+ const lastDay = addDays(today, days);
213
+ const endIso = new Date(localMidnightMs(addDays(lastDay, 1), tz)).toISOString().replace(/\.\d{3}Z$/, "Z");
214
+ const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
215
+ return `(due_on IS NOT NULL AND column_name <> 'done' 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)})))`;
216
+ }
217
+
218
+ // ── 扫描 ───────────────────────────────────────────────────
219
+ type DueRow = {
220
+ requirement_id: string; network_id: string; seq: number | null; title: string; due_on: string;
221
+ owner_json: string | null; participants_json: string | null; agent_owner_json: string | null;
222
+ };
223
+ const parse = (raw: string | null) => { try { return raw ? JSON.parse(raw) : null; } catch { return null; } };
224
+ const userIdOf = (ref: unknown): string | null => {
225
+ const r = ref as { kind?: unknown; id?: unknown } | null;
226
+ return r && r.kind === "user" && typeof r.id === "string" ? r.id : null;
227
+ };
228
+
229
+ export function humanRecipients(row: Pick<DueRow, "owner_json" | "participants_json">): string[] {
230
+ const ids = new Set<string>();
231
+ const owner = userIdOf(parse(row.owner_json));
232
+ if (owner) ids.add(owner);
233
+ const participants = parse(row.participants_json);
234
+ if (Array.isArray(participants)) for (const p of participants) { const id = userIdOf(p); if (id) ids.add(id); }
235
+ return [...ids];
236
+ }
237
+
238
+ function fmtDue(due: string, tz: string): string {
239
+ if (isDateOnly(due)) return due;
240
+ const t = Date.parse(due);
241
+ 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));
242
+ return s;
243
+ }
244
+
245
+ export function dueReminderText(row: Pick<DueRow, "seq" | "title" | "due_on">, r: { kind: DueKind; overdueDays: number }, tz: string): { title: string; text: string } {
246
+ const card = `${row.seq ? `#${row.seq} ` : ""}「${row.title}」`;
247
+ const when = fmtDue(row.due_on, tz);
248
+ if (r.kind === "due_soon") return { title: "任务即将到期", text: `任务${card}即将到期(预计完成 ${when})。` };
249
+ if (r.kind === "due_today") return { title: "任务今天到期", text: `任务${card}今天到期(预计完成 ${when})。` };
250
+ const late = r.overdueDays >= 1 ? `已逾期 ${r.overdueDays} 天` : "已过预计完成时间";
251
+ return { title: "任务已逾期", text: `任务${card}${late}(预计完成 ${when}),还没有完成。` };
252
+ }
253
+
254
+ export type DueReminderSent = { requirement_id: string; kind: DueKind; users: string[]; node: string | null };
255
+
256
+ /**
257
+ * 跑一次。now 可注入(测试用假时钟)。返回这次真正发出的提醒。
258
+ * 一条按 due_on 范围走索引的查询取候选;每个候选先抢去重行,抢到才发。
259
+ */
260
+ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[] {
261
+ const nowMs = opts.now ?? Date.now();
262
+ const tz = dueReminderTimezone();
263
+ const maxOverdue = overdueMaxDays();
264
+ const today = localDate(nowMs, tz);
265
+ // 范围:最老 = 今天 - maxOverdue - 1(UTC 日期 ≤ 本地日期,多留一天);最新 < 今天 + 2(24 小时内的时刻型 UTC 日期 ≤ 明天)。
266
+ const lo = addDays(today, -maxOverdue - 1);
267
+ const hi = addDays(today, 2);
268
+ const scope = dueReminderScope();
269
+ // 范围为空(关闭 / 白名单全被排除)→ 什么都不读、不写基线。
270
+ if (dueScopeSql(scope, []) === null) return [];
271
+ const sentAt = new Date(nowMs).toISOString();
272
+ // 基线:范围内每个网络第一次被扫到的那一刻(已有就不动)。一条 INSERT … SELECT。
273
+ {
274
+ const bp: unknown[] = [BASELINE_KIND, sentAt];
275
+ // WHERE 必须有:SQLite 里「INSERT … SELECT … FROM t ON CONFLICT」会把 ON 读成 JOIN 约束(语法错),要一个 WHERE 隔开。
276
+ const where = ` WHERE 1=1${dueScopeSql(scope, bp) ?? ""}`;
277
+ db.run(
278
+ `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
279
+ SELECT '__baseline__:' || network_id, ?1, '', '', network_id, ?2 FROM networks${where}
280
+ ON CONFLICT DO NOTHING`,
281
+ bp,
282
+ );
283
+ }
284
+ const baselines = new Map(
285
+ db.all<{ network_id: string; sent_at: string }>("SELECT network_id, sent_at FROM requirement_due_reminders WHERE kind = ?1", BASELINE_KIND)
286
+ .map((b) => [b.network_id, parseHubTimestamp(b.sent_at)] as const)
287
+ .filter((e): e is readonly [string, number] => e[1] !== null),
288
+ );
289
+ const params: unknown[] = [lo, hi];
290
+ const netFilter = dueScopeSql(scope, params) ?? "";
291
+ const rows = db.all<DueRow>(
292
+ `SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
293
+ FROM requirements
294
+ WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name <> 'done' AND COALESCE(archived, 0) = 0${netFilter}`,
295
+ ...params,
296
+ );
297
+ const out: DueReminderSent[] = [];
298
+ for (const row of rows) {
299
+ const r = dueReminderFor(row.due_on, nowMs, tz, maxOverdue);
300
+ if (!r) continue;
301
+ if (r.kind === "overdue") {
302
+ // 基线之前就已经逾期 → 不发(开通时不补发)。没有基线(网络行不存在)= 当作此刻开通。
303
+ const base = baselines.get(row.network_id) ?? nowMs;
304
+ if (overdueSinceMs(row.due_on, tz) < base) continue;
305
+ }
306
+ try {
307
+ const claimed = db.run(
308
+ `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
309
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT DO NOTHING`,
310
+ [row.requirement_id, r.kind, row.due_on, r.day, row.network_id, sentAt],
311
+ );
312
+ if (claimed.changes < 1) continue;
313
+ out.push(deliver(row, r, tz));
314
+ } catch (e: any) {
315
+ console.error(`[due-reminders] ${row.requirement_id} ${r.kind} failed: ${e?.message || e}`);
316
+ }
317
+ }
318
+ // 去重行保留 RETENTION_DAYS 天(比逾期上限长得多,删掉的行不会再被需要)。
319
+ try { db.run("DELETE FROM requirement_due_reminders WHERE sent_at < ?1 AND kind <> ?2", [new Date(nowMs - RETENTION_DAYS * DAY_MS).toISOString(), BASELINE_KIND]); } catch {}
320
+ return out;
321
+ }
322
+
323
+ function deliver(row: DueRow, r: { kind: DueKind; overdueDays: number; dueDay: string }, tz: string): DueReminderSent {
324
+ const { title, text } = dueReminderText(row, r, tz);
325
+ const agent = parse(row.agent_owner_json) as { kind?: unknown; id?: unknown } | null;
326
+ const nodeId = agent?.kind === "node" && typeof agent.id === "string" ? agent.id : null;
327
+ const node = nodeId
328
+ ? db.get<{ alias: string | null }>("SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", nodeId, row.network_id)
329
+ : null;
330
+ const alias = node?.alias || null;
331
+ 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 } };
332
+ const users: string[] = [];
333
+ for (const userId of humanRecipients(row)) {
334
+ const fromAlias = alias && canSeeAgent(userId, row.network_id, { alias, nodeId }) ? alias : DUE_REMINDER_SENDER;
335
+ const id = sendAgentNotice({
336
+ networkId: row.network_id, userId, fromAlias, kind: DUE_REMINDER_KIND, title, text,
337
+ severity: r.kind === "overdue" ? "warning" : "info", meta, idPrefix: "dm_due_",
338
+ });
339
+ if (id) users.push(userId);
340
+ }
341
+ let notifiedNode: string | null = null;
342
+ if (alias && nodeRemindersEnabled() && assertNodeActive(alias, row.network_id).ok) {
343
+ const id = uuidv4();
344
+ const message = `[${title}] ${text}\n(这是提醒,不需要回复。requirement_id=${row.requirement_id})`;
345
+ db.run(
346
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
347
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, ?5, ?6)`,
348
+ [id, alias, nodeId, message, DUE_REMINDER_SENDER, row.network_id],
349
+ );
350
+ pushEvent(alias, { type: "new_message", inbox_count: pendingInboxCount(alias, row.network_id), from: DUE_REMINDER_SENDER, message_id: id }, row.network_id);
351
+ notifiedNode = alias;
352
+ }
353
+ return { requirement_id: row.requirement_id, kind: r.kind as DueKind, users, node: notifiedNode };
354
+ }
355
+
356
+ /**
357
+ * startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉(除非设了 COMMHUB_DUE_REMINDERS_NETWORKS,见 dueReminderScope);
358
+ * bootServer(测试)不调用它。第一次扫描延后一个间隔外的 30 秒,不和启动抢。启动时打一行生效范围(只有网络 id)。
359
+ */
360
+ export function startDueReminderTimer(): ReturnType<typeof setInterval> | null {
361
+ const scope = dueReminderScope();
362
+ console.log(`[due-reminders] scope: ${describeDueReminderScope(scope)}`);
363
+ if (scope.mode === "off") return null;
364
+ const ms = envNumber("COMMHUB_DUE_REMINDER_TICK_MS", 10 * 60_000, 1_000);
365
+ const tick = () => { try { runDueReminders(); } catch (e: any) { console.error(`[due-reminders] tick failed: ${e?.message || e}`); } };
366
+ const first = setTimeout(tick, Math.min(ms, 30_000));
367
+ (first as any)?.unref?.();
368
+ return setInterval(tick, ms);
369
+ }
@@ -0,0 +1,257 @@
1
+ // 任务动态(#429)—— HTTP 集成测试(真实 Bun.serve,临时库;PG 梯子上用 COMMHUB_TEST_PG_URL 原样再跑一遍)。
2
+ // 写路径 —— 新建 / PATCH 的每个字段 / 勾子任务 / 标签改名 / 删项目 / 删卡,各记字段级流水:谁、旧值 → 新值
3
+ // 同一事务 —— 流水写不进去,那次改动也不生效
4
+ // 可见范围 —— 与列表相同:scoped 成员只看到看得见的卡(删掉的按墓碑);Agent 受限成员看不见的节点被隐去
5
+ // 翻页 —— limit / next_cursor / since / requirement_id;参数不合法 400;capability `events`
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const DIR = mkdtempSync(join(tmpdir(), "anet-requirement-events-"));
12
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
13
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
14
+
15
+ const PW = "EventsPassw0rd!x";
16
+ let BASE = "";
17
+ let hub: any = null;
18
+ let NET = "";
19
+ let admin = { token: "", id: "" };
20
+ let member = { token: "", id: "" };
21
+ let scoped = { token: "", id: "" };
22
+ let restricted = { token: "", id: "" };
23
+ let nodeToken = "";
24
+ let NODE = "";
25
+
26
+ type R = { status: number; body: any };
27
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
28
+ const res = await fetch(`${BASE}${path}`, {
29
+ method,
30
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
31
+ body: payload === undefined ? undefined : JSON.stringify(payload),
32
+ });
33
+ const text = await res.text();
34
+ let body: any = null;
35
+ try { body = JSON.parse(text); } catch {}
36
+ return { status: res.status, body };
37
+ }
38
+ const create = async (t: string, card: Record<string, unknown>) => {
39
+ const r = await send(t, "POST", "/api/requirements", { network_id: NET, ...card });
40
+ expect(r.status).toBe(201);
41
+ return r.body.requirement as { id: string; seq: number };
42
+ };
43
+ const patch = (t: string, id: string, p: Record<string, unknown>) => send(t, "PATCH", `/api/requirements/${id}?network_id=${NET}`, p);
44
+ const events = (t: string, qs = "") => send(t, "GET", `/api/requirements/events?network_id=${NET}${qs}`);
45
+ const of = (list: any[], id: string) => list.filter(e => e.requirement_id === id);
46
+ const user = (id: string) => ({ kind: "user", id });
47
+
48
+ beforeAll(async () => {
49
+ process.env.HOST = "127.0.0.1";
50
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
51
+ const { db } = await import("./db.js");
52
+ const a = register(`ev_admin_${Date.now()}`, PW, undefined, "Admin");
53
+ expect(a.ok).toBe(true);
54
+ admin = { token: a.token!, id: a.user!.user_id };
55
+ NET = a.network_id!;
56
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
57
+ const mod: any = await import("./server.js");
58
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
59
+ BASE = `http://127.0.0.1:${hub.port}`;
60
+ const mk = async (username: string) => {
61
+ const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
62
+ expect(r.status).toBe(200);
63
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
64
+ expect(login.status).toBe(200);
65
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
66
+ };
67
+ const stamp = Date.now();
68
+ member = await mk(`ev_member_${stamp}`);
69
+ scoped = await mk(`ev_scoped_${stamp}`);
70
+ restricted = await mk(`ev_restricted_${stamp}`);
71
+ db.run("UPDATE network_members SET task_access = 'all', agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
72
+ db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, scoped.id]);
73
+ // 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
74
+ db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
75
+ NODE = `n_ev_${stamp}`;
76
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [NODE, "ev-node", "ev-node", NET]);
77
+ const ntok = createNetworkTokenForNode(admin.id, NET, "ev-node");
78
+ expect(ntok.ok).toBe(true);
79
+ nodeToken = ntok.token!;
80
+ }, 30_000);
81
+
82
+ afterAll(() => {
83
+ try { hub?.stop?.(true); } catch {}
84
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
85
+ });
86
+
87
+ describe("requirement events", () => {
88
+ test("capability `events` is advertised", async () => {
89
+ const r = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
90
+ expect(r.body.capabilities).toContain("events");
91
+ });
92
+
93
+ test("create records one `created` event with the actor, title and seq", async () => {
94
+ const card = await create(member.token, { name: "动态示例一" });
95
+ const r = await events(admin.token, `&requirement_id=${card.id}`);
96
+ expect(r.status).toBe(200);
97
+ expect(r.body.events.length).toBe(1);
98
+ const [e] = r.body.events;
99
+ expect(e).toMatchObject({ kind: "created", field: null, actor: user(member.id), title: "动态示例一", seq: card.seq, new: { title: "动态示例一", column: "pool" } });
100
+ expect(typeof e.id).toBe("string");
101
+ expect(Date.parse(e.at)).toBeGreaterThan(0);
102
+ });
103
+
104
+ test("a PATCH records one event per changed field, old → new; unchanged fields and no-op PATCHes record nothing", async () => {
105
+ const card = await create(admin.token, { name: "动态示例二", priority: "normal", tags: ["UI"] });
106
+ const r1 = await patch(member.token, card.id, {
107
+ column: "doing", priority: "high", due: "2026-10-03", name: "动态示例二(改)", tags: ["UI", "后端"],
108
+ agent_owner: { kind: "node", id: NODE }, participants: [user(admin.id)], assignee: "",
109
+ });
110
+ expect(r1.status).toBe(200);
111
+ const list = of((await events(admin.token)).body.events, card.id);
112
+ const byField = Object.fromEntries(list.filter((e: any) => e.kind === "changed").map((e: any) => [e.field, e]));
113
+ expect(Object.keys(byField).sort()).toEqual(["agent_owner", "column", "due", "participants", "priority", "tags", "title"]);
114
+ expect(byField.column).toMatchObject({ old: "pool", new: "doing", actor: user(member.id) });
115
+ expect(byField.priority).toMatchObject({ old: "normal", new: "high" });
116
+ expect(byField.due).toMatchObject({ old: null, new: "2026-10-03" });
117
+ expect(byField.title).toMatchObject({ old: "动态示例二", new: "动态示例二(改)" });
118
+ expect(byField.tags).toMatchObject({ old: ["UI"], new: ["UI", "后端"] });
119
+ expect(byField.agent_owner).toMatchObject({ old: null, new: { kind: "node", id: NODE } });
120
+ expect(byField.participants).toMatchObject({ old: [], new: [user(admin.id)] });
121
+ // every event of that PATCH carries the card's title after the change
122
+ expect(new Set(list.filter((e: any) => e.kind === "changed").map((e: any) => e.title))).toEqual(new Set(["动态示例二(改)"]));
123
+ const before = list.length;
124
+ expect((await patch(member.token, card.id, { priority: "high", column: "doing" })).status).toBe(200);
125
+ expect(of((await events(admin.token)).body.events, card.id).length).toBe(before);
126
+ // archive / unarchive, description (length only, never the text), move to done
127
+ await patch(member.token, card.id, { description: "一段描述" });
128
+ await patch(member.token, card.id, { column: "done" });
129
+ await patch(member.token, card.id, { archived: true });
130
+ const later = of((await events(admin.token)).body.events, card.id).slice(0, 3);
131
+ expect(later.map((e: any) => e.field)).toEqual(["archived", "column", "description"]);
132
+ expect(later[0]).toMatchObject({ old: false, new: true });
133
+ expect(later[1]).toMatchObject({ old: "doing", new: "done" });
134
+ expect(later[2]).toMatchObject({ old: { chars: 0 }, new: { chars: 4 } });
135
+ expect(JSON.stringify(later[2])).not.toContain("一段描述");
136
+ });
137
+
138
+ test("checklist: ticking an item is `checklist_item` (repeat is a no-op); adding items is `checklist`", async () => {
139
+ const card = await create(admin.token, { name: "动态示例三", checklist: [{ id: "a", text: "第一项" }, { id: "b", text: "第二项" }] });
140
+ const tick = (done: boolean) => send(member.token, "PATCH", `/api/requirements/${card.id}/checklist/a?network_id=${NET}`, { done });
141
+ expect((await tick(true)).status).toBe(200);
142
+ expect((await tick(true)).status).toBe(200);
143
+ let list = of((await events(admin.token)).body.events, card.id);
144
+ const ticks = list.filter((e: any) => e.field === "checklist_item");
145
+ expect(ticks.length).toBe(1);
146
+ expect(ticks[0]).toMatchObject({ old: { id: "a", text: "第一项", done: false }, new: { id: "a", text: "第一项", done: true }, actor: user(member.id) });
147
+ // a PATCH that only flips one item's done is also `checklist_item`
148
+ await patch(member.token, card.id, { checklist: [{ id: "a", text: "第一项", done: true }, { id: "b", text: "第二项", done: true }] });
149
+ await patch(member.token, card.id, { checklist: [{ id: "a", text: "第一项", done: true }, { id: "b", text: "第二项", done: true }, { id: "c", text: "第三项" }] });
150
+ list = of((await events(admin.token)).body.events, card.id);
151
+ expect(list[0]).toMatchObject({ field: "checklist", old: { total: 2, done: 2 }, new: { total: 3, done: 2 } });
152
+ expect(list[1]).toMatchObject({ field: "checklist_item", new: { id: "b", done: true } });
153
+ });
154
+
155
+ test("the actor of a node token is the node", async () => {
156
+ const card = await create(admin.token, { name: "动态示例四" });
157
+ expect((await patch(nodeToken, card.id, { column: "doing" })).status).toBe(200);
158
+ const [e] = of((await events(nodeToken)).body.events, card.id);
159
+ expect(e).toMatchObject({ field: "column", actor: { kind: "node", id: NODE } });
160
+ });
161
+
162
+ test("tag rename, project delete and card delete each record per-card events", async () => {
163
+ const p = await send(admin.token, "POST", `/api/requirements/projects?network_id=${NET}`, { name: `动态项目-${Date.now()}` });
164
+ expect(p.status).toBe(201);
165
+ const card = await create(admin.token, { name: "动态示例五", tags: ["旧标签"], project_id: p.body.project.id });
166
+ expect((await send(admin.token, "POST", `/api/requirements/tags/ops?network_id=${NET}`, { op: "rename", from: "旧标签", to: "新标签" })).status).toBe(200);
167
+ expect((await send(admin.token, "DELETE", `/api/requirements/projects/${p.body.project.id}?network_id=${NET}`)).status).toBe(200);
168
+ expect((await send(admin.token, "DELETE", `/api/requirements/${card.id}?network_id=${NET}`)).status).toBe(200);
169
+ const list = of((await events(admin.token)).body.events, card.id);
170
+ expect(list.slice(0, 3).map((e: any) => [e.kind, e.field])).toEqual([["deleted", null], ["changed", "project"], ["changed", "tags"]]);
171
+ expect(list[0]).toMatchObject({ title: "动态示例五", seq: expect.any(Number), actor: user(admin.id) });
172
+ expect(list[1]).toMatchObject({ old: p.body.project.id, new: null });
173
+ expect(list[2]).toMatchObject({ old: ["旧标签"], new: ["新标签"] });
174
+ });
175
+
176
+ test("the write and its events are one transaction: no events table → the PATCH fails and the card is unchanged", async () => {
177
+ const { db } = await import("./db.js");
178
+ const card = await create(admin.token, { name: "动态示例六", priority: "low" });
179
+ db.exec("ALTER TABLE requirement_events RENAME TO requirement_events_away");
180
+ try {
181
+ const r = await patch(admin.token, card.id, { priority: "high" });
182
+ expect(r.status).toBeGreaterThanOrEqual(500);
183
+ } finally {
184
+ db.exec("ALTER TABLE requirement_events_away RENAME TO requirement_events");
185
+ }
186
+ const now = await send(admin.token, "GET", `/api/requirements/${card.id}?network_id=${NET}`);
187
+ expect(now.body.requirement.priority).toBe("low");
188
+ });
189
+
190
+ test("a scoped member sees events only for cards they can see — deleted ones by their tombstone", async () => {
191
+ const mine = await create(scoped.token, { name: "动态示例七(scoped 自己建的)" });
192
+ const theirs = await create(admin.token, { name: "动态示例八(scoped 看不见)" });
193
+ const joined = await create(admin.token, { name: "动态示例九(scoped 是参与人)", participants: [user(scoped.id)] });
194
+ await patch(admin.token, mine.id, { priority: "high" });
195
+ await patch(admin.token, theirs.id, { priority: "high" });
196
+ await send(admin.token, "DELETE", `/api/requirements/${joined.id}?network_id=${NET}`);
197
+ const seen = (await events(scoped.token, "&limit=500")).body.events;
198
+ expect(of(seen, mine.id).length).toBe(2);
199
+ expect(of(seen, theirs.id).length).toBe(0);
200
+ expect(of(seen, joined.id).map((e: any) => e.kind)).toEqual(["deleted", "created"]);
201
+ // a filter on a card they cannot see answers like a card with no events
202
+ expect((await events(scoped.token, `&requirement_id=${theirs.id}`)).body.events).toEqual([]);
203
+ });
204
+
205
+ test("an agent-restricted member: hidden nodes are masked, and an event that only differs by a hidden node is dropped", async () => {
206
+ const card = await create(admin.token, { name: "动态示例十" });
207
+ await patch(nodeToken, card.id, { priority: "high" });
208
+ await patch(admin.token, card.id, { agent_owner: { kind: "node", id: NODE } });
209
+ await patch(admin.token, card.id, { participants: [user(admin.id), { kind: "node", id: NODE }] });
210
+ const seen = of((await events(restricted.token)).body.events, card.id);
211
+ const all = of((await events(admin.token)).body.events, card.id);
212
+ expect(all.map((e: any) => e.field)).toEqual(["participants", "agent_owner", "priority", null]);
213
+ expect(seen.map((e: any) => e.field)).toEqual(["participants", "priority", null]);
214
+ expect(seen[0]).toMatchObject({ old: [], new: [user(admin.id)] });
215
+ expect(seen[1]).toMatchObject({ field: "priority", actor: null });
216
+ expect(JSON.stringify(seen)).not.toContain(NODE);
217
+ });
218
+
219
+ test("paging: newest first, limit + next_cursor without overlap; since; bad params 400", async () => {
220
+ const card = await create(admin.token, { name: "动态示例十一" });
221
+ for (const priority of ["high", "low", "lowest", "normal"]) await patch(admin.token, card.id, { priority });
222
+ const q = `&requirement_id=${card.id}`;
223
+ const p1 = await events(admin.token, `${q}&limit=2`);
224
+ expect(p1.body.events.length).toBe(2);
225
+ expect(p1.body.has_more).toBe(true);
226
+ expect(p1.body.events[0].new).toBe("normal");
227
+ const p2 = await events(admin.token, `${q}&limit=2&cursor=${p1.body.next_cursor}`);
228
+ const p3 = await events(admin.token, `${q}&limit=2&cursor=${p2.body.next_cursor}`);
229
+ const ids = [...p1.body.events, ...p2.body.events, ...p3.body.events].map((e: any) => e.id);
230
+ expect(new Set(ids).size).toBe(5);
231
+ expect(p3.body.has_more).toBe(false);
232
+ expect(p3.body.next_cursor).toBe(null);
233
+ expect(ids.map(Number)).toEqual([...ids.map(Number)].sort((a, b) => b - a));
234
+ // since = server_time of an earlier read → only what came after
235
+ const t = p1.body.server_time;
236
+ await patch(admin.token, card.id, { priority: "high" });
237
+ const after = await events(admin.token, `${q}&since=${encodeURIComponent(t)}`);
238
+ // since is inclusive (>=): an event written in the same millisecond as server_time comes back again,
239
+ // and clients dedupe by id. Only the events not already seen must be exactly the new one.
240
+ expect(after.body.events.every((e: any) => Date.parse(e.at) >= Date.parse(t))).toBe(true);
241
+ const seen = new Set(ids);
242
+ expect(after.body.events.filter((e: any) => !seen.has(e.id)).map((e: any) => e.new)).toEqual(["high"]);
243
+ for (const bad of ["&limit=0", "&limit=501", "&limit=x", "&since=nope", "&cursor=abc", "&cursor=0"]) {
244
+ expect((await events(admin.token, bad)).status).toBe(400);
245
+ }
246
+ });
247
+
248
+ test("migration is idempotent and leaves the rows alone", async () => {
249
+ const { db } = await import("./db.js");
250
+ const { ensureRequirementEvents } = await import("./requirement-events.js");
251
+ const n = Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_events")!.n);
252
+ ensureRequirementEvents(db);
253
+ ensureRequirementEvents(db);
254
+ expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_events")!.n)).toBe(n);
255
+ expect(n).toBeGreaterThan(0);
256
+ });
257
+ });