@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,89 @@
1
+ /** Small, network-local labels; count Unicode code points rather than UTF-16 units. */
2
+ export function normalizeTags(value: unknown): string[] | null {
3
+ if (!Array.isArray(value) || value.length > 10) return null;
4
+ const tags: string[] = [];
5
+ for (const valueTag of value) {
6
+ if (typeof valueTag !== "string") return null;
7
+ const tag = valueTag.trim();
8
+ if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return null;
9
+ if (!tags.includes(tag)) tags.push(tag);
10
+ }
11
+ return tags;
12
+ }
13
+
14
+ export function storedTags(value: string | null): string[] {
15
+ try { return normalizeTags(JSON.parse(value || "[]")) ?? []; } catch { return []; }
16
+ }
17
+
18
+ // ── 标签管理(改名 / 合并 / 删除 / 颜色) ──
19
+ // 标签只是卡片 tags 数组里的字符串,没有自己的表;批量操作 = 把网络里每张带这个标签的卡的数组改写一遍。
20
+ // 颜色是可选的,单独存在 network_tags(network_id, name, color);没行 = 客户端用默认灰。
21
+ export const MAX_MERGE_SOURCES = 50;
22
+ export type TagOp =
23
+ | { op: "rename"; from: string; to: string }
24
+ | { op: "merge"; from: string[]; to: string }
25
+ | { op: "delete"; tag: string }
26
+ | { op: "color"; tag: string; color: string | null };
27
+
28
+ /** 单个标签名,规则同 normalizeTags。不合法 = null。 */
29
+ export function tagName(value: unknown): string | null {
30
+ const tags = normalizeTags([value]);
31
+ return tags && tags.length === 1 ? tags[0] : null;
32
+ }
33
+
34
+ const TAG_COLOR = /^#[0-9a-fA-F]{6}$/;
35
+ /** 请求体 → 操作;不合法返回错误码。 */
36
+ export function parseTagOp(body: Record<string, unknown>): TagOp | { error: string } {
37
+ switch (body.op) {
38
+ case "rename": {
39
+ const from = tagName(body.from), to = tagName(body.to);
40
+ if (!from || !to) return { error: "invalid_tag" };
41
+ if (from === to) return { error: "same_tag" };
42
+ return { op: "rename", from, to };
43
+ }
44
+ case "merge": {
45
+ if (!Array.isArray(body.from) || body.from.length === 0 || body.from.length > MAX_MERGE_SOURCES) return { error: "invalid_tag" };
46
+ const from: string[] = [];
47
+ for (const value of body.from) {
48
+ const tag = tagName(value);
49
+ if (!tag) return { error: "invalid_tag" };
50
+ if (!from.includes(tag)) from.push(tag);
51
+ }
52
+ const to = tagName(body.to);
53
+ if (!to) return { error: "invalid_tag" };
54
+ // 目标自己出现在来源里不算来源(合并进自己 = 什么都不做)。
55
+ const sources = from.filter(tag => tag !== to);
56
+ if (!sources.length) return { error: "same_tag" };
57
+ return { op: "merge", from: sources, to };
58
+ }
59
+ case "delete": {
60
+ const tag = tagName(body.tag);
61
+ return tag ? { op: "delete", tag } : { error: "invalid_tag" };
62
+ }
63
+ case "color": {
64
+ const tag = tagName(body.tag);
65
+ if (!tag) return { error: "invalid_tag" };
66
+ if (body.color !== null && (typeof body.color !== "string" || !TAG_COLOR.test(body.color))) return { error: "invalid_tag_color" };
67
+ return { op: "color", tag, color: body.color === null ? null : (body.color as string).toLowerCase() };
68
+ }
69
+ default:
70
+ return { error: "invalid_tag_op" };
71
+ }
72
+ }
73
+
74
+ /** 这张卡的标签被这个操作改写后的样子;没变返回 null。顺序保留(新名字占第一个来源的位置),去重。 */
75
+ export function applyTagOp(tags: string[], op: TagOp): string[] | null {
76
+ let sources: string[];
77
+ let to: string | null;
78
+ if (op.op === "rename") { sources = [op.from]; to = op.to; }
79
+ else if (op.op === "merge") { sources = op.from; to = op.to; }
80
+ else if (op.op === "delete") { sources = [op.tag]; to = null; }
81
+ else return null;
82
+ if (!tags.some(tag => sources.includes(tag))) return null;
83
+ const next: string[] = [];
84
+ for (const tag of tags) {
85
+ const mapped = sources.includes(tag) ? to : tag;
86
+ if (mapped !== null && !next.includes(mapped)) next.push(mapped);
87
+ }
88
+ return next;
89
+ }
@@ -0,0 +1,236 @@
1
+ // 任务状态「废弃」(abandoned)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ // test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
3
+ //
4
+ // 废弃是关闭态(同 done):不算开着、不逾期、不发到期提醒、不记完成时间;改进 / 改出都记动态;能改回 pool / doing。
5
+ // 旧 App(desktop ≤ 0.2.220)把不认识的状态当 pool —— 所以没声明 `X-Anet-Accept-Columns: abandoned`
6
+ // (或 `accept_columns=abandoned`)的调用者看到的是 done;MCP 总是声明,看到真值。
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-abandoned-"));
13
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
14
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
15
+ process.env.HOST = "127.0.0.1";
16
+ for (const k of ["COMMHUB_DUE_REMINDER_TZ", "COMMHUB_DUE_OVERDUE_MAX_DAYS", "COMMHUB_DUE_REMINDER_NETWORKS", "COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDERS_OWNERS"]) delete process.env[k];
17
+ const PW = "AbandonedPassw0rd!x";
18
+ const T0 = Date.UTC(2026, 9, 10, 2, 0, 0);
19
+ const DAY = 86_400_000;
20
+ const AWARE = { "X-Anet-Accept-Columns": "abandoned" };
21
+
22
+ let BASE = "";
23
+ let hub: any = null;
24
+ let NET = "";
25
+ let TOKEN = "";
26
+ let OWNER = "";
27
+ let db: any;
28
+ let due: typeof import("./requirement-due-reminders.js");
29
+
30
+ type R = { status: number; body: any };
31
+ async function send(method: string, path: string, payload?: unknown, headers: Record<string, string> = {}): Promise<R> {
32
+ const res = await fetch(`${BASE}${path}`, {
33
+ method,
34
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...headers },
35
+ body: payload === undefined ? undefined : JSON.stringify(payload),
36
+ }, 60_000);
37
+ const text = await res.text();
38
+ let body: any = null;
39
+ try { body = JSON.parse(text); } catch {}
40
+ return { status: res.status, body };
41
+ }
42
+ async function mcp(name: string, args: Record<string, unknown>): Promise<any> {
43
+ const res = await fetch(`${BASE}/mcp`, {
44
+ method: "POST",
45
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
46
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
47
+ }, 60_000);
48
+ const raw = await res.text();
49
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
50
+ const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
51
+ return JSON.parse(payload.result.content[0].text);
52
+ }
53
+ async function card(name: string, extra: Record<string, unknown> = {}): Promise<string> {
54
+ const r = await send("POST", "/api/requirements", { network_id: NET, name, owner: { kind: "user", id: OWNER }, ...extra });
55
+ expect(r.status).toBe(201);
56
+ return r.body.requirement.id as string;
57
+ }
58
+ const get = (id: string, headers: Record<string, string> = {}) => send("GET", `/api/requirements/${id}?network_id=${NET}`, undefined, headers);
59
+ const patch = (id: string, body: Record<string, unknown>, headers: Record<string, string> = {}) => send("PATCH", `/api/requirements/${id}?network_id=${NET}`, body, headers);
60
+ const list = (qs = "", headers: Record<string, string> = {}) => send("GET", `/api/requirements?network_id=${NET}${qs}`, undefined, headers);
61
+ const ids = (r: R) => (r.body.requirements as any[]).map(x => x.id);
62
+ const columnIn = (r: R, id: string) => (r.body.requirements as any[]).find(x => x.id === id)?.column;
63
+ const sentFor = (out: Array<{ requirement_id: string; kind: string }>, id: string) => out.filter(x => x.requirement_id === id).map(x => x.kind);
64
+ const columnEvents = async (id: string) => ((await send("GET", `/api/requirements/events?network_id=${NET}&requirement_id=${id}`, undefined, AWARE)).body.events as any[])
65
+ .filter(e => e.kind === "changed" && e.field === "column").map(e => [e.old, e.new]).reverse();
66
+
67
+ beforeAll(async () => {
68
+ ({ db } = await import("./db.js"));
69
+ const { register } = await import("./auth.js");
70
+ const r = register(`ab_owner_${Date.now()}`, PW);
71
+ expect(r.ok).toBe(true);
72
+ TOKEN = r.token!;
73
+ OWNER = r.user!.user_id;
74
+ NET = r.network_id!;
75
+ due = await import("./requirement-due-reminders.js");
76
+ const mod: any = await import("./server.js");
77
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
78
+ BASE = `http://127.0.0.1:${hub.port}`;
79
+ due.runDueReminders({ now: T0 - 30 * DAY }); // 到期提醒基线(同 requirement-due-reminders-http.test.ts)
80
+ }, 30_000);
81
+
82
+ afterAll(() => {
83
+ try { hub?.stop?.(true); } catch {}
84
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
85
+ });
86
+
87
+ describe("abandoned column", () => {
88
+ test("schema accepts abandoned; the legacy CHECK migration is idempotent (SQLite rebuild / PG constraint swap)", async () => {
89
+ const { migrateRequirementColumnCheck } = await import("./requirements-migrate.js");
90
+ if (db.dialect === "postgres") {
91
+ // 模拟旧 PG 库:换回旧约束,迁移后 abandoned 能写,再跑一次照样不报错。
92
+ db.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check");
93
+ db.exec("ALTER TABLE requirements ADD CONSTRAINT requirements_column_name_check CHECK(column_name IN ('pool', 'doing', 'done'))");
94
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(true);
95
+ // 已经认识 abandoned:不再动表(不是每次启动都删了重加)
96
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(false);
97
+ // 加约束失败要响亮地失败,而且是一条语句:旧约束原样留着,不会落到「没约束」
98
+ db.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check");
99
+ db.exec("ALTER TABLE requirements ADD CONSTRAINT requirements_column_name_check CHECK(column_name IN ('pool', 'doing', 'done', 'bogus'))");
100
+ db.run("INSERT INTO requirements (requirement_id, network_id, title, column_name) VALUES ('req_ab_bogus', ?1, 'bogus', 'bogus')", [NET]);
101
+ expect(() => migrateRequirementColumnCheck(db)).toThrow();
102
+ const def = (db.get("SELECT pg_get_constraintdef(c.oid) AS def FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid WHERE t.relname = 'requirements' AND c.conname = 'requirements_column_name_check'") as any)?.def ?? "";
103
+ expect(def).toContain("'bogus'");
104
+ db.run("DELETE FROM requirements WHERE requirement_id = 'req_ab_bogus'");
105
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(true);
106
+ } else {
107
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(false);
108
+ }
109
+ const id = await card("ab-schema");
110
+ expect((await patch(id, { column: "abandoned" }, AWARE)).status).toBe(200);
111
+ expect((await patch(id, { column: "cancelled" }, AWARE)).body.error).toBe("invalid_column");
112
+ expect((await list()).body.capabilities).toContain("column_abandoned");
113
+ }, 60_000);
114
+
115
+ test("set and restore: events recorded each way, no completedAt, restorable to pool and doing", async () => {
116
+ const id = await card("ab-set");
117
+ const a = await patch(id, { column: "abandoned" }, AWARE);
118
+ expect(a.status).toBe(200);
119
+ expect(a.body.requirement.column).toBe("abandoned");
120
+ expect(a.body.requirement.completedAt).toBeNull();
121
+ expect((await patch(id, { column: "doing" }, AWARE)).body.requirement.column).toBe("doing");
122
+ expect((await patch(id, { column: "abandoned" }, AWARE)).body.requirement.column).toBe("abandoned");
123
+ expect((await patch(id, { column: "pool" }, AWARE)).body.requirement.column).toBe("pool");
124
+ expect(await columnEvents(id)).toEqual([["pool", "abandoned"], ["abandoned", "doing"], ["doing", "abandoned"], ["abandoned", "pool"]]);
125
+ // 直接建在废弃列(带声明)也行
126
+ const created = await send("POST", "/api/requirements", { network_id: NET, name: "ab-born", column: "abandoned" }, AWARE);
127
+ expect(created.body.requirement.column).toBe("abandoned");
128
+ }, 60_000);
129
+
130
+ test("closed like done: not overdue, no due reminder, not counted as open in stats; restoring reopens it", async () => {
131
+ // id:到期提醒用假时钟 T0(10-10),到期 10-07 = 逾期 3 天;old:列表的 overdue 筛选用真时钟,到期给一个早就过了的日子。
132
+ const id = await card("ab-overdue", { due: "2026-10-07", column: "doing" });
133
+ const old = await card("ab-overdue-list", { due: "2020-01-01", column: "doing" });
134
+ expect(ids(await list("&overdue=1"))).toContain(old);
135
+ await patch(id, { column: "abandoned" }, AWARE);
136
+ await patch(old, { column: "abandoned" }, AWARE);
137
+ expect(ids(await list("&overdue=1"))).not.toContain(old);
138
+ expect(ids(await list("&overdue=0"))).toContain(old);
139
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual([]);
140
+ const stats = (await send("GET", `/api/requirements/stats?network_id=${NET}`)).body.totals;
141
+ const raw = db.all("SELECT column_name FROM requirements WHERE network_id = ?1 AND COALESCE(archived, 0) = 0", NET) as any[];
142
+ const n = (c: string) => raw.filter(r => r.column_name === c).length;
143
+ expect(stats.doing).toBe(n("doing"));
144
+ expect(stats.pool).toBe(n("pool"));
145
+ expect(stats.abandoned).toBe(n("abandoned"));
146
+ expect(n("abandoned")).toBeGreaterThan(0);
147
+ // 子任务进度不把废弃的子任务算进去
148
+ const parent = await card("ab-parent");
149
+ const kid = await card("ab-kid", { parent_id: parent });
150
+ await card("ab-kid2", { parent_id: parent });
151
+ await patch(kid, { column: "abandoned" }, AWARE);
152
+ // 声明了:废弃的子需求不算进度;没声明(旧 App 把它当 done 列出来):算进总数、算作完成,和它看到的列表对得上
153
+ expect((await get(parent, AWARE)).body.requirement.children).toEqual({ total: 1, done: 0 });
154
+ expect((await get(parent)).body.requirement.children).toEqual({ total: 2, done: 1 });
155
+ expect(columnIn(await list("&parent_id=" + parent), kid)).toBe("done");
156
+ // 改回 doing:又逾期、又提醒
157
+ await patch(id, { column: "doing" }, AWARE);
158
+ await patch(old, { column: "doing" }, AWARE);
159
+ expect(ids(await list("&overdue=1"))).toContain(old);
160
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual(["overdue"]);
161
+ }, 60_000);
162
+
163
+ test("old clients (no declaration) see abandoned as done; their writes cannot un-abandon it by echo", async () => {
164
+ const id = await card("ab-legacy");
165
+ await patch(id, { column: "abandoned" }, AWARE);
166
+ expect((await get(id)).body.requirement.column).toBe("done");
167
+ expect((await get(id, AWARE)).body.requirement.column).toBe("abandoned");
168
+ expect((await send("GET", `/api/requirements/${id}?network_id=${NET}&accept_columns=abandoned`)).body.requirement.column).toBe("abandoned");
169
+ expect(columnIn(await list(), id)).toBe("done");
170
+ expect(columnIn(await list("&view=summary"), id)).toBe("done");
171
+ expect(columnIn(await list("", AWARE), id)).toBe("abandoned");
172
+ // 列表缓存按声明分开:同一个查询串,先旧后新,不串值
173
+ expect(columnIn(await list(), id)).toBe("done");
174
+ expect(columnIn(await list("", AWARE), id)).toBe("abandoned");
175
+ // 旧客户端按 done 筛:带上废弃的(它眼里就是 done)
176
+ expect(ids(await list("&status=done"))).toContain(id);
177
+ expect(ids(await list("&status=done", AWARE))).not.toContain(id);
178
+ expect(ids(await list("&status=abandoned", AWARE))).toContain(id);
179
+ // 旧客户端改别的字段 / 回传它看到的 done:仍是废弃;响应里仍是 done
180
+ const p1 = await patch(id, { priority: "high" });
181
+ expect(p1.body.requirement.column).toBe("done");
182
+ const p2 = await patch(id, { column: "done" });
183
+ expect(p2.body.requirement.column).toBe("done");
184
+ expect((await get(id, AWARE)).body.requirement.column).toBe("abandoned");
185
+ // 旧客户端明确改回 pool:照办
186
+ expect((await patch(id, { column: "pool" })).body.requirement.column).toBe("pool");
187
+ // 新客户端从 abandoned 改成 done:真的完成
188
+ await patch(id, { column: "abandoned" }, AWARE);
189
+ const done = await patch(id, { column: "done" }, AWARE);
190
+ expect(done.body.requirement.column).toBe("done");
191
+ expect(done.body.requirement.completedAt).not.toBeNull();
192
+ }, 60_000);
193
+
194
+ test("events and last_event: undeclared callers see the projected column; done ↔ abandoned disappears for them", async () => {
195
+ const id = await card("ab-events");
196
+ await patch(id, { column: "abandoned" }, AWARE);
197
+ await patch(id, { column: "done" }, AWARE);
198
+ await patch(id, { column: "abandoned" }, AWARE);
199
+ const evs = async (headers: Record<string, string> = {}) => ((await send("GET", `/api/requirements/events?network_id=${NET}&requirement_id=${id}`, undefined, headers)).body.events as any[])
200
+ .filter(e => e.kind === "changed" && e.field === "column").map(e => [e.old, e.new]).reverse();
201
+ expect(await evs(AWARE)).toEqual([["pool", "abandoned"], ["abandoned", "done"], ["done", "abandoned"]]);
202
+ expect(await evs()).toEqual([["pool", "done"]]);
203
+ const legacyRaw = JSON.stringify((await send("GET", `/api/requirements/events?network_id=${NET}`)).body.events);
204
+ expect(legacyRaw).not.toContain("abandoned");
205
+ // 每行的 last_event:最新一条是 done → abandoned,旧客户端那里投影后前后一样 → 不给字段 / 摘要
206
+ const legacyRow = (await list()).body.requirements.find((r: any) => r.id === id);
207
+ expect(legacyRow.last_event.field).toBeNull();
208
+ expect(legacyRow.last_event.summary).toBeUndefined();
209
+ const awareRow = (await list("", AWARE)).body.requirements.find((r: any) => r.id === id);
210
+ expect(awareRow.last_event).toMatchObject({ field: "column", summary: "done → abandoned" });
211
+ await patch(id, { column: "doing" }, AWARE);
212
+ const back = (await list()).body.requirements.find((r: any) => r.id === id);
213
+ expect(back.last_event).toMatchObject({ field: "column", summary: "done → doing" });
214
+ expect(JSON.stringify((await list()).body.requirements)).not.toContain("abandoned");
215
+ }, 60_000);
216
+
217
+ test("checklist toggle response is projected for undeclared callers", async () => {
218
+ const id = await card("ab-check", { checklist: [{ id: "c1", text: "one" }] });
219
+ await patch(id, { column: "abandoned" }, AWARE);
220
+ const legacy = await send("PATCH", `/api/requirements/${id}/checklist/c1?network_id=${NET}`, { done: true });
221
+ expect(legacy.status).toBe(200);
222
+ expect(legacy.body.requirement.column).toBe("done");
223
+ const aware = await send("PATCH", `/api/requirements/${id}/checklist/c1?network_id=${NET}`, { done: false }, AWARE);
224
+ expect(aware.body.requirement.column).toBe("abandoned");
225
+ }, 60_000);
226
+
227
+ test("MCP declares support: requirements_update / get / list carry the real value", async () => {
228
+ const id = await card("ab-mcp");
229
+ const up = await mcp("requirements_update", { id, network_id: NET, status: "abandoned" });
230
+ expect(up.requirement.column).toBe("abandoned");
231
+ expect((await mcp("requirements_get", { id, network_id: NET })).requirement.column).toBe("abandoned");
232
+ const l = await mcp("requirements_list", { network_id: NET, status: "abandoned" });
233
+ expect(l.requirements.map((x: any) => x.id)).toContain(id);
234
+ expect((await mcp("requirements_update", { id, network_id: NET, column: "doing" })).requirement.column).toBe("doing");
235
+ }, 60_000);
236
+ });
@@ -0,0 +1,167 @@
1
+ // #474(MCP 任务生命周期测试报告问题 5)—— 评论 / 进展:只追加,进「动态」(requirement_events kind=comment),
2
+ // 不改任务本身。钉住:
3
+ // - Agent(节点令牌)经 MCP、人经 REST 都能评论,署名是调用者;description / updated_at 一个字节都不动;
4
+ // - requirements_events 里是 kind=comment、new.text 全文,单任务的动态体积可控;
5
+ // - 看不见这张卡的人评论 = 404(和读一样),只读角色 = 403,空 / 超长 = 400 + 提示;
6
+ // - 只追加:没有改 / 删评论的路由。
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
+ import { COMMENT_MAX_CHARS } from "./requirements.js";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-comment-"));
16
+ let BASE = "";
17
+ let hub: any = null;
18
+ const PW = "ReqCommentPassw0rd!xyz";
19
+ let NET = "";
20
+ const U: Record<string, { token: string; id: string }> = {};
21
+ let card = { id: "", seq: 0 };
22
+
23
+ async function rest(token: string, method: string, path: string, body?: unknown) {
24
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
25
+ const text = await res.text();
26
+ let json: any = null;
27
+ try { json = JSON.parse(text); } catch {}
28
+ return { status: res.status, body: json, bytes: text.length };
29
+ }
30
+ async function rpc(token: string, name: string, args: Record<string, unknown>): Promise<{ data: any; raw: any; bytes: number }> {
31
+ const res = await fetch(`${BASE}/mcp`, {
32
+ method: "POST",
33
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
34
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
35
+ });
36
+ const raw = await res.text();
37
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
38
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
39
+ const text = out.result?.content?.[0]?.text ?? "";
40
+ let data: any = null;
41
+ try { data = JSON.parse(text); } catch { data = text; }
42
+ return { data, raw: out, bytes: text.length };
43
+ }
44
+ const mcp = async (token: string, name: string, args: Record<string, unknown>) => (await rpc(token, name, args)).data;
45
+ const cardRow = (id: string) => db.get<{ description: string | null; updated_at: string | null; updated_by_json: string | null }>("SELECT description, updated_at, updated_by_json FROM requirements WHERE requirement_id = ?1", id)!;
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(`cmt_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 mk = async (username: string, role: string) => {
59
+ expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
60
+ const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
61
+ // #746 起新成员默认 scoped;这里的 member / viewer 模拟老成员('all'),scoped 那位下面再收窄。
62
+ expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${login.body.user.user_id}/task-grants`, { task_access: "all" })).status).toBe(200);
63
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
64
+ };
65
+ const stamp = Date.now();
66
+ U.member = await mk(`cmt_member_${stamp}`, "member");
67
+ U.viewer = await mk(`cmt_viewer_${stamp}`, "viewer");
68
+ U.scoped = await mk(`cmt_scoped_${stamp}`, "member");
69
+ expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.scoped.id}/task-grants`, { task_access: "scoped", project_grants: [] })).status).toBe(200);
70
+ U.nodeA = { token: createNetworkTokenForNode(U.owner.id, NET, "示例-甲", "node_cmt_a").token!, id: "node_cmt_a" };
71
+ const created = (await mcp(U.nodeA.token, "requirements_create", { name: "示例任务:评论", description: "## 背景\n原文。" })).requirement;
72
+ card = { id: created.id, seq: created.seq };
73
+ }, 30_000);
74
+
75
+ afterAll(() => {
76
+ try { hub?.stop?.(true); } catch {}
77
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
78
+ });
79
+
80
+ describe("requirements_comment", () => {
81
+ test("an Agent comments over MCP (#N): one comment event, attributed to the node; the task itself is untouched", async () => {
82
+ const before = cardRow(card.id);
83
+ const r = await mcp(U.nodeA.token, "requirements_comment", { id: `#${card.seq}`, text: "进展:示例草稿已写完,等人看。" });
84
+ expect(r.ok).toBe(true);
85
+ expect(r.event.kind).toBe("comment");
86
+ expect(r.event.field).toBeNull();
87
+ expect(r.event.new).toEqual({ text: "进展:示例草稿已写完,等人看。" });
88
+ expect(r.event.actor).toEqual({ kind: "node", id: "node_cmt_a" });
89
+ expect(r.event.requirement_id).toBe(card.id);
90
+ const after = cardRow(card.id);
91
+ expect(after).toEqual(before); // description, updated_at, updated_by all unchanged
92
+ });
93
+
94
+ test("a person comments over REST; both show in requirements_events with the full text, newest first", async () => {
95
+ const r = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: " 好的,我下午看。 " });
96
+ expect(r.status).toBe(201);
97
+ expect(r.body.event.new.text).toBe("好的,我下午看。"); // trimmed
98
+ expect(r.body.event.actor).toEqual({ kind: "user", id: U.member.id });
99
+ const ev = await rpc(U.nodeA.token, "requirements_events", { requirement_id: `#${card.seq}` });
100
+ const comments = ev.data.events.filter((e: any) => e.kind === "comment");
101
+ expect(comments.map((e: any) => e.new.text)).toEqual(["好的,我下午看。", "进展:示例草稿已写完,等人看。"]);
102
+ expect(ev.data.events.at(-1).kind).toBe("created");
103
+ });
104
+
105
+ test("full text up to the cap, and the timeline stays small", async () => {
106
+ const long = "长".repeat(COMMENT_MAX_CHARS);
107
+ const r = await mcp(U.nodeA.token, "requirements_comment", { id: card.id, text: long });
108
+ expect(r.ok).toBe(true);
109
+ expect(r.event.new.text.length).toBe(COMMENT_MAX_CHARS);
110
+ const ev = await rpc(U.nodeA.token, "requirements_events", { requirement_id: card.id });
111
+ // 1 created + 3 comments (one of them 4000 chars): well under 20 KB.
112
+ expect(ev.bytes).toBeLessThan(20_000);
113
+ expect(ev.data.events.some((e: any) => e.kind === "comment" && e.new.text === long)).toBe(true);
114
+ });
115
+
116
+ test("bad input: empty → invalid_comment, over the cap → comment_too_long (REST), MCP schema → -32602; both with hints", async () => {
117
+ const empty = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: " " });
118
+ expect(empty.status).toBe(400);
119
+ expect(empty.body.error).toBe("invalid_comment");
120
+ expect(empty.body.field).toBe("text");
121
+ const tooLong = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: "x".repeat(COMMENT_MAX_CHARS + 1) });
122
+ expect(tooLong.status).toBe(400);
123
+ expect(tooLong.body.error).toBe("comment_too_long");
124
+ expect(tooLong.body.hint).toContain("Split");
125
+ const viaMcp = await rpc(U.nodeA.token, "requirements_comment", { id: card.id, text: "x".repeat(COMMENT_MAX_CHARS + 1) });
126
+ expect(String(viaMcp.raw.error?.code ?? viaMcp.raw.result?.content?.[0]?.text)).toMatch(/-32602|too big|Too big/i);
127
+ });
128
+
129
+ test("permissions: a non-reader gets 404 (like reading), a read-only role 403; neither writes anything", async () => {
130
+ const count = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_events WHERE requirement_id = ?1 AND kind = 'comment'", card.id)!.n;
131
+ const n = count();
132
+ // scoped member with no grant cannot see the task → cannot read it → cannot comment on it.
133
+ expect((await rest(U.scoped.token, "GET", `/api/requirements/${card.id}`)).status).toBe(404);
134
+ const scoped = await rest(U.scoped.token, "POST", `/api/requirements/${card.id}/comments`, { text: "看不见也想评论" });
135
+ expect(scoped.status).toBe(404);
136
+ expect(scoped.body.error).toBe("requirement_not_found");
137
+ const viewer = await rest(U.viewer.token, "POST", `/api/requirements/${card.id}/comments`, { text: "只读角色" });
138
+ expect(viewer.status).toBe(403);
139
+ expect(viewer.body.error).toBe("permission_denied");
140
+ expect(count()).toBe(n);
141
+ // …and the scoped member does not see the comments in the network timeline either.
142
+ const ev = await rest(U.scoped.token, "GET", `/api/requirements/events?network_id=${NET}`);
143
+ expect((ev.body.events ?? []).some((e: any) => e.requirement_id === card.id)).toBe(false);
144
+ });
145
+
146
+ test("append-only: no route edits or deletes a comment; unknown task → requirement_not_found with a hint", async () => {
147
+ expect((await rest(U.owner.token, "PATCH", `/api/requirements/${card.id}/comments`, { text: "改" })).status).toBe(404);
148
+ expect((await rest(U.owner.token, "DELETE", `/api/requirements/${card.id}/comments`)).status).toBe(404);
149
+ const missing = await mcp(U.nodeA.token, "requirements_comment", { id: "#9999", text: "没有这张卡" });
150
+ expect(missing.error).toBe("requirement_not_found");
151
+ expect(missing.hint).toContain("requirements_list");
152
+ });
153
+
154
+ test("discoverable: tools/list carries requirements_comment, and requirements_events mentions comments", async () => {
155
+ const res = await fetch(`${BASE}/mcp`, {
156
+ method: "POST",
157
+ headers: { Authorization: `Bearer ${U.nodeA.token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
158
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
159
+ });
160
+ const raw = await res.text();
161
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
162
+ const tools = (lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw)).result.tools as any[];
163
+ const byName = new Map(tools.map(t => [t.name, t]));
164
+ expect(String(byName.get("requirements_comment")?.description)).toContain("append-only");
165
+ expect(String(byName.get("requirements_events")?.description)).toContain("kind=comment");
166
+ });
167
+ });
@@ -0,0 +1,70 @@
1
+ // 完成时间的启动迁移(requirements-migrate.ts ensureRequirementCompletedAt):用加完成时间之前的表结构建库、塞行,
2
+ // 再 import db.ts + requirements.ts(真实启动路径)。覆盖:两种旧时间形状、没有 updated_at 的行、
3
+ // 回滚到旧 Hub 期间「移出完成」留下的残值被清掉、「移进完成」的卡被补上,以及重复执行不再改任何东西。
4
+ import { afterAll, expect, test } from "bun:test";
5
+ import { Database } from "bun:sqlite";
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-completed-at-"));
11
+ const file = join(dir, "hub.db");
12
+ process.env.COMMHUB_DB = file;
13
+
14
+ const legacy = new Database(file);
15
+ legacy.exec(`CREATE TABLE requirements (
16
+ requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
17
+ column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')), priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
18
+ client_id TEXT, issues_json TEXT, created_by TEXT,
19
+ created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
20
+ owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]', updated_by_json TEXT)`);
21
+ const seed: [string, string, string, string][] = [
22
+ // id, column, created_at, updated_at
23
+ ["r_done_sqlite_ts", "done", "2026-08-01 08:00:00", "2026-09-01 10:00:00"],
24
+ ["r_done_iso", "done", "2026-08-02T00:00:00.000Z", "2026-09-02T03:04:05.678Z"],
25
+ ["r_done_bad_updated", "done", "2026-08-03T00:00:00.000Z", "not a time"],
26
+ ["r_doing", "doing", "2026-08-04T00:00:00.000Z", "2026-09-04T00:00:00.000Z"],
27
+ ["r_pool", "pool", "2026-08-05T00:00:00.000Z", "2026-09-05T00:00:00.000Z"],
28
+ ];
29
+ for (const [id, column, createdAt, updatedAt] of seed) {
30
+ legacy.run("INSERT INTO requirements (requirement_id, network_id, title, column_name, created_at, updated_at, updated_by_json) VALUES (?1, 'net', ?1, ?2, ?3, ?4, ?5)",
31
+ [id, column, createdAt, updatedAt, JSON.stringify({ kind: "user", id: "u_last_editor" })]);
32
+ }
33
+ legacy.close();
34
+
35
+ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
36
+
37
+ const read = (db: any) => Object.fromEntries((db.all("SELECT requirement_id, column_name, updated_at, completed_at, completed_by_json, completed_at_approx FROM requirements") as any[]).map(r => [r.requirement_id, r]));
38
+
39
+ test("backfill: done rows get an approximate ISO completed_at from updated_at; others stay empty; idempotent", async () => {
40
+ const { db } = await import("./db.js");
41
+ await import("./requirements.js");
42
+ const { ensureRequirementCompletedAt } = await import("./requirements-migrate.js");
43
+ const by = read(db);
44
+ expect(by.r_done_sqlite_ts.completed_at).toBe("2026-09-01T10:00:00.000Z");
45
+ expect(by.r_done_iso.completed_at).toBe("2026-09-02T03:04:05.678Z");
46
+ // updated_at 读不懂 → 退到 created_at,而不是留空(留空的完成卡在仪表盘上就「没完成过」)
47
+ expect(by.r_done_bad_updated.completed_at).toBe("2026-08-03T00:00:00.000Z");
48
+ for (const id of ["r_done_sqlite_ts", "r_done_iso", "r_done_bad_updated"]) {
49
+ expect(by[id].completed_at_approx).toBe(1);
50
+ // 最后改的人不是「完成的人」:近似值不猜完成者
51
+ expect(by[id].completed_by_json).toBeNull();
52
+ // 补值不碰 updated_at
53
+ expect(by[id].updated_at).toBe(seed.find(s => s[0] === id)![3]);
54
+ }
55
+ for (const id of ["r_doing", "r_pool"]) expect([by[id].completed_at, by[id].completed_by_json, by[id].completed_at_approx]).toEqual([null, null, 0]);
56
+
57
+ // 下次启动:什么都不动
58
+ expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
59
+ expect(read(db)).toEqual(by);
60
+
61
+ // 回滚到旧 Hub 期间:旧 Hub 不认识这三列 —— 把一张完成的卡移出(列留着旧值)、把一张卡移进完成(列是空的)。
62
+ db.run("UPDATE requirements SET column_name = 'doing' WHERE requirement_id = 'r_done_iso'");
63
+ db.run("UPDATE requirements SET column_name = 'done', updated_at = '2026-09-06T00:00:00.000Z' WHERE requirement_id = 'r_pool'");
64
+ expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 1, cleared: 1 });
65
+ const after = read(db);
66
+ expect([after.r_done_iso.completed_at, after.r_done_iso.completed_by_json, after.r_done_iso.completed_at_approx]).toEqual([null, null, 0]);
67
+ expect([after.r_pool.completed_at, after.r_pool.completed_at_approx]).toEqual(["2026-09-06T00:00:00.000Z", 1]);
68
+ expect(after.r_done_sqlite_ts).toEqual(by.r_done_sqlite_ts);
69
+ expect(ensureRequirementCompletedAt(db)).toEqual({ backfilled: 0, cleared: 0 });
70
+ });