@sleep2agi/commhub-server 0.9.0-preview.96 → 0.9.0-preview.98

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.96",
3
+ "version": "0.9.0-preview.98",
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",
@@ -106,6 +106,7 @@ const ENTRIES: Record<string, Entry> = {
106
106
  client_id_taken: { field: 'client_id', message: 'That client_id was already used for another task.', hint: 'Generate a new client_id for each new task.' },
107
107
  // ── 列表 / 事件 ──
108
108
  invalid_view: { field: 'view', message: 'view must be full or summary.' },
109
+ invalid_last_event: { field: 'last_event', message: 'last_event must be 1 or 0 (REST). On MCP use include_last_event: true / false.' },
109
110
  invalid_overdue: { field: 'overdue', message: 'overdue must be true or false (REST: 1 / 0 / true / false).' },
110
111
  invalid_due_within_days: { field: 'due_within_days', message: 'due_within_days must be an integer from 0 to 365 (0 = due later today).' },
111
112
  invalid_limit: { field: 'limit', message: 'limit is out of range.' },
@@ -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", "events"]);
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", "last_event"]);
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,344 @@
1
+ // #506 —— GET /api/requirements 每行带 last_event:这张卡最新的一条动态(requirement_events,评论也算)。
2
+ // HTTP 集成测试(真实 Bun.serve,临时库;PG 梯子上用 COMMHUB_TEST_PG_URL 原样再跑一遍)。钉住:
3
+ // - 形状:{ type, field, actor: { id, kind, display_name } | null, at, summary? };没有流水的卡 = null(字段总在);
4
+ // - 评论(不改卡的任何一列)成为 last_event,summary 是正文摘要;
5
+ // - 节点(节点令牌)改状态 → actor.kind = "node",display_name = 节点名,summary「pool → doing」;
6
+ // - ETag:没变时 304,加一条评论之后同一个 If-None-Match 不再命中(列表缓存也作废);
7
+ // - 可见范围同行:受限成员看不见的节点 actor = null;scoped 成员看不见的卡根本不在列表里;
8
+ // - view=summary 同样带 last_event;REST last_event=0 不带(其他值 400 invalid_last_event);
9
+ // - MCP requirements_list 默认不带(守住 #471 的上下文预算),include_last_event=true 才带,参数严格(非布尔 → 报错)。
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-req-last-event-"));
16
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
17
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
18
+
19
+ const PW = "LastEventPassw0rd!x";
20
+ let BASE = "";
21
+ let hub: any = null;
22
+ let NET = "";
23
+ let db: any = null;
24
+ let admin = { token: "", id: "" };
25
+ let member = { token: "", id: "" };
26
+ let scoped = { token: "", id: "" };
27
+ let restricted = { token: "", id: "" };
28
+ let nodeToken = "";
29
+ let NODE = "";
30
+
31
+ type R = { status: number; body: any; etag: string | null };
32
+ async function send(token: string, method: string, path: string, payload?: unknown, headers: Record<string, string> = {}): Promise<R> {
33
+ const res = await fetch(`${BASE}${path}`, {
34
+ method,
35
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json", ...headers },
36
+ body: payload === undefined ? undefined : JSON.stringify(payload),
37
+ });
38
+ const text = await res.text();
39
+ let body: any = null;
40
+ try { body = JSON.parse(text); } catch {}
41
+ return { status: res.status, body, etag: res.headers.get("etag") };
42
+ }
43
+ const create = async (t: string, card: Record<string, unknown>) => {
44
+ const r = await send(t, "POST", "/api/requirements", { network_id: NET, ...card });
45
+ expect(r.status).toBe(201);
46
+ return r.body.requirement as { id: string; seq: number };
47
+ };
48
+ const list = (t: string, qs = "", headers: Record<string, string> = {}) => send(t, "GET", `/api/requirements?network_id=${NET}${qs}`, undefined, headers);
49
+ const rowOf = (r: R, id: string) => (r.body.requirements as any[]).find(x => x.id === id);
50
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<{ data: any; text: string; isError: boolean }> {
51
+ const res = await fetch(`${BASE}/mcp`, {
52
+ method: "POST",
53
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
54
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
55
+ });
56
+ const raw = await res.text();
57
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
58
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
59
+ const text = out.result?.content?.[0]?.text ?? out.error?.message ?? "";
60
+ let data: any = null;
61
+ try { data = JSON.parse(text); } catch {}
62
+ return { data, text, isError: !!out.error || !!out.result?.isError };
63
+ }
64
+ const comment = (t: string, id: string, text: string) => send(t, "POST", `/api/requirements/${id}/comments?network_id=${NET}`, { text });
65
+
66
+ beforeAll(async () => {
67
+ process.env.HOST = "127.0.0.1";
68
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
69
+ db = (await import("./db.js")).db;
70
+ const a = register(`le_admin_${Date.now()}`, PW, undefined, "示例管理员");
71
+ expect(a.ok).toBe(true);
72
+ admin = { token: a.token!, id: a.user!.user_id };
73
+ NET = a.network_id!;
74
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
75
+ const mod: any = await import("./server.js");
76
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
77
+ BASE = `http://127.0.0.1:${hub.port}`;
78
+ const mk = async (username: string) => {
79
+ const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
80
+ expect(r.status).toBe(200);
81
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
82
+ expect(login.status).toBe(200);
83
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
84
+ };
85
+ const stamp = Date.now();
86
+ member = await mk(`le_member_${stamp}`);
87
+ scoped = await mk(`le_scoped_${stamp}`);
88
+ restricted = await mk(`le_restricted_${stamp}`);
89
+ db.run("UPDATE users SET display_name = ?1 WHERE user_id = ?2", ["示例成员", member.id]);
90
+ db.run("UPDATE network_members SET task_access = 'all', agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
91
+ db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, scoped.id]);
92
+ // 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
93
+ db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
94
+ NODE = `n_le_${stamp}`;
95
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [NODE, "le-node", "le-node", NET]);
96
+ const ntok = createNetworkTokenForNode(admin.id, NET, "le-node");
97
+ expect(ntok.ok).toBe(true);
98
+ nodeToken = ntok.token!;
99
+ }, 30_000);
100
+
101
+ afterAll(() => {
102
+ try { hub?.stop?.(true); } catch {}
103
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
104
+ });
105
+
106
+ describe("#506 GET /api/requirements last_event", () => {
107
+ test("capability `last_event` is advertised; a fresh card's last_event is its `created` event", async () => {
108
+ const card = await create(member.token, { name: "最新动态示例一" });
109
+ const r = await list(admin.token);
110
+ expect(r.status).toBe(200);
111
+ expect(r.body.capabilities).toContain("last_event");
112
+ const row = rowOf(r, card.id);
113
+ expect(row.last_event).toEqual({ type: "created", field: null, actor: { id: member.id, kind: "user", display_name: "示例成员" }, at: row.last_event.at });
114
+ expect(Date.parse(row.last_event.at)).toBeGreaterThan(0);
115
+ });
116
+
117
+ test("rows with no events → last_event null (field present)", async () => {
118
+ const card = await create(admin.token, { name: "没有流水的旧卡" });
119
+ // 动态上线前建的卡 / 过了 180 天保留期的卡:流水里没有它
120
+ db.run("DELETE FROM requirement_events WHERE requirement_id = ?1", [card.id]);
121
+ const row = rowOf(await list(admin.token), card.id);
122
+ expect("last_event" in row).toBe(true);
123
+ expect(row.last_event).toBeNull();
124
+ });
125
+
126
+ test("a comment becomes last_event (type comment, summary = text), though it changes no column", async () => {
127
+ const card = await create(admin.token, { name: "评论示例" });
128
+ const before = rowOf(await list(admin.token), card.id);
129
+ expect((await comment(member.token, card.id, "第一条进展:\n已经复现, 正在修。")).status).toBe(201);
130
+ const row = rowOf(await list(admin.token), card.id);
131
+ expect(row.updatedAt).toBe(before.updatedAt); // 评论不改卡
132
+ expect(row.last_event).toMatchObject({ type: "comment", field: null, actor: { id: member.id, kind: "user", display_name: "示例成员" }, summary: "第一条进展: 已经复现, 正在修。" });
133
+ // 很长的评论:摘要截断到 120 字
134
+ await comment(admin.token, card.id, "长".repeat(500));
135
+ const long = rowOf(await list(admin.token), card.id).last_event;
136
+ expect(long.actor).toEqual({ id: admin.id, kind: "user", display_name: "示例管理员" });
137
+ expect(long.summary.length).toBe(120);
138
+ expect(long.summary.endsWith("…")).toBe(true);
139
+ });
140
+
141
+ test("a status change by a node shows kind=node with the node's name and `pool → doing`", async () => {
142
+ const card = await create(admin.token, { name: "节点改状态示例" });
143
+ const p = await send(nodeToken, "PATCH", `/api/requirements/${card.id}?network_id=${NET}`, { column: "doing" });
144
+ expect(p.status).toBe(200);
145
+ const row = rowOf(await list(admin.token), card.id);
146
+ expect(row.last_event).toMatchObject({ type: "changed", field: "column", actor: { id: NODE, kind: "node", display_name: "le-node" }, summary: "pool → doing" });
147
+ // 节点自己读列表(MCP requirements_list 走同一条路,默认 view=summary)也带着
148
+ const viaNode = rowOf(await list(nodeToken, "&view=summary"), card.id);
149
+ expect(viaNode.last_event).toEqual(row.last_event);
150
+ });
151
+
152
+ test("ETag: 304 while nothing changed; adding a comment changes the ETag (cache invalidated)", async () => {
153
+ const card = await create(admin.token, { name: "ETag 示例" });
154
+ const first = await list(admin.token);
155
+ expect(first.etag).toBeTruthy();
156
+ const again = await list(admin.token, "", { "If-None-Match": first.etag! });
157
+ expect(again.status).toBe(304);
158
+ expect((await comment(admin.token, card.id, "加一条评论")).status).toBe(201);
159
+ const after = await list(admin.token, "", { "If-None-Match": first.etag! });
160
+ expect(after.status).toBe(200);
161
+ expect(after.etag).not.toBe(first.etag);
162
+ expect(rowOf(after, card.id).last_event).toMatchObject({ type: "comment", summary: "加一条评论" });
163
+ // summary 视图的缓存键不同,同样作废
164
+ const s1 = await list(admin.token, "&view=summary");
165
+ await comment(admin.token, card.id, "再加一条");
166
+ const s2 = await list(admin.token, "&view=summary", { "If-None-Match": s1.etag! });
167
+ expect(s2.status).toBe(200);
168
+ expect(rowOf(s2, card.id).last_event.summary).toBe("再加一条");
169
+ });
170
+
171
+ test("visibility: a restricted member sees the node actor masked; a scoped member does not get the row at all", async () => {
172
+ const card = await create(admin.token, { name: "可见范围示例" });
173
+ expect((await send(nodeToken, "PATCH", `/api/requirements/${card.id}?network_id=${NET}`, { priority: "high" })).status).toBe(200);
174
+ const seen = rowOf(await list(restricted.token), card.id);
175
+ expect(seen.last_event).toMatchObject({ type: "changed", field: "priority", actor: null, summary: "normal → high" });
176
+ expect(JSON.stringify(seen.last_event)).not.toContain(NODE);
177
+ expect(JSON.stringify(seen.last_event)).not.toContain("le-node");
178
+ // 只看相关任务的成员:这张卡与他无关 → 不在列表里,last_event 也就无从谈起
179
+ const s = await list(scoped.token);
180
+ expect(s.status).toBe(200);
181
+ expect(rowOf(s, card.id)).toBeUndefined();
182
+ expect(JSON.stringify(s.body)).not.toContain(NODE);
183
+ });
184
+
185
+ test("restricted member: an agent_owner change between hidden nodes degrades to a bare `changed` (no field / summary)", async () => {
186
+ const other = `n_le_other_${Date.now()}`;
187
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [other, "le-other", "le-other", NET]);
188
+ const card = await create(admin.token, { name: "隐去示例", agent_owner: { kind: "node", id: NODE } });
189
+ expect((await send(admin.token, "PATCH", `/api/requirements/${card.id}?network_id=${NET}`, { agent_owner: { kind: "node", id: other } })).status).toBe(200);
190
+ expect(rowOf(await list(admin.token), card.id).last_event).toMatchObject({ type: "changed", field: "agent_owner", actor: { id: admin.id, kind: "user" } });
191
+ const seen = rowOf(await list(restricted.token), card.id).last_event;
192
+ expect(seen).toEqual({ type: "changed", field: null, actor: { id: admin.id, kind: "user", display_name: "示例管理员" }, at: seen.at });
193
+ });
194
+
195
+ test("REST last_event=0 drops the field; any other value is 400 invalid_last_event with field", async () => {
196
+ const card = await create(admin.token, { name: "REST 开关示例" });
197
+ const off = await list(admin.token, "&last_event=0");
198
+ expect(off.status).toBe(200);
199
+ expect("last_event" in rowOf(off, card.id)).toBe(false);
200
+ expect(off.body.requirements.some((r: any) => "last_event" in r)).toBe(false);
201
+ expect(rowOf(await list(admin.token, "&last_event=1"), card.id).last_event).toMatchObject({ type: "created" });
202
+ const bad = await list(admin.token, "&last_event=yes");
203
+ expect(bad.status).toBe(400);
204
+ expect(bad.body).toMatchObject({ ok: false, error: "invalid_last_event", field: "last_event" });
205
+ });
206
+
207
+ test("MCP requirements_list: no last_event by default; include_last_event=true adds it; non-boolean rejected", async () => {
208
+ const card = await create(admin.token, { name: "MCP 开关示例" });
209
+ await comment(admin.token, card.id, "MCP 能看到的评论");
210
+ const def = await mcp(nodeToken, "requirements_list", {});
211
+ expect(def.isError).toBe(false);
212
+ expect(def.data.requirements.length).toBeGreaterThan(0);
213
+ expect(def.data.requirements.some((r: any) => "last_event" in r)).toBe(false);
214
+ expect(def.text).not.toContain(`"last_event":`); // (capabilities still lists "last_event" — the Hub supports it)
215
+ const off = await mcp(nodeToken, "requirements_list", { include_last_event: false });
216
+ expect(off.data.requirements.some((r: any) => "last_event" in r)).toBe(false);
217
+ const on = await mcp(nodeToken, "requirements_list", { include_last_event: true });
218
+ expect(on.isError).toBe(false);
219
+ expect(on.data.requirements.every((r: any) => "last_event" in r)).toBe(true);
220
+ expect(on.data.requirements.find((r: any) => r.id === card.id).last_event).toMatchObject({ type: "comment", actor: { id: admin.id, kind: "user" }, summary: "MCP 能看到的评论" });
221
+ const bad = await mcp(nodeToken, "requirements_list", { include_last_event: "yes" });
222
+ expect(bad.isError).toBe(true);
223
+ expect(bad.text).toContain("include_last_event");
224
+ });
225
+
226
+ test("one page of many cards: every row gets its own latest event", async () => {
227
+ const ids: string[] = [];
228
+ for (let i = 0; i < 12; i++) ids.push((await create(admin.token, { name: `批量 ${i}` })).id);
229
+ for (let i = 0; i < ids.length; i += 2) await comment(member.token, ids[i], `评论 ${i}`);
230
+ const r = await list(admin.token, "&limit=50");
231
+ for (let i = 0; i < ids.length; i++) {
232
+ const ev = rowOf(r, ids[i]).last_event;
233
+ if (i % 2 === 0) expect(ev).toMatchObject({ type: "comment", summary: `评论 ${i}` });
234
+ else expect(ev).toMatchObject({ type: "created", actor: { id: admin.id } });
235
+ }
236
+ });
237
+ });
238
+
239
+ // #506 跟进 —— 评论不动 updated_at,所以增量读(changes=1 + updated_since)过去看不见它:「评论了」要等下次整读。
240
+ // 现在 changes=1(且带 last_event)时,游标之后有新流水的卡也回。updated_at 语义不变(评论不改卡)。
241
+ describe("#506 changes=1 returns cards whose newest event is after the cursor", () => {
242
+ const tick = () => new Promise(r => setTimeout(r, 5));
243
+ const changes = (t: string, since: string, extra = "", headers: Record<string, string> = {}) =>
244
+ list(t, `&changes=1&updated_since=${encodeURIComponent(since)}${extra}`, headers);
245
+ const cursorNow = async (t: string) => {
246
+ const base = await changes(t, new Date().toISOString());
247
+ expect(base.status).toBe(200);
248
+ await tick();
249
+ return base.body.server_time as string;
250
+ };
251
+
252
+ test("a comment after the cursor → that row comes back with the new last_event; untouched rows do not", async () => {
253
+ const a = await create(admin.token, { name: "增量评论 A" });
254
+ const b = await create(admin.token, { name: "增量评论 B" });
255
+ const before = rowOf(await list(admin.token), a.id);
256
+ await tick();
257
+ const since = await cursorNow(admin.token);
258
+ expect((await comment(member.token, a.id, "增量里看得到这条")).status).toBe(201);
259
+ const r = await changes(admin.token, since);
260
+ expect(r.status).toBe(200);
261
+ const ids = (r.body.requirements as any[]).map(x => x.id);
262
+ expect(ids).toEqual([a.id]);
263
+ expect(ids).not.toContain(b.id);
264
+ const row = rowOf(r, a.id);
265
+ expect(row.updatedAt).toBe(before.updatedAt); // updated_at 语义不变:评论不改卡
266
+ expect(row.last_event).toMatchObject({ type: "comment", actor: { id: member.id, kind: "user" }, summary: "增量里看得到这条" });
267
+ expect(r.body.deleted).toEqual([]);
268
+ // summary 视图同样回
269
+ const s = await changes(admin.token, since, "&view=summary");
270
+ expect((s.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
271
+ expect(rowOf(s, a.id).last_event.summary).toBe("增量里看得到这条");
272
+ });
273
+
274
+ test("no event and no update after the cursor → nothing returned; a comment then changes the result and its ETag", async () => {
275
+ const a = await create(admin.token, { name: "增量空" });
276
+ await tick();
277
+ const since = await cursorNow(admin.token);
278
+ const empty = await changes(admin.token, since);
279
+ expect(empty.status).toBe(200);
280
+ expect(empty.body.requirements).toEqual([]);
281
+ expect(empty.body.deleted).toEqual([]);
282
+ expect(empty.etag).toBeTruthy();
283
+ expect((await comment(admin.token, a.id, "空之后的评论")).status).toBe(201);
284
+ const after = await changes(admin.token, since, "", { "If-None-Match": empty.etag! });
285
+ expect(after.status).toBe(200);
286
+ expect(after.etag).not.toBe(empty.etag);
287
+ expect((after.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
288
+ });
289
+
290
+ test("full-list ETag changes on a comment (list cache invalidated by the write)", async () => {
291
+ const a = await create(admin.token, { name: "整表 ETag" });
292
+ const first = await list(admin.token);
293
+ expect((await list(admin.token, "", { "If-None-Match": first.etag! })).status).toBe(304);
294
+ expect((await comment(admin.token, a.id, "整表也变")).status).toBe(201);
295
+ const after = await list(admin.token, "", { "If-None-Match": first.etag! });
296
+ expect(after.status).toBe(200);
297
+ expect(after.etag).not.toBe(first.etag);
298
+ });
299
+
300
+ test("unchanged contracts: last_event=0 (MCP default) and plain updated_since (no changes=1) still filter by updated_at only", async () => {
301
+ const a = await create(admin.token, { name: "旧语义" });
302
+ await tick();
303
+ const since = await cursorNow(admin.token);
304
+ expect((await comment(admin.token, a.id, "只是一条评论")).status).toBe(201);
305
+ expect((await changes(admin.token, since, "&last_event=0")).body.requirements).toEqual([]);
306
+ const plain = await list(admin.token, `&updated_since=${encodeURIComponent(since)}`);
307
+ expect(plain.status).toBe(200);
308
+ expect(rowOf(plain, a.id)).toBeUndefined();
309
+ // MCP changes=true 默认不带 last_event → 同样不回;include_last_event=true → 回
310
+ const mDef = await mcp(nodeToken, "requirements_list", { changes: true, updated_since: since });
311
+ expect(mDef.isError).toBe(false);
312
+ expect(mDef.data.requirements.map((r: any) => r.id)).not.toContain(a.id);
313
+ const mOn = await mcp(nodeToken, "requirements_list", { changes: true, updated_since: since, include_last_event: true });
314
+ expect(mOn.isError).toBe(false);
315
+ expect(mOn.data.requirements.find((r: any) => r.id === a.id)?.last_event).toMatchObject({ type: "comment", summary: "只是一条评论" });
316
+ });
317
+
318
+ test("a real update after the cursor still comes back (updated_at path); a delete is reported in `deleted`, not as a row", async () => {
319
+ const a = await create(admin.token, { name: "增量改动" });
320
+ const d = await create(admin.token, { name: "增量删除" });
321
+ await comment(admin.token, d.id, "删之前的评论");
322
+ await tick();
323
+ const since = await cursorNow(admin.token);
324
+ expect((await send(admin.token, "PATCH", `/api/requirements/${a.id}?network_id=${NET}`, { priority: "high" })).status).toBe(200);
325
+ expect((await send(admin.token, "DELETE", `/api/requirements/${d.id}?network_id=${NET}`)).status).toBe(200);
326
+ const r = await changes(admin.token, since);
327
+ expect((r.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
328
+ expect(rowOf(r, a.id).last_event).toMatchObject({ type: "changed", field: "priority" });
329
+ expect(r.body.deleted).toEqual([d.id]);
330
+ });
331
+
332
+ test("visibility: a scoped member does not get a card it cannot see, even when it was commented on", async () => {
333
+ const hidden = await create(admin.token, { name: "scoped 看不见" });
334
+ const mine = await create(admin.token, { name: "scoped 看得见", owner: { kind: "user", id: scoped.id } });
335
+ await tick();
336
+ const since = await cursorNow(scoped.token);
337
+ expect((await comment(admin.token, hidden.id, "别人的卡上的评论")).status).toBe(201);
338
+ expect((await comment(admin.token, mine.id, "他的卡上的评论")).status).toBe(201);
339
+ const r = await changes(scoped.token, since);
340
+ expect(r.status).toBe(200);
341
+ expect((r.body.requirements as any[]).map(x => x.id)).toEqual([mine.id]);
342
+ expect(JSON.stringify(r.body)).not.toContain(hidden.id);
343
+ });
344
+ });
@@ -124,7 +124,11 @@ describe("view=summary", () => {
124
124
  expect(summary.body.requirements.map((r: any) => r.id)).toEqual(full.body.requirements.map((r: any) => r.id));
125
125
  const one = await send(admin.token, "GET", `/api/requirements/${card.id}?network_id=${NET}`);
126
126
  expect(one.body.requirement.description).toBe(f.description);
127
- expect(summary.text.length).toBeLessThan(full.text.length / 2);
127
+ // 比的是 summary 省掉的那部分(正文 / 子任务):两边一样多的 last_event(#506,每行约 150 B)不算,
128
+ // 否则这张只有几行的小表上,固定开销会把比例拉过一半。
129
+ const bare = (body: any) => JSON.stringify({ ...body, requirements: body.requirements.map(({ last_event: _l, ...r }: any) => r) }).length;
130
+ expect(summary.body.requirements.every((r: any) => "last_event" in r)).toBe(true);
131
+ expect(bare(summary.body)).toBeLessThan(bare(full.body) / 2);
128
132
  });
129
133
 
130
134
  test("view=full is the plain list; an unknown view is a 400", async () => {
@@ -729,7 +729,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
729
729
  // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
730
730
  // (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
731
731
  // events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
732
- 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;
732
+ 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", "last_event"] as const;
733
733
 
734
734
  // ── 子需求 ──
735
735
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -1105,7 +1105,16 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
1105
1105
  if (since !== null) {
1106
1106
  const ms = Date.parse(since);
1107
1107
  if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
1108
- sql += ` AND updated_at >= ?${params.push(new Date(ms).toISOString())}`;
1108
+ const n = params.push(new Date(ms).toISOString());
1109
+ // #506 —— 增量(changes=1)且带 last_event 时,「之后有新流水」的卡也算改过:评论只追加 requirement_events、
1110
+ // 不动 updated_at(updatedAt 是「卡本身最后一次被改」,评论不算 —— 见 last_event 测试),不这样评论要等下次整读才出现。
1111
+ // 同一个占位符用两次(SQLite / PostgreSQL 都认,没有绑了不用的参数);子查询走 idx_requirement_events_created,
1112
+ // 只扫游标之后的流水。不带 changes 的 updated_since 照旧只按 updated_at 过滤(同步方 / MCP 筛选语义不变);
1113
+ // last_event=0(MCP 默认)行上没有动态可看,也照旧。
1114
+ const eventsToo = q.get("changes") === "1" && q.get("last_event") !== "0";
1115
+ sql += eventsToo
1116
+ ? ` AND (updated_at >= ?${n} OR requirement_id IN (SELECT requirement_id FROM requirement_events WHERE created_at >= ?${n}))`
1117
+ : ` AND updated_at >= ?${n}`;
1109
1118
  }
1110
1119
  const seq = q.get("seq");
1111
1120
  if (seq !== null) {
@@ -1260,6 +1269,95 @@ function deletedSince(ctx: RequirementsRequestContext, sinceIso: string): string
1260
1269
  return db.all<TombstoneRow>(sql, ...params).filter(row => canSeeTask(caller, row)).map(row => row.requirement_id);
1261
1270
  }
1262
1271
 
1272
+ // ── 列表每行的「最新动态」(#506)──
1273
+ // 卡片上一行「谁 · 刚刚 · 干了什么」。以前 App 只能拿 updatedAt / updated_by 拼,评论不改卡、所以不算;也说不出改了什么。
1274
+ // 这里从 requirement_events(评论也在里面,kind=comment)取每张卡最新的一条:一页只一次查询
1275
+ // (MAX(id) GROUP BY,走 idx_requirement_events_card;SQLite / PostgreSQL 同一句),再按出现的人一次查名字。
1276
+ // 只算这一页的卡(已经按调用者可见范围筛过);受限成员看不见的节点:actor 隐去,
1277
+ // 人员字段(owner / agent_owner / participants)的改动隐去之后前后一样的,只剩「改过」(field / summary 都不给)——
1278
+ // 与 updatedAt 透露的信息相同,不多泄一个字。180 天保留期外 / 没有流水的卡 = null。
1279
+ export type LastEvent = {
1280
+ type: string;
1281
+ field: string | null;
1282
+ actor: { id: string; kind: "user" | "node"; display_name: string | null } | null;
1283
+ at: string;
1284
+ summary?: string;
1285
+ };
1286
+ const LAST_EVENT_SUMMARY_MAX = 120;
1287
+ const clip = (s: string) => (s.length > LAST_EVENT_SUMMARY_MAX ? `${s.slice(0, LAST_EVENT_SUMMARY_MAX - 1)}…` : s);
1288
+ const isRef = (v: unknown): v is { kind: string; id: string } => !!v && typeof v === "object" && typeof (v as { id?: unknown }).id === "string" && ((v as { kind?: unknown }).kind === "user" || (v as { kind?: unknown }).kind === "node");
1289
+
1290
+ /** 一条流水 → 一句话(人员字段不给:名字另有 actor 显示,引用里的 id 对卡片没用)。 */
1291
+ function lastEventSummary(ev: ReturnType<typeof eventPublic>): string | undefined {
1292
+ const text = (v: unknown) => (v === null || v === undefined || v === "" ? "—" : String(v));
1293
+ if (ev.kind === "comment") {
1294
+ const t = (ev.new as { text?: unknown } | null)?.text;
1295
+ return typeof t === "string" ? clip(t.replace(/\s+/g, " ").trim()) : undefined;
1296
+ }
1297
+ if (ev.kind !== "changed") return undefined;
1298
+ switch (ev.field) {
1299
+ case "column": case "priority": case "due": case "start": case "archived": case "project": case "parent":
1300
+ return clip(`${text(ev.old)} → ${text(ev.new)}`);
1301
+ case "title": return typeof ev.new === "string" ? clip(ev.new) : undefined;
1302
+ case "checklist_item": {
1303
+ const item = ev.new as { text?: unknown; done?: unknown } | null;
1304
+ return item && typeof item.text === "string" ? clip(`${item.done ? "[x]" : "[ ]"} ${item.text}`) : undefined;
1305
+ }
1306
+ case "checklist": {
1307
+ const c = ev.new as { total?: unknown; done?: unknown } | null;
1308
+ return c ? `${Number(c.done ?? 0)}/${Number(c.total ?? 0)}` : undefined;
1309
+ }
1310
+ case "tags": return Array.isArray(ev.new) ? clip(ev.new.join(", ")) : undefined;
1311
+ default: return undefined;
1312
+ }
1313
+ }
1314
+
1315
+ function lastEventsFor(ctx: RequirementsRequestContext, rows: readonly Row[]): Map<string, LastEvent> {
1316
+ const out = new Map<string, LastEvent>();
1317
+ if (!rows.length) return out;
1318
+ const ids = rows.map(r => r.requirement_id);
1319
+ const marks = ids.map((_, i) => `?${i + 1}`).join(",");
1320
+ const events = db.all<EventRow>(
1321
+ `SELECT id, network_id, requirement_id, seq, title, actor_json, kind, field, old_json, new_json, created_at FROM requirement_events
1322
+ WHERE id IN (SELECT MAX(id) FROM requirement_events WHERE requirement_id IN (${marks}) GROUP BY requirement_id)`,
1323
+ ...ids,
1324
+ );
1325
+ if (!events.length) return out;
1326
+ const pubs = events.map(eventPublic);
1327
+ const userIds = new Set<string>(), nodeIds = new Set<string>();
1328
+ for (const ev of pubs) if (isRef(ev.actor)) (ev.actor.kind === "user" ? userIds : nodeIds).add(ev.actor.id);
1329
+ // 名字规则同 GET /api/requirements/people 的 name(没设显示名 → 用户名 / alias / node_name)。
1330
+ const names = new Map<string, string>();
1331
+ if (userIds.size) {
1332
+ const u = [...userIds];
1333
+ for (const r of db.all<{ id: string; name: string }>(`SELECT user_id AS id, COALESCE(NULLIF(display_name,''), username) AS name FROM users WHERE user_id IN (${u.map((_, i) => `?${i + 1}`).join(",")})`, ...u)) names.set(`user:${r.id}`, r.name);
1334
+ }
1335
+ if (nodeIds.size) {
1336
+ const n = [...nodeIds];
1337
+ for (const r of db.all<{ id: string; name: string }>(`SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE node_id IN (${n.map((_, i) => `?${i + 1}`).join(",")})`, ...n)) names.set(`node:${r.id}`, r.name);
1338
+ }
1339
+ const networkOf = new Map(rows.map(r => [r.requirement_id, r.network_id] as const));
1340
+ const hiddenBy = new Map<string, HiddenNode>();
1341
+ for (const ev of pubs) {
1342
+ const networkId = networkOf.get(ev.requirement_id)!;
1343
+ if (!hiddenBy.has(networkId)) hiddenBy.set(networkId, hiddenNodeFilter(ctx, networkId));
1344
+ const hidden = hiddenBy.get(networkId)!;
1345
+ const actor = isRef(ev.actor) && !isHiddenRef(ev.actor, hidden)
1346
+ ? { id: ev.actor.id, kind: ev.actor.kind as "user" | "node", display_name: names.get(`${ev.actor.kind}:${ev.actor.id}`) ?? null }
1347
+ : null;
1348
+ let field = ev.field;
1349
+ let summary = lastEventSummary(ev);
1350
+ if (hidden && (field === "owner" || field === "agent_owner" || field === "participants")) {
1351
+ const strip = (v: unknown) => (Array.isArray(v) ? v.filter(ref => !isHiddenRef(ref, hidden)) : isHiddenRef(v, hidden) ? null : v);
1352
+ if (JSON.stringify(strip(ev.old)) === JSON.stringify(strip(ev.new))) { field = null; summary = undefined; }
1353
+ }
1354
+ const last: LastEvent = { type: ev.kind, field, actor, at: ev.at };
1355
+ if (summary !== undefined) last.summary = summary;
1356
+ out.set(ev.requirement_id, last);
1357
+ }
1358
+ return out;
1359
+ }
1360
+
1263
1361
  // ── 任务动态(requirement-events.ts)──
1264
1362
  // GET /api/requirements/events?network_id&since&limit&cursor&requirement_id:一个网络的改动流水,按 id 从新到旧。
1265
1363
  // 可见范围与列表相同:卡还在 → 按当前这张卡判断调用者看不看得见;删掉了 → 按墓碑(过了墓碑保留期的删除,
@@ -1491,6 +1589,10 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1491
1589
  if (view !== null && view !== "full" && view !== "summary") return jsonError("invalid_view", 400);
1492
1590
  const changes = url.searchParams.get("changes") === "1";
1493
1591
  if (changes && url.searchParams.get("updated_since") === null) return jsonError("updated_since_required", 400);
1592
+ // last_event=0:不带每行的 last_event(MCP requirements_list 默认这么调,保住 #471 的 Agent 上下文预算)。不带 / =1 = 带。
1593
+ const lastEventParam = url.searchParams.get("last_event");
1594
+ if (lastEventParam !== null && lastEventParam !== "0" && lastEventParam !== "1") return jsonError("invalid_last_event", 400);
1595
+ const withLastEvent = lastEventParam !== "0";
1494
1596
  // changes 模式:server_time 取在读表之前,下次拿它当 updated_since,读表期间的写入不会漏(>= 会重复回一次,客户端按 id 覆盖)。
1495
1597
  const serverTime = new Date().toISOString();
1496
1598
  nodeReadNote(ctx, "GET /api/requirements", null);
@@ -1505,7 +1607,9 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1505
1607
  return conditionalBody(ctx.req, cached.body, cached.etag);
1506
1608
  }
1507
1609
  const page = listPage(ctx, filtered, params, lq);
1508
- const rows = page.rows.map(row => toPublicFor(ctx, row));
1610
+ // last_event(#506):每行最新一条动态(含评论),没有 = null。旧 App 不认识,忽略即可。
1611
+ const lastEvents = withLastEvent ? lastEventsFor(ctx, page.rows) : null;
1612
+ const rows = lastEvents ? page.rows.map(row => ({ ...toPublicFor(ctx, row), last_event: lastEvents.get(row.requirement_id) ?? null })) : page.rows.map(row => toPublicFor(ctx, row));
1509
1613
  // capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
1510
1614
  // has_more / next_cursor:后面还有没有(带 cursor=next_cursor 再读一页)。旧客户端不认识,忽略即可。
1511
1615
  const payload: Record<string, unknown> = { ok: true, requirements: view === "summary" ? rows.map(toSummary) : rows, capabilities: REQUIREMENT_CAPABILITIES, has_more: page.hasMore, next_cursor: page.nextCursor };
package/src/tools.ts CHANGED
@@ -5977,13 +5977,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5977
5977
  limit: z.number().int().min(1).max(1000).optional().describe(`page size, default ${REQ_LIST_MCP_DEFAULT_LIMIT}`),
5978
5978
  cursor: z.string().max(600).optional().describe("next_cursor from the previous page"),
5979
5979
  view: z.enum(["full", "summary"]).optional().describe("default summary (no description / checklist bodies; has_description, checklist_count instead); full = everything"),
5980
- changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included) + deleted ids + server_time"),
5980
+ changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included; with include_last_event also ones with a newer event, e.g. a comment) + deleted ids + server_time"),
5981
+ include_last_event: z.boolean().optional().describe("default false; true adds last_event per row (newest event incl. comments: type, field, actor, at, summary; null if none), ~150 B/row"),
5981
5982
  };
5982
5983
  const reqListKeys = Object.keys(reqListShape);
5983
5984
  server.registerTool(
5984
5985
  "requirements_list",
5985
5986
  {
5986
- description: `List requirement tasks in your network, newest first. Defaults (MCP): view='summary' (no description / checklist bodies; has_description, checklist_count instead; ~0.5 KB per task) and ${REQ_LIST_MCP_DEFAULT_LIMIT} per page; has_more + next_cursor → pass cursor. Full text: requirements_get, or view='full' with a small limit (≤ 1000). Filters: seq (#N), status, project_id ('none'), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), tag (exact), updated_since (ISO), overdue / due_within_days (open tasks only), external_ref, parent_id / top_level, department_id (owner in it or below), include_archived; q searches title / description / people / project / tags (terms ANDed). Rows carry children {total, done}. changes=true (with updated_since) returns only tasks changed since then, archived included, plus deleted ids and server_time for the next updated_since. Unknown parameters are rejected (-32602).`,
5987
+ description: `List requirement tasks in your network, newest first. Defaults (MCP): view='summary' (no description / checklist bodies; has_description, checklist_count instead; ~0.5 KB per task) and ${REQ_LIST_MCP_DEFAULT_LIMIT} per page; has_more + next_cursor → pass cursor. Full text: requirements_get, or view='full' with a small limit (≤ 1000). Filters: seq (#N), status, project_id ('none'), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), tag (exact), updated_since (ISO), overdue / due_within_days (open tasks only), external_ref, parent_id / top_level, department_id (owner in it or below), include_archived; q searches title / description / people / project / tags (terms ANDed). Rows carry children {total, done}; include_last_event=true adds last_event (newest event incl. comments). changes=true (with updated_since) returns only tasks changed since then, archived included, plus deleted ids and server_time for the next updated_since. Unknown parameters are rejected (-32602).`,
5987
5988
  // Strict: registerTool honours a constructed object's unknownKeys (server.tool() would strip them silently).
5988
5989
  inputSchema: z.strictObject(reqListShape, {
5989
5990
  error: (iss: any) => iss.code === "unrecognized_keys"
@@ -5999,6 +6000,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5999
6000
  if (args.include_archived) q.set("include_archived", "1");
6000
6001
  if (args.top_level) q.set("top_level", "1");
6001
6002
  if (args.changes) q.set("changes", "1");
6003
+ // #506 —— REST 每行默认带 last_event(App 用);MCP 默认不带,守住 #471 的上下文预算。
6004
+ if (!args.include_last_event) q.set("last_event", "0");
6002
6005
  if (args.overdue !== undefined) q.set("overdue", args.overdue ? "1" : "0");
6003
6006
  if (args.due_within_days !== undefined) q.set("due_within_days", String(args.due_within_days));
6004
6007
  return requirementsCall("GET", `/api/requirements?${q}`, args.network_id);