@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,319 @@
1
+ // GET /api/requirements 省流(2026-09-30,App「连接较慢 · 数据可能稍有延迟」)—— HTTP 集成测试(真实 Bun.serve,临时库)。
2
+ // view=summary —— 不带 description / checklist,换成 has_description / checklist_count;不带 view = 与原来逐字相同。
3
+ // changes=1 —— updated_since 之后改过的卡(含归档的)+ deleted 墓碑 + server_time;受限成员只收到他看得见的卡的删除。
4
+ // 列表缓存 —— 表没变时复用上一次的正文 / ETag;任何写入(REST、MCP、项目删除)之后都不会拿旧的。
5
+ // /api/stats/routes —— 每个路由的耗时汇总,只给管理员。
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
+ import { gunzipSync } from "node:zlib";
11
+
12
+ const DIR = mkdtempSync(join(tmpdir(), "anet-requirements-slim-"));
13
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
14
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
15
+
16
+ const PW = "SlimListPassw0rd!x";
17
+ let BASE = "";
18
+ let hub: any = null;
19
+ let NET = "";
20
+ let admin = { token: "", id: "" };
21
+ let member = { token: "", id: "" };
22
+ let scoped = { token: "", id: "" };
23
+ let cache: { size: () => number; clear: () => void; generation: () => number };
24
+ let gzipStats: { hits: number; misses: number };
25
+
26
+ type R = { status: number; body: any; text: string; headers: Headers };
27
+ async function send(token: string, method: string, path: string, payload?: unknown, headers: Record<string, string> = {}): Promise<R> {
28
+ const res = await fetch(`${BASE}${path}`, {
29
+ method,
30
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json", ...headers },
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, text, headers: res.headers };
37
+ }
38
+ const list = (t: string, qs = "", headers: Record<string, string> = {}) => send(t, "GET", `/api/requirements?network_id=${NET}${qs}`, undefined, headers);
39
+ const create = async (t: string, card: Record<string, unknown>) => {
40
+ const r = await send(t, "POST", "/api/requirements", { network_id: NET, ...card });
41
+ expect(r.status).toBe(201);
42
+ return r.body.requirement as { id: string; updatedAt: string };
43
+ };
44
+ const patch = (t: string, id: string, p: Record<string, unknown>) => send(t, "PATCH", `/api/requirements/${id}?network_id=${NET}`, p);
45
+ const del = (t: string, id: string) => send(t, "DELETE", `/api/requirements/${id}?network_id=${NET}`);
46
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
47
+ const res = await fetch(`${BASE}/mcp`, {
48
+ method: "POST",
49
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
50
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
51
+ });
52
+ const raw = await res.text();
53
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
54
+ const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
55
+ return JSON.parse(payload.result.content[0].text);
56
+ }
57
+ const tick = () => new Promise(r => setTimeout(r, 5));
58
+
59
+ beforeAll(async () => {
60
+ process.env.HOST = "127.0.0.1";
61
+ const { register } = await import("./auth.js");
62
+ const { db } = await import("./db.js");
63
+ const a = register(`slim_admin_${Date.now()}`, PW, undefined, "Admin");
64
+ expect(a.ok).toBe(true);
65
+ admin = { token: a.token!, id: a.user!.user_id };
66
+ NET = a.network_id!;
67
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
68
+ const mod: any = await import("./server.js");
69
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
70
+ BASE = `http://127.0.0.1:${hub.port}`;
71
+ const mk = async (username: string) => {
72
+ const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
73
+ expect(r.status).toBe(200);
74
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
75
+ expect(login.status).toBe(200);
76
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
77
+ };
78
+ const stamp = Date.now();
79
+ member = await mk(`slim_member_${stamp}`);
80
+ scoped = await mk(`slim_scoped_${stamp}`);
81
+ // member:看全部任务、全部 Agent(可缓存);scoped:只看相关任务(不缓存,且只收到看得见的卡的删除)。
82
+ db.run("UPDATE network_members SET task_access = 'all', agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
83
+ db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, scoped.id]);
84
+ cache = (await import("./requirements.js")).__requirementsListCacheForTest();
85
+ gzipStats = (await import("./http-gzip.js")).gzipCacheStats;
86
+ }, 30_000);
87
+
88
+ afterAll(() => {
89
+ try { hub?.stop?.(true); } catch {}
90
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
91
+ });
92
+
93
+ describe("view=summary", () => {
94
+ test("drops description and checklist bodies, keeps everything else, adds has_description / checklist_count", async () => {
95
+ const card = await create(admin.token, {
96
+ name: "slim-summary",
97
+ description: "一段很长的描述 ".repeat(200),
98
+ checklist: [{ text: "第一项", done: true }, { text: "第二项", done: false }, { text: "第三项", done: true }],
99
+ });
100
+ await create(admin.token, { name: "slim-empty" });
101
+ const full = await list(admin.token);
102
+ const summary = await list(admin.token, "&view=summary");
103
+ expect(full.status).toBe(200);
104
+ expect(summary.status).toBe(200);
105
+ expect(summary.body.view).toBe("summary");
106
+ expect(full.body.view).toBeUndefined();
107
+ expect(summary.body.capabilities).toContain("list_summary");
108
+ expect(summary.body.capabilities).toContain("changes");
109
+ const f = full.body.requirements.find((r: any) => r.id === card.id);
110
+ const s = summary.body.requirements.find((r: any) => r.id === card.id);
111
+ expect(f.description.length).toBeGreaterThan(1000);
112
+ expect(f.checklist).toHaveLength(3);
113
+ expect("description" in s).toBe(false);
114
+ expect("checklist" in s).toBe(false);
115
+ expect(s.has_description).toBe(true);
116
+ expect(s.checklist_count).toEqual({ total: 3, done: 2 });
117
+ const { description: _d, checklist: _c, ...rest } = f;
118
+ const { has_description: _h, checklist_count: _cc, ...sRest } = s;
119
+ expect(sRest).toEqual(rest);
120
+ const empty = summary.body.requirements.find((r: any) => r.name === "slim-empty");
121
+ expect(empty.has_description).toBe(false);
122
+ expect(empty.checklist_count).toEqual({ total: 0, done: 0 });
123
+ // 同样的卡、同样的顺序;全文在单卡接口上照旧拿得到
124
+ expect(summary.body.requirements.map((r: any) => r.id)).toEqual(full.body.requirements.map((r: any) => r.id));
125
+ const one = await send(admin.token, "GET", `/api/requirements/${card.id}?network_id=${NET}`);
126
+ expect(one.body.requirement.description).toBe(f.description);
127
+ // 比的是 summary 省掉的那部分(正文 / 子任务):两边一样多的 last_event(#506,每行约 150 B)不算,
128
+ // 否则这张只有几行的小表上,固定开销会把比例拉过一半。capabilities 同理:两边一样、每加一个能力就长一截。
129
+ const bare = (body: any) => JSON.stringify({ ...body, capabilities: [], requirements: body.requirements.map(({ last_event: _l, ...r }: any) => r) }).length;
130
+ expect(summary.body.requirements.every((r: any) => "last_event" in r)).toBe(true);
131
+ expect(bare(summary.body)).toBeLessThan(bare(full.body) / 2);
132
+ });
133
+
134
+ test("view=full is the plain list; an unknown view is a 400", async () => {
135
+ const plain = await list(admin.token);
136
+ const explicit = await list(admin.token, "&view=full");
137
+ expect(explicit.body.requirements).toEqual(plain.body.requirements);
138
+ expect((await list(admin.token, "&view=tiny")).status).toBe(400);
139
+ expect((await list(admin.token, "&view=tiny")).body.error).toBe("invalid_view");
140
+ });
141
+ });
142
+
143
+ describe("changes=1", () => {
144
+ test("requires updated_since", async () => {
145
+ const r = await list(admin.token, "&changes=1");
146
+ expect(r.status).toBe(400);
147
+ expect(r.body.error).toBe("updated_since_required");
148
+ });
149
+
150
+ test("returns only cards changed since, archived ones included, plus deleted ids and a server_time to continue from", async () => {
151
+ const keep = await create(admin.token, { name: "slim-keep" });
152
+ const edit = await create(admin.token, { name: "slim-edit" });
153
+ const arch = await create(admin.token, { name: "slim-arch" });
154
+ const gone = await create(admin.token, { name: "slim-gone" });
155
+ const first = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(new Date(Date.now() - 60_000).toISOString())}`);
156
+ expect(first.status).toBe(200);
157
+ const t0 = first.body.server_time as string;
158
+ expect(Date.parse(t0)).toBeGreaterThan(0);
159
+ expect(first.body.deleted).toEqual([]);
160
+ await tick();
161
+
162
+ expect((await patch(admin.token, edit.id, { name: "slim-edit-2" })).status).toBe(200);
163
+ expect((await patch(admin.token, arch.id, { archived: true })).status).toBe(200);
164
+ expect((await del(admin.token, gone.id)).status).toBe(200);
165
+
166
+ const delta = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
167
+ expect(delta.status).toBe(200);
168
+ const ids = delta.body.requirements.map((r: any) => r.id).sort();
169
+ expect(ids).toEqual([edit.id, arch.id].sort());
170
+ expect(delta.body.requirements.find((r: any) => r.id === arch.id).archived).toBe(true);
171
+ expect(delta.body.requirements.find((r: any) => r.id === edit.id).name).toBe("slim-edit-2");
172
+ expect(delta.body.deleted).toEqual([gone.id]);
173
+ expect(ids).not.toContain(keep.id);
174
+ expect(Date.parse(delta.body.tombstones_since)).toBeLessThan(Date.parse(t0));
175
+
176
+ // 接着读:什么都没变就是空的
177
+ const again = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(delta.body.server_time)}`);
178
+ expect(again.body.requirements).toEqual([]);
179
+ expect(again.body.deleted).toEqual([]);
180
+
181
+ // 与 view=summary 组合
182
+ const slim = await list(admin.token, `&changes=1&view=summary&updated_since=${encodeURIComponent(t0)}`);
183
+ expect(slim.body.requirements.every((r: any) => !("description" in r) && "checklist_count" in r)).toBe(true);
184
+
185
+ // 不带 changes 的 updated_since 与原来一样:归档的不回、没有 deleted
186
+ const legacy = await list(admin.token, `&updated_since=${encodeURIComponent(t0)}`);
187
+ expect(legacy.body.requirements.map((r: any) => r.id)).toEqual([edit.id]);
188
+ expect(legacy.body.deleted).toBeUndefined();
189
+ expect(legacy.body.server_time).toBeUndefined();
190
+ });
191
+
192
+ test("side effects that used to leave updated_at alone now show up: orphaned children, cleared project", async () => {
193
+ const parent = await create(admin.token, { name: "slim-parent" });
194
+ const child = await create(admin.token, { name: "slim-child", parent_id: parent.id });
195
+ const proj = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "slim-proj" });
196
+ expect(proj.status).toBe(201);
197
+ const inProj = await create(admin.token, { name: "slim-in-proj", project_id: proj.body.project.id });
198
+ await tick();
199
+ const t0 = new Date().toISOString();
200
+ await tick();
201
+ expect((await del(admin.token, parent.id)).status).toBe(200);
202
+ expect((await send(admin.token, "DELETE", `/api/requirements/projects/${proj.body.project.id}?network_id=${NET}`)).status).toBe(200);
203
+ const delta = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
204
+ const byId = new Map(delta.body.requirements.map((r: any) => [r.id, r]));
205
+ expect((byId.get(child.id) as any)?.parent_id).toBeNull();
206
+ expect((byId.get(inProj.id) as any)?.project_id).toBeNull();
207
+ expect(delta.body.deleted).toEqual([parent.id]);
208
+ });
209
+
210
+ test("a scoped member only hears about deletions of cards they could see", async () => {
211
+ const theirs = await create(admin.token, { name: "slim-theirs", owner: { kind: "user", id: scoped.id } });
212
+ const hidden = await create(admin.token, { name: "slim-hidden" });
213
+ const seen = await list(scoped.token);
214
+ expect(seen.body.requirements.map((r: any) => r.id)).toContain(theirs.id);
215
+ expect(seen.body.requirements.map((r: any) => r.id)).not.toContain(hidden.id);
216
+ await tick();
217
+ const t0 = new Date().toISOString();
218
+ await tick();
219
+ expect((await del(admin.token, theirs.id)).status).toBe(200);
220
+ expect((await del(admin.token, hidden.id)).status).toBe(200);
221
+ const mine = await list(scoped.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
222
+ expect(mine.body.deleted).toEqual([theirs.id]);
223
+ const all = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
224
+ expect(all.body.deleted.sort()).toEqual([theirs.id, hidden.id].sort());
225
+ });
226
+ });
227
+
228
+ describe("list cache", () => {
229
+ test("an unchanged table is served from the cache: same ETag, and If-None-Match still 304s", async () => {
230
+ cache.clear();
231
+ const a = await list(admin.token);
232
+ expect(cache.size()).toBe(1);
233
+ const b = await list(admin.token);
234
+ expect(b.headers.get("etag")).toBe(a.headers.get("etag"));
235
+ expect(b.text).toBe(a.text);
236
+ expect((await list(admin.token, "", { "If-None-Match": a.headers.get("etag")! })).status).toBe(304);
237
+ });
238
+
239
+ test("every kind of write invalidates it: REST patch, MCP update, checklist tick, delete", async () => {
240
+ const card = await create(admin.token, { name: "slim-cache", checklist: [{ text: "x", done: false }] });
241
+ const seen = async () => (await list(admin.token)).body.requirements.find((r: any) => r.id === card.id);
242
+ expect((await seen()).name).toBe("slim-cache");
243
+ await patch(admin.token, card.id, { name: "slim-cache-rest" });
244
+ expect((await seen()).name).toBe("slim-cache-rest");
245
+ const viaMcp = await mcp(admin.token, "requirements_update", { id: card.id, network_id: NET, name: "slim-cache-mcp" });
246
+ expect(viaMcp.ok).toBe(true);
247
+ expect((await seen()).name).toBe("slim-cache-mcp");
248
+ const item = (await seen()).checklist[0];
249
+ expect((await send(admin.token, "PATCH", `/api/requirements/${card.id}/checklist/${item.id}?network_id=${NET}`, { done: true })).status).toBe(200);
250
+ expect((await seen()).checklist[0].done).toBe(true);
251
+ await del(admin.token, card.id);
252
+ expect(await seen()).toBeUndefined();
253
+ });
254
+
255
+ test("a failed write still invalidates (the handler may have written before failing)", async () => {
256
+ const g = cache.generation();
257
+ await send(admin.token, "PATCH", `/api/requirements/does-not-exist?network_id=${NET}`, { name: "x" });
258
+ expect(cache.generation()).toBe(g + 1);
259
+ // 不是需求接口的写入不动代数
260
+ await send(admin.token, "POST", "/api/task", { alias: "nobody", task: "x" });
261
+ expect(cache.generation()).toBe(g + 1);
262
+ });
263
+
264
+ test("callers are cached separately; a member's access change is seen at once; scoped / agent-restricted members are never cached", async () => {
265
+ cache.clear();
266
+ await list(admin.token);
267
+ await list(member.token);
268
+ expect(cache.size()).toBe(2);
269
+ await list(scoped.token);
270
+ expect(cache.size()).toBe(2);
271
+ const { db } = await import("./db.js");
272
+ const before = await list(member.token);
273
+ db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
274
+ const after = await list(member.token);
275
+ expect(after.body.requirements.length).toBeLessThan(before.body.requirements.length);
276
+ db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
277
+ expect((await list(member.token)).body.requirements.length).toBe(before.body.requirements.length);
278
+ // Agent 受限(看得见哪些 Agent 取决于节点表)→ 不缓存
279
+ cache.clear();
280
+ db.run("UPDATE network_members SET agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
281
+ await list(member.token);
282
+ expect(cache.size()).toBe(0);
283
+ db.run("UPDATE network_members SET agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
284
+ });
285
+
286
+ test("changes=1 and search are not cached", async () => {
287
+ cache.clear();
288
+ await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(new Date(0).toISOString())}`);
289
+ await list(admin.token, "&q=slim");
290
+ expect(cache.size()).toBe(0);
291
+ });
292
+
293
+ test("gzip of an unchanged list is computed once", async () => {
294
+ const before = { ...gzipStats };
295
+ const a = await fetch(`${BASE}/api/requirements?network_id=${NET}`, { headers: { Authorization: `Bearer ${admin.token}`, "Accept-Encoding": "gzip" }, decompress: false } as any);
296
+ const b = await fetch(`${BASE}/api/requirements?network_id=${NET}`, { headers: { Authorization: `Bearer ${admin.token}`, "Accept-Encoding": "gzip" }, decompress: false } as any);
297
+ expect(a.headers.get("content-encoding")).toBe("gzip");
298
+ const ga = new Uint8Array(await a.arrayBuffer());
299
+ const gb = new Uint8Array(await b.arrayBuffer());
300
+ expect(Buffer.from(gunzipSync(gb)).toString()).toBe(Buffer.from(gunzipSync(ga)).toString());
301
+ expect(gzipStats.hits).toBeGreaterThanOrEqual(before.hits + 1);
302
+ expect(JSON.parse(Buffer.from(gunzipSync(gb)).toString()).ok).toBe(true);
303
+ });
304
+ });
305
+
306
+ describe("GET /api/stats/routes", () => {
307
+ test("admin sees per-route timings with ids collapsed; a member gets 403", async () => {
308
+ await list(admin.token, "&view=summary");
309
+ const r = await send(admin.token, "GET", "/api/stats/routes?minutes=5");
310
+ expect(r.status).toBe(200);
311
+ const routes = r.body.routes.map((x: any) => x.route);
312
+ expect(routes).toContain("GET /api/requirements?view=summary");
313
+ expect(routes.some((x: string) => x.startsWith("PATCH /api/requirements/:id"))).toBe(true);
314
+ const row = r.body.routes.find((x: any) => x.route === "GET /api/requirements");
315
+ expect(row.count).toBeGreaterThan(0);
316
+ expect(row.max_ms).toBeGreaterThanOrEqual(row.avg_ms);
317
+ expect((await send(member.token, "GET", "/api/stats/routes")).status).toBe(403);
318
+ });
319
+ });
@@ -0,0 +1,163 @@
1
+ // 需求池 MCP 工具:走真 MCP in-process transport。Agent(节点令牌)在自己网络里读 / 建 / 改 / 勾 / upsert、
2
+ // 别的网络的节点碰不到、删除不存在于工具里(只有人能在 REST 删)。同步场景:同一个 GitHub issue 同步两次不重复。
3
+ // 跑法:cd server && COMMHUB_DB=/tmp/req-mcp.db bun test src/requirements-mcp.test.ts
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-mcp-"));
10
+ process.env.COMMHUB_DB = join(dir, "hub.db");
11
+
12
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
13
+ const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
14
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
15
+ const { db } = await import("./db.js");
16
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
17
+ const { registerTools } = await import("./tools.js");
18
+
19
+ type Identity = { net: string; user: string; alias: string; isNetworkToken: boolean; tokenId: string };
20
+ let owner: Identity, bot: Identity, foreignBot: Identity;
21
+ let ownerNet = "";
22
+
23
+ async function connect(id: Identity) {
24
+ const server = new McpServer({ name: "req-mcp-test", version: "1" });
25
+ registerTools(server, undefined, id.isNetworkToken ? id.net : null, id.user, id.alias, id.isNetworkToken, id.tokenId);
26
+ const client = new Client({ name: "req-mcp-client", version: "1" });
27
+ const [ct, st] = InMemoryTransport.createLinkedPair();
28
+ await server.connect(st);
29
+ await client.connect(ct);
30
+ const call = async (name: string, args: Record<string, unknown>) => {
31
+ const r: any = await client.callTool({ name, arguments: args });
32
+ const first = r.content?.[0];
33
+ if (r.isError) return { mcpError: true, text: first?.text };
34
+ return JSON.parse(first.text);
35
+ };
36
+ return { server, call, close: async () => { await client.close(); await server.close(); } };
37
+ }
38
+
39
+ beforeAll(() => {
40
+ const o = register(`reqmcp_owner_${Date.now()}`, "ReqMcpOwner123!", undefined, "seed");
41
+ const x = register(`reqmcp_other_${Date.now()}`, "ReqMcpOther123!", undefined, "seed");
42
+ ownerNet = o.network_id!;
43
+ const t1 = createNetworkTokenForNode(o.user!.user_id, ownerNet, "gh-sync", "node_gh_sync");
44
+ const t2 = createNetworkTokenForNode(x.user!.user_id, x.network_id!, "foreign", "node_foreign");
45
+ owner = { net: ownerNet, user: o.user!.user_id, alias: o.user!.username, isNetworkToken: false, tokenId: "tok_user" };
46
+ bot = { net: ownerNet, user: o.user!.user_id, alias: "gh-sync", isNetworkToken: true, tokenId: t1.token_id! };
47
+ foreignBot = { net: x.network_id!, user: x.user!.user_id, alias: "foreign", isNetworkToken: true, tokenId: t2.token_id! };
48
+ });
49
+ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
50
+
51
+ describe("requirements MCP tools", () => {
52
+ test("the seven tools are registered with validated schemas; there is no delete tool", async () => {
53
+ const s = await connect(owner);
54
+ try {
55
+ const reg: Record<string, any> = (s.server as any)._registeredTools;
56
+ for (const n of ["requirements_list", "requirements_get", "requirements_create", "requirements_update", "requirements_checklist_toggle", "requirements_upsert_by_external_ref", "projects_list"]) expect(reg[n], n).toBeDefined();
57
+ expect(Object.keys(reg).some(n => /^requirements_delete/.test(n))).toBe(false);
58
+ // 校验:坏的 status / 负责人种类 / 子任务 id 在协议边界被拒
59
+ expect((await s.call("requirements_list", { status: "nope" })).mcpError).toBe(true);
60
+ expect((await s.call("requirements_create", { name: "x", owner: { kind: "robot", id: "a" } })).mcpError).toBe(true);
61
+ expect((await s.call("requirements_checklist_toggle", { id: "r", item_id: "has space", done: true })).mcpError).toBe(true);
62
+ expect((await s.call("requirements_create", { name: "" })).mcpError).toBe(true);
63
+ // P0–P3:lowest(P3 极低)在建 / 改 / upsert 都能写;不认识的值在协议边界被拒
64
+ expect((await s.call("requirements_create", { name: "x", priority: "urgent" })).mcpError).toBe(true);
65
+ const low = await s.call("requirements_create", { name: "极低", priority: "lowest" });
66
+ expect(low.requirement.priority).toBe("lowest");
67
+ expect((await s.call("requirements_update", { id: low.requirement.id, priority: "high" })).requirement.priority).toBe("high");
68
+ expect((await s.call("requirements_update", { id: low.requirement.id, priority: "lowest" })).requirement.priority).toBe("lowest");
69
+ expect((await s.call("requirements_upsert_by_external_ref", { external_ref: "github:acme/p3#1", name: "同步", priority: "lowest" })).requirement.priority).toBe("lowest");
70
+ } finally { await s.close(); }
71
+ });
72
+
73
+ test("an agent syncs a GitHub issue idempotently, edits it, ticks a checklist item and archives it", async () => {
74
+ const s = await connect(bot);
75
+ try {
76
+ const ref = "github:acme/widgets#42";
77
+ const first = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, external_url: "https://github.com/acme/widgets/issues/42", name: "修复登录", description: "issue 正文", checklist: [{ id: "c1", text: "复现" }, { id: "c2", text: "修" }] });
78
+ expect(first.ok).toBe(true);
79
+ expect(first.created).toBe(true);
80
+ const tagged = await s.call("requirements_update", { id: first.requirement.id, tags: ["release"] });
81
+ expect(tagged.requirement.tags).toEqual(["release"]);
82
+ expect(first.requirement.created_by).toEqual({ kind: "node", id: "node_gh_sync" });
83
+ const again = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, name: "修复登录(改了标题)" });
84
+ expect(again.created).toBe(false);
85
+ expect(again.requirement.tags).toEqual(["release"]);
86
+ expect(again.requirement.id).toBe(first.requirement.id);
87
+ const listed = await s.call("requirements_list", { external_ref: ref });
88
+ expect(listed.requirements.length).toBe(1);
89
+ expect(listed.requirements[0].name).toBe("修复登录(改了标题)");
90
+ const dup = await s.call("requirements_create", { name: "重复", external_ref: ref });
91
+ expect(dup.error).toBe("external_ref_exists");
92
+ expect(dup.existing_id).toBe(first.requirement.id);
93
+ expect(dup.status).toBe(409);
94
+ const id = first.requirement.id;
95
+ expect((await s.call("requirements_update", { id, column: "doing", agent_owner: { kind: "node", id: "node_gh_sync" } })).requirement.column).toBe("doing");
96
+ const ticked = await s.call("requirements_checklist_toggle", { id, item_id: "c1", done: true });
97
+ expect(ticked.requirement.checklist.map((i: any) => i.done)).toEqual([true, false]);
98
+ expect((await s.call("requirements_get", { id })).requirement.agent_owner).toEqual({ kind: "node", id: "node_gh_sync" });
99
+ expect((await s.call("requirements_list", { status: "doing", agent_owner: "node:node_gh_sync" })).requirements.map((r: any) => r.id)).toContain(id);
100
+ expect((await s.call("requirements_update", { id, owner: { kind: "node", id: "node_gh_sync" }, agent_owner: null })).error).toBe("owner_must_be_human");
101
+ expect((await s.call("requirements_update", { id, archived: true })).requirement.archived).toBe(true);
102
+ expect((await s.call("requirements_list", {})).requirements.some((r: any) => r.id === id)).toBe(false);
103
+ expect((await s.call("requirements_list", { include_archived: true })).requirements.some((r: any) => r.id === id)).toBe(true);
104
+ expect(Array.isArray((await s.call("projects_list", {})).projects)).toBe(true);
105
+ // 子需求:建子卡、父卡上的进度、按父卡 / 顶层过滤
106
+ const child = await s.call("requirements_create", { name: "子需求", parent_id: id });
107
+ expect(child.requirement.parent_id).toBe(id);
108
+ expect((await s.call("requirements_get", { id })).requirement.children.total).toBe(1);
109
+ expect((await s.call("requirements_list", { parent_id: id })).requirements.map((r: any) => r.id)).toEqual([child.requirement.id]);
110
+ expect((await s.call("requirements_list", { top_level: true, include_archived: true })).requirements.some((r: any) => r.id === child.requirement.id)).toBe(false);
111
+ expect((await s.call("requirements_update", { id, parent_id: child.requirement.id })).error).toBe("parent_cycle");
112
+ // q= / limit / cursor 透传到 GET /api/requirements
113
+ expect((await s.call("requirements_list", { q: "子需求" })).requirements.map((r: any) => r.id)).toEqual([child.requirement.id]);
114
+ const page = await s.call("requirements_list", { limit: 1, include_archived: true });
115
+ expect(page.requirements.length).toBe(1);
116
+ expect(page.has_more).toBe(true);
117
+ expect((await s.call("requirements_list", { limit: 1, include_archived: true, cursor: page.next_cursor })).requirements[0].id).not.toBe(page.requirements[0].id);
118
+ // view / changes 透传(Hub capability list_summary / changes):精简行没有正文;增量带 deleted 与 server_time
119
+ const slim = await s.call("requirements_list", { view: "summary", include_archived: true });
120
+ const slimRow = slim.requirements.find((r: any) => r.id === id);
121
+ expect(slim.view).toBe("summary");
122
+ expect("description" in slimRow).toBe(false);
123
+ expect(typeof slimRow.checklist_count.total).toBe("number");
124
+ expect(typeof slimRow.has_description).toBe("boolean");
125
+ expect((await s.call("requirements_list", { view: "tiny" })).mcpError).toBe(true);
126
+ expect((await s.call("requirements_list", { changes: true })).error).toBe("updated_since_required");
127
+ const since = new Date(Date.now() - 1000).toISOString();
128
+ await s.call("requirements_update", { id: child.requirement.id, name: "子需求改名" });
129
+ const delta = await s.call("requirements_list", { changes: true, updated_since: since, view: "summary" });
130
+ expect(delta.requirements.map((r: any) => r.id)).toContain(child.requirement.id);
131
+ expect(Array.isArray(delta.deleted)).toBe(true);
132
+ expect(typeof delta.server_time).toBe("string");
133
+ } finally { await s.close(); }
134
+ });
135
+
136
+ test("a node token from another network cannot see or touch these tasks, nor write into this network", async () => {
137
+ const mine = await connect(bot);
138
+ const theirs = await connect(foreignBot);
139
+ try {
140
+ const made = await mine.call("requirements_create", { name: "只属于本网络" });
141
+ const id = made.requirement.id;
142
+ expect((await theirs.call("requirements_get", { id })).error).toBe("requirement_not_found");
143
+ expect((await theirs.call("requirements_update", { id, name: "越界" })).error).toBe("requirement_not_found");
144
+ expect((await theirs.call("requirements_checklist_toggle", { id, item_id: "x", done: true })).error).toBe("requirement_not_found");
145
+ expect((await theirs.call("requirements_list", { network_id: ownerNet })).requirements.some((r: any) => r.id === id)).toBe(false);
146
+ const planted = await theirs.call("requirements_create", { name: "塞进去", network_id: ownerNet });
147
+ expect(planted.ok).toBe(true);
148
+ const row = db.get<{ network_id: string }>("SELECT network_id FROM requirements WHERE requirement_id = ?1", planted.requirement.id)!;
149
+ expect(row.network_id).toBe(foreignBot.net);
150
+ expect((await mine.call("requirements_get", { id })).requirement.name).toBe("只属于本网络");
151
+ } finally { await mine.close(); await theirs.close(); }
152
+ });
153
+
154
+ test("a user token keeps working through the same tools", async () => {
155
+ const s = await connect(owner);
156
+ try {
157
+ const made = await s.call("requirements_create", { name: "人建的", network_id: ownerNet, owner: { kind: "user", id: owner.user } });
158
+ expect(made.ok).toBe(true);
159
+ expect(made.requirement.created_by).toEqual({ kind: "user", id: owner.user });
160
+ expect((await s.call("requirements_list", { network_id: ownerNet, owner: `user:${owner.user}` })).requirements.map((r: any) => r.id)).toContain(made.requirement.id);
161
+ } finally { await s.close(); }
162
+ });
163
+ });