@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71

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 (170) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +177 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access.ts +461 -0
  9. package/src/agent-acl-groups-http.test.ts +247 -0
  10. package/src/agent-acl-http.test.ts +510 -0
  11. package/src/agent-acl-slice3-http.test.ts +267 -0
  12. package/src/agent-groups-perf.test.ts +167 -0
  13. package/src/alias-filter.test.ts +72 -0
  14. package/src/alias-filter.ts +32 -0
  15. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  16. package/src/api-host-supervisors-fallback.test.ts +271 -0
  17. package/src/api-nodes-shape.test.ts +26 -10
  18. package/src/auth-sessions-http.test.ts +250 -0
  19. package/src/auth-validate.test.ts +5 -1
  20. package/src/auth.ts +423 -29
  21. package/src/avatar-validate.test.ts +163 -0
  22. package/src/avatar-validate.ts +103 -0
  23. package/src/blocked-exit-on-reply.test.ts +122 -0
  24. package/src/boot-side-effect.test.ts +124 -0
  25. package/src/config-apply-sec1.test.ts +107 -0
  26. package/src/config-apply-validate.test.ts +92 -0
  27. package/src/config-apply-validate.ts +81 -3
  28. package/src/create-node-blocked-gate.test.ts +356 -0
  29. package/src/create-node-tool-schema.test.ts +39 -0
  30. package/src/create-node-validate.test.ts +89 -1
  31. package/src/create-node-validate.ts +89 -7
  32. package/src/create-node-workdir.test.ts +255 -0
  33. package/src/create-node.ts +51 -1
  34. package/src/dashboard-slash-routing-http.test.ts +107 -0
  35. package/src/db-adapter-guard.test.ts +85 -0
  36. package/src/db-adapter-translate.test.ts +112 -0
  37. package/src/db-adapter.ts +246 -83
  38. package/src/db-timestamp.test.ts +38 -0
  39. package/src/db-timestamp.ts +15 -0
  40. package/src/db.ts +799 -33
  41. package/src/delivered-without-push.test.ts +140 -0
  42. package/src/desktop-message-seam.test.ts +150 -0
  43. package/src/desktop-message.test.ts +368 -0
  44. package/src/ext-token-shared.test.ts +60 -0
  45. package/src/ext-validation-regression.test.ts +131 -0
  46. package/src/external-schedule-edits-http.test.ts +321 -0
  47. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  48. package/src/external-schedule-edits.ts +327 -0
  49. package/src/file-download-authz-dev-open.test.ts +135 -0
  50. package/src/file-download-authz.test.ts +404 -0
  51. package/src/file-download-date-bucket.test.ts +436 -0
  52. package/src/file-network-scope-dev-open.test.ts +97 -0
  53. package/src/file-network-scope.test.ts +732 -0
  54. package/src/health-redaction.test.ts +642 -0
  55. package/src/http-gzip.test.ts +73 -0
  56. package/src/http-gzip.ts +60 -0
  57. package/src/hub-timestamp.test.ts +38 -0
  58. package/src/hub-timestamp.ts +27 -0
  59. package/src/human-dm.ts +149 -0
  60. package/src/inbox-count-sse.test.ts +310 -0
  61. package/src/inbox-count.ts +29 -0
  62. package/src/index.ts +15 -2331
  63. package/src/lifecycle-guard.ts +50 -0
  64. package/src/mcp-write-network-resolution.test.ts +429 -0
  65. package/src/messages-ack-by-agent-http.test.ts +142 -0
  66. package/src/network-name-http.test.ts +75 -0
  67. package/src/network-scope.ts +224 -0
  68. package/src/node-attrs-validate.ts +88 -0
  69. package/src/node-attrs.test.ts +217 -0
  70. package/src/node-create-request-status-http.test.ts +92 -0
  71. package/src/node-files-transport.test.ts +344 -0
  72. package/src/node-id-alias.test.ts +188 -0
  73. package/src/node-lifecycle-controllable.test.ts +64 -0
  74. package/src/node-lifecycle-controllable.ts +51 -0
  75. package/src/node-logs-transport.test.ts +263 -0
  76. package/src/node-request-retention.test.ts +281 -0
  77. package/src/node-request-retention.ts +120 -0
  78. package/src/node-skills-transport.test.ts +209 -0
  79. package/src/node-token-privilege-http.test.ts +149 -0
  80. package/src/observer-avatar-http.test.ts +429 -0
  81. package/src/observer-push.test.ts +252 -0
  82. package/src/package-version-consistency.test.ts +25 -0
  83. package/src/peer-reply-atomic.test.ts +455 -0
  84. package/src/peer-reply-capability.test.ts +70 -0
  85. package/src/pg-worker.ts +87 -0
  86. package/src/push.ts +204 -7
  87. package/src/redact-tokens.test.ts +65 -0
  88. package/src/redact-tokens.ts +40 -0
  89. package/src/rename.ts +7 -2
  90. package/src/reply-target-derivation.test.ts +96 -0
  91. package/src/report-status-host-schema.test.ts +211 -0
  92. package/src/report-status-resume-id-handover.test.ts +150 -0
  93. package/src/require-explicit-test-db.ts +9 -0
  94. package/src/requirement-tags.ts +16 -0
  95. package/src/requirements-etag-http.test.ts +91 -0
  96. package/src/requirements-http.test.ts +708 -0
  97. package/src/requirements-mcp.test.ts +142 -0
  98. package/src/requirements-migrate.ts +83 -0
  99. package/src/requirements-priority-lowest.test.ts +114 -0
  100. package/src/requirements-start-date.test.ts +125 -0
  101. package/src/requirements-upgrade-e2e.test.ts +126 -0
  102. package/src/requirements-upgrade-migration.test.ts +85 -0
  103. package/src/requirements.ts +899 -0
  104. package/src/resolve-port.test.ts +57 -0
  105. package/src/resolve-port.ts +44 -0
  106. package/src/response-charset.test.ts +130 -0
  107. package/src/rest-explicit-columns-http.test.ts +216 -0
  108. package/src/rest-identity.test.ts +129 -0
  109. package/src/rest-identity.ts +100 -0
  110. package/src/rest-projections.ts +68 -0
  111. package/src/rest-write-network-resolution.test.ts +133 -0
  112. package/src/restricted-files.ts +73 -0
  113. package/src/retention.ts +10 -1
  114. package/src/rules-file-duplicate-alias.test.ts +234 -0
  115. package/src/rules-file-session-target.test.ts +207 -0
  116. package/src/rules-file-transport.test.ts +237 -0
  117. package/src/runtime-label.test.ts +22 -0
  118. package/src/runtime-label.ts +21 -0
  119. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  120. package/src/scheduled-run-terminal.test.ts +268 -0
  121. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  122. package/src/scheduled-tasks-http.test.ts +625 -0
  123. package/src/scheduled-tasks.ts +597 -0
  124. package/src/send-dedup-request-id.test.ts +229 -0
  125. package/src/send-reply-agent-warning.test.ts +202 -0
  126. package/src/send-reply-alias-optional.test.ts +131 -0
  127. package/src/send-reply-attachments.test.ts +458 -0
  128. package/src/send_dedup.test.ts +54 -0
  129. package/src/send_dedup.ts +52 -6
  130. package/src/serve-error.test.ts +38 -0
  131. package/src/serve-error.ts +27 -0
  132. package/src/server.ts +4385 -0
  133. package/src/shared/external-schedule-contract.ts +60 -0
  134. package/src/side-thread-command-transport.test.ts +302 -0
  135. package/src/side-thread-command-transport.ts +318 -0
  136. package/src/side-thread-http-integration.test.ts +233 -0
  137. package/src/side-thread-http.ts +504 -0
  138. package/src/side-thread-production.test.ts +72 -0
  139. package/src/side-thread-production.ts +118 -0
  140. package/src/side-thread.test.ts +1067 -0
  141. package/src/side-thread.ts +2495 -0
  142. package/src/skillhub-http.test.ts +92 -0
  143. package/src/start-node.test.ts +109 -0
  144. package/src/status-alias-filter-http.test.ts +106 -0
  145. package/src/stop-delete-node.test.ts +1164 -0
  146. package/src/task-auth-origin-http.test.ts +91 -0
  147. package/src/task-auth-origin.test.ts +24 -0
  148. package/src/task-auth-origin.ts +7 -0
  149. package/src/task-consumption.test.ts +482 -0
  150. package/src/task-diagnostic-http.test.ts +149 -0
  151. package/src/task-diagnostic.test.ts +115 -0
  152. package/src/task-diagnostic.ts +118 -0
  153. package/src/task-idempotency-mcp.test.ts +142 -0
  154. package/src/task-idempotency.test.ts +60 -0
  155. package/src/task-idempotency.ts +75 -0
  156. package/src/task-lifecycle-watcher.test.ts +204 -0
  157. package/src/task-lifecycle-watcher.ts +49 -0
  158. package/src/task-network-resolution-http.test.ts +141 -0
  159. package/src/tasks-pagination-http.test.ts +141 -0
  160. package/src/tools-list-json-schema.test.ts +66 -0
  161. package/src/tools.ts +3446 -257
  162. package/src/update-provider.test.ts +65 -0
  163. package/src/upload-early-reject-guard.test.ts +155 -0
  164. package/src/uploads-http.test.ts +97 -15
  165. package/src/uploads.test.ts +66 -0
  166. package/src/uploads.ts +130 -5
  167. package/src/user-inbox-by-agent-http.test.ts +161 -0
  168. package/src/user-inbox-netid-notnull.test.ts +122 -0
  169. package/src/user-inbox-read-http.test.ts +223 -0
  170. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,142 @@
1
+ // 需求池 MCP 工具:走真 MCP in-process transport。Agent(节点令牌)在自己网络里读 / 建 / 改 / 勾 / upsert、
2
+ // 别的网络的节点碰不到、删除不存在于工具里(只有人能在 REST 删)。同步场景:同一个 GitHub issue 同步两次不重复。
3
+ // 跑法:cd server && COMMHUB_DB=/tmp/req-mcp.db bun test src/requirements-mcp.test.ts
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-mcp-"));
10
+ process.env.COMMHUB_DB = join(dir, "hub.db");
11
+
12
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
13
+ const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
14
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
15
+ const { db } = await import("./db.js");
16
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
17
+ const { registerTools } = await import("./tools.js");
18
+
19
+ type Identity = { net: string; user: string; alias: string; isNetworkToken: boolean; tokenId: string };
20
+ let owner: Identity, bot: Identity, foreignBot: Identity;
21
+ let ownerNet = "";
22
+
23
+ async function connect(id: Identity) {
24
+ const server = new McpServer({ name: "req-mcp-test", version: "1" });
25
+ registerTools(server, undefined, id.isNetworkToken ? id.net : null, id.user, id.alias, id.isNetworkToken, id.tokenId);
26
+ const client = new Client({ name: "req-mcp-client", version: "1" });
27
+ const [ct, st] = InMemoryTransport.createLinkedPair();
28
+ await server.connect(st);
29
+ await client.connect(ct);
30
+ const call = async (name: string, args: Record<string, unknown>) => {
31
+ const r: any = await client.callTool({ name, arguments: args });
32
+ const first = r.content?.[0];
33
+ if (r.isError) return { mcpError: true, text: first?.text };
34
+ return JSON.parse(first.text);
35
+ };
36
+ return { server, call, close: async () => { await client.close(); await server.close(); } };
37
+ }
38
+
39
+ beforeAll(() => {
40
+ const o = register(`reqmcp_owner_${Date.now()}`, "ReqMcpOwner123!", undefined, "seed");
41
+ const x = register(`reqmcp_other_${Date.now()}`, "ReqMcpOther123!", undefined, "seed");
42
+ ownerNet = o.network_id!;
43
+ const t1 = createNetworkTokenForNode(o.user!.user_id, ownerNet, "gh-sync", "node_gh_sync");
44
+ const t2 = createNetworkTokenForNode(x.user!.user_id, x.network_id!, "foreign", "node_foreign");
45
+ owner = { net: ownerNet, user: o.user!.user_id, alias: o.user!.username, isNetworkToken: false, tokenId: "tok_user" };
46
+ bot = { net: ownerNet, user: o.user!.user_id, alias: "gh-sync", isNetworkToken: true, tokenId: t1.token_id! };
47
+ foreignBot = { net: x.network_id!, user: x.user!.user_id, alias: "foreign", isNetworkToken: true, tokenId: t2.token_id! };
48
+ });
49
+ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
50
+
51
+ describe("requirements MCP tools", () => {
52
+ test("the seven tools are registered with validated schemas; there is no delete tool", async () => {
53
+ const s = await connect(owner);
54
+ try {
55
+ const reg: Record<string, any> = (s.server as any)._registeredTools;
56
+ for (const n of ["requirements_list", "requirements_get", "requirements_create", "requirements_update", "requirements_checklist_toggle", "requirements_upsert_by_external_ref", "projects_list"]) expect(reg[n], n).toBeDefined();
57
+ expect(Object.keys(reg).some(n => /^requirements_delete/.test(n))).toBe(false);
58
+ // 校验:坏的 status / 负责人种类 / 子任务 id 在协议边界被拒
59
+ expect((await s.call("requirements_list", { status: "nope" })).mcpError).toBe(true);
60
+ expect((await s.call("requirements_create", { name: "x", owner: { kind: "robot", id: "a" } })).mcpError).toBe(true);
61
+ expect((await s.call("requirements_checklist_toggle", { id: "r", item_id: "has space", done: true })).mcpError).toBe(true);
62
+ expect((await s.call("requirements_create", { name: "" })).mcpError).toBe(true);
63
+ // P0–P3:lowest(P3 极低)在建 / 改 / upsert 都能写;不认识的值在协议边界被拒
64
+ expect((await s.call("requirements_create", { name: "x", priority: "urgent" })).mcpError).toBe(true);
65
+ const low = await s.call("requirements_create", { name: "极低", priority: "lowest" });
66
+ expect(low.requirement.priority).toBe("lowest");
67
+ expect((await s.call("requirements_update", { id: low.requirement.id, priority: "high" })).requirement.priority).toBe("high");
68
+ expect((await s.call("requirements_update", { id: low.requirement.id, priority: "lowest" })).requirement.priority).toBe("lowest");
69
+ expect((await s.call("requirements_upsert_by_external_ref", { external_ref: "github:acme/p3#1", name: "同步", priority: "lowest" })).requirement.priority).toBe("lowest");
70
+ } finally { await s.close(); }
71
+ });
72
+
73
+ test("an agent syncs a GitHub issue idempotently, edits it, ticks a checklist item and archives it", async () => {
74
+ const s = await connect(bot);
75
+ try {
76
+ const ref = "github:acme/widgets#42";
77
+ const first = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, external_url: "https://github.com/acme/widgets/issues/42", name: "修复登录", description: "issue 正文", checklist: [{ id: "c1", text: "复现" }, { id: "c2", text: "修" }] });
78
+ expect(first.ok).toBe(true);
79
+ expect(first.created).toBe(true);
80
+ const tagged = await s.call("requirements_update", { id: first.requirement.id, tags: ["release"] });
81
+ expect(tagged.requirement.tags).toEqual(["release"]);
82
+ expect(first.requirement.created_by).toEqual({ kind: "node", id: "node_gh_sync" });
83
+ const again = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, name: "修复登录(改了标题)" });
84
+ expect(again.created).toBe(false);
85
+ expect(again.requirement.tags).toEqual(["release"]);
86
+ expect(again.requirement.id).toBe(first.requirement.id);
87
+ const listed = await s.call("requirements_list", { external_ref: ref });
88
+ expect(listed.requirements.length).toBe(1);
89
+ expect(listed.requirements[0].name).toBe("修复登录(改了标题)");
90
+ const dup = await s.call("requirements_create", { name: "重复", external_ref: ref });
91
+ expect(dup.error).toBe("external_ref_exists");
92
+ expect(dup.existing_id).toBe(first.requirement.id);
93
+ expect(dup.status).toBe(409);
94
+ const id = first.requirement.id;
95
+ expect((await s.call("requirements_update", { id, column: "doing", agent_owner: { kind: "node", id: "node_gh_sync" } })).requirement.column).toBe("doing");
96
+ const ticked = await s.call("requirements_checklist_toggle", { id, item_id: "c1", done: true });
97
+ expect(ticked.requirement.checklist.map((i: any) => i.done)).toEqual([true, false]);
98
+ expect((await s.call("requirements_get", { id })).requirement.agent_owner).toEqual({ kind: "node", id: "node_gh_sync" });
99
+ expect((await s.call("requirements_list", { status: "doing", agent_owner: "node:node_gh_sync" })).requirements.map((r: any) => r.id)).toContain(id);
100
+ expect((await s.call("requirements_update", { id, owner: { kind: "node", id: "node_gh_sync" }, agent_owner: null })).error).toBe("owner_must_be_human");
101
+ expect((await s.call("requirements_update", { id, archived: true })).requirement.archived).toBe(true);
102
+ expect((await s.call("requirements_list", {})).requirements.some((r: any) => r.id === id)).toBe(false);
103
+ expect((await s.call("requirements_list", { include_archived: true })).requirements.some((r: any) => r.id === id)).toBe(true);
104
+ expect(Array.isArray((await s.call("projects_list", {})).projects)).toBe(true);
105
+ // 子需求:建子卡、父卡上的进度、按父卡 / 顶层过滤
106
+ const child = await s.call("requirements_create", { name: "子需求", parent_id: id });
107
+ expect(child.requirement.parent_id).toBe(id);
108
+ expect((await s.call("requirements_get", { id })).requirement.children.total).toBe(1);
109
+ expect((await s.call("requirements_list", { parent_id: id })).requirements.map((r: any) => r.id)).toEqual([child.requirement.id]);
110
+ expect((await s.call("requirements_list", { top_level: true, include_archived: true })).requirements.some((r: any) => r.id === child.requirement.id)).toBe(false);
111
+ expect((await s.call("requirements_update", { id, parent_id: child.requirement.id })).error).toBe("parent_cycle");
112
+ } finally { await s.close(); }
113
+ });
114
+
115
+ test("a node token from another network cannot see or touch these tasks, nor write into this network", async () => {
116
+ const mine = await connect(bot);
117
+ const theirs = await connect(foreignBot);
118
+ try {
119
+ const made = await mine.call("requirements_create", { name: "只属于本网络" });
120
+ const id = made.requirement.id;
121
+ expect((await theirs.call("requirements_get", { id })).error).toBe("requirement_not_found");
122
+ expect((await theirs.call("requirements_update", { id, name: "越界" })).error).toBe("requirement_not_found");
123
+ expect((await theirs.call("requirements_checklist_toggle", { id, item_id: "x", done: true })).error).toBe("requirement_not_found");
124
+ expect((await theirs.call("requirements_list", { network_id: ownerNet })).requirements.some((r: any) => r.id === id)).toBe(false);
125
+ const planted = await theirs.call("requirements_create", { name: "塞进去", network_id: ownerNet });
126
+ expect(planted.ok).toBe(true);
127
+ const row = db.get<{ network_id: string }>("SELECT network_id FROM requirements WHERE requirement_id = ?1", planted.requirement.id)!;
128
+ expect(row.network_id).toBe(foreignBot.net);
129
+ expect((await mine.call("requirements_get", { id })).requirement.name).toBe("只属于本网络");
130
+ } finally { await mine.close(); await theirs.close(); }
131
+ });
132
+
133
+ test("a user token keeps working through the same tools", async () => {
134
+ const s = await connect(owner);
135
+ try {
136
+ const made = await s.call("requirements_create", { name: "人建的", network_id: ownerNet, owner: { kind: "user", id: owner.user } });
137
+ expect(made.ok).toBe(true);
138
+ expect(made.requirement.created_by).toEqual({ kind: "user", id: owner.user });
139
+ expect((await s.call("requirements_list", { network_id: ownerNet, owner: `user:${owner.user}` })).requirements.map((r: any) => r.id)).toContain(made.requirement.id);
140
+ } finally { await s.close(); }
141
+ });
142
+ });
@@ -0,0 +1,83 @@
1
+ // 需求卡的启动迁移。requirements.ts 载入时调用一次(列由 db.ts 的加列循环加上)。
2
+ import type { DbAdapter } from "./db-adapter";
3
+
4
+ /**
5
+ * 负责人 = 人类(owner, kind user);负责 Agent = 节点(agent_owner, kind node)。
6
+ * 之前 owner 可以是节点:这些卡把节点挪到 agent_owner、owner 置空。只动 agent_owner 还空着的行,
7
+ * 所以重复执行不会再改任何东西;不删行,不碰别的列。在 JS 里判 kind(不依赖 SQLite 的 json_extract)。
8
+ */
9
+ export function migrateRequirementAgentOwners(database: DbAdapter): { moved: number } {
10
+ const rows = database.all<{ requirement_id: string; owner_json: string | null }>(
11
+ "SELECT requirement_id, owner_json FROM requirements WHERE owner_json IS NOT NULL AND agent_owner_json IS NULL",
12
+ );
13
+ let moved = 0;
14
+ for (const row of rows) {
15
+ let owner: { kind?: unknown; id?: unknown } | null = null;
16
+ try { owner = JSON.parse(row.owner_json || "null"); } catch { continue; }
17
+ if (!owner || owner.kind !== "node" || typeof owner.id !== "string" || !owner.id) continue;
18
+ database.run(
19
+ "UPDATE requirements SET agent_owner_json = ?1, owner_json = NULL WHERE requirement_id = ?2 AND agent_owner_json IS NULL",
20
+ [JSON.stringify({ kind: "node", id: owner.id }), row.requirement_id],
21
+ );
22
+ moved += 1;
23
+ }
24
+ return { moved };
25
+ }
26
+
27
+ /**
28
+ * 项目表(需求卡的「项目」)。只建表、建索引,不塞任何项目 —— 军团项目 / TMAI 由 owner 在界面里建。
29
+ * CREATE ... IF NOT EXISTS:重复执行无副作用。放在这里而不是 db.ts,db.ts 的行号被文档钉着。
30
+ */
31
+ export function ensureRequirementProjects(database: DbAdapter): void {
32
+ database.exec(`
33
+ CREATE TABLE IF NOT EXISTS requirement_projects (
34
+ project_id TEXT PRIMARY KEY,
35
+ network_id TEXT NOT NULL,
36
+ name TEXT NOT NULL,
37
+ color TEXT NOT NULL,
38
+ sort INTEGER NOT NULL DEFAULT 0,
39
+ archived INTEGER NOT NULL DEFAULT 0,
40
+ created_at TEXT NOT NULL
41
+ );
42
+ CREATE INDEX IF NOT EXISTS idx_requirement_projects_network ON requirement_projects(network_id, sort);
43
+ `);
44
+ }
45
+
46
+ /** external_ref 在同一网络里唯一(只管有值的行)。IF NOT EXISTS:重复执行无副作用。 */
47
+ export function ensureRequirementIndexes(database: DbAdapter): void {
48
+ database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_external_ref ON requirements(network_id, external_ref) WHERE external_ref IS NOT NULL");
49
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_network_updated ON requirements(network_id, updated_at)");
50
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_parent ON requirements(parent_id) WHERE parent_id IS NOT NULL");
51
+ }
52
+
53
+ /**
54
+ * 优先级加 lowest(P3 极低)。旧库建表时带 CHECK(priority IN ('high', 'normal', 'low')),SQLite 不能就地改
55
+ * CHECK,只能重建一次:拿 sqlite_master 里的原建表语句(含历次加的列)只换掉那个 CHECK,整表拷过去,
56
+ * 再按原样重建这张表的索引。已经放开的库什么都不做。存量值不改。
57
+ */
58
+ const OLD_PRIORITY_CHECK = /CHECK\s*\(\s*priority\s+IN\s*\(\s*'high'\s*,\s*'normal'\s*,\s*'low'\s*\)\s*\)/i;
59
+ export const PRIORITY_CHECK = "CHECK(priority IN ('high', 'normal', 'low', 'lowest'))";
60
+ export function migrateRequirementPriorityCheck(database: DbAdapter): { rebuilt: boolean } {
61
+ if (database.dialect === "postgres") {
62
+ try {
63
+ database.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_priority_check");
64
+ database.exec(`ALTER TABLE requirements ADD CONSTRAINT requirements_priority_check ${PRIORITY_CHECK}`);
65
+ } catch {}
66
+ return { rebuilt: false };
67
+ }
68
+ const table = database.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'");
69
+ if (!table?.sql || !OLD_PRIORITY_CHECK.test(table.sql)) return { rebuilt: false };
70
+ const indexes = database.all<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'requirements' AND sql IS NOT NULL");
71
+ const createSql = table.sql
72
+ .replace(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?requirements["`]?/i, "CREATE TABLE requirements_migrated")
73
+ .replace(OLD_PRIORITY_CHECK, PRIORITY_CHECK);
74
+ database.transaction(() => {
75
+ database.exec("DROP TABLE IF EXISTS requirements_migrated");
76
+ database.exec(createSql);
77
+ database.exec("INSERT INTO requirements_migrated SELECT * FROM requirements");
78
+ database.exec("DROP TABLE requirements");
79
+ database.exec("ALTER TABLE requirements_migrated RENAME TO requirements");
80
+ for (const index of indexes) database.exec(index.sql);
81
+ });
82
+ return { rebuilt: true };
83
+ }
@@ -0,0 +1,114 @@
1
+ // P0–P3 优先级:lowest(P3 极低)。生产库的 requirements 表建于旧 db.ts,带 CHECK(priority IN ('high', 'normal', 'low')),
2
+ // 光放开校验集合写进去也会被 SQLite 拒掉。这里先用旧建表语句建库、塞行,再走真实启动路径(db.ts + server.ts),
3
+ // 看:表重建一次、存量行 / 索引原样、lowest 在 POST / PATCH / upsert 都能写、坏值仍被拒、再启动不再重建。
4
+ import { afterAll, beforeAll, describe, 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-priority-"));
11
+ const file = join(dir, "hub.db");
12
+ process.env.COMMHUB_DB = file;
13
+
14
+ const legacy = new Database(file);
15
+ legacy.exec(`
16
+ CREATE TABLE IF NOT EXISTS requirements (
17
+ requirement_id TEXT PRIMARY KEY,
18
+ network_id TEXT NOT NULL,
19
+ title TEXT NOT NULL,
20
+ column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
21
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low')),
22
+ due_on TEXT,
23
+ assignee TEXT,
24
+ client_id TEXT,
25
+ issues_json TEXT,
26
+ created_by TEXT,
27
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
28
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
29
+ );
30
+ CREATE INDEX IF NOT EXISTS idx_requirements_network ON requirements(network_id, created_at DESC);
31
+ `);
32
+ legacy.exec("ALTER TABLE requirements ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'");
33
+ legacy.exec("ALTER TABLE requirements ADD COLUMN description TEXT");
34
+ const seeded = [["r_high", "high"], ["r_normal", "normal"], ["r_low", "low"]] as const;
35
+ for (const [id, p] of seeded) {
36
+ legacy.run("INSERT INTO requirements (requirement_id, network_id, title, priority, client_id, description, tags_json) VALUES (?1, 'net_legacy', ?2, ?3, ?4, ?5, '[\"旧\"]')", [id, `卡 ${id}`, p, `c_${id}`, `desc ${id}`]);
37
+ }
38
+ // 旧库确实拒 lowest:这就是光改 PRIORITIES 不够的原因
39
+ expect(() => legacy.run("INSERT INTO requirements (requirement_id, network_id, title, priority) VALUES ('x', 'n', 't', 'lowest')")).toThrow(/CHECK/);
40
+ legacy.close();
41
+
42
+ let base = "";
43
+ let token = "";
44
+ let server: { port: number; stop?: (force?: boolean) => void };
45
+ let db: any;
46
+
47
+ async function api(path: string, init?: RequestInit) {
48
+ const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
49
+ return { status: res.status, body: await res.json() as any };
50
+ }
51
+
52
+ beforeAll(async () => {
53
+ ({ db } = await import("./db.js"));
54
+ const { register } = await import("./auth.js");
55
+ const owner = register(`prio_owner_${Date.now()}`, "PriorityOwner123!", undefined, "seed");
56
+ token = owner.token!;
57
+ const mod: any = await import("./server.js");
58
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
59
+ base = `http://127.0.0.1:${server.port}`;
60
+ }, 30_000);
61
+
62
+ afterAll(() => {
63
+ try { server?.stop?.(true); } catch {}
64
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
65
+ });
66
+
67
+ describe("P3 lowest priority", () => {
68
+ test("startup rebuilds the legacy CHECK once and keeps every row and index", async () => {
69
+ const sql = db.get("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'").sql as string;
70
+ expect(sql).toContain("'lowest'");
71
+ expect(sql).toContain("CHECK(column_name IN ('pool', 'doing', 'done'))");
72
+ const rows = db.all("SELECT requirement_id, network_id, title, priority, client_id, description, tags_json FROM requirements WHERE network_id = 'net_legacy' ORDER BY requirement_id") as any[];
73
+ expect(rows.map(r => [r.requirement_id, r.priority])).toEqual([["r_high", "high"], ["r_low", "low"], ["r_normal", "normal"]]);
74
+ for (const r of rows) {
75
+ expect(r.title).toBe(`卡 ${r.requirement_id}`);
76
+ expect(r.client_id).toBe(`c_${r.requirement_id}`);
77
+ expect(r.description).toBe(`desc ${r.requirement_id}`);
78
+ expect(r.tags_json).toBe('["旧"]');
79
+ }
80
+ const indexes = (db.all("PRAGMA index_list(requirements)") as { name: string; unique: number }[]).map(i => i.name);
81
+ for (const name of ["idx_requirements_network", "idx_requirements_client", "idx_requirements_external_ref", "idx_requirements_network_updated", "idx_requirements_parent"]) expect(indexes).toContain(name);
82
+ expect(db.get("SELECT name FROM sqlite_master WHERE name = 'requirements_migrated'")).toBeNull();
83
+ // 坏值仍被数据库拒
84
+ expect(() => db.run("INSERT INTO requirements (requirement_id, network_id, title, priority) VALUES ('bad', 'n', 't', 'urgent')")).toThrow(/CHECK/);
85
+ // 下次启动:已经放开,不再重建
86
+ const { migrateRequirementPriorityCheck } = await import("./requirements-migrate.js");
87
+ expect(migrateRequirementPriorityCheck(db).rebuilt).toBe(false);
88
+ });
89
+
90
+ test("lowest is accepted on create, PATCH and upsert; unknown values stay 400", async () => {
91
+ const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "极低的活", priority: "lowest" }) });
92
+ expect(created.status).toBe(201);
93
+ expect(created.body.requirement.priority).toBe("lowest");
94
+ const id = created.body.requirement.id;
95
+ const patched = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ priority: "low" }) });
96
+ expect(patched.body.requirement.priority).toBe("low");
97
+ const back = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ priority: "lowest" }) });
98
+ expect(back.status).toBe(200);
99
+ expect(back.body.requirement.priority).toBe("lowest");
100
+ const up = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步来的", external_ref: "github:t/p#1", priority: "lowest" }) });
101
+ expect(up.status).toBe(201);
102
+ expect(up.body.requirement.priority).toBe("lowest");
103
+ const upAgain = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: "github:t/p#1", priority: "high" }) });
104
+ expect(upAgain.body.requirement.priority).toBe("high");
105
+ for (const [path, method] of [["/api/requirements", "POST"], [`/api/requirements/${id}`, "PATCH"]] as const) {
106
+ const bad = await api(path, { method, body: JSON.stringify({ name: "x", priority: "urgent" }) });
107
+ expect(bad.status).toBe(400);
108
+ expect(bad.body.error).toBe("invalid_priority");
109
+ }
110
+ const listed = await api("/api/requirements");
111
+ expect(listed.body.capabilities).toContain("priority_lowest");
112
+ expect(listed.body.requirements.find((r: any) => r.id === id).priority).toBe("lowest");
113
+ });
114
+ });
@@ -0,0 +1,125 @@
1
+ // 需求的可选开始字段(start,存 start_on):App 甘特图用。
2
+ // 先用「没有 start_on 列」的旧库形状建库、塞行,再走真实启动路径(db.ts + server.ts),看:
3
+ // 列被加上、存量行读出来 start = "" 且别的列原样;POST / PATCH / upsert 都能写;只 PATCH due 不动 start 和描述;
4
+ // 旧客户端(不带 start)的 PATCH 不清掉已有的 start;坏值 400 invalid_start;capabilities 带 start_date。
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { Database } from "bun:sqlite";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-start-"));
12
+ const file = join(dir, "hub.db");
13
+ process.env.COMMHUB_DB = file;
14
+
15
+ const legacy = new Database(file);
16
+ legacy.exec(`
17
+ CREATE TABLE IF NOT EXISTS requirements (
18
+ requirement_id TEXT PRIMARY KEY,
19
+ network_id TEXT NOT NULL,
20
+ title TEXT NOT NULL,
21
+ column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
22
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low', 'lowest')),
23
+ due_on TEXT,
24
+ assignee TEXT,
25
+ client_id TEXT,
26
+ issues_json TEXT,
27
+ created_by TEXT,
28
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
29
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
30
+ );
31
+ `);
32
+ legacy.exec("ALTER TABLE requirements ADD COLUMN description TEXT");
33
+ legacy.run("INSERT INTO requirements (requirement_id, network_id, title, due_on, description) VALUES ('r_old', 'net_legacy', '旧卡', '2026-10-01', '旧描述')");
34
+ expect(legacy.query("SELECT name FROM pragma_table_info('requirements') WHERE name = 'start_on'").all()).toEqual([]);
35
+ legacy.close();
36
+
37
+ let base = "";
38
+ let token = "";
39
+ let server: { port: number; stop?: (force?: boolean) => void };
40
+ let db: any;
41
+
42
+ async function api(path: string, init?: RequestInit) {
43
+ const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
44
+ return { status: res.status, body: await res.json() as any };
45
+ }
46
+
47
+ beforeAll(async () => {
48
+ ({ db } = await import("./db.js"));
49
+ const { register } = await import("./auth.js");
50
+ const owner = register(`start_owner_${Date.now()}`, "StartOwner123!", undefined, "seed");
51
+ token = owner.token!;
52
+ const mod: any = await import("./server.js");
53
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
54
+ base = `http://127.0.0.1:${server.port}`;
55
+ }, 30_000);
56
+
57
+ afterAll(() => {
58
+ try { server?.stop?.(true); } catch {}
59
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
60
+ });
61
+
62
+ describe("optional requirement start date", () => {
63
+ test("startup adds start_on to an old table; existing rows keep every value and read start = ''", () => {
64
+ const cols = (db.all("PRAGMA table_info(requirements)") as { name: string }[]).map(c => c.name);
65
+ expect(cols).toContain("start_on");
66
+ const row = db.get("SELECT title, due_on, description, start_on FROM requirements WHERE requirement_id = 'r_old'") as any;
67
+ expect(row).toEqual({ title: "旧卡", due_on: "2026-10-01", description: "旧描述", start_on: null });
68
+ });
69
+
70
+ test("create / PATCH / upsert write start; due-only PATCH and start-less PATCH keep it", async () => {
71
+ const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "有开始的", start: "2026-09-20", due: "2026-10-01", description: "别动我" }) });
72
+ expect(created.status).toBe(201);
73
+ expect(created.body.requirement.start).toBe("2026-09-20");
74
+ const id = created.body.requirement.id;
75
+
76
+ // 甘特图拖动只发 due:start 和描述原样
77
+ const dueOnly = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ due: "2026-10-05" }) });
78
+ expect(dueOnly.status).toBe(200);
79
+ expect(dueOnly.body.requirement.due).toBe("2026-10-05");
80
+ expect(dueOnly.body.requirement.start).toBe("2026-09-20");
81
+ expect(dueOnly.body.requirement.description).toBe("别动我");
82
+
83
+ // 旧客户端不认识 start:它的 PATCH 不带这个字段,不能清掉
84
+ const old = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: "改名了" }) });
85
+ expect(old.body.requirement.start).toBe("2026-09-20");
86
+
87
+ const instant = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "2026-09-21T09:30:00+08:00" }) });
88
+ expect(instant.body.requirement.start).toBe("2026-09-21T01:30:00Z");
89
+ const cleared = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "" }) });
90
+ expect(cleared.status).toBe(200);
91
+ expect(cleared.body.requirement.start).toBe("");
92
+ // 只带 start 的 PATCH 不是 empty_patch
93
+ const onlyStart = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "2026-09-22" }) });
94
+ expect(onlyStart.status).toBe(200);
95
+
96
+ const up = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步来的", external_ref: "github:t/p#9", start: "2026-09-01" }) });
97
+ expect(up.status).toBe(201);
98
+ expect(up.body.requirement.start).toBe("2026-09-01");
99
+ const upAgain = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: "github:t/p#9", start: "2026-09-02" }) });
100
+ expect(upAgain.body.requirement.start).toBe("2026-09-02");
101
+
102
+ const noStart = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "没开始字段" }) });
103
+ expect(noStart.body.requirement.start).toBe("");
104
+ });
105
+
106
+ test("bad start values are 400 invalid_start and change nothing", async () => {
107
+ const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "坏值", start: "2026-09-20" }) });
108
+ const id = created.body.requirement.id;
109
+ for (const bad of ["2026-02-30", "tomorrow", "2026-09-20T10:00:00", 20260920]) {
110
+ const post = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "x", start: bad }) });
111
+ const patch = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: bad }) });
112
+ expect(patch.status).toBe(400);
113
+ expect(patch.body.error).toBe("invalid_start");
114
+ if (typeof bad === "string") {
115
+ expect(post.status).toBe(400);
116
+ expect(post.body.error).toBe("invalid_start");
117
+ }
118
+ }
119
+ const listed = await api("/api/requirements");
120
+ expect(listed.body.requirements.find((r: any) => r.id === id).start).toBe("2026-09-20");
121
+ expect(listed.body.capabilities).toContain("start_date");
122
+ // 旧卡在列表里 start = ""(不是缺字段:新 App 按 capabilities 判断支持与否)
123
+ expect(listed.body.requirements.every((r: any) => typeof r.start === "string")).toBe(true);
124
+ });
125
+ });
@@ -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);