@sleep2agi/commhub-server 0.9.0-preview.76 → 0.9.0-preview.78

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.76",
3
+ "version": "0.9.0-preview.78",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,120 @@
1
+ // POST /mcp in /api/stats/routes carries the JSON-RPC method and, for tools/call, the tool name
2
+ // ("POST /mcp tools/call report_status"). Production showed POST /mcp as the biggest route (15 req/s,
3
+ // ~13% of a core) with no way to tell which tool. This suite pins, over real HTTP:
4
+ // - the labels (tools/call <name>, tools/list, batch) and that MCP responses are unchanged in kind;
5
+ // - arguments never reach the stats (a marker in arguments / a hostile tool name never shows up);
6
+ // - odd bodies still get the transport's own answers (invalid JSON → -32700, oversized → parsed by
7
+ // the transport itself) and are labelled "?";
8
+ // - unauthenticated /mcp stays plain "POST /mcp" (the body is never read before auth).
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-mcp-route-label-"));
16
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
17
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
18
+
19
+ const PW = "McpRouteLabelPassw0rd!";
20
+ const MARKER = "zz-argument-marker-7f3a";
21
+ let BASE = "";
22
+ let hub: any = null;
23
+ let admin = "";
24
+
25
+ const MCP_HEADERS = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" };
26
+ async function mcpRaw(token: string, body: string): Promise<{ status: number; text: string }> {
27
+ const res = await fetch(`${BASE}/mcp`, { method: "POST", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...MCP_HEADERS }, body });
28
+ return { status: res.status, text: await res.text() };
29
+ }
30
+ function rpc(text: string): any {
31
+ const lines = text.split("\n").filter(x => x.startsWith("data:"));
32
+ return lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(text);
33
+ }
34
+ async function routes(): Promise<{ text: string; keys: string[]; rows: any[] }> {
35
+ const res = await fetch(`${BASE}/api/stats/routes?minutes=5`, { headers: { Authorization: `Bearer ${admin}` } });
36
+ const text = await res.text();
37
+ const rows = JSON.parse(text).routes as any[];
38
+ return { text, keys: rows.map(r => r.route), rows };
39
+ }
40
+
41
+ beforeAll(async () => {
42
+ process.env.HOST = "127.0.0.1";
43
+ const { register } = await import("./auth.js");
44
+ const { db } = await import("./db.js");
45
+ const a = register(`mrl_admin_${Date.now()}`, PW);
46
+ admin = a.token!;
47
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [a.user!.user_id]);
48
+ const mod: any = await import("./server.js");
49
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
50
+ BASE = `http://127.0.0.1:${hub.port}`;
51
+ }, 30_000);
52
+
53
+ afterAll(() => {
54
+ try { hub?.stop?.(true); } catch {}
55
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
56
+ });
57
+
58
+ describe("POST /mcp route label", () => {
59
+ test("tools/call is labelled with the tool name and still answers normally", async () => {
60
+ const r = await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_all_status", arguments: { note: MARKER } } }));
61
+ expect(r.status).toBe(200);
62
+ expect(rpc(r.text).result).toBeDefined();
63
+ const { keys } = await routes();
64
+ expect(keys).toContain("POST /mcp tools/call get_all_status");
65
+ });
66
+
67
+ test("tools/list and a batch get their own labels", async () => {
68
+ const list = await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }));
69
+ expect(list.status).toBe(200);
70
+ expect(rpc(list.text).result.tools.length).toBeGreaterThan(10);
71
+ await mcpRaw(admin, JSON.stringify([{ jsonrpc: "2.0", id: 3, method: "tools/list" }]));
72
+ const { keys } = await routes();
73
+ expect(keys).toContain("POST /mcp tools/list");
74
+ expect(keys).toContain("POST /mcp batch");
75
+ });
76
+
77
+ test("arguments and hostile tool names never reach the stats", async () => {
78
+ await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: `x ${MARKER}`, arguments: { a: MARKER } } }));
79
+ await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "n".repeat(500), arguments: {} } }));
80
+ await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 6, method: `tools/${MARKER} with spaces` }));
81
+ const { text, keys } = await routes();
82
+ expect(text).not.toContain(MARKER);
83
+ expect(text).not.toContain("n".repeat(65));
84
+ expect(keys).toContain("POST /mcp tools/call ?");
85
+ expect(keys).toContain("POST /mcp ?");
86
+ for (const k of keys) expect(k.length).toBeLessThan(200);
87
+ });
88
+
89
+ test("invalid JSON still gets the transport's parse error, labelled ?", async () => {
90
+ const r = await mcpRaw(admin, `{"jsonrpc":"2.0","method":"tools/list",`);
91
+ expect(r.status).toBe(400);
92
+ expect(rpc(r.text).error.code).toBe(-32700);
93
+ expect(rpc(r.text).error.message).toBe("Parse error: Invalid JSON");
94
+ const notRpc = await mcpRaw(admin, "null");
95
+ expect(notRpc.status).toBe(400);
96
+ expect(rpc(notRpc.text).error.code).toBe(-32700);
97
+ });
98
+
99
+ test("a body over the label limit is left to the transport and still works, labelled ?", async () => {
100
+ const { __resetRouteTimingForTest } = await import("./route-timing.js");
101
+ __resetRouteTimingForTest();
102
+ const padded = JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }).replace("{", "{" + " ".repeat(1024 * 1024 + 10));
103
+ const r = await mcpRaw(admin, padded);
104
+ expect(r.status).toBe(200);
105
+ expect(rpc(r.text).result.tools.length).toBeGreaterThan(10);
106
+ const { keys } = await routes();
107
+ expect(keys).toContain("POST /mcp ?");
108
+ expect(keys).not.toContain("POST /mcp tools/list");
109
+ });
110
+
111
+ test("unauthenticated /mcp is refused as before and not labelled", async () => {
112
+ const { __resetRouteTimingForTest } = await import("./route-timing.js");
113
+ __resetRouteTimingForTest();
114
+ const r = await mcpRaw("", JSON.stringify({ jsonrpc: "2.0", id: 8, method: "tools/list" }));
115
+ expect(r.status).toBe(401);
116
+ const { keys } = await routes();
117
+ expect(keys).toContain("POST /mcp");
118
+ expect(keys.filter(k => k.startsWith("POST /mcp "))).toEqual([]);
119
+ });
120
+ });
@@ -0,0 +1,257 @@
1
+ // 任务动态(#429)—— HTTP 集成测试(真实 Bun.serve,临时库;PG 梯子上用 COMMHUB_TEST_PG_URL 原样再跑一遍)。
2
+ // 写路径 —— 新建 / PATCH 的每个字段 / 勾子任务 / 标签改名 / 删项目 / 删卡,各记字段级流水:谁、旧值 → 新值
3
+ // 同一事务 —— 流水写不进去,那次改动也不生效
4
+ // 可见范围 —— 与列表相同:scoped 成员只看到看得见的卡(删掉的按墓碑);Agent 受限成员看不见的节点被隐去
5
+ // 翻页 —— limit / next_cursor / since / requirement_id;参数不合法 400;capability `events`
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const DIR = mkdtempSync(join(tmpdir(), "anet-requirement-events-"));
12
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
13
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
14
+
15
+ const PW = "EventsPassw0rd!x";
16
+ let BASE = "";
17
+ let hub: any = null;
18
+ let NET = "";
19
+ let admin = { token: "", id: "" };
20
+ let member = { token: "", id: "" };
21
+ let scoped = { token: "", id: "" };
22
+ let restricted = { token: "", id: "" };
23
+ let nodeToken = "";
24
+ let NODE = "";
25
+
26
+ type R = { status: number; body: any };
27
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
28
+ const res = await fetch(`${BASE}${path}`, {
29
+ method,
30
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
31
+ body: payload === undefined ? undefined : JSON.stringify(payload),
32
+ });
33
+ const text = await res.text();
34
+ let body: any = null;
35
+ try { body = JSON.parse(text); } catch {}
36
+ return { status: res.status, body };
37
+ }
38
+ const create = async (t: string, card: Record<string, unknown>) => {
39
+ const r = await send(t, "POST", "/api/requirements", { network_id: NET, ...card });
40
+ expect(r.status).toBe(201);
41
+ return r.body.requirement as { id: string; seq: number };
42
+ };
43
+ const patch = (t: string, id: string, p: Record<string, unknown>) => send(t, "PATCH", `/api/requirements/${id}?network_id=${NET}`, p);
44
+ const events = (t: string, qs = "") => send(t, "GET", `/api/requirements/events?network_id=${NET}${qs}`);
45
+ const of = (list: any[], id: string) => list.filter(e => e.requirement_id === id);
46
+ const user = (id: string) => ({ kind: "user", id });
47
+
48
+ beforeAll(async () => {
49
+ process.env.HOST = "127.0.0.1";
50
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
51
+ const { db } = await import("./db.js");
52
+ const a = register(`ev_admin_${Date.now()}`, PW, undefined, "Admin");
53
+ expect(a.ok).toBe(true);
54
+ admin = { token: a.token!, id: a.user!.user_id };
55
+ NET = a.network_id!;
56
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
57
+ const mod: any = await import("./server.js");
58
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
59
+ BASE = `http://127.0.0.1:${hub.port}`;
60
+ const mk = async (username: string) => {
61
+ const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
62
+ expect(r.status).toBe(200);
63
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
64
+ expect(login.status).toBe(200);
65
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
66
+ };
67
+ const stamp = Date.now();
68
+ member = await mk(`ev_member_${stamp}`);
69
+ scoped = await mk(`ev_scoped_${stamp}`);
70
+ restricted = await mk(`ev_restricted_${stamp}`);
71
+ db.run("UPDATE network_members SET task_access = 'all', agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
72
+ db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, scoped.id]);
73
+ // 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
74
+ db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
75
+ NODE = `n_ev_${stamp}`;
76
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [NODE, "ev-node", "ev-node", NET]);
77
+ const ntok = createNetworkTokenForNode(admin.id, NET, "ev-node");
78
+ expect(ntok.ok).toBe(true);
79
+ nodeToken = ntok.token!;
80
+ }, 30_000);
81
+
82
+ afterAll(() => {
83
+ try { hub?.stop?.(true); } catch {}
84
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
85
+ });
86
+
87
+ describe("requirement events", () => {
88
+ test("capability `events` is advertised", async () => {
89
+ const r = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
90
+ expect(r.body.capabilities).toContain("events");
91
+ });
92
+
93
+ test("create records one `created` event with the actor, title and seq", async () => {
94
+ const card = await create(member.token, { name: "动态示例一" });
95
+ const r = await events(admin.token, `&requirement_id=${card.id}`);
96
+ expect(r.status).toBe(200);
97
+ expect(r.body.events.length).toBe(1);
98
+ const [e] = r.body.events;
99
+ expect(e).toMatchObject({ kind: "created", field: null, actor: user(member.id), title: "动态示例一", seq: card.seq, new: { title: "动态示例一", column: "pool" } });
100
+ expect(typeof e.id).toBe("string");
101
+ expect(Date.parse(e.at)).toBeGreaterThan(0);
102
+ });
103
+
104
+ test("a PATCH records one event per changed field, old → new; unchanged fields and no-op PATCHes record nothing", async () => {
105
+ const card = await create(admin.token, { name: "动态示例二", priority: "normal", tags: ["UI"] });
106
+ const r1 = await patch(member.token, card.id, {
107
+ column: "doing", priority: "high", due: "2026-10-03", name: "动态示例二(改)", tags: ["UI", "后端"],
108
+ agent_owner: { kind: "node", id: NODE }, participants: [user(admin.id)], assignee: "",
109
+ });
110
+ expect(r1.status).toBe(200);
111
+ const list = of((await events(admin.token)).body.events, card.id);
112
+ const byField = Object.fromEntries(list.filter((e: any) => e.kind === "changed").map((e: any) => [e.field, e]));
113
+ expect(Object.keys(byField).sort()).toEqual(["agent_owner", "column", "due", "participants", "priority", "tags", "title"]);
114
+ expect(byField.column).toMatchObject({ old: "pool", new: "doing", actor: user(member.id) });
115
+ expect(byField.priority).toMatchObject({ old: "normal", new: "high" });
116
+ expect(byField.due).toMatchObject({ old: null, new: "2026-10-03" });
117
+ expect(byField.title).toMatchObject({ old: "动态示例二", new: "动态示例二(改)" });
118
+ expect(byField.tags).toMatchObject({ old: ["UI"], new: ["UI", "后端"] });
119
+ expect(byField.agent_owner).toMatchObject({ old: null, new: { kind: "node", id: NODE } });
120
+ expect(byField.participants).toMatchObject({ old: [], new: [user(admin.id)] });
121
+ // every event of that PATCH carries the card's title after the change
122
+ expect(new Set(list.filter((e: any) => e.kind === "changed").map((e: any) => e.title))).toEqual(new Set(["动态示例二(改)"]));
123
+ const before = list.length;
124
+ expect((await patch(member.token, card.id, { priority: "high", column: "doing" })).status).toBe(200);
125
+ expect(of((await events(admin.token)).body.events, card.id).length).toBe(before);
126
+ // archive / unarchive, description (length only, never the text), move to done
127
+ await patch(member.token, card.id, { description: "一段描述" });
128
+ await patch(member.token, card.id, { column: "done" });
129
+ await patch(member.token, card.id, { archived: true });
130
+ const later = of((await events(admin.token)).body.events, card.id).slice(0, 3);
131
+ expect(later.map((e: any) => e.field)).toEqual(["archived", "column", "description"]);
132
+ expect(later[0]).toMatchObject({ old: false, new: true });
133
+ expect(later[1]).toMatchObject({ old: "doing", new: "done" });
134
+ expect(later[2]).toMatchObject({ old: { chars: 0 }, new: { chars: 4 } });
135
+ expect(JSON.stringify(later[2])).not.toContain("一段描述");
136
+ });
137
+
138
+ test("checklist: ticking an item is `checklist_item` (repeat is a no-op); adding items is `checklist`", async () => {
139
+ const card = await create(admin.token, { name: "动态示例三", checklist: [{ id: "a", text: "第一项" }, { id: "b", text: "第二项" }] });
140
+ const tick = (done: boolean) => send(member.token, "PATCH", `/api/requirements/${card.id}/checklist/a?network_id=${NET}`, { done });
141
+ expect((await tick(true)).status).toBe(200);
142
+ expect((await tick(true)).status).toBe(200);
143
+ let list = of((await events(admin.token)).body.events, card.id);
144
+ const ticks = list.filter((e: any) => e.field === "checklist_item");
145
+ expect(ticks.length).toBe(1);
146
+ expect(ticks[0]).toMatchObject({ old: { id: "a", text: "第一项", done: false }, new: { id: "a", text: "第一项", done: true }, actor: user(member.id) });
147
+ // a PATCH that only flips one item's done is also `checklist_item`
148
+ await patch(member.token, card.id, { checklist: [{ id: "a", text: "第一项", done: true }, { id: "b", text: "第二项", done: true }] });
149
+ await patch(member.token, card.id, { checklist: [{ id: "a", text: "第一项", done: true }, { id: "b", text: "第二项", done: true }, { id: "c", text: "第三项" }] });
150
+ list = of((await events(admin.token)).body.events, card.id);
151
+ expect(list[0]).toMatchObject({ field: "checklist", old: { total: 2, done: 2 }, new: { total: 3, done: 2 } });
152
+ expect(list[1]).toMatchObject({ field: "checklist_item", new: { id: "b", done: true } });
153
+ });
154
+
155
+ test("the actor of a node token is the node", async () => {
156
+ const card = await create(admin.token, { name: "动态示例四" });
157
+ expect((await patch(nodeToken, card.id, { column: "doing" })).status).toBe(200);
158
+ const [e] = of((await events(nodeToken)).body.events, card.id);
159
+ expect(e).toMatchObject({ field: "column", actor: { kind: "node", id: NODE } });
160
+ });
161
+
162
+ test("tag rename, project delete and card delete each record per-card events", async () => {
163
+ const p = await send(admin.token, "POST", `/api/requirements/projects?network_id=${NET}`, { name: `动态项目-${Date.now()}` });
164
+ expect(p.status).toBe(201);
165
+ const card = await create(admin.token, { name: "动态示例五", tags: ["旧标签"], project_id: p.body.project.id });
166
+ expect((await send(admin.token, "POST", `/api/requirements/tags/ops?network_id=${NET}`, { op: "rename", from: "旧标签", to: "新标签" })).status).toBe(200);
167
+ expect((await send(admin.token, "DELETE", `/api/requirements/projects/${p.body.project.id}?network_id=${NET}`)).status).toBe(200);
168
+ expect((await send(admin.token, "DELETE", `/api/requirements/${card.id}?network_id=${NET}`)).status).toBe(200);
169
+ const list = of((await events(admin.token)).body.events, card.id);
170
+ expect(list.slice(0, 3).map((e: any) => [e.kind, e.field])).toEqual([["deleted", null], ["changed", "project"], ["changed", "tags"]]);
171
+ expect(list[0]).toMatchObject({ title: "动态示例五", seq: expect.any(Number), actor: user(admin.id) });
172
+ expect(list[1]).toMatchObject({ old: p.body.project.id, new: null });
173
+ expect(list[2]).toMatchObject({ old: ["旧标签"], new: ["新标签"] });
174
+ });
175
+
176
+ test("the write and its events are one transaction: no events table → the PATCH fails and the card is unchanged", async () => {
177
+ const { db } = await import("./db.js");
178
+ const card = await create(admin.token, { name: "动态示例六", priority: "low" });
179
+ db.exec("ALTER TABLE requirement_events RENAME TO requirement_events_away");
180
+ try {
181
+ const r = await patch(admin.token, card.id, { priority: "high" });
182
+ expect(r.status).toBeGreaterThanOrEqual(500);
183
+ } finally {
184
+ db.exec("ALTER TABLE requirement_events_away RENAME TO requirement_events");
185
+ }
186
+ const now = await send(admin.token, "GET", `/api/requirements/${card.id}?network_id=${NET}`);
187
+ expect(now.body.requirement.priority).toBe("low");
188
+ });
189
+
190
+ test("a scoped member sees events only for cards they can see — deleted ones by their tombstone", async () => {
191
+ const mine = await create(scoped.token, { name: "动态示例七(scoped 自己建的)" });
192
+ const theirs = await create(admin.token, { name: "动态示例八(scoped 看不见)" });
193
+ const joined = await create(admin.token, { name: "动态示例九(scoped 是参与人)", participants: [user(scoped.id)] });
194
+ await patch(admin.token, mine.id, { priority: "high" });
195
+ await patch(admin.token, theirs.id, { priority: "high" });
196
+ await send(admin.token, "DELETE", `/api/requirements/${joined.id}?network_id=${NET}`);
197
+ const seen = (await events(scoped.token, "&limit=500")).body.events;
198
+ expect(of(seen, mine.id).length).toBe(2);
199
+ expect(of(seen, theirs.id).length).toBe(0);
200
+ expect(of(seen, joined.id).map((e: any) => e.kind)).toEqual(["deleted", "created"]);
201
+ // a filter on a card they cannot see answers like a card with no events
202
+ expect((await events(scoped.token, `&requirement_id=${theirs.id}`)).body.events).toEqual([]);
203
+ });
204
+
205
+ test("an agent-restricted member: hidden nodes are masked, and an event that only differs by a hidden node is dropped", async () => {
206
+ const card = await create(admin.token, { name: "动态示例十" });
207
+ await patch(nodeToken, card.id, { priority: "high" });
208
+ await patch(admin.token, card.id, { agent_owner: { kind: "node", id: NODE } });
209
+ await patch(admin.token, card.id, { participants: [user(admin.id), { kind: "node", id: NODE }] });
210
+ const seen = of((await events(restricted.token)).body.events, card.id);
211
+ const all = of((await events(admin.token)).body.events, card.id);
212
+ expect(all.map((e: any) => e.field)).toEqual(["participants", "agent_owner", "priority", null]);
213
+ expect(seen.map((e: any) => e.field)).toEqual(["participants", "priority", null]);
214
+ expect(seen[0]).toMatchObject({ old: [], new: [user(admin.id)] });
215
+ expect(seen[1]).toMatchObject({ field: "priority", actor: null });
216
+ expect(JSON.stringify(seen)).not.toContain(NODE);
217
+ });
218
+
219
+ test("paging: newest first, limit + next_cursor without overlap; since; bad params 400", async () => {
220
+ const card = await create(admin.token, { name: "动态示例十一" });
221
+ for (const priority of ["high", "low", "lowest", "normal"]) await patch(admin.token, card.id, { priority });
222
+ const q = `&requirement_id=${card.id}`;
223
+ const p1 = await events(admin.token, `${q}&limit=2`);
224
+ expect(p1.body.events.length).toBe(2);
225
+ expect(p1.body.has_more).toBe(true);
226
+ expect(p1.body.events[0].new).toBe("normal");
227
+ const p2 = await events(admin.token, `${q}&limit=2&cursor=${p1.body.next_cursor}`);
228
+ const p3 = await events(admin.token, `${q}&limit=2&cursor=${p2.body.next_cursor}`);
229
+ const ids = [...p1.body.events, ...p2.body.events, ...p3.body.events].map((e: any) => e.id);
230
+ expect(new Set(ids).size).toBe(5);
231
+ expect(p3.body.has_more).toBe(false);
232
+ expect(p3.body.next_cursor).toBe(null);
233
+ expect(ids.map(Number)).toEqual([...ids.map(Number)].sort((a, b) => b - a));
234
+ // since = server_time of an earlier read → only what came after
235
+ const t = p1.body.server_time;
236
+ await patch(admin.token, card.id, { priority: "high" });
237
+ const after = await events(admin.token, `${q}&since=${encodeURIComponent(t)}`);
238
+ // since is inclusive (>=): an event written in the same millisecond as server_time comes back again,
239
+ // and clients dedupe by id. Only the events not already seen must be exactly the new one.
240
+ expect(after.body.events.every((e: any) => Date.parse(e.at) >= Date.parse(t))).toBe(true);
241
+ const seen = new Set(ids);
242
+ expect(after.body.events.filter((e: any) => !seen.has(e.id)).map((e: any) => e.new)).toEqual(["high"]);
243
+ for (const bad of ["&limit=0", "&limit=501", "&limit=x", "&since=nope", "&cursor=abc", "&cursor=0"]) {
244
+ expect((await events(admin.token, bad)).status).toBe(400);
245
+ }
246
+ });
247
+
248
+ test("migration is idempotent and leaves the rows alone", async () => {
249
+ const { db } = await import("./db.js");
250
+ const { ensureRequirementEvents } = await import("./requirement-events.js");
251
+ const n = Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_events")!.n);
252
+ ensureRequirementEvents(db);
253
+ ensureRequirementEvents(db);
254
+ expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_events")!.n)).toBe(n);
255
+ expect(n).toBeGreaterThan(0);
256
+ });
257
+ });
@@ -0,0 +1,45 @@
1
+ // diffRequirement 的边角(HTTP 层的写路径在 requirement-events-http.test.ts)。
2
+ import { describe, expect, test } from "bun:test";
3
+ import { diffRequirement, eventPublic, type EventCard } from "./requirement-events.js";
4
+
5
+ const base: EventCard = {
6
+ requirement_id: "req_1", network_id: "net_1", seq: 7, title: "示例", column_name: "pool", priority: "normal", due_on: null, start_on: null,
7
+ assignee: null, owner_json: null, agent_owner_json: null, participants_json: "[]", tags_json: "[]", checklist_json: "[]", description: null,
8
+ project_id: null, parent_id: null, archived: 0,
9
+ };
10
+ const ck = (items: { id: string; text: string; done?: boolean }[]) => JSON.stringify(items.map(i => ({ done: false, ...i })));
11
+
12
+ describe("diffRequirement", () => {
13
+ test("no change → no events; null vs empty string are the same", () => {
14
+ expect(diffRequirement(base, { ...base })).toEqual([]);
15
+ expect(diffRequirement(base, { ...base, due_on: "", assignee: "", description: "" })).toEqual([]);
16
+ });
17
+ test("created carries title and column only", () => {
18
+ expect(diffRequirement(null, base)).toEqual([{ kind: "created", field: null, old: null, new: { title: "示例", column: "pool" } }]);
19
+ });
20
+ test("checklist: reorder, rename, add or remove is one `checklist` event with counts", () => {
21
+ const a = { ...base, checklist_json: ck([{ id: "a", text: "一" }, { id: "b", text: "二", done: true }]) };
22
+ for (const next of [ck([{ id: "b", text: "二", done: true }, { id: "a", text: "一" }]), ck([{ id: "a", text: "一改" }, { id: "b", text: "二", done: true }]), ck([{ id: "a", text: "一" }])]) {
23
+ const out = diffRequirement(a, { ...a, checklist_json: next });
24
+ expect(out.length).toBe(1);
25
+ expect(out[0].field).toBe("checklist");
26
+ }
27
+ expect(diffRequirement(a, { ...a, checklist_json: ck([{ id: "a", text: "一" }]) })[0]).toMatchObject({ old: { total: 2, done: 1 }, new: { total: 1, done: 0 } });
28
+ });
29
+ test("checklist: two items ticked in one write are two `checklist_item` events", () => {
30
+ const a = { ...base, checklist_json: ck([{ id: "a", text: "一" }, { id: "b", text: "二" }]) };
31
+ const out = diffRequirement(a, { ...a, checklist_json: ck([{ id: "a", text: "一", done: true }, { id: "b", text: "二", done: true }]) });
32
+ expect(out.map(e => [e.field, (e.new as { id: string }).id])).toEqual([["checklist_item", "a"], ["checklist_item", "b"]]);
33
+ });
34
+ test("malformed stored JSON reads as empty rather than throwing", () => {
35
+ expect(() => diffRequirement({ ...base, tags_json: "{nope", participants_json: "x" }, base)).not.toThrow();
36
+ });
37
+ test("description records lengths, never text", () => {
38
+ const [e] = diffRequirement(base, { ...base, description: "secret text" });
39
+ expect(e).toEqual({ kind: "changed", field: "description", old: { chars: 0 }, new: { chars: 11 } });
40
+ });
41
+ test("eventPublic: bigint / string ids from PostgreSQL come out as strings, seq as a number", () => {
42
+ const row = { id: 12n as unknown as string, network_id: "n", requirement_id: "r", seq: "3", title: "t", actor_json: null, kind: "changed", field: "priority", old_json: "\"low\"", new_json: "\"high\"", created_at: "2026-10-01T00:00:00.000Z" };
43
+ expect(eventPublic(row)).toMatchObject({ id: "12", seq: 3, old: "low", new: "high", actor: null });
44
+ });
45
+ });
@@ -0,0 +1,143 @@
1
+ // 任务动态(#429,Vincent 2026-09-30「任务有新的状态…这完成了,这是有这种动态」)。
2
+ //
3
+ // requirements 表只存「现在是什么」和「最后是谁改的」(updated_by_json),改之前是什么没有任何地方留着 ——
4
+ // 「优先级 P2 → P0」「状态 需求池 → 进行中」画不出来。这里给每一次写入记字段级的流水:谁(用户 / 节点)、
5
+ // 什么时候、哪个字段、旧值 → 新值。写入点在 requirements.ts 的各条写路径里,和那次 UPDATE 在同一个事务里。
6
+ //
7
+ // 表只加不改:旧 Hub(回滚之后)不认识这张表,也不会碰它;再升回来接着记(回滚期间的改动没有流水)。
8
+ // 读:GET /api/requirements/events(requirements.ts,可见范围与列表相同),capability `events`。
9
+ import type { DbAdapter } from "./db-adapter.js";
10
+
11
+ /** 流水保留多久。更早的在写入时顺手清掉(每 PRUNE_EVERY 次写一次)。 */
12
+ export const EVENTS_RETENTION_MS = 180 * 86_400_000;
13
+ const PRUNE_EVERY = 500;
14
+
15
+ /** IF NOT EXISTS:重复执行无副作用;SQLite / PostgreSQL 同一段(适配层把 AUTOINCREMENT 译成 BIGSERIAL)。 */
16
+ export function ensureRequirementEvents(database: DbAdapter): void {
17
+ database.exec(`
18
+ CREATE TABLE IF NOT EXISTS requirement_events (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ network_id TEXT NOT NULL,
21
+ requirement_id TEXT NOT NULL,
22
+ seq INTEGER,
23
+ title TEXT,
24
+ actor_json TEXT,
25
+ kind TEXT NOT NULL,
26
+ field TEXT,
27
+ old_json TEXT,
28
+ new_json TEXT,
29
+ created_at TEXT NOT NULL
30
+ );
31
+ `);
32
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_events_network ON requirement_events(network_id, id)");
33
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_events_card ON requirement_events(requirement_id, id)");
34
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_events_created ON requirement_events(created_at)");
35
+ }
36
+
37
+ /** 流水要比较的列(requirements.ts 的 Row 的子集)。 */
38
+ export type EventCard = {
39
+ requirement_id: string;
40
+ network_id: string;
41
+ seq: number | null;
42
+ title: string;
43
+ column_name: string;
44
+ priority: string;
45
+ due_on: string | null;
46
+ start_on: string | null;
47
+ assignee: string | null;
48
+ owner_json: string | null;
49
+ agent_owner_json: string | null;
50
+ participants_json: string | null;
51
+ tags_json: string | null;
52
+ checklist_json: string | null;
53
+ description: string | null;
54
+ project_id: string | null;
55
+ parent_id: string | null;
56
+ archived: number | null;
57
+ };
58
+
59
+ export type EventKind = "created" | "changed" | "deleted";
60
+ /**
61
+ * field(kind = changed):column / title / priority / due / start / assignee / owner / agent_owner / participants /
62
+ * tags / checklist_item(勾选或取消一项:{id,text,done})/ checklist(增删改条目:{total,done})/
63
+ * description(只记字数,不存正文)/ project / parent / archived。
64
+ */
65
+ export type EventDraft = { kind: EventKind; field: string | null; old: unknown; new: unknown };
66
+
67
+ const parse = (json: string | null | undefined): unknown => {
68
+ if (!json) return null;
69
+ try { return JSON.parse(json); } catch { return null; }
70
+ };
71
+ const list = (json: string | null | undefined): unknown[] => { const v = parse(json); return Array.isArray(v) ? v : []; };
72
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
73
+ type Item = { id: string; text: string; done: boolean };
74
+ const items = (json: string | null | undefined): Item[] =>
75
+ list(json).filter((x): x is Item => !!x && typeof (x as Item).id === "string" && typeof (x as Item).text === "string").map(x => ({ id: x.id, text: x.text, done: !!x.done }));
76
+ const counts = (xs: Item[]) => ({ total: xs.length, done: xs.filter(x => x.done).length });
77
+
78
+ /** 一次写入前后的差异。before = null:新建(只记一条 created)。 */
79
+ export function diffRequirement(before: EventCard | null, after: EventCard): EventDraft[] {
80
+ if (!before) return [{ kind: "created", field: null, old: null, new: { title: after.title, column: after.column_name } }];
81
+ const out: EventDraft[] = [];
82
+ const scalar = (field: string, a: unknown, b: unknown) => { if (!same(a, b)) out.push({ kind: "changed", field, old: a, new: b }); };
83
+ scalar("column", before.column_name, after.column_name);
84
+ scalar("title", before.title, after.title);
85
+ scalar("priority", before.priority, after.priority);
86
+ scalar("due", before.due_on || null, after.due_on || null);
87
+ scalar("start", before.start_on || null, after.start_on || null);
88
+ scalar("assignee", before.assignee || null, after.assignee || null);
89
+ scalar("owner", parse(before.owner_json), parse(after.owner_json));
90
+ scalar("agent_owner", parse(before.agent_owner_json), parse(after.agent_owner_json));
91
+ scalar("participants", list(before.participants_json), list(after.participants_json));
92
+ scalar("tags", list(before.tags_json), list(after.tags_json));
93
+ // 子任务:同一条(同 id、同文字)只是勾选变了 = 一条 checklist_item;条目增删 / 改字 / 换顺序 = 一条 checklist。
94
+ const a = items(before.checklist_json), b = items(after.checklist_json);
95
+ const shape = (xs: Item[]) => xs.map(x => `${x.id}\u0000${x.text}`).join("\u0001");
96
+ if (shape(a) === shape(b)) {
97
+ for (let i = 0; i < b.length; i++) if (a[i].done !== b[i].done) out.push({ kind: "changed", field: "checklist_item", old: a[i], new: b[i] });
98
+ } else {
99
+ out.push({ kind: "changed", field: "checklist", old: counts(a), new: counts(b) });
100
+ }
101
+ if ((before.description || "") !== (after.description || "")) {
102
+ out.push({ kind: "changed", field: "description", old: { chars: (before.description || "").length }, new: { chars: (after.description || "").length } });
103
+ }
104
+ scalar("project", before.project_id || null, after.project_id || null);
105
+ scalar("parent", before.parent_id || null, after.parent_id || null);
106
+ scalar("archived", !!before.archived, !!after.archived);
107
+ return out;
108
+ }
109
+
110
+ let writes = 0;
111
+
112
+ /** 记下一张卡的一组流水(调用方负责放进同一个事务)。actorJson = updated_by_json 的那个值。 */
113
+ export function recordRequirementEvents(database: DbAdapter, card: Pick<EventCard, "requirement_id" | "network_id" | "seq" | "title">, actorJson: string | null, drafts: readonly EventDraft[], at: string): void {
114
+ for (const d of drafts) {
115
+ database.run(
116
+ `INSERT INTO requirement_events (network_id, requirement_id, seq, title, actor_json, kind, field, old_json, new_json, created_at)
117
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)`,
118
+ [card.network_id, card.requirement_id, card.seq ?? null, card.title, actorJson && actorJson !== "null" ? actorJson : null, d.kind, d.field,
119
+ d.old === null || d.old === undefined ? null : JSON.stringify(d.old), d.new === null || d.new === undefined ? null : JSON.stringify(d.new), at],
120
+ );
121
+ writes++;
122
+ }
123
+ if (drafts.length && writes >= PRUNE_EVERY) {
124
+ writes = 0;
125
+ database.run("DELETE FROM requirement_events WHERE created_at < ?1", [new Date(Date.parse(at) - EVENTS_RETENTION_MS).toISOString()]);
126
+ }
127
+ }
128
+
129
+ export type EventRow = { id: number | string; network_id: string; requirement_id: string; seq: number | string | null; title: string | null; actor_json: string | null; kind: string; field: string | null; old_json: string | null; new_json: string | null; created_at: string };
130
+
131
+ /** 对外的一条(id 转成字符串:PostgreSQL 的 BIGSERIAL 可能回成字符串或 bigint,客户端一律按字符串比较 / 翻页)。 */
132
+ export const eventPublic = (row: EventRow) => ({
133
+ id: String(row.id),
134
+ requirement_id: row.requirement_id,
135
+ seq: row.seq == null ? null : Number(row.seq),
136
+ title: row.title,
137
+ actor: parse(row.actor_json),
138
+ kind: row.kind,
139
+ field: row.field,
140
+ old: parse(row.old_json),
141
+ new: parse(row.new_json),
142
+ at: row.created_at,
143
+ });
@@ -373,7 +373,7 @@ describe("requirements stay on the hub", () => {
373
373
  expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
374
374
  const listed = await api(ownerToken, "/api/requirements");
375
375
  expect(listed.body.capabilities).toContain("due_datetime");
376
- expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes"]);
376
+ expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events"]);
377
377
  });
378
378
 
379
379
  test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {