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

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 (267) 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 +965 -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 +390 -0
  135. package/src/requirement-due-reminders.ts +310 -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-reply-unread-http.test.ts +212 -0
  191. package/src/scheduled-run-terminal.test.ts +268 -0
  192. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  193. package/src/scheduled-stuck-http.test.ts +238 -0
  194. package/src/scheduled-stuck.ts +153 -0
  195. package/src/scheduled-tasks-http.test.ts +638 -0
  196. package/src/scheduled-tasks.ts +641 -0
  197. package/src/send-dedup-request-id.test.ts +229 -0
  198. package/src/send-reply-agent-warning.test.ts +202 -0
  199. package/src/send-reply-alias-optional.test.ts +131 -0
  200. package/src/send-reply-attachments.test.ts +458 -0
  201. package/src/send_dedup.test.ts +54 -0
  202. package/src/send_dedup.ts +52 -6
  203. package/src/serve-error.test.ts +38 -0
  204. package/src/serve-error.ts +27 -0
  205. package/src/server.ts +5038 -0
  206. package/src/shared/external-schedule-contract.ts +60 -0
  207. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  208. package/src/shared/probe-host-allowlist.ts +128 -0
  209. package/src/shared/reserved-env-drift.test.ts +43 -0
  210. package/src/shared/reserved-env.test.ts +35 -0
  211. package/src/shared/reserved-env.ts +31 -0
  212. package/src/side-thread-command-transport.test.ts +312 -0
  213. package/src/side-thread-command-transport.ts +318 -0
  214. package/src/side-thread-http-integration.test.ts +233 -0
  215. package/src/side-thread-http.ts +504 -0
  216. package/src/side-thread-production.test.ts +72 -0
  217. package/src/side-thread-production.ts +118 -0
  218. package/src/side-thread.test.ts +1067 -0
  219. package/src/side-thread.ts +2499 -0
  220. package/src/skillhub-http.test.ts +108 -0
  221. package/src/sqlite-close-checkpoint.test.ts +78 -0
  222. package/src/start-node.test.ts +109 -0
  223. package/src/status-alias-filter-http.test.ts +106 -0
  224. package/src/status-alias-resolver-http.test.ts +163 -0
  225. package/src/status-alias-resolver.ts +19 -0
  226. package/src/status-node-id-filter-http.test.ts +161 -0
  227. package/src/status-read-cache-http.test.ts +229 -0
  228. package/src/status-read-cache.ts +122 -0
  229. package/src/stop-delete-node.test.ts +1164 -0
  230. package/src/task-access-http.test.ts +379 -0
  231. package/src/task-access.ts +413 -0
  232. package/src/task-auth-origin-http.test.ts +91 -0
  233. package/src/task-auth-origin.test.ts +24 -0
  234. package/src/task-auth-origin.ts +7 -0
  235. package/src/task-consumption.test.ts +482 -0
  236. package/src/task-diagnostic-http.test.ts +149 -0
  237. package/src/task-diagnostic.test.ts +115 -0
  238. package/src/task-diagnostic.ts +118 -0
  239. package/src/task-expiry-notice-http.test.ts +287 -0
  240. package/src/task-expiry-notice.ts +313 -0
  241. package/src/task-idempotency-mcp.test.ts +142 -0
  242. package/src/task-idempotency.test.ts +60 -0
  243. package/src/task-idempotency.ts +75 -0
  244. package/src/task-lifecycle-watcher.test.ts +204 -0
  245. package/src/task-lifecycle-watcher.ts +49 -0
  246. package/src/task-network-resolution-http.test.ts +141 -0
  247. package/src/task-queue-ahead-http.test.ts +309 -0
  248. package/src/task-queue-ahead-scale.test.ts +211 -0
  249. package/src/task-queue-ahead.ts +186 -0
  250. package/src/task-stale-open-http.test.ts +290 -0
  251. package/src/task-stale-open.ts +103 -0
  252. package/src/tasks-pagination-http.test.ts +141 -0
  253. package/src/tool-audience-http.test.ts +195 -0
  254. package/src/tool-audience.ts +109 -0
  255. package/src/tools-list-json-schema.test.ts +67 -0
  256. package/src/tools.ts +4955 -300
  257. package/src/update-provider.test.ts +234 -0
  258. package/src/upload-early-reject-guard.test.ts +155 -0
  259. package/src/uploads-http.test.ts +97 -15
  260. package/src/uploads.test.ts +66 -0
  261. package/src/uploads.ts +138 -5
  262. package/src/user-inbox-by-agent-http.test.ts +161 -0
  263. package/src/user-inbox-netid-notnull.test.ts +122 -0
  264. package/src/user-inbox-read-http.test.ts +223 -0
  265. package/src/user-inbox-schema.test.ts +54 -0
  266. package/src/vault.test.ts +200 -0
  267. package/src/vault.ts +156 -0
@@ -0,0 +1,310 @@
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
+ // COMMHUB_DUE_REMINDER_NODES=1 给负责 Agent 节点发消息;默认关(人照常提醒)。
25
+ // 开通时不补发:每个网络第一次被扫描到时记一行基线(kind='baseline',sent_at = 那一刻)。只有在基线**之后**才变成逾期的卡
26
+ // 会收到「已逾期」提醒;基线之前就已经逾期的卡永远不发逾期提醒(打开功能 / 把网络加进白名单都不会刷一批旧卡)。
27
+ // 选这个而不是「把旧卡逐张记成已发」:不用每卡每天写行,也不怕某张旧卡第一次没被扫到、第二天又冒出来。
28
+ // 即将到期 / 今天到期不受基线影响(它们本来就只在临近时发一次)。
29
+
30
+ import type { DbAdapter } from "./db-adapter.js";
31
+ import { db, uuidv4 } from "./db.js";
32
+ import { sendAgentNotice } from "./agent-notice.js";
33
+ import { canSeeAgent } from "./agent-access.js";
34
+ import { assertNodeActive } from "./lifecycle-guard.js";
35
+ import { pushEvent } from "./push.js";
36
+ import { pendingInboxCount } from "./inbox-count.js";
37
+ import { parseHubTimestamp } from "./hub-timestamp.js";
38
+
39
+ export const DUE_REMINDER_KIND = "task_due";
40
+ export const DUE_REMINDER_SENDER = "任务提醒";
41
+ export type DueKind = "due_soon" | "due_today" | "overdue";
42
+
43
+ const DAY_MS = 86_400_000;
44
+ const RETENTION_DAYS = 60;
45
+
46
+ /** IF NOT EXISTS:重复执行无副作用;SQLite / PostgreSQL 同一段。 */
47
+ export function ensureDueReminders(database: DbAdapter): void {
48
+ database.exec(`
49
+ CREATE TABLE IF NOT EXISTS requirement_due_reminders (
50
+ requirement_id TEXT NOT NULL,
51
+ kind TEXT NOT NULL,
52
+ due_on TEXT NOT NULL,
53
+ day TEXT NOT NULL,
54
+ network_id TEXT NOT NULL,
55
+ sent_at TEXT NOT NULL,
56
+ PRIMARY KEY (requirement_id, kind, due_on, day)
57
+ );
58
+ `);
59
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_due_reminders_sent ON requirement_due_reminders(sent_at)");
60
+ // 扫描查询按 due_on 取一段范围:只给有 due 的行建索引。
61
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_due_on ON requirements(due_on) WHERE due_on IS NOT NULL");
62
+ }
63
+
64
+ function envNumber(name: string, fallback: number, min: number): number {
65
+ const raw = process.env[name];
66
+ if (raw === undefined || raw.trim() === "") return fallback;
67
+ const n = Number(raw);
68
+ return Number.isFinite(n) && n >= min ? n : fallback;
69
+ }
70
+
71
+ export function dueReminderTimezone(): string {
72
+ const tz = process.env.COMMHUB_DUE_REMINDER_TZ?.trim() || "Asia/Shanghai";
73
+ try { new Intl.DateTimeFormat("en-US", { timeZone: tz }); return tz; } catch { return "Asia/Shanghai"; }
74
+ }
75
+ /** COMMHUB_DUE_REMINDER_NETWORKS:逗号分隔的网络 id;没设(或只有空白)→ null = 所有网络。 */
76
+ export function reminderNetworks(): string[] | null {
77
+ const raw = process.env.COMMHUB_DUE_REMINDER_NETWORKS;
78
+ if (raw === undefined) return null;
79
+ const ids = raw.split(",").map((x) => x.trim()).filter(Boolean);
80
+ return ids.length ? ids : null;
81
+ }
82
+ export function nodeRemindersEnabled(): boolean { return process.env.COMMHUB_DUE_REMINDER_NODES === "1"; }
83
+ export const BASELINE_KIND = "baseline";
84
+
85
+ export function overdueMaxDays(): number { return Math.floor(envNumber("COMMHUB_DUE_OVERDUE_MAX_DAYS", 7, 0)); }
86
+
87
+ // ── 时区小工具 ─────────────────────────────────────────────
88
+ /** 某一时刻在 tz 的日期「YYYY-MM-DD」。 */
89
+ export function localDate(ms: number, tz: string): string {
90
+ return new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(ms));
91
+ }
92
+ /** 日期 + n 天(纯日历运算,与时区无关)。 */
93
+ export function addDays(date: string, n: number): string {
94
+ const [y, m, d] = date.split("-").map(Number);
95
+ return new Date(Date.UTC(y, m - 1, d) + n * DAY_MS).toISOString().slice(0, 10);
96
+ }
97
+ /** 两个日期相差几天(a - b)。 */
98
+ export function dayDiff(a: string, b: string): number {
99
+ const ms = (s: string) => { const [y, m, d] = s.split("-").map(Number); return Date.UTC(y, m - 1, d); };
100
+ return Math.round((ms(a) - ms(b)) / DAY_MS);
101
+ }
102
+ function tzOffsetMs(ms: number, tz: string): number {
103
+ 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" })
104
+ .formatToParts(new Date(ms)).filter((x) => x.type !== "literal").map((x) => [x.type, Number(x.value)]));
105
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000;
106
+ }
107
+ /** tz 里某天 00:00 对应的 UTC 时刻(ms)。 */
108
+ export function localMidnightMs(date: string, tz: string): number {
109
+ const [y, m, d] = date.split("-").map(Number);
110
+ const guess = Date.UTC(y, m - 1, d);
111
+ let t = guess - tzOffsetMs(guess, tz);
112
+ t = guess - tzOffsetMs(t, tz);
113
+ return t;
114
+ }
115
+
116
+ const isDateOnly = (due: string) => due.length === 10;
117
+
118
+ /** 从哪一刻起算逾期:日期型 = tz 里第二天零点;时刻型 = 那个时刻。 */
119
+ export function overdueSinceMs(due: string, tz: string): number {
120
+ return isDateOnly(due) ? localMidnightMs(addDays(due, 1), tz) : Date.parse(due);
121
+ }
122
+
123
+ /** 这张卡此刻该发哪一档(没有 → null)。day = 去重用的那一天。 */
124
+ export function dueReminderFor(due: string, nowMs: number, tz: string, maxOverdue = overdueMaxDays()): { kind: DueKind; day: string; overdueDays: number; dueDay: string } | null {
125
+ const today = localDate(nowMs, tz);
126
+ if (isDateOnly(due)) {
127
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(due)) return null;
128
+ if (due === today) return { kind: "due_today", day: due, overdueDays: 0, dueDay: due };
129
+ if (due === addDays(today, 1)) return { kind: "due_soon", day: due, overdueDays: 0, dueDay: due };
130
+ if (due < today) {
131
+ const n = dayDiff(today, due);
132
+ return n <= maxOverdue ? { kind: "overdue", day: today, overdueDays: n, dueDay: due } : null;
133
+ }
134
+ return null;
135
+ }
136
+ const t = Date.parse(due);
137
+ if (!Number.isFinite(t)) return null;
138
+ const dueDay = localDate(t, tz);
139
+ if (t <= nowMs) {
140
+ const n = dayDiff(today, dueDay);
141
+ return n <= maxOverdue ? { kind: "overdue", day: today, overdueDays: n, dueDay } : null;
142
+ }
143
+ if (dueDay === today) return { kind: "due_today", day: dueDay, overdueDays: 0, dueDay };
144
+ if (t - nowMs <= DAY_MS) return { kind: "due_soon", day: dueDay, overdueDays: 0, dueDay };
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * 列表筛选(requirements.ts listFilters)用的条件:overdue / due_within_days。只看没完成的卡。
150
+ * 日期型(长度 10)按 tz 的「今天」比日期;时刻型(UTC 字符串)按此刻比时刻。字符串比较:两种形状各自同长、同格式。
151
+ */
152
+ export function overdueSql(params: unknown[], nowMs: number, tz: string): string {
153
+ const today = localDate(nowMs, tz);
154
+ const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
155
+ 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)})))`;
156
+ }
157
+ /** 没逾期、且在 tz 的「今天 + days」那天结束之前到期(0 = 今天内)。 */
158
+ export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz: string): string {
159
+ const today = localDate(nowMs, tz);
160
+ const lastDay = addDays(today, days);
161
+ const endIso = new Date(localMidnightMs(addDays(lastDay, 1), tz)).toISOString().replace(/\.\d{3}Z$/, "Z");
162
+ const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
163
+ 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)})))`;
164
+ }
165
+
166
+ // ── 扫描 ───────────────────────────────────────────────────
167
+ type DueRow = {
168
+ requirement_id: string; network_id: string; seq: number | null; title: string; due_on: string;
169
+ owner_json: string | null; participants_json: string | null; agent_owner_json: string | null;
170
+ };
171
+ const parse = (raw: string | null) => { try { return raw ? JSON.parse(raw) : null; } catch { return null; } };
172
+ const userIdOf = (ref: unknown): string | null => {
173
+ const r = ref as { kind?: unknown; id?: unknown } | null;
174
+ return r && r.kind === "user" && typeof r.id === "string" ? r.id : null;
175
+ };
176
+
177
+ export function humanRecipients(row: Pick<DueRow, "owner_json" | "participants_json">): string[] {
178
+ const ids = new Set<string>();
179
+ const owner = userIdOf(parse(row.owner_json));
180
+ if (owner) ids.add(owner);
181
+ const participants = parse(row.participants_json);
182
+ if (Array.isArray(participants)) for (const p of participants) { const id = userIdOf(p); if (id) ids.add(id); }
183
+ return [...ids];
184
+ }
185
+
186
+ function fmtDue(due: string, tz: string): string {
187
+ if (isDateOnly(due)) return due;
188
+ const t = Date.parse(due);
189
+ 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));
190
+ return s;
191
+ }
192
+
193
+ export function dueReminderText(row: Pick<DueRow, "seq" | "title" | "due_on">, r: { kind: DueKind; overdueDays: number }, tz: string): { title: string; text: string } {
194
+ const card = `${row.seq ? `#${row.seq} ` : ""}「${row.title}」`;
195
+ const when = fmtDue(row.due_on, tz);
196
+ if (r.kind === "due_soon") return { title: "任务即将到期", text: `任务${card}即将到期(预计完成 ${when})。` };
197
+ if (r.kind === "due_today") return { title: "任务今天到期", text: `任务${card}今天到期(预计完成 ${when})。` };
198
+ const late = r.overdueDays >= 1 ? `已逾期 ${r.overdueDays} 天` : "已过预计完成时间";
199
+ return { title: "任务已逾期", text: `任务${card}${late}(预计完成 ${when}),还没有完成。` };
200
+ }
201
+
202
+ export type DueReminderSent = { requirement_id: string; kind: DueKind; users: string[]; node: string | null };
203
+
204
+ /**
205
+ * 跑一次。now 可注入(测试用假时钟)。返回这次真正发出的提醒。
206
+ * 一条按 due_on 范围走索引的查询取候选;每个候选先抢去重行,抢到才发。
207
+ */
208
+ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[] {
209
+ const nowMs = opts.now ?? Date.now();
210
+ const tz = dueReminderTimezone();
211
+ const maxOverdue = overdueMaxDays();
212
+ const today = localDate(nowMs, tz);
213
+ // 范围:最老 = 今天 - maxOverdue - 1(UTC 日期 ≤ 本地日期,多留一天);最新 < 今天 + 2(24 小时内的时刻型 UTC 日期 ≤ 明天)。
214
+ const lo = addDays(today, -maxOverdue - 1);
215
+ const hi = addDays(today, 2);
216
+ const allow = reminderNetworks();
217
+ const sentAt = new Date(nowMs).toISOString();
218
+ // 基线:范围内每个网络第一次被扫到的那一刻(已有就不动)。一条 INSERT … SELECT。
219
+ {
220
+ const bp: unknown[] = [BASELINE_KIND, sentAt];
221
+ // WHERE 必须有:SQLite 里「INSERT … SELECT … FROM t ON CONFLICT」会把 ON 读成 JOIN 约束(语法错),要一个 WHERE 隔开。
222
+ const where = ` WHERE 1=1${allow ? ` AND network_id IN (${allow.map((id) => `?${bp.push(id)}`).join(", ")})` : ""}`;
223
+ db.run(
224
+ `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
225
+ SELECT '__baseline__:' || network_id, ?1, '', '', network_id, ?2 FROM networks${where}
226
+ ON CONFLICT DO NOTHING`,
227
+ bp,
228
+ );
229
+ }
230
+ const baselines = new Map(
231
+ db.all<{ network_id: string; sent_at: string }>("SELECT network_id, sent_at FROM requirement_due_reminders WHERE kind = ?1", BASELINE_KIND)
232
+ .map((b) => [b.network_id, parseHubTimestamp(b.sent_at)] as const)
233
+ .filter((e): e is readonly [string, number] => e[1] !== null),
234
+ );
235
+ const params: unknown[] = [lo, hi];
236
+ const netFilter = allow ? ` AND network_id IN (${allow.map((id) => `?${params.push(id)}`).join(", ")})` : "";
237
+ const rows = db.all<DueRow>(
238
+ `SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
239
+ FROM requirements
240
+ WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name <> 'done' AND COALESCE(archived, 0) = 0${netFilter}`,
241
+ ...params,
242
+ );
243
+ const out: DueReminderSent[] = [];
244
+ for (const row of rows) {
245
+ const r = dueReminderFor(row.due_on, nowMs, tz, maxOverdue);
246
+ if (!r) continue;
247
+ if (r.kind === "overdue") {
248
+ // 基线之前就已经逾期 → 不发(开通时不补发)。没有基线(网络行不存在)= 当作此刻开通。
249
+ const base = baselines.get(row.network_id) ?? nowMs;
250
+ if (overdueSinceMs(row.due_on, tz) < base) continue;
251
+ }
252
+ try {
253
+ const claimed = db.run(
254
+ `INSERT INTO requirement_due_reminders (requirement_id, kind, due_on, day, network_id, sent_at)
255
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT DO NOTHING`,
256
+ [row.requirement_id, r.kind, row.due_on, r.day, row.network_id, sentAt],
257
+ );
258
+ if (claimed.changes < 1) continue;
259
+ out.push(deliver(row, r, tz));
260
+ } catch (e: any) {
261
+ console.error(`[due-reminders] ${row.requirement_id} ${r.kind} failed: ${e?.message || e}`);
262
+ }
263
+ }
264
+ // 去重行保留 RETENTION_DAYS 天(比逾期上限长得多,删掉的行不会再被需要)。
265
+ 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 {}
266
+ return out;
267
+ }
268
+
269
+ function deliver(row: DueRow, r: { kind: DueKind; overdueDays: number; dueDay: string }, tz: string): DueReminderSent {
270
+ const { title, text } = dueReminderText(row, r, tz);
271
+ const agent = parse(row.agent_owner_json) as { kind?: unknown; id?: unknown } | null;
272
+ const nodeId = agent?.kind === "node" && typeof agent.id === "string" ? agent.id : null;
273
+ const node = nodeId
274
+ ? db.get<{ alias: string | null }>("SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", nodeId, row.network_id)
275
+ : null;
276
+ const alias = node?.alias || null;
277
+ 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 } };
278
+ const users: string[] = [];
279
+ for (const userId of humanRecipients(row)) {
280
+ const fromAlias = alias && canSeeAgent(userId, row.network_id, { alias, nodeId }) ? alias : DUE_REMINDER_SENDER;
281
+ const id = sendAgentNotice({
282
+ networkId: row.network_id, userId, fromAlias, kind: DUE_REMINDER_KIND, title, text,
283
+ severity: r.kind === "overdue" ? "warning" : "info", meta, idPrefix: "dm_due_",
284
+ });
285
+ if (id) users.push(userId);
286
+ }
287
+ let notifiedNode: string | null = null;
288
+ if (alias && nodeRemindersEnabled() && assertNodeActive(alias, row.network_id).ok) {
289
+ const id = uuidv4();
290
+ const message = `[${title}] ${text}\n(这是提醒,不需要回复。requirement_id=${row.requirement_id})`;
291
+ db.run(
292
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
293
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, ?5, ?6)`,
294
+ [id, alias, nodeId, message, DUE_REMINDER_SENDER, row.network_id],
295
+ );
296
+ pushEvent(alias, { type: "new_message", inbox_count: pendingInboxCount(alias, row.network_id), from: DUE_REMINDER_SENDER, message_id: id }, row.network_id);
297
+ notifiedNode = alias;
298
+ }
299
+ return { requirement_id: row.requirement_id, kind: r.kind as DueKind, users, node: notifiedNode };
300
+ }
301
+
302
+ /** startHub 里调用。COMMHUB_DUE_REMINDERS=0 关掉;bootServer(测试)不调用它。第一次扫描延后一个间隔外的 30 秒,不和启动抢。 */
303
+ export function startDueReminderTimer(): ReturnType<typeof setInterval> | null {
304
+ if (process.env.COMMHUB_DUE_REMINDERS === "0") return null;
305
+ const ms = envNumber("COMMHUB_DUE_REMINDER_TICK_MS", 10 * 60_000, 1_000);
306
+ const tick = () => { try { runDueReminders(); } catch (e: any) { console.error(`[due-reminders] tick failed: ${e?.message || e}`); } };
307
+ const first = setTimeout(tick, Math.min(ms, 30_000));
308
+ (first as any)?.unref?.();
309
+ return setInterval(tick, ms);
310
+ }
@@ -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
+ });
@@ -0,0 +1,45 @@
1
+ // diffRequirement 的边角(HTTP 层的写路径在 requirement-events-http.test.ts)。
2
+ import { describe, expect, test } from "bun:test";
3
+ import { diffRequirement, eventPublic, type EventCard } from "./requirement-events.js";
4
+
5
+ const base: EventCard = {
6
+ requirement_id: "req_1", network_id: "net_1", seq: 7, title: "示例", column_name: "pool", priority: "normal", due_on: null, start_on: null,
7
+ assignee: null, owner_json: null, agent_owner_json: null, participants_json: "[]", tags_json: "[]", checklist_json: "[]", description: null,
8
+ project_id: null, parent_id: null, archived: 0,
9
+ };
10
+ const ck = (items: { id: string; text: string; done?: boolean }[]) => JSON.stringify(items.map(i => ({ done: false, ...i })));
11
+
12
+ describe("diffRequirement", () => {
13
+ test("no change → no events; null vs empty string are the same", () => {
14
+ expect(diffRequirement(base, { ...base })).toEqual([]);
15
+ expect(diffRequirement(base, { ...base, due_on: "", assignee: "", description: "" })).toEqual([]);
16
+ });
17
+ test("created carries title and column only", () => {
18
+ expect(diffRequirement(null, base)).toEqual([{ kind: "created", field: null, old: null, new: { title: "示例", column: "pool" } }]);
19
+ });
20
+ test("checklist: reorder, rename, add or remove is one `checklist` event with counts", () => {
21
+ const a = { ...base, checklist_json: ck([{ id: "a", text: "一" }, { id: "b", text: "二", done: true }]) };
22
+ for (const next of [ck([{ id: "b", text: "二", done: true }, { id: "a", text: "一" }]), ck([{ id: "a", text: "一改" }, { id: "b", text: "二", done: true }]), ck([{ id: "a", text: "一" }])]) {
23
+ const out = diffRequirement(a, { ...a, checklist_json: next });
24
+ expect(out.length).toBe(1);
25
+ expect(out[0].field).toBe("checklist");
26
+ }
27
+ expect(diffRequirement(a, { ...a, checklist_json: ck([{ id: "a", text: "一" }]) })[0]).toMatchObject({ old: { total: 2, done: 1 }, new: { total: 1, done: 0 } });
28
+ });
29
+ test("checklist: two items ticked in one write are two `checklist_item` events", () => {
30
+ const a = { ...base, checklist_json: ck([{ id: "a", text: "一" }, { id: "b", text: "二" }]) };
31
+ const out = diffRequirement(a, { ...a, checklist_json: ck([{ id: "a", text: "一", done: true }, { id: "b", text: "二", done: true }]) });
32
+ expect(out.map(e => [e.field, (e.new as { id: string }).id])).toEqual([["checklist_item", "a"], ["checklist_item", "b"]]);
33
+ });
34
+ test("malformed stored JSON reads as empty rather than throwing", () => {
35
+ expect(() => diffRequirement({ ...base, tags_json: "{nope", participants_json: "x" }, base)).not.toThrow();
36
+ });
37
+ test("description records lengths, never text", () => {
38
+ const [e] = diffRequirement(base, { ...base, description: "secret text" });
39
+ expect(e).toEqual({ kind: "changed", field: "description", old: { chars: 0 }, new: { chars: 11 } });
40
+ });
41
+ test("eventPublic: bigint / string ids from PostgreSQL come out as strings, seq as a number", () => {
42
+ const row = { id: 12n as unknown as string, network_id: "n", requirement_id: "r", seq: "3", title: "t", actor_json: null, kind: "changed", field: "priority", old_json: "\"low\"", new_json: "\"high\"", created_at: "2026-10-01T00:00:00.000Z" };
43
+ expect(eventPublic(row)).toMatchObject({ id: "12", seq: 3, old: "low", new: "high", actor: null });
44
+ });
45
+ });