@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80

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 (202) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +5 -5
  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 +478 -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/alias-filter.test.ts +72 -0
  15. package/src/alias-filter.ts +32 -0
  16. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  17. package/src/api-host-supervisors-fallback.test.ts +271 -0
  18. package/src/api-nodes-shape.test.ts +26 -10
  19. package/src/auth-sessions-http.test.ts +250 -0
  20. package/src/auth-validate.test.ts +5 -1
  21. package/src/auth.ts +450 -31
  22. package/src/avatar-validate.test.ts +163 -0
  23. package/src/avatar-validate.ts +103 -0
  24. package/src/blocked-exit-on-reply.test.ts +122 -0
  25. package/src/boot-side-effect.test.ts +124 -0
  26. package/src/commhub-bin-args.test.ts +85 -0
  27. package/src/config-apply-sec1.test.ts +107 -0
  28. package/src/config-apply-validate.test.ts +92 -0
  29. package/src/config-apply-validate.ts +81 -3
  30. package/src/create-node-blocked-gate.test.ts +356 -0
  31. package/src/create-node-tool-schema.test.ts +39 -0
  32. package/src/create-node-validate.test.ts +89 -1
  33. package/src/create-node-validate.ts +89 -7
  34. package/src/create-node-workdir.test.ts +255 -0
  35. package/src/create-node.ts +46 -1
  36. package/src/dashboard-slash-routing-http.test.ts +107 -0
  37. package/src/db-adapter-guard.test.ts +148 -0
  38. package/src/db-adapter-translate.test.ts +128 -0
  39. package/src/db-adapter.ts +323 -87
  40. package/src/db-timestamp.test.ts +38 -0
  41. package/src/db-timestamp.ts +15 -0
  42. package/src/db.ts +805 -33
  43. package/src/delivered-without-push.test.ts +140 -0
  44. package/src/desktop-message-seam.test.ts +150 -0
  45. package/src/desktop-message.test.ts +368 -0
  46. package/src/dm-file-scope-http.test.ts +178 -0
  47. package/src/ext-token-shared.test.ts +60 -0
  48. package/src/ext-validation-regression.test.ts +131 -0
  49. package/src/external-schedule-edits-http.test.ts +321 -0
  50. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  51. package/src/external-schedule-edits.ts +327 -0
  52. package/src/file-download-authz-dev-open.test.ts +135 -0
  53. package/src/file-download-authz.test.ts +404 -0
  54. package/src/file-download-date-bucket.test.ts +436 -0
  55. package/src/file-network-scope-dev-open.test.ts +97 -0
  56. package/src/file-network-scope.test.ts +732 -0
  57. package/src/health-redaction.test.ts +642 -0
  58. package/src/http-gzip.test.ts +94 -0
  59. package/src/http-gzip.ts +110 -0
  60. package/src/hub-timestamp.test.ts +38 -0
  61. package/src/hub-timestamp.ts +27 -0
  62. package/src/human-dm.ts +215 -0
  63. package/src/inbox-count-sse.test.ts +310 -0
  64. package/src/inbox-count.ts +29 -0
  65. package/src/index.ts +15 -2331
  66. package/src/lifecycle-guard.ts +50 -0
  67. package/src/mcp-route-label-http.test.ts +120 -0
  68. package/src/mcp-write-network-resolution.test.ts +429 -0
  69. package/src/member-audit-network-id-http.test.ts +93 -0
  70. package/src/member-presence-http.test.ts +206 -0
  71. package/src/messages-ack-by-agent-http.test.ts +142 -0
  72. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  73. package/src/migrate-sqlite-to-pg.ts +263 -0
  74. package/src/network-name-http.test.ts +75 -0
  75. package/src/network-scope.ts +224 -0
  76. package/src/node-attrs-validate.ts +88 -0
  77. package/src/node-attrs.test.ts +217 -0
  78. package/src/node-create-request-status-http.test.ts +92 -0
  79. package/src/node-files-transport.test.ts +344 -0
  80. package/src/node-id-alias.test.ts +188 -0
  81. package/src/node-lifecycle-controllable.test.ts +64 -0
  82. package/src/node-lifecycle-controllable.ts +51 -0
  83. package/src/node-logs-transport.test.ts +263 -0
  84. package/src/node-request-retention.test.ts +281 -0
  85. package/src/node-request-retention.ts +120 -0
  86. package/src/node-skills-transport.test.ts +209 -0
  87. package/src/node-token-privilege-http.test.ts +149 -0
  88. package/src/observer-avatar-http.test.ts +429 -0
  89. package/src/observer-push.test.ts +252 -0
  90. package/src/package-version-consistency.test.ts +25 -0
  91. package/src/peer-reply-atomic.test.ts +455 -0
  92. package/src/peer-reply-capability.test.ts +70 -0
  93. package/src/pg-worker.ts +87 -0
  94. package/src/push.ts +284 -7
  95. package/src/redact-tokens.test.ts +65 -0
  96. package/src/redact-tokens.ts +40 -0
  97. package/src/rename.ts +7 -2
  98. package/src/reply-target-derivation.test.ts +96 -0
  99. package/src/report-status-host-schema.test.ts +211 -0
  100. package/src/report-status-resume-id-handover.test.ts +150 -0
  101. package/src/require-explicit-test-db.ts +9 -0
  102. package/src/requirement-events-http.test.ts +257 -0
  103. package/src/requirement-events.test.ts +45 -0
  104. package/src/requirement-events.ts +143 -0
  105. package/src/requirement-notify.ts +134 -0
  106. package/src/requirement-participant-notify-http.test.ts +309 -0
  107. package/src/requirement-tag-ops-http.test.ts +266 -0
  108. package/src/requirement-tags.ts +89 -0
  109. package/src/requirements-completed-at-migration.test.ts +70 -0
  110. package/src/requirements-etag-http.test.ts +91 -0
  111. package/src/requirements-http.test.ts +709 -0
  112. package/src/requirements-list-slim-http.test.ts +315 -0
  113. package/src/requirements-mcp.test.ts +163 -0
  114. package/src/requirements-migrate.ts +225 -0
  115. package/src/requirements-people-http.test.ts +114 -0
  116. package/src/requirements-priority-lowest.test.ts +114 -0
  117. package/src/requirements-search-http.test.ts +182 -0
  118. package/src/requirements-search.test.ts +32 -0
  119. package/src/requirements-search.ts +138 -0
  120. package/src/requirements-seq-http.test.ts +193 -0
  121. package/src/requirements-seq-migration.test.ts +63 -0
  122. package/src/requirements-start-date.test.ts +125 -0
  123. package/src/requirements-stats-http.test.ts +288 -0
  124. package/src/requirements-stats.ts +164 -0
  125. package/src/requirements-upgrade-e2e.test.ts +126 -0
  126. package/src/requirements-upgrade-migration.test.ts +98 -0
  127. package/src/requirements.ts +1439 -0
  128. package/src/resolve-port.test.ts +57 -0
  129. package/src/resolve-port.ts +44 -0
  130. package/src/response-charset.test.ts +130 -0
  131. package/src/rest-explicit-columns-http.test.ts +216 -0
  132. package/src/rest-identity.test.ts +129 -0
  133. package/src/rest-identity.ts +100 -0
  134. package/src/rest-projections.ts +68 -0
  135. package/src/rest-write-network-resolution.test.ts +133 -0
  136. package/src/restricted-files.ts +73 -0
  137. package/src/retention.ts +15 -3
  138. package/src/route-callers-http.test.ts +120 -0
  139. package/src/route-timing.test.ts +211 -0
  140. package/src/route-timing.ts +258 -0
  141. package/src/rules-file-duplicate-alias.test.ts +234 -0
  142. package/src/rules-file-session-target.test.ts +207 -0
  143. package/src/rules-file-transport.test.ts +237 -0
  144. package/src/runtime-label.test.ts +22 -0
  145. package/src/runtime-label.ts +21 -0
  146. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  147. package/src/scheduled-reply-unread-http.test.ts +212 -0
  148. package/src/scheduled-run-terminal.test.ts +268 -0
  149. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  150. package/src/scheduled-tasks-http.test.ts +638 -0
  151. package/src/scheduled-tasks.ts +597 -0
  152. package/src/send-dedup-request-id.test.ts +229 -0
  153. package/src/send-reply-agent-warning.test.ts +202 -0
  154. package/src/send-reply-alias-optional.test.ts +131 -0
  155. package/src/send-reply-attachments.test.ts +458 -0
  156. package/src/send_dedup.test.ts +54 -0
  157. package/src/send_dedup.ts +52 -6
  158. package/src/serve-error.test.ts +38 -0
  159. package/src/serve-error.ts +27 -0
  160. package/src/server.ts +4522 -0
  161. package/src/shared/external-schedule-contract.ts +60 -0
  162. package/src/side-thread-command-transport.test.ts +312 -0
  163. package/src/side-thread-command-transport.ts +318 -0
  164. package/src/side-thread-http-integration.test.ts +233 -0
  165. package/src/side-thread-http.ts +504 -0
  166. package/src/side-thread-production.test.ts +72 -0
  167. package/src/side-thread-production.ts +118 -0
  168. package/src/side-thread.test.ts +1067 -0
  169. package/src/side-thread.ts +2499 -0
  170. package/src/skillhub-http.test.ts +108 -0
  171. package/src/sqlite-close-checkpoint.test.ts +78 -0
  172. package/src/start-node.test.ts +109 -0
  173. package/src/status-alias-filter-http.test.ts +106 -0
  174. package/src/status-node-id-filter-http.test.ts +161 -0
  175. package/src/stop-delete-node.test.ts +1164 -0
  176. package/src/task-access-http.test.ts +379 -0
  177. package/src/task-access.ts +307 -0
  178. package/src/task-auth-origin-http.test.ts +91 -0
  179. package/src/task-auth-origin.test.ts +24 -0
  180. package/src/task-auth-origin.ts +7 -0
  181. package/src/task-consumption.test.ts +482 -0
  182. package/src/task-diagnostic-http.test.ts +149 -0
  183. package/src/task-diagnostic.test.ts +115 -0
  184. package/src/task-diagnostic.ts +118 -0
  185. package/src/task-idempotency-mcp.test.ts +142 -0
  186. package/src/task-idempotency.test.ts +60 -0
  187. package/src/task-idempotency.ts +75 -0
  188. package/src/task-lifecycle-watcher.test.ts +204 -0
  189. package/src/task-lifecycle-watcher.ts +49 -0
  190. package/src/task-network-resolution-http.test.ts +141 -0
  191. package/src/tasks-pagination-http.test.ts +141 -0
  192. package/src/tools-list-json-schema.test.ts +66 -0
  193. package/src/tools.ts +3534 -343
  194. package/src/update-provider.test.ts +65 -0
  195. package/src/upload-early-reject-guard.test.ts +155 -0
  196. package/src/uploads-http.test.ts +97 -15
  197. package/src/uploads.test.ts +66 -0
  198. package/src/uploads.ts +138 -5
  199. package/src/user-inbox-by-agent-http.test.ts +161 -0
  200. package/src/user-inbox-netid-notnull.test.ts +122 -0
  201. package/src/user-inbox-read-http.test.ts +223 -0
  202. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,211 @@
1
+ // report_status 的 host 遥测 schema 必须接受 agent-node **声明会发**的 null。
2
+ //
3
+ // 来源不是推理,是 #1225 复现容器里量到的一次真崩:`--network none` 里
4
+ // agent-node 一启动就死在
5
+ // MCP error -32602: Invalid input: expected string, received null at host.ip
6
+ // 而 register() 是 `await callCommHub("report_status", …)` 且没有 catch ——
7
+ // 于是**整个节点进程当场退出**,和 runtime 无关(opencode/claude/codex 一样)。
8
+ //
9
+ // 触发条件不是"容器",是"这台机器没有非回环 IPv4":
10
+ // agent-node 的 firstNonInternalIPv4()(host-telemetry.ts:48-59)在那种机器上
11
+ // 返回 null,而 HostTelemetry 的类型本来就写着 `ip: string | null`。
12
+ //
13
+ // 🔴 这里断言的是 **schema 本身**,不是 handler。直接调 handler 会绕过 zod ——
14
+ // 而缺陷恰恰只存在于 zod 那一层,绕过去就永远绿。
15
+ import { describe, expect, test } from "bun:test";
16
+ import { z } from "zod";
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { registerTools } from "./tools.js";
19
+
20
+ function captureToolSchemas(): Record<string, any> {
21
+ const mcp = new McpServer({ name: "schema-probe", version: "0" }) as any;
22
+ const schemas: Record<string, any> = {};
23
+ const origTool = mcp.tool.bind(mcp);
24
+ mcp.tool = (name: string, desc: string, schema: any, handler: any) => {
25
+ schemas[name] = schema;
26
+ return origTool(name, desc, schema, handler);
27
+ };
28
+ registerTools(mcp, undefined, null, null, null, false, "tok_schema_probe");
29
+ return schemas;
30
+ }
31
+
32
+ const schemas = captureToolSchemas();
33
+
34
+ describe("report_status host telemetry schema", () => {
35
+ test("前提:抓到的确实是 report_status 的 host 形状", () => {
36
+ expect(schemas.report_status).toBeTruthy();
37
+ expect(schemas.report_status.host).toBeTruthy();
38
+ });
39
+
40
+ test("🔴 host.ip = null 必须被接受(没有非回环 IPv4 的机器就是发 null)", () => {
41
+ const host = schemas.report_status.host;
42
+ const parsed = host.parse({ hostname: "boxy", ip: null });
43
+ expect(parsed.ip).toBeNull();
44
+ });
45
+
46
+ test("🔴 host.hostname = null 同样被接受(与 ip 同为字符串字段,一起对齐)", () => {
47
+ const host = schemas.report_status.host;
48
+ expect(host.parse({ hostname: null, ip: "10.0.0.2" }).hostname).toBeNull();
49
+ });
50
+
51
+ test("agent-node 那条完整心跳负载整体过 schema", () => {
52
+ // 形状抄自 agent-node/src/host-telemetry.ts 的 HostTelemetry:
53
+ // 没有网络、没有 /proc 时每一项都可能是 null。
54
+ const full = {
55
+ hostname: "unknown", ip: null,
56
+ cpu_load_1min: null, cpu_cores: null,
57
+ mem_total_gb: null, mem_used_gb: null, mem_avail_gb: null,
58
+ disk_total_gb: null, disk_used_gb: null, disk_avail_gb: null,
59
+ };
60
+ expect(() => schemas.report_status.host.parse(full)).not.toThrow();
61
+ });
62
+
63
+ test("放宽的只有 null —— 类型错的值仍然被拒", () => {
64
+ const host = schemas.report_status.host;
65
+ expect(() => host.parse({ ip: 12345 })).toThrow();
66
+ expect(() => host.parse({ ip: "x".repeat(201) })).toThrow();
67
+ });
68
+
69
+ test("整条 report_status 负载(不只是 host 子对象)也接受 ip=null", () => {
70
+ const full = z.object(schemas.report_status);
71
+ expect(() => full.parse({
72
+ resume_id: "probe-resume", alias: "probe-node", status: "idle",
73
+ host: { hostname: "unknown", ip: null },
74
+ })).not.toThrow();
75
+ });
76
+ });
77
+
78
+ /* #1545 —— `create_capability_observed_ms_ago` 的**验证宽度**本身就是判据。
79
+ *
80
+ * 这一格是纯诊断信息(那个 can_create_nodes 是多久以前测出来的)。zod 对象里
81
+ * 任何一个**已知字段**验证失败,整份 report_status 都会被拒 —— 不是丢掉这一格。
82
+ * 所以给它加 `.int()/.min()/.max()` 等于:一台节点发了个奇怪的诊断数字,
83
+ * 就在 hub 上整个失联。**#1225/#1498 已经用 host.ip 演过一遍这件事了。**
84
+ *
85
+ * 消毒在读取处(`/api/host-supervisors`),不在这里。 */
86
+ describe("#1545 create_capability_observed_ms_ago —— schema 必须收得宽", () => {
87
+ const caps = () => schemas.report_status.config_snapshot;
88
+
89
+ test("前提:抓到的确实是 config_snapshot 形状,且它接受 daemon_capabilities", () => {
90
+ expect(caps()).toBeTruthy();
91
+ const parsed = caps().parse({
92
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
93
+ daemon_capabilities: { can_create_nodes: true },
94
+ });
95
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
96
+ });
97
+
98
+ test("正常值:透传", () => {
99
+ const parsed = caps().parse({
100
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
101
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: 0 },
102
+ });
103
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBe(0);
104
+ });
105
+
106
+ test("null 必须被接受(显式「我没测过」和「我不发这一格」都要能表达)", () => {
107
+ const parsed = caps().parse({
108
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
109
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: null },
110
+ });
111
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
112
+ });
113
+
114
+ /* 🔴 这四个值**一个都不许让整份 report 被拒**。它们在读取侧会被当成「没报」,
115
+ * 但那是读取侧的事 —— 这一层的职责只有一个:别把节点踢下线。 */
116
+ test.each([
117
+ ["负数", -1],
118
+ ["非整数", 1234.5678],
119
+ ["超过一年", 400 * 24 * 60 * 60 * 1000],
120
+ ["NaN", Number.NaN],
121
+ ])("怪值不得拒掉整份 report_status:%s", (_label, value) => {
122
+ expect(() => caps().parse({
123
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
124
+ daemon_capabilities: { can_create_nodes: false,
125
+ create_nodes_blocked_reason: "anet_bin_source",
126
+ create_capability_observed_ms_ago: value },
127
+ })).not.toThrow();
128
+ });
129
+
130
+ /* 🔴 反向锚:上面那组「不抛」如果只是因为**这个 key 压根没被 schema 看见**,
131
+ * 它们会一样绿。所以这里证明 schema 确实在看它:喂一个非数字,值必须**被改写成
132
+ * null**(而不是原样穿过去)。原样穿过去 = key 被忽略,那组测试就什么也没测。 */
133
+ test("反向锚:非数字被改写成 null(不是原样穿过)⇒ 证明 schema 确实在看这个 key", () => {
134
+ const parsed = caps().parse({
135
+ flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
136
+ daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: "0" },
137
+ });
138
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
139
+ expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).not.toBe("0");
140
+ });
141
+ });
142
+
143
+ /* 🔴 #1545 —— 把 `host:` 上方那段方框注释里的表**钉住**。
144
+ *
145
+ * 那段注释说的是:这份 schema 的子对象一半 strict 一半不 strict,而「往里加一个
146
+ * hub 还不认识的键」的后果完全相反 —— 非 strict 静默丢弃(节点活着、字段蒸发),
147
+ * strict 拒掉整份 report(节点当场失联,#1225 的形状)。
148
+ *
149
+ * 注释会烂。这组测试的作用**不是禁止改 strict**(两种都各有其位),而是:谁改了,
150
+ * 谁就会在这里撞红,从而**被迫连注释一起改**。所以断言写成「和注释里那张表一致」,
151
+ * 而不是「必须是某个值」。 */
152
+ describe("#1545 report_status 子对象的 strict 分布(注释里那张表的可执行副本)", () => {
153
+ const rs = () => schemas.report_status;
154
+ const UNKNOWN = { __hub_does_not_know_this_key__: 1 };
155
+
156
+ function acceptsUnknownKey(schema: any, base: object): "dropped" | "rejected" {
157
+ try {
158
+ const parsed = schema.parse({ ...base, ...UNKNOWN });
159
+ return "__hub_does_not_know_this_key__" in parsed ? "rejected" /* 不可能:留着说明它根本没被处理 */ : "dropped";
160
+ } catch { return "rejected"; }
161
+ }
162
+
163
+ const SIDE_THREAD = {
164
+ supported: true, runtime: "codex", runtimeVersion: "1.0",
165
+ topology: "native-exact-fork", evidenceRevision: "r1",
166
+ };
167
+ const SNAP_BASE = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
168
+
169
+ test.each([
170
+ ["host", () => rs().host, {}, "dropped"],
171
+ ["process_telemetry", () => rs().process_telemetry, {}, "dropped"],
172
+ ["config_snapshot", () => rs().config_snapshot, SNAP_BASE, "dropped"],
173
+ ["external_schedules", () => rs().external_schedules, { schedules: [] }, "rejected"],
174
+ ])("%s → 未知键被 %s", (_name, get, base, expected) => {
175
+ expect(acceptsUnknownKey(get(), base)).toBe(expected as string);
176
+ });
177
+
178
+ test("daemon_capabilities(嵌在 config_snapshot 里)→ 未知键被丢弃,不拒整份", () => {
179
+ const parsed: any = rs().config_snapshot.parse({
180
+ ...SNAP_BASE,
181
+ daemon_capabilities: { can_create_nodes: true, ...UNKNOWN },
182
+ });
183
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
184
+ expect("__hub_does_not_know_this_key__" in parsed.daemon_capabilities).toBe(false);
185
+ });
186
+
187
+ test("side_thread_capability(同一个 config_snapshot 里)→ 未知键拒掉**整份**", () => {
188
+ expect(() => rs().config_snapshot.parse({
189
+ ...SNAP_BASE,
190
+ side_thread_capability: { ...SIDE_THREAD, ...UNKNOWN },
191
+ })).toThrow();
192
+ // 反向锚:去掉那个未知键必须过 —— 否则上面那条可能是被别的必填字段拒的,
193
+ // 和 strict 无关。
194
+ expect(() => rs().config_snapshot.parse({
195
+ ...SNAP_BASE, side_thread_capability: SIDE_THREAD,
196
+ })).not.toThrow();
197
+ });
198
+
199
+ /* 分母自证:上面这张表**两侧都有**。如果哪天全变成 dropped(或全 rejected),
200
+ * 那段注释描述的「不对称」就不存在了,而这条会先红。 */
201
+ test("这张表确实是不对称的(两种后果各至少一个)", () => {
202
+ const results = [
203
+ acceptsUnknownKey(rs().host, {}),
204
+ acceptsUnknownKey(rs().process_telemetry, {}),
205
+ acceptsUnknownKey(rs().config_snapshot, SNAP_BASE),
206
+ acceptsUnknownKey(rs().external_schedules, { schedules: [] }),
207
+ ];
208
+ expect(results.filter(r => r === "dropped").length).toBeGreaterThan(0);
209
+ expect(results.filter(r => r === "rejected").length).toBeGreaterThan(0);
210
+ });
211
+ });
@@ -0,0 +1,150 @@
1
+ // 同一 alias 换 resume_id 上报时,hub 的 report_status 走的是「DELETE 旧行 + INSERT 新行」,
2
+ // ON CONFLICT 里的 COALESCE 一次都不会触发 —— 上报里没带的描述性列(version / agent /
3
+ // hostname / 遥测 / registered_at)全部变 NULL。
4
+ //
5
+ // 2026-09-04 DEV 真机(hub 日志):
6
+ // 12:46:47 grok-v1 (sdk-n_6c) → report_status: working version=2.5.0-preview.64
7
+ // 12:46:53 grok-v1 (grok-cli) → report_status: working ← TUI 的 MCP 上报,不带 version
8
+ // 12:46:54 grok-v1 (sdk-n_6c) → report_status: idle ← 又换回来,也不带 version
9
+ // 结果:version=NULL、registered_at 被改成 12:46:54。舰队里 grok 3/10、claude 5/15 有 version,
10
+ // codex 34/34 有 —— 差别就是有没有第二个上报者。
11
+ //
12
+ // 判据:换 resume_id 之后,只有一行;上报了的列以上报为准,没上报的列从被替换的那行接手。
13
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
14
+ import { db } from "./db.js";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { registerTools } from "./tools.js";
17
+
18
+ const NET_ID = "net_handover";
19
+ const USER_ID = "u_handover";
20
+ const NODE_ID = "n_handover";
21
+ const ALIAS = "grok-handover";
22
+ const TOK_ID = "tok_handover";
23
+ const RESUME_SDK = `sdk-${NODE_ID}`;
24
+ const RESUME_CLI = `grok-cli-${NODE_ID}`;
25
+ const OLD_REGISTERED_AT = "2026-01-02 03:04:05";
26
+
27
+ type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
28
+
29
+ function cleanup() {
30
+ for (const [sql, params] of [
31
+ ["DELETE FROM agent_telemetry WHERE network_id = ?1", [NET_ID]],
32
+ ["DELETE FROM sessions WHERE network_id = ?1", [NET_ID]],
33
+ ["DELETE FROM nodes WHERE network_id = ?1", [NET_ID]],
34
+ ["DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]],
35
+ ["DELETE FROM network_members WHERE network_id = ?1", [NET_ID]],
36
+ ["DELETE FROM networks WHERE network_id = ?1", [NET_ID]],
37
+ ["DELETE FROM users WHERE user_id = ?1", [USER_ID]],
38
+ ] as const) {
39
+ try { db.run(sql, params as any); } catch {}
40
+ }
41
+ }
42
+ beforeEach(cleanup);
43
+ afterAll(cleanup);
44
+
45
+ function seed() {
46
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
47
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET_ID, NET_ID, USER_ID]);
48
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER_ID, NET_ID]);
49
+ db.run(
50
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
51
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
52
+ [NODE_ID, ALIAS, ALIAS, NET_ID, "dev-box"],
53
+ );
54
+ // agent-node 自己注册的那行:带 version / agent / hostname / 遥测 / 早就存在的 registered_at
55
+ db.run(
56
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, version, agent, hostname, server, project_dir,
57
+ cpu_cores, mem_total_gb, disk_total_gb, registered_at, updated_at, last_seen_at)
58
+ VALUES (?1, ?2, 'working', ?3, ?4, '2.5.0-preview.64', 'agent-node:grok-build-cli', 'dev-box', 'dev-box', '/home/x/proj',
59
+ 8, 61.4, 500, ?5, datetime('now'), datetime('now'))`,
60
+ [RESUME_SDK, ALIAS, NODE_ID, NET_ID, OLD_REGISTERED_AT],
61
+ );
62
+ db.run(
63
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
64
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
65
+ [TOK_ID, USER_ID, NET_ID, `node:${ALIAS}`, `hash_${TOK_ID}`],
66
+ );
67
+ }
68
+
69
+ function buildHandlers(): Record<string, ToolHandler> {
70
+ const server = new McpServer({ name: "test", version: "0" }) as any;
71
+ const tools: Record<string, ToolHandler> = {};
72
+ const origTool = server.tool.bind(server);
73
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => { tools[name] = handler; return origTool(name, _desc, _schema, handler); };
74
+ const origRegisterTool = server.registerTool?.bind(server);
75
+ if (origRegisterTool) {
76
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => { tools[name] = handler; return origRegisterTool(name, _cfg, handler); };
77
+ }
78
+ registerTools(server, undefined, NET_ID, USER_ID, ALIAS, true, TOK_ID);
79
+ return tools;
80
+ }
81
+
82
+ async function call(handler: ToolHandler, args: any): Promise<any> {
83
+ const r = await handler(args);
84
+ return JSON.parse(r.content[0]!.text);
85
+ }
86
+
87
+ type Row = { resume_id: string; status: string; version: string | null; agent: string | null; hostname: string | null;
88
+ cpu_cores: number | null; mem_total_gb: number | null; registered_at: string; task: string | null };
89
+ function rows(): Row[] {
90
+ return db.all("SELECT resume_id, status, version, agent, hostname, cpu_cores, mem_total_gb, registered_at, task FROM sessions WHERE alias = ?1 AND network_id = ?2", [ALIAS, NET_ID]) as Row[];
91
+ }
92
+
93
+ describe("report_status: 同 alias 换 resume_id 时,描述性列从被替换的那行接手", () => {
94
+ test("第二个上报者(grok-cli)不带 version/agent/遥测 → 这些列保留,status/task 以本次为准,仍只有一行", async () => {
95
+ seed();
96
+ const tools = buildHandlers();
97
+ const r = await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "working", task: "PING 已收到", network_id: NET_ID });
98
+ expect(r.ok, JSON.stringify(r)).toBe(true);
99
+ const all = rows();
100
+ expect(all.length).toBe(1);
101
+ const row = all[0]!;
102
+ expect(row.resume_id).toBe(RESUME_CLI);
103
+ expect(row.status).toBe("working");
104
+ expect(row.task).toBe("PING 已收到");
105
+ expect(row.version).toBe("2.5.0-preview.64");
106
+ expect(row.agent).toBe("agent-node:grok-build-cli");
107
+ expect(row.hostname).toBe("dev-box");
108
+ expect(row.cpu_cores).toBe(8);
109
+ expect(row.mem_total_gb).toBe(61.4);
110
+ expect(row.registered_at).toBe(OLD_REGISTERED_AT);
111
+ });
112
+
113
+ test("上报里带了的列以上报为准(version 变了就用新的)", async () => {
114
+ seed();
115
+ const tools = buildHandlers();
116
+ const r = await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "idle", version: "9.9.9", hostname: "other-box", network_id: NET_ID });
117
+ expect(r.ok, JSON.stringify(r)).toBe(true);
118
+ const row = rows()[0]!;
119
+ expect(rows().length).toBe(1);
120
+ expect(row.version).toBe("9.9.9");
121
+ expect(row.hostname).toBe("other-box");
122
+ expect(row.agent).toBe("agent-node:grok-build-cli");
123
+ });
124
+
125
+ test("再换回原 resume_id(sdk)也不丢:两个上报者轮流说话,version 始终在", async () => {
126
+ seed();
127
+ const tools = buildHandlers();
128
+ await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "working", network_id: NET_ID });
129
+ const r = await call(tools.report_status!, { resume_id: RESUME_SDK, alias: ALIAS, status: "idle", network_id: NET_ID });
130
+ expect(r.ok, JSON.stringify(r)).toBe(true);
131
+ const all = rows();
132
+ expect(all.length).toBe(1);
133
+ expect(all[0]!.resume_id).toBe(RESUME_SDK);
134
+ expect(all[0]!.status).toBe("idle");
135
+ expect(all[0]!.version).toBe("2.5.0-preview.64");
136
+ expect(all[0]!.registered_at).toBe(OLD_REGISTERED_AT);
137
+ });
138
+
139
+ test("没有别的行可接手时(全新 alias)行为不变:INSERT 一行,registered_at 取 now", async () => {
140
+ seed();
141
+ db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]);
142
+ const tools = buildHandlers();
143
+ const r = await call(tools.report_status!, { resume_id: RESUME_SDK, alias: ALIAS, status: "idle", network_id: NET_ID });
144
+ expect(r.ok, JSON.stringify(r)).toBe(true);
145
+ const all = rows();
146
+ expect(all.length).toBe(1);
147
+ expect(all[0]!.version).toBeNull();
148
+ expect(all[0]!.registered_at).not.toBe(OLD_REGISTERED_AT);
149
+ });
150
+ });
@@ -0,0 +1,9 @@
1
+ // #434: real-server integration tests must know their DB before importing
2
+ // auth/db/server modules. Setting COMMHUB_DB in beforeAll is too late because
3
+ // ESM dependencies have already evaluated the db singleton.
4
+ if (!process.env.COMMHUB_DB) {
5
+ throw new Error(
6
+ "explicit_test_database_required: run `cd server && bun run test`, " +
7
+ "or set COMMHUB_DB to an isolated temporary path before running this file",
8
+ );
9
+ }
@@ -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
+ });