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

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 (318) 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 +933 -36
  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 +130 -0
  205. package/src/rest-identity.ts +139 -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 +93 -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 +140 -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-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,222 @@
1
+ // #475(MCP 任务生命周期测试报告问题 6)—— Agent 写任务时两处常见的坑:
2
+ // - status 当 column 用(列表的筛选参数就叫 status):MCP 的 create / update / upsert 把 status 当 column 的别名,
3
+ // 两个都给且不同 → 400 status_conflicts_with_column;REST 不变(仍是 empty_patch + 提示用 column)。
4
+ // - participants 是整体替换,Agent 想「加一个人」时把别人全删了:新增 participants_add / participants_remove,
5
+ // 在当前列表上加减、同一次同步读改写里完成;加已有 = 不变,减不在的 = 不变。
6
+ // 外加 #476 第 1 部分:项目工具的描述写明节点令牌只能读项目(行为本来就是 403 user_token_required)。
7
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "fs";
9
+ import { join } from "path";
10
+ import { tmpdir } from "os";
11
+ import { register, createNetworkTokenForNode } from "./auth.js";
12
+ import { db } from "./db.js";
13
+
14
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-status-part-"));
15
+ let BASE = "";
16
+ let hub: any = null;
17
+ const PW = "ReqStatusPartPassw0rd!xyz";
18
+ let NET = "";
19
+ const U: Record<string, { token: string; id: string }> = {};
20
+
21
+ async function rest(token: string, method: string, path: string, body?: unknown) {
22
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
23
+ const text = await res.text();
24
+ let json: any = null;
25
+ try { json = JSON.parse(text); } catch {}
26
+ return { status: res.status, body: json };
27
+ }
28
+ async function rpc(token: string, method: string, params: Record<string, unknown>) {
29
+ const res = await fetch(`${BASE}/mcp`, {
30
+ method: "POST",
31
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
32
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
33
+ });
34
+ const raw = await res.text();
35
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
36
+ return lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
37
+ }
38
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
39
+ const out = await rpc(token, "tools/call", { name, arguments: args });
40
+ const text = out.result?.content?.[0]?.text ?? "";
41
+ try { return JSON.parse(text); } catch { return { raw: out, text }; }
42
+ }
43
+ const keys = (refs: { kind: string; id: string }[]) => refs.map(r => `${r.kind}:${r.id}`).sort();
44
+ const row = (id: string) => db.get<{ column_name: string; participants_json: string; updated_at: string }>("SELECT column_name, participants_json, updated_at FROM requirements WHERE requirement_id = ?1", id)!;
45
+ const participantEvents = (id: string) => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_events WHERE requirement_id = ?1 AND field = 'participants'", id)!.n;
46
+
47
+ beforeAll(async () => {
48
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
49
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
50
+ process.env.HOST = "127.0.0.1";
51
+ const a = register(`sp_owner_${Date.now()}`, PW, undefined, "Owner");
52
+ U.owner = { token: a.token!, id: a.user!.user_id };
53
+ NET = a.network_id!;
54
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
55
+ const mod: any = await import("./server.js");
56
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
57
+ BASE = `http://127.0.0.1:${hub.port}`;
58
+ const username = `sp_member_${Date.now()}`;
59
+ expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" })).status).toBe(200);
60
+ const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
61
+ U.member = { token: login.body.token, id: login.body.user.user_id };
62
+ // #746 起新成员默认 scoped;这里测的是 'all' 成员的 REST 键,显式放宽。
63
+ expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/task-grants`, { task_access: "all" })).status).toBe(200);
64
+ for (const [k, alias] of [["nodeA", "示例-甲"], ["nodeB", "示例-乙"], ["nodeC", "示例-丙"]] as const) {
65
+ const nodeId = `node_sp_${k}`;
66
+ U[k] = { token: createNetworkTokenForNode(U.owner.id, NET, alias, nodeId).token!, id: nodeId };
67
+ }
68
+ }, 30_000);
69
+
70
+ afterAll(() => {
71
+ try { hub?.stop?.(true); } catch {}
72
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
73
+ });
74
+
75
+ describe("status is an alias of column (MCP)", () => {
76
+ test("create / update / upsert with status land exactly where column would", async () => {
77
+ const viaStatus = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:status 建", status: "doing" })).requirement;
78
+ const viaColumn = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:column 建", column: "doing" })).requirement;
79
+ expect(viaStatus.column).toBe("doing");
80
+ expect(viaStatus.column).toBe(viaColumn.column);
81
+
82
+ const done = await mcp(U.nodeA.token, "requirements_update", { id: `#${viaStatus.seq}`, status: "done" });
83
+ expect(done.requirement.column).toBe("done");
84
+ expect(done.requirement.completedAt).toBeTruthy(); // same side effects as column=done
85
+ expect(row(viaStatus.id).column_name).toBe("done");
86
+
87
+ const same = await mcp(U.nodeA.token, "requirements_update", { id: viaColumn.id, status: "pool", column: "pool" });
88
+ expect(same.requirement.column).toBe("pool");
89
+
90
+ const up = await mcp(U.nodeA.token, "requirements_upsert_by_external_ref", { external_ref: "demo:status-alias-1", name: "示例:upsert", status: "doing" });
91
+ expect(up.created).toBe(true);
92
+ expect(up.requirement.column).toBe("doing");
93
+ });
94
+
95
+ test("status and column both sent and different → 400 status_conflicts_with_column with a hint; nothing written", async () => {
96
+ const card = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:冲突", column: "pool" })).requirement;
97
+ const before = row(card.id);
98
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: card.id, status: "doing", column: "done" });
99
+ expect(r.ok).toBe(false);
100
+ expect(r.status).toBe(400);
101
+ expect(r.error).toBe("status_conflicts_with_column");
102
+ expect(r.field).toBe("status");
103
+ expect(r.hint).toContain("only one");
104
+ expect(row(card.id)).toEqual(before);
105
+ const c = await mcp(U.nodeA.token, "requirements_create", { name: "示例:冲突建", status: "doing", column: "done" });
106
+ expect(c.error).toBe("status_conflicts_with_column");
107
+ });
108
+
109
+ test("a bad status value is a schema error (-32602), like a bad column", async () => {
110
+ const out = await rpc(U.nodeA.token, "tools/call", { name: "requirements_update", arguments: { id: "#1", status: "finished" } });
111
+ expect(String(out.error?.code ?? out.result?.content?.[0]?.text)).toMatch(/-32602|invalid/i);
112
+ });
113
+
114
+ test("REST is unchanged: status on PATCH is still not a writable field", async () => {
115
+ const card = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:REST", column: "pool" })).requirement;
116
+ const r = await rest(U.member.token, "PATCH", `/api/requirements/${card.id}`, { status: "doing" });
117
+ expect(r.status).toBe(400);
118
+ expect(r.body.error).toBe("empty_patch");
119
+ expect(row(card.id).column_name).toBe("pool");
120
+ });
121
+ });
122
+
123
+ describe("participants_add / participants_remove", () => {
124
+ let card = { id: "", seq: 0 };
125
+ const node = (k: string) => ({ kind: "node", id: U[k].id });
126
+ const user = (k: string) => ({ kind: "user", id: U[k].id });
127
+
128
+ test("add keeps the people already there", async () => {
129
+ card = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:参与人", participants: [user("member"), node("nodeA")] })).requirement;
130
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: `#${card.seq}`, participants_add: [node("nodeB")] });
131
+ expect(keys(r.requirement.participants)).toEqual(keys([user("member"), node("nodeA"), node("nodeB")]));
132
+ expect(keys(JSON.parse(row(card.id).participants_json))).toEqual(keys([user("member"), node("nodeA"), node("nodeB")]));
133
+ });
134
+
135
+ test("add is idempotent: someone already there → same list, no participants event", async () => {
136
+ const events = participantEvents(card.id);
137
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants_add: [node("nodeB"), node("nodeA")] });
138
+ expect(keys(r.requirement.participants)).toEqual(keys([user("member"), node("nodeA"), node("nodeB")]));
139
+ expect(participantEvents(card.id)).toBe(events);
140
+ });
141
+
142
+ test("remove drops only those people; removing someone absent (or no longer in the network) is a no-op", async () => {
143
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants_remove: [user("member"), { kind: "user", id: "u_left_the_network" }] });
144
+ expect(r.ok).toBe(true);
145
+ expect(keys(r.requirement.participants)).toEqual(keys([node("nodeA"), node("nodeB")]));
146
+ });
147
+
148
+ test("add and remove in one call are applied together", async () => {
149
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants_add: [node("nodeC"), user("member")], participants_remove: [node("nodeA")] });
150
+ expect(keys(r.requirement.participants)).toEqual(keys([node("nodeB"), node("nodeC"), user("member")]));
151
+ });
152
+
153
+ test("concurrent adds from two Agents both stick (no lost update)", async () => {
154
+ const fresh = (await mcp(U.owner.token, "requirements_create", { name: "示例:并发", participants: [user("owner")], network_id: NET })).requirement;
155
+ await Promise.all([
156
+ mcp(U.nodeA.token, "requirements_update", { id: fresh.id, participants_add: [node("nodeA")] }),
157
+ mcp(U.nodeB.token, "requirements_update", { id: fresh.id, participants_add: [node("nodeB")] }),
158
+ mcp(U.nodeC.token, "requirements_update", { id: fresh.id, participants_add: [node("nodeC")] }),
159
+ ]);
160
+ expect(keys(JSON.parse(row(fresh.id).participants_json))).toEqual(keys([user("owner"), node("nodeA"), node("nodeB"), node("nodeC")]));
161
+ });
162
+
163
+ test("name forms (#473): add by alias / username, remove by username", async () => {
164
+ const fresh = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:按名字", participants: [node("nodeA")] })).requirement;
165
+ const memberName = db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", U.member.id)!.username;
166
+ const added = await mcp(U.nodeA.token, "requirements_update", { id: fresh.id, participants_add: [{ kind: "node", alias: "示例-乙" }, { kind: "user", username: memberName }] });
167
+ expect(keys(added.requirement.participants)).toEqual(keys([node("nodeA"), node("nodeB"), user("member")]));
168
+ const removed = await mcp(U.nodeA.token, "requirements_update", { id: fresh.id, participants_remove: [{ kind: "user", username: memberName }] });
169
+ expect(keys(removed.requirement.participants)).toEqual(keys([node("nodeA"), node("nodeB")]));
170
+ });
171
+
172
+ test("participants is still a full replace", async () => {
173
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants: [node("nodeA")] });
174
+ expect(keys(r.requirement.participants)).toEqual(keys([node("nodeA")]));
175
+ });
176
+
177
+ test("bad combinations → 400 with hints, nothing written", async () => {
178
+ const before = row(card.id);
179
+ const both = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants: [], participants_add: [node("nodeB")] });
180
+ expect(both.status).toBe(400);
181
+ expect(both.error).toBe("participants_conflict");
182
+ expect(both.hint).toContain("participants_add");
183
+ const overlap = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants_add: [node("nodeB")], participants_remove: [node("nodeB")] });
184
+ expect(overlap.error).toBe("participants_add_remove_overlap");
185
+ const stranger = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants_add: [{ kind: "node", id: "node_not_here" }] });
186
+ expect(stranger.error).toBe("person_not_in_network");
187
+ expect(stranger.field).toBe("participants_add");
188
+ expect(row(card.id)).toEqual(before);
189
+ });
190
+
191
+ test("REST accepts the same keys (additive; old clients never send them)", async () => {
192
+ const r = await rest(U.member.token, "PATCH", `/api/requirements/${card.id}`, { participants_add: [user("member")] });
193
+ expect(r.status).toBe(200);
194
+ // the member has no grant on nodeA, so the response leaves it out — but the stored list keeps it
195
+ expect(keys(JSON.parse(row(card.id).participants_json))).toEqual(keys([node("nodeA"), user("member")]));
196
+ expect(keys(r.body.requirement.participants)).toEqual(keys([user("member")]));
197
+ // …and the member cannot remove an Agent they cannot see (same error as a node that does not exist)
198
+ const hidden = await rest(U.member.token, "PATCH", `/api/requirements/${card.id}`, { participants_remove: [node("nodeA")] });
199
+ expect(hidden.status).toBe(400);
200
+ expect(hidden.body.error).toBe("person_not_in_network");
201
+ expect(keys(JSON.parse(row(card.id).participants_json))).toEqual(keys([node("nodeA"), user("member")]));
202
+ });
203
+ });
204
+
205
+ describe("tool descriptions", () => {
206
+ test("participants says it replaces; projects tools say node tokens only read; node token project write → 403", async () => {
207
+ const tools = (await rpc(U.nodeA.token, "tools/list", {})).result.tools as any[];
208
+ const byName = new Map(tools.map(t => [t.name, t]));
209
+ const update = byName.get("requirements_update");
210
+ expect(update.description).toContain("REPLACES the whole list");
211
+ expect(update.inputSchema.properties.participants.description).toContain("REPLACES");
212
+ expect(Object.keys(update.inputSchema.properties)).toEqual(expect.arrayContaining(["status", "participants_add", "participants_remove"]));
213
+ // #478:节点令牌的 tools/list 里本来就没有 projects_create / projects_update(调了也是 403),那两条的说明从人的列表里读。
214
+ expect(byName.has("projects_create")).toBe(false);
215
+ const human = new Map(((await rpc(U.member.token, "tools/list", {})).result.tools as any[]).map(t => [t.name, t]));
216
+ expect(byName.get("projects_list").description).toMatch(/node \(Agent\) tokens can only read projects/i);
217
+ for (const name of ["projects_create", "projects_update"]) expect(human.get(name).description).toMatch(/node \(Agent\) tokens can only read projects/i);
218
+ const denied = await mcp(U.nodeA.token, "projects_create", { name: "示例项目" });
219
+ expect(denied.status).toBe(403);
220
+ expect(denied.error).toBe("user_token_required");
221
+ });
222
+ });
@@ -0,0 +1,126 @@
1
+ // 负责人 / 负责 Agent 拆分:一次性 Hub 进程的升级端到端。
2
+ // 真起两次 `bun run src/index.ts`(HOME=临时目录、随机端口、绝不是 9200、独立库):
3
+ // 第一次:注册、建节点、建一张「旧式」卡(owner 是节点 —— 用 SQL 写进去,因为新 Hub 已不接受);
4
+ // 再把 agent_owner_json 列删掉,库回到 #2065 时代的样子。
5
+ // 第二次:同一个库启动 = 升级。启动迁移把节点挪到 agent_owner,HTTP 读回;然后走一遍新规则。
6
+ import { afterAll, expect, test } from "bun:test";
7
+ import { Database } from "bun:sqlite";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, resolve } from "node:path";
11
+ import { spawn, type ChildProcess } from "node:child_process";
12
+
13
+ const home = mkdtempSync(join(tmpdir(), "anet-req-agent-owner-e2e-"));
14
+ const dbFile = join(home, "hub.db");
15
+ const port = 20000 + Math.floor(Math.random() * 20000);
16
+ if (port === 9200) throw new Error("9200 is the production hub port");
17
+ const base = `http://127.0.0.1:${port}`;
18
+ const serverDir = resolve(import.meta.dir, "..");
19
+ let proc: ChildProcess | null = null;
20
+
21
+ async function startHub() {
22
+ proc = spawn("bun", ["run", "src/index.ts"], {
23
+ cwd: serverDir,
24
+ env: { PATH: process.env.PATH || "", HOME: home, COMMHUB_DB: dbFile, COMMHUB_SERVER: "1", PORT: String(port), HOST: "127.0.0.1" },
25
+ stdio: ["ignore", "ignore", "pipe"],
26
+ });
27
+ let err = "";
28
+ proc.stderr?.on("data", d => { err += String(d); });
29
+ for (let i = 0; i < 100; i++) {
30
+ try { if ((await fetch(`${base}/health`)).ok) return; } catch {}
31
+ await new Promise(r => setTimeout(r, 150));
32
+ }
33
+ throw new Error(`hub did not start on ${port}: ${err.slice(-600)}`);
34
+ }
35
+ async function stopHub() {
36
+ if (!proc) return;
37
+ const p = proc;
38
+ proc = null;
39
+ await new Promise<void>(res => { p.once("exit", () => res()); p.kill("SIGTERM"); setTimeout(() => { p.kill("SIGKILL"); res(); }, 5000); });
40
+ }
41
+ afterAll(async () => { await stopHub(); try { rmSync(home, { recursive: true, force: true }); } catch {} });
42
+
43
+ async function api(token: string, path: string, init?: RequestInit) {
44
+ const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
45
+ return { status: res.status, body: await res.json() as any };
46
+ }
47
+
48
+ test("an upgraded hub moves node owners to agent_owner and then enforces human owner / agent executor", async () => {
49
+ await startHub();
50
+ const reg = await (await fetch(`${base}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "e2e_owner", password: "E2eOwner123!pass" }) })).json() as any;
51
+ expect(reg.ok).toBe(true);
52
+ const token = reg.token as string, net = reg.network_id as string, uid = reg.user.user_id as string;
53
+ const human = { kind: "user", id: uid }, agent = { kind: "node", id: "node_e2e_exec" };
54
+ {
55
+ const db = new Database(dbFile);
56
+ db.run("INSERT INTO nodes (node_id,node_name,alias,network_id) VALUES (?1,'e2e-exec','e2e-exec',?2)", [agent.id, net]);
57
+ db.close();
58
+ }
59
+ const card = await api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "旧式卡", network_id: net, participants: [human] }) });
60
+ expect(card.status).toBe(201);
61
+ const keep = await api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "人类负责人卡", network_id: net, owner: human }) });
62
+ expect(keep.status).toBe(201);
63
+ await stopHub();
64
+ {
65
+ // 退回旧库:节点当负责人,且没有 agent_owner_json 列
66
+ const db = new Database(dbFile);
67
+ db.run("UPDATE requirements SET owner_json = ?1 WHERE requirement_id = ?2", [JSON.stringify(agent), card.body.requirement.id]);
68
+ db.exec("ALTER TABLE requirements DROP COLUMN agent_owner_json");
69
+ const cols = (db.query("PRAGMA table_info(requirements)").all() as { name: string }[]).map(c => c.name);
70
+ expect(cols.includes("agent_owner_json")).toBe(false);
71
+ db.close();
72
+ }
73
+ await startHub();
74
+ const listed = await api(token, `/api/requirements?network_id=${net}`);
75
+ expect(listed.status).toBe(200);
76
+ expect(listed.body.requirements.length).toBe(2);
77
+ const upgraded = listed.body.requirements.find((r: any) => r.id === card.body.requirement.id);
78
+ expect(upgraded.owner).toBeNull();
79
+ expect(upgraded.agent_owner).toEqual(agent);
80
+ expect(upgraded.participants).toEqual([human]);
81
+ expect(upgraded.name).toBe("旧式卡");
82
+ const untouched = listed.body.requirements.find((r: any) => r.id === keep.body.requirement.id);
83
+ expect(untouched.owner).toEqual(human);
84
+ expect(untouched.agent_owner).toBeNull();
85
+
86
+ const path = `/api/requirements/${upgraded.id}?network_id=${net}`;
87
+ const both = await api(token, path, { method: "PATCH", body: JSON.stringify({ owner: human }) });
88
+ expect(both.status).toBe(200);
89
+ expect(both.body.requirement.owner).toEqual(human);
90
+ expect(both.body.requirement.agent_owner).toEqual(agent);
91
+ expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ owner: agent, agent_owner: agent }) })).body.error).toBe("owner_must_be_human");
92
+ expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ agent_owner: human }) })).body.error).toBe("agent_owner_must_be_agent");
93
+
94
+ // 预计完成:旧卡的全天值(升级前写的)原样读回;改成带时刻的,存 UTC
95
+ const dated = await api(token, path, { method: "PATCH", body: JSON.stringify({ due: "2026-10-01" }) });
96
+ expect(dated.body.requirement.due).toBe("2026-10-01");
97
+ // 描述 / 子任务:升级后的旧卡可写,单项勾选只动那一项
98
+ expect(upgraded.description).toBe("");
99
+ expect(upgraded.checklist).toEqual([]);
100
+ const withList = await api(token, path, { method: "PATCH", body: JSON.stringify({ description: "# 验收\n- 能用", checklist: [{ id: "one", text: "第一步" }, { id: "two", text: "第二步" }] }) });
101
+ expect(withList.status).toBe(200);
102
+ const ticked = await api(token, `/api/requirements/${upgraded.id}/checklist/two?network_id=${net}`, { method: "PATCH", body: JSON.stringify({ done: true }) });
103
+ expect(ticked.body.requirement.checklist).toEqual([{ id: "one", text: "第一步", done: false }, { id: "two", text: "第二步", done: true }]);
104
+
105
+ // 项目:升级后没有任何项目(不预置),建一个、给旧卡挂上
106
+ expect((await api(token, `/api/requirements/projects?network_id=${net}`)).body.projects).toEqual([]);
107
+ const proj = await api(token, `/api/requirements/projects?network_id=${net}`, { method: "POST", body: JSON.stringify({ name: "军团项目" }) });
108
+ expect(proj.status).toBe(201);
109
+ expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ project_id: proj.body.project.id }) })).body.requirement.project_id).toBe(proj.body.project.id);
110
+
111
+ // 第三次启动(同一个库):迁移不再改任何东西
112
+ await stopHub();
113
+ await startHub();
114
+ const again = (await api(token, `/api/requirements?network_id=${net}`)).body.requirements;
115
+ expect(again.length).toBe(2);
116
+ const same = again.find((r: any) => r.id === upgraded.id);
117
+ expect(same.owner).toEqual(human);
118
+ expect(same.agent_owner).toEqual(agent);
119
+ expect(same.description).toBe("# 验收\n- 能用");
120
+ expect(same.checklist.map((i: any) => i.done)).toEqual([false, true]);
121
+ expect(same.project_id).toBe(proj.body.project.id);
122
+ expect(same.due).toBe("2026-10-01");
123
+ const timed = await api(token, `/api/requirements/${upgraded.id}?network_id=${net}`, { method: "PATCH", body: JSON.stringify({ due: "2026-10-01T18:30:45+08:00" }) });
124
+ expect(timed.body.requirement.due).toBe("2026-10-01T10:30:45Z");
125
+ expect((await api(token, `/api/requirements/projects?network_id=${net}`)).body.projects.map((p: any) => p.name)).toEqual(["军团项目"]);
126
+ }, 60_000);
@@ -0,0 +1,98 @@
1
+ // 负责人 / 负责 Agent 拆分的启动迁移:旧库里 owner 是节点的卡,节点挪到 agent_owner、owner 置空。
2
+ // 用 #2065 时代的表结构(没有 agent_owner_json 列)先建库、塞行,再 import db.ts + requirements.ts(真实启动路径)。
3
+ import { afterAll, expect, test } from "bun:test";
4
+ import { Database } from "bun:sqlite";
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-agent-owner-"));
10
+ const file = join(dir, "hub.db");
11
+ process.env.COMMHUB_DB = file;
12
+
13
+ const legacy = new Database(file);
14
+ legacy.exec(`CREATE TABLE requirements (
15
+ requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
16
+ column_name TEXT NOT NULL DEFAULT 'pool', priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
17
+ client_id TEXT, issues_json TEXT, created_by TEXT,
18
+ created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
19
+ owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]')`);
20
+ const rows: [string, string | null, string][] = [
21
+ ["r_node_owner", JSON.stringify({ kind: "node", id: "node_x" }), JSON.stringify([{ kind: "user", id: "u1" }])],
22
+ ["r_user_owner", JSON.stringify({ kind: "user", id: "u1" }), "[]"],
23
+ ["r_no_owner", null, "[]"],
24
+ ["r_bad_json", "{not json", "[]"],
25
+ ["r_node_no_id", JSON.stringify({ kind: "node" }), "[]"],
26
+ ["r_node_owner_2", JSON.stringify({ kind: "node", id: "node_y" }), "[]"],
27
+ ];
28
+ for (const [id, owner, participants] of rows) {
29
+ legacy.run("INSERT INTO requirements (requirement_id, network_id, title, due_on, assignee, owner_json, participants_json) VALUES (?1, 'net', ?2, '2026-10-01', 'legacy', ?3, ?4)", [id, `卡 ${id}`, owner, participants]);
30
+ }
31
+ // 升级前就在「完成」列的一张(旧库 updated_at 是 datetime('now') 的「YYYY-MM-DD HH:MM:SS」UTC 形状)。
32
+ legacy.run("UPDATE requirements SET column_name = 'done', updated_at = '2026-09-01 10:00:00' WHERE requirement_id = 'r_user_owner'");
33
+ legacy.close();
34
+
35
+ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
36
+
37
+ const snapshot = (db: any) => db.all("SELECT requirement_id, network_id, title, due_on, assignee, participants_json, owner_json, agent_owner_json, project_id FROM requirements ORDER BY requirement_id") as any[];
38
+
39
+ test("startup migration moves node owners to agent_owner, keeps every row, and is idempotent", async () => {
40
+ const { db } = await import("./db.js");
41
+ await import("./requirements.js"); // 真实启动路径:server.ts 载入 requirements.ts 时迁移
42
+ const { migrateRequirementAgentOwners } = await import("./requirements-migrate.js");
43
+ const after = snapshot(db);
44
+ expect(after.length).toBe(rows.length);
45
+ const by = Object.fromEntries(after.map(r => [r.requirement_id, r]));
46
+ expect(by.r_node_owner.owner_json).toBeNull();
47
+ expect(JSON.parse(by.r_node_owner.agent_owner_json)).toEqual({ kind: "node", id: "node_x" });
48
+ expect(by.r_node_owner.participants_json).toBe(JSON.stringify([{ kind: "user", id: "u1" }]));
49
+ expect(JSON.parse(by.r_node_owner_2.agent_owner_json)).toEqual({ kind: "node", id: "node_y" });
50
+ expect(JSON.parse(by.r_user_owner.owner_json)).toEqual({ kind: "user", id: "u1" });
51
+ expect(by.r_user_owner.agent_owner_json).toBeNull();
52
+ expect(by.r_no_owner.owner_json).toBeNull();
53
+ // 读不懂的旧值原样留着,不猜、不删
54
+ expect(by.r_bad_json.owner_json).toBe("{not json");
55
+ expect(by.r_node_no_id.owner_json).toBe(JSON.stringify({ kind: "node" }));
56
+ for (const r of after) {
57
+ expect(r.title).toBe(`卡 ${r.requirement_id}`);
58
+ expect(r.network_id).toBe("net");
59
+ expect(r.due_on).toBe("2026-10-01");
60
+ expect(r.assignee).toBe("legacy");
61
+ }
62
+ // 加列只加不改:旧行的描述 / 子任务是空
63
+ const cols = (db.all("PRAGMA table_info(requirements)") as { name: string }[]).map(c => c.name);
64
+ expect(cols.includes("description") && cols.includes("checklist_json") && cols.includes("agent_owner_json")).toBe(true);
65
+ const extra = db.all("SELECT description, checklist_json FROM requirements") as any[];
66
+ expect(extra.every(r => r.description === null && r.checklist_json === null)).toBe(true);
67
+ // 项目表:建了、是空的(不预置),重复执行不报错;卡片的 project_id 列只加不改
68
+ expect(cols.includes("project_id")).toBe(true);
69
+ // PR B:外部引用 / 归档 / 谁做的 —— 只加不改;旧行 archived = 0、其余 NULL;external_ref 的部分唯一索引建了
70
+ for (const c of ["external_ref", "external_url", "archived", "created_by_json", "updated_by_json", "parent_id"]) expect(cols.includes(c)).toBe(true);
71
+ expect((db.all("SELECT parent_id FROM requirements WHERE parent_id IS NOT NULL") as any[]).length).toBe(0);
72
+ expect((db.all("PRAGMA index_list(requirements)") as { name: string }[]).some(i => i.name === "idx_requirements_parent")).toBe(true);
73
+ expect((db.all("SELECT archived, external_ref FROM requirements") as any[]).every(r => r.archived === 0 && r.external_ref === null)).toBe(true);
74
+ const idx = (db.all("PRAGMA index_list(requirements)") as { name: string; unique: number }[]).find(i => i.name === "idx_requirements_external_ref");
75
+ expect(idx?.unique).toBe(1);
76
+ const { ensureRequirementIndexes } = await import("./requirements-migrate.js");
77
+ ensureRequirementIndexes(db);
78
+ expect((db.all("SELECT project_id FROM requirements WHERE project_id IS NOT NULL") as any[]).length).toBe(0);
79
+ expect((db.get("SELECT COUNT(*) AS n FROM requirement_projects") as any).n).toBe(0);
80
+ const { ensureRequirementProjects } = await import("./requirements-migrate.js");
81
+ ensureRequirementProjects(db);
82
+ ensureRequirementProjects(db);
83
+ expect((db.get("SELECT COUNT(*) AS n FROM requirement_projects") as any).n).toBe(0);
84
+ // 完成时间:三列加上;升级前已完成的卡按 updated_at 补成 ISO、标近似、完成者留空;别的卡不动;索引建了
85
+ for (const c of ["completed_at", "completed_by_json", "completed_at_approx"]) expect(cols.includes(c)).toBe(true);
86
+ const completed = db.all("SELECT requirement_id, completed_at, completed_by_json, completed_at_approx FROM requirements ORDER BY requirement_id") as any[];
87
+ for (const r of completed) {
88
+ if (r.requirement_id === "r_user_owner") expect(r).toEqual({ requirement_id: "r_user_owner", completed_at: "2026-09-01T10:00:00.000Z", completed_by_json: null, completed_at_approx: 1 });
89
+ else expect([r.completed_at, r.completed_by_json, r.completed_at_approx]).toEqual([null, null, 0]);
90
+ }
91
+ expect((db.all("PRAGMA index_list(requirements)") as { name: string }[]).some(i => i.name === "idx_requirements_network_completed")).toBe(true);
92
+ const { ensureRequirementCompletedAt } = await import("./requirements-migrate.js");
93
+ // 再跑一次(= 下次启动):什么都不动
94
+ expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
95
+ expect(db.all("SELECT requirement_id, completed_at, completed_by_json, completed_at_approx FROM requirements ORDER BY requirement_id")).toEqual(completed);
96
+ expect(migrateRequirementAgentOwners(db).moved).toBe(0);
97
+ expect(snapshot(db)).toEqual(after);
98
+ });