@sleep2agi/commhub-server 0.9.0-preview.75 → 0.9.0-preview.77

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.75",
3
+ "version": "0.9.0-preview.77",
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,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 () => {
@@ -0,0 +1,114 @@
1
+ // GET /api/requirements/people 的 display_name 字段 —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ // test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
3
+ //
4
+ // name 保持原样:成员 = display_name,没设回落到用户名;节点 = display_name → alias → node_name(旧 app 只读它)。
5
+ // display_name 另给一份,没设就是 "" —— 分享图等对外场景据此不把 admin 之类的账号名印出去。
6
+ // 成员的 display_name 等于用户名也返回 "":register() 没给显示名时存的就是 username。
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-people-"));
14
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
15
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
16
+ process.env.HOST = "127.0.0.1";
17
+ const PW = "ReqPeoplePassw0rd!x";
18
+
19
+ let BASE = "";
20
+ let hub: any = null;
21
+ let NET = "";
22
+ let owner = { token: "", id: "", username: "" };
23
+ let named = { id: "" };
24
+ let plain = { id: "", username: "" };
25
+ let same = { id: "", username: "" };
26
+
27
+ async function people(token: string) {
28
+ const res = await fetch(`${BASE}/api/requirements/people?network_id=${NET}`, { headers: { Authorization: `Bearer ${token}` } });
29
+ return { status: res.status, body: await res.json() as any };
30
+ }
31
+
32
+ beforeAll(async () => {
33
+ const { db } = await import("./db.js");
34
+ const { addNetworkMember, register } = await import("./auth.js");
35
+ const stamp = Date.now();
36
+ const o = register(`rp_owner_${stamp}`, PW, undefined, "");
37
+ expect(o.ok).toBe(true);
38
+ owner = { token: o.token!, id: o.user!.user_id, username: o.user!.username };
39
+ NET = o.network_id!;
40
+ const m = register(`rp_member_${stamp}`, PW, undefined, "");
41
+ expect(m.ok).toBe(true);
42
+ named = { id: m.user!.user_id };
43
+ addNetworkMember(NET, named.id, "member", owner.id, { taskAccess: "all" });
44
+ // owner:没有显示名(= 只有用户名);member:有显示名。
45
+ db.run("UPDATE users SET display_name = NULL WHERE user_id = ?1", [owner.id]);
46
+ db.run("UPDATE users SET display_name = ?1 WHERE user_id = ?2", ["Placeholder Person", named.id]);
47
+ // plain:注册时没给显示名 —— register() 存的是 display_name = username,不改。
48
+ const pl = register(`rp_plain_${stamp}`, PW);
49
+ expect(pl.ok).toBe(true);
50
+ plain = { id: pl.user!.user_id, username: pl.user!.username };
51
+ addNetworkMember(NET, plain.id, "member", owner.id, { taskAccess: "all" });
52
+ // same:显示名被显式设成和用户名一模一样。
53
+ const sm = register(`rp_same_${stamp}`, PW, undefined, "Placeholder Other");
54
+ expect(sm.ok).toBe(true);
55
+ same = { id: sm.user!.user_id, username: sm.user!.username };
56
+ addNetworkMember(NET, same.id, "member", owner.id, { taskAccess: "all" });
57
+ db.run("UPDATE users SET display_name = username WHERE user_id = ?1", [same.id]);
58
+ db.run("INSERT INTO nodes(node_id,node_name,alias,display_name,network_id) VALUES (?1,?2,?3,?4,?5)", ["rp-node-alias", "rp-node-name", "placeholder-alias", "", NET]);
59
+ db.run("INSERT INTO nodes(node_id,node_name,alias,display_name,network_id) VALUES (?1,?2,?3,?4,?5)", ["rp-node-named", "rp-node-name-2", "placeholder-alias-2", "Placeholder Agent", NET]);
60
+ db.run("INSERT INTO nodes(node_id,node_name,network_id) VALUES (?1,?2,?3)", ["rp-node-bare", "rp-node-bare-name", NET]);
61
+ const mod: any = await import("./server.js");
62
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
63
+ BASE = `http://127.0.0.1:${hub.port}`;
64
+ }, 30_000);
65
+
66
+ afterAll(() => {
67
+ try { hub?.stop?.(true); } catch {}
68
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
69
+ });
70
+
71
+ describe("GET /api/requirements/people display_name", () => {
72
+ test("display_name is its own field, empty when unset; name keeps the username / alias fallback", async () => {
73
+ const r = await people(owner.token);
74
+ expect(r.status).toBe(200);
75
+ const find = (kind: string, id: string) => r.body.people.find((p: any) => p.kind === kind && p.id === id);
76
+ expect(find("user", owner.id)).toMatchObject({ name: owner.username, display_name: "", networkId: NET });
77
+ expect(find("user", named.id)).toMatchObject({ name: "Placeholder Person", display_name: "Placeholder Person" });
78
+ expect(find("node", "rp-node-alias")).toMatchObject({ name: "placeholder-alias", display_name: "" });
79
+ expect(find("node", "rp-node-named")).toMatchObject({ name: "Placeholder Agent", display_name: "Placeholder Agent" });
80
+ expect(find("node", "rp-node-bare")).toMatchObject({ name: "rp-node-bare-name", display_name: "" });
81
+ expect(r.body.people.every((p: any) => typeof p.display_name === "string")).toBe(true);
82
+ });
83
+
84
+ test("a member whose display_name is just the username gets display_name \"\" (name still the username)", async () => {
85
+ const { db } = await import("./db.js");
86
+ // 前提是真的:register() 没给显示名时存的就是用户名。
87
+ expect(db.get<{ display_name: string }>("SELECT display_name FROM users WHERE user_id = ?1", plain.id)!.display_name).toBe(plain.username);
88
+ const r = await people(owner.token);
89
+ const find = (id: string) => r.body.people.find((p: any) => p.kind === "user" && p.id === id);
90
+ expect(find(plain.id)).toMatchObject({ name: plain.username, display_name: "" });
91
+ expect(find(same.id)).toMatchObject({ name: same.username, display_name: "" });
92
+ // 显示名只是「包含」用户名、不等于它:照常返回。
93
+ db.run("UPDATE users SET display_name = ?1 WHERE user_id = ?2", [`${same.username} (placeholder)`, same.id]);
94
+ const r2 = await people(owner.token);
95
+ expect(r2.body.people.find((p: any) => p.kind === "user" && p.id === same.id)).toMatchObject({ display_name: `${same.username} (placeholder)` });
96
+ db.run("UPDATE users SET display_name = username WHERE user_id = ?1", [same.id]);
97
+ });
98
+
99
+ test("sort order is still by name, then id", async () => {
100
+ const r = await people(owner.token);
101
+ const users = r.body.people.filter((p: any) => p.kind === "user");
102
+ const sorted = [...users].sort((a: any, b: any) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.id < b.id ? -1 : 1));
103
+ expect(users.map((p: any) => p.id)).toEqual(sorted.map((p: any) => p.id));
104
+ });
105
+
106
+ test("the existing fields keep their shape for old apps", async () => {
107
+ const r = await people(owner.token);
108
+ for (const p of r.body.people) {
109
+ expect(Object.keys(p).sort()).toEqual(["display_name", "id", "kind", "name", "networkId"]);
110
+ expect(typeof p.name).toBe("string");
111
+ expect(p.name.length).toBeGreaterThan(0);
112
+ }
113
+ });
114
+ });
@@ -12,6 +12,7 @@ import {
12
12
  addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject,
13
13
  isTaskScoped, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
14
14
  } from "./task-access.js";
15
+ import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
15
16
  import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
16
17
 
17
18
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
@@ -24,6 +25,7 @@ ensureRequirementSeq(db);
24
25
  ensureRequirementCompletedAt(db);
25
26
  ensureNetworkTags(db);
26
27
  ensureRequirementTombstones(db);
28
+ ensureRequirementEvents(db);
27
29
 
28
30
  type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
29
31
 
@@ -285,8 +287,15 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
285
287
  if (req.method === "DELETE") {
286
288
  // 先把卡片上的引用置空,再删项目:卡片一张不少。
287
289
  // updated_at 跟着动:按 updated_since 增量同步的客户端要看得见「项目被清空」这一改动。
288
- db.run("UPDATE requirements SET project_id = NULL, updated_at = ?2 WHERE project_id = ?1", [current.project_id, new Date().toISOString()]);
289
- db.run("DELETE FROM requirement_projects WHERE project_id = ?1", [current.project_id]);
290
+ // 每张被清空项目的卡记一条「项目 X → 无」(与清空同一个事务)。
291
+ const at = new Date().toISOString();
292
+ const actor = JSON.stringify(actorOf(ctx));
293
+ db.transaction(() => {
294
+ const cards = db.all<{ requirement_id: string; network_id: string; seq: number | null; title: string }>("SELECT requirement_id, network_id, seq, title FROM requirements WHERE project_id = ?1", current.project_id);
295
+ db.run("UPDATE requirements SET project_id = NULL, updated_at = ?2 WHERE project_id = ?1", [current.project_id, at]);
296
+ for (const card of cards) recordRequirementEvents(db, card, actor, [{ kind: "changed", field: "project", old: current.project_id, new: null }], at);
297
+ db.run("DELETE FROM requirement_projects WHERE project_id = ?1", [current.project_id]);
298
+ });
290
299
  deleteTaskGrantsForProject(current.project_id);
291
300
  return Response.json({ ok: true });
292
301
  }
@@ -335,8 +344,8 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
335
344
  const actor = JSON.stringify(actorOf(ctx));
336
345
  // 读-改-写整个网络的标签在一个事务里:中途失败一张都不改;颜色表跟着一起挪。
337
346
  const result = db.transaction(() => {
338
- const cards = db.all<{ requirement_id: string; tags_json: string | null }>(
339
- "SELECT requirement_id, tags_json FROM requirements WHERE network_id = ?1 AND tags_json IS NOT NULL AND tags_json <> '[]'", networkId,
347
+ const cards = db.all<{ requirement_id: string; network_id: string; seq: number | null; title: string; tags_json: string | null }>(
348
+ "SELECT requirement_id, network_id, seq, title, tags_json FROM requirements WHERE network_id = ?1 AND tags_json IS NOT NULL AND tags_json <> '[]'", networkId,
340
349
  );
341
350
  const colorOf = (name: string) => db.get<{ color: string }>("SELECT color FROM network_tags WHERE network_id = ?1 AND name = ?2", networkId, name)?.color ?? null;
342
351
  const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
@@ -352,6 +361,7 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
352
361
  const next = applyTagOp(storedTags(card.tags_json), op);
353
362
  if (!next) continue;
354
363
  db.run("UPDATE requirements SET tags_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(next), now, actor, card.requirement_id]);
364
+ recordRequirementEvents(db, card, actor, [{ kind: "changed", field: "tags", old: storedTags(card.tags_json), new: next }], now);
355
365
  affected++;
356
366
  }
357
367
  moveTagColors(networkId, op, colorOf, now);
@@ -581,7 +591,8 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
581
591
  // search:GET 认 q=(服务端搜索,语义同 App 的任务搜索);paging:认 limit / cursor,响应带 has_more / next_cursor。
582
592
  // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
583
593
  // (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
584
- export const REQUIREMENT_CAPABILITIES = ["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"] as const;
594
+ // events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
595
+ export const REQUIREMENT_CAPABILITIES = ["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"] as const;
585
596
 
586
597
  // ── 子需求 ──
587
598
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -738,15 +749,19 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
738
749
  try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
739
750
  const actor = JSON.stringify(actorOf(ctx));
740
751
  try {
741
- // 领号和插入在同一个事务里:插入被唯一索引挡下时,号一起回滚,不留空洞。
742
- db.transaction(() => db.run(
743
- `INSERT INTO requirements
744
- (requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json, start_on, seq, completed_at, completed_by_json, completed_at_approx)
745
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22, ?23, ?24, ?25, ?26, 0)`,
746
- [id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags), start || null, nextRequirementSeq(db, networkId),
747
- // 直接建在「完成」列 = 此刻完成、由建卡的人完成。
748
- column === "done" ? createdAt : null, column === "done" ? actor : null],
749
- ));
752
+ // 领号、插入、记「新建」这条动态在同一个事务里:插入被唯一索引挡下时,号和动态一起回滚,不留空洞。
753
+ db.transaction(() => {
754
+ const seq = nextRequirementSeq(db, networkId);
755
+ db.run(
756
+ `INSERT INTO requirements
757
+ (requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json, start_on, seq, completed_at, completed_by_json, completed_at_approx)
758
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22, ?23, ?24, ?25, ?26, 0)`,
759
+ [id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags), start || null, seq,
760
+ // 直接建在「完成」列 = 此刻完成、由建卡的人完成。
761
+ column === "done" ? createdAt : null, column === "done" ? actor : null],
762
+ );
763
+ recordRequirementEvents(db, { requirement_id: id, network_id: networkId, seq, title: name }, actor, [{ kind: "created", field: null, old: null, new: { title: name, column } }], createdAt);
764
+ });
750
765
  } catch {
751
766
  // 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
752
767
  if (ref) {
@@ -857,16 +872,21 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
857
872
  const completed = column !== "done" ? { at: null, by: null, approx: 0 }
858
873
  : row.column_name !== "done" ? { at: updatedAt, by: actor, approx: 0 }
859
874
  : { at: row.completed_at, by: row.completed_by_json, approx: row.completed_at_approx ? 1 : 0 };
860
- db.run(
861
- `UPDATE requirements SET column_name = ?1, updated_at = ?2, title = ?7, priority = ?8, due_on = ?9, assignee = ?10, owner_json = ?4, participants_json = ?5, issues_json = ?6,
862
- agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14, external_ref = ?15, external_url = ?16, archived = ?17, updated_by_json = ?18, parent_id = ?19, tags_json = ?20, start_on = ?21,
863
- completed_at = ?22, completed_by_json = ?23, completed_at_approx = ?24
864
- WHERE requirement_id = ?3`,
865
- [column, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, has("issues") ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
866
- has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, actor, parentId, has("tags") ? JSON.stringify(tags) : row.tags_json, start || null,
867
- completed.at, completed.by, completed.approx],
868
- );
869
- const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
875
+ // 改卡和记动态(改前 row → 改后 updated 的字段差异)在同一个事务里:要么都在,要么都不在。
876
+ const updated = db.transaction(() => {
877
+ db.run(
878
+ `UPDATE requirements SET column_name = ?1, updated_at = ?2, title = ?7, priority = ?8, due_on = ?9, assignee = ?10, owner_json = ?4, participants_json = ?5, issues_json = ?6,
879
+ agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14, external_ref = ?15, external_url = ?16, archived = ?17, updated_by_json = ?18, parent_id = ?19, tags_json = ?20, start_on = ?21,
880
+ completed_at = ?22, completed_by_json = ?23, completed_at_approx = ?24
881
+ WHERE requirement_id = ?3`,
882
+ [column, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, has("issues") ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
883
+ has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, actor, parentId, has("tags") ? JSON.stringify(tags) : row.tags_json, start || null,
884
+ completed.at, completed.by, completed.approx],
885
+ );
886
+ const after = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
887
+ recordRequirementEvents(db, after, actor, diffRequirement(row, after), updatedAt);
888
+ return after;
889
+ });
870
890
  return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
871
891
  }
872
892
 
@@ -1046,6 +1066,86 @@ function deletedSince(ctx: RequirementsRequestContext, sinceIso: string): string
1046
1066
  return db.all<TombstoneRow>(sql, ...params).filter(row => canSeeTask(caller, row)).map(row => row.requirement_id);
1047
1067
  }
1048
1068
 
1069
+ // ── 任务动态(requirement-events.ts)──
1070
+ // GET /api/requirements/events?network_id&since&limit&cursor&requirement_id:一个网络的改动流水,按 id 从新到旧。
1071
+ // 可见范围与列表相同:卡还在 → 按当前这张卡判断调用者看不看得见;删掉了 → 按墓碑(过了墓碑保留期的删除,
1072
+ // 只有不受任务范围限制的调用者看得到)。受限成员看不见的节点:操作者隐去,负责人 / 负责 Agent / 参与人里的隐去,
1073
+ // 隐去之后前后一样的那条整条不回(否则是「这张卡的 Agent 换过」的探测器)。
1074
+ // 按可见性过滤是在读出来之后做的:一页最多扫 EVENTS_SCAN_MAX 条,扫满还没凑够就带 next_cursor 回去,客户端接着翻。
1075
+ const EVENTS_PAGE_DEFAULT = 200;
1076
+ const EVENTS_PAGE_MAX = 500;
1077
+ const EVENTS_SCAN_MAX = 5000;
1078
+ type VisibilityRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null };
1079
+
1080
+ function listEvents(ctx: RequirementsRequestContext): Response {
1081
+ const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
1082
+ if (!networkId) return jsonError("network_id_required", 400);
1083
+ const q = ctx.url.searchParams;
1084
+ const limit = q.get("limit") === null ? EVENTS_PAGE_DEFAULT : Number(q.get("limit"));
1085
+ if (!Number.isInteger(limit) || limit < 1 || limit > EVENTS_PAGE_MAX) return jsonError("invalid_limit", 400);
1086
+ let since: string | null = null;
1087
+ if (q.get("since") !== null) {
1088
+ const ms = Date.parse(q.get("since")!);
1089
+ if (!Number.isFinite(ms)) return jsonError("invalid_since", 400);
1090
+ since = new Date(ms).toISOString();
1091
+ }
1092
+ const cursor = q.get("cursor");
1093
+ if (cursor !== null && !/^[1-9]\d{0,17}$/.test(cursor)) return jsonError("invalid_cursor", 400);
1094
+ const card = q.get("requirement_id");
1095
+ // server_time 取在读表之前:下次拿它当 since,读的期间写进来的不会漏(>= 会重复回,客户端按 id 去重)。
1096
+ const serverTime = new Date().toISOString();
1097
+ const caller = taskCaller(ctx);
1098
+ const hidden = hiddenNodeFilter(ctx, networkId);
1099
+ const seen = new Map<string, boolean>();
1100
+ const canSee = (id: string): boolean => {
1101
+ if (!caller) return true;
1102
+ let ok = seen.get(id);
1103
+ if (ok === undefined) {
1104
+ const cols = "network_id, owner_json, participants_json, created_by, created_by_json, project_id";
1105
+ const row = db.get<VisibilityRow>(`SELECT ${cols} FROM requirements WHERE requirement_id = ?1`, id)
1106
+ ?? db.get<VisibilityRow>(`SELECT ${cols} FROM requirement_tombstones WHERE requirement_id = ?1`, id);
1107
+ seen.set(id, ok = !!row && row.network_id === networkId && canSeeTask(caller, row));
1108
+ }
1109
+ return ok;
1110
+ };
1111
+ const shown = (row: EventRow) => {
1112
+ if (!canSee(row.requirement_id)) return null;
1113
+ const ev = eventPublic(row);
1114
+ if (!hidden) return ev;
1115
+ const mask = (ref: unknown) => (isHiddenRef(ref, hidden) ? null : ref);
1116
+ const masked = { ...ev, actor: mask(ev.actor) };
1117
+ if (ev.field === "owner" || ev.field === "agent_owner") { masked.old = mask(ev.old); masked.new = mask(ev.new); }
1118
+ if (ev.field === "participants") {
1119
+ masked.old = Array.isArray(ev.old) ? ev.old.filter(ref => !isHiddenRef(ref, hidden)) : ev.old;
1120
+ masked.new = Array.isArray(ev.new) ? ev.new.filter(ref => !isHiddenRef(ref, hidden)) : ev.new;
1121
+ }
1122
+ return ev.field && JSON.stringify(masked.old) === JSON.stringify(masked.new) ? null : masked;
1123
+ };
1124
+ const events: ReturnType<typeof eventPublic>[] = [];
1125
+ let before = cursor === null ? null : Number(cursor);
1126
+ let scanned = 0;
1127
+ let nextCursor: string | null = null;
1128
+ scan: for (;;) {
1129
+ const params: unknown[] = [networkId];
1130
+ let sql = "SELECT id, network_id, requirement_id, seq, title, actor_json, kind, field, old_json, new_json, created_at FROM requirement_events WHERE network_id = ?1";
1131
+ if (since) sql += ` AND created_at >= ?${params.push(since)}`;
1132
+ if (card) sql += ` AND requirement_id = ?${params.push(card)}`;
1133
+ if (before !== null) sql += ` AND id < ?${params.push(before)}`;
1134
+ const batch = db.all<EventRow>(`${sql} ORDER BY id DESC LIMIT ${limit + 1}`, ...params);
1135
+ for (const row of batch) {
1136
+ const ev = shown(row);
1137
+ if (ev && events.length === limit) { nextCursor = events[events.length - 1].id; break scan; }
1138
+ scanned++;
1139
+ before = Number(row.id);
1140
+ if (ev) events.push(ev);
1141
+ }
1142
+ if (batch.length < limit + 1) break;
1143
+ // 扫满了(调用者看不见的太多):从扫到的地方接着翻。
1144
+ if (scanned >= EVENTS_SCAN_MAX) { nextCursor = String(before); break; }
1145
+ }
1146
+ return Response.json({ ok: true, network_id: networkId, events, has_more: nextCursor !== null, next_cursor: nextCursor, server_time: serverTime });
1147
+ }
1148
+
1049
1149
  /**
1050
1150
  * 需求表的写入代数:每个经过 handleRequirementsRequest 的非 GET 请求处理完就 +1(REST 和 MCP 的写入都走它,
1051
1151
  * tools.ts 调的是同一个处理函数)。列表缓存只在代数没变时复用。进程重启 = 缓存清空。
@@ -1152,15 +1252,21 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1152
1252
  return conditionalJson(ctx.req, { ok: true, networkId, ...aggregateStats(rows, sq, hiddenNodeFilter(ctx, networkId)) });
1153
1253
  }
1154
1254
 
1255
+ if (url.pathname === "/api/requirements/events" && req.method === "GET") return listEvents(ctx);
1256
+
1155
1257
  if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
1156
1258
  const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
1157
1259
  if (!networkId) return jsonError('network_id_required', 400);
1158
- const users = db.all<{ id: string; name: string }>(
1159
- "SELECT u.user_id AS id, COALESCE(NULLIF(u.display_name,''), u.username) AS name FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 ORDER BY name, id", networkId,
1260
+ // name = 显示用的名字(没设 display_name 时回落到用户名 / alias,旧 app 只读它);display_name 单独给出、没设为 ""
1261
+ // —— 分享图等对外场景据此判断「只有用户名」,不把 admin 之类的账号名印出去。
1262
+ // 成员的 display_name 等于用户名也算「没设」:register() 在没给显示名时存的就是 username(auth.ts),
1263
+ // 否则没设过显示名的账号(比如 admin)照样把账号名当显示名送出去。
1264
+ const users = db.all<{ id: string; name: string; display_name: string }>(
1265
+ "SELECT u.user_id AS id, COALESCE(NULLIF(u.display_name,''), u.username) AS name, CASE WHEN u.display_name IS NULL OR u.display_name = '' OR u.display_name = u.username THEN '' ELSE u.display_name END AS display_name FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 ORDER BY name, id", networkId,
1160
1266
  );
1161
1267
  const hidden = hiddenNodeFilter(ctx, networkId);
1162
- const nodes = db.all<{ id: string; name: string }>(
1163
- "SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE network_id=?1 ORDER BY name, id", networkId,
1268
+ const nodes = db.all<{ id: string; name: string; display_name: string }>(
1269
+ "SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name, COALESCE(display_name,'') AS display_name FROM nodes WHERE network_id=?1 ORDER BY name, id", networkId,
1164
1270
  ).filter(row => !hidden?.(row.id));
1165
1271
  return Response.json({ ok: true, people: [...users.map(row => ({ ...row, kind: 'user', networkId })), ...nodes.map(row => ({ ...row, kind: 'node', networkId }))] });
1166
1272
  }
@@ -1257,9 +1363,16 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1257
1363
  const itemId = decodeURIComponent(itemMatch[2]);
1258
1364
  const item = items.find(entry => entry.id === itemId);
1259
1365
  if (!item) return jsonError("checklist_item_not_found", 404);
1366
+ const was = item.done;
1260
1367
  item.done = body.done;
1261
- db.run("UPDATE requirements SET checklist_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(items), new Date().toISOString(), JSON.stringify(actorOf(ctx)), row.requirement_id]);
1262
- const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
1368
+ const at = new Date().toISOString();
1369
+ const actor = JSON.stringify(actorOf(ctx));
1370
+ const updated = db.transaction(() => {
1371
+ db.run("UPDATE requirements SET checklist_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(items), at, actor, row.requirement_id]);
1372
+ // 重复勾同一个值(done 是显式值,幂等)不算一次改动,不记。
1373
+ if (was !== item.done) recordRequirementEvents(db, row, actor, [{ kind: "changed", field: "checklist_item", old: { ...item, done: was }, new: { ...item } }], at);
1374
+ return db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
1375
+ });
1263
1376
  return Response.json({ ok: true, requirement: toPublicFor(ctx, updated) });
1264
1377
  }
1265
1378
 
@@ -1278,9 +1391,13 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1278
1391
  if (!canDeleteTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_delete_denied");
1279
1392
  // 子需求不跟着删:先解挂(变成顶层),再删父卡。
1280
1393
  const deletedAt = new Date().toISOString();
1281
- db.run("UPDATE requirements SET parent_id = NULL, updated_at = ?2 WHERE parent_id = ?1", [row.requirement_id, deletedAt]);
1282
- db.run("DELETE FROM requirements WHERE requirement_id = ?1", [row.requirement_id]);
1283
- recordTombstone(row, deletedAt);
1394
+ db.transaction(() => {
1395
+ db.run("UPDATE requirements SET parent_id = NULL, updated_at = ?2 WHERE parent_id = ?1", [row.requirement_id, deletedAt]);
1396
+ db.run("DELETE FROM requirements WHERE requirement_id = ?1", [row.requirement_id]);
1397
+ recordTombstone(row, deletedAt);
1398
+ // 卡没了,动态里还要画得出「谁删了 #N 标题」:标题 / 短号随行存(seq、title 列)。
1399
+ recordRequirementEvents(db, row, JSON.stringify(actorOf(ctx)), [{ kind: "deleted", field: null, old: null, new: null }], deletedAt);
1400
+ });
1284
1401
  // 硬删除以前不留痕(RFC-038 §9.1):记下是谁删了哪张(标题 + 短号),卡本身已经没了。
1285
1402
  if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_deleted", "requirement", row.requirement_id, JSON.stringify({ title: row.title, seq: row.seq ?? null }).slice(0, 1000), undefined, row.network_id);
1286
1403
  return Response.json({ ok: true, deleted: row.requirement_id });