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

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.74",
3
+ "version": "0.9.0-preview.76",
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",
@@ -71,3 +71,24 @@ test("trimLightTask keeps a one-line preview under the cap", () => {
71
71
  expect([...t].length).toBe(LIGHT_TASK_MAX_CHARS + 1);
72
72
  expect(trimLightTask("short")).toBe("short");
73
73
  });
74
+
75
+ test("a response marked reusable is gzipped once per key; unmarked ones every time; different keys never share bytes", async () => {
76
+ const { markGzipReusable, gzipCacheStats, __resetGzipCacheForTest } = await import("./http-gzip");
77
+ __resetGzipCacheForTest();
78
+ const first = await maybeGzipResponse(reqWith("gzip"), markGzipReusable(json(big), "W/\"k1\""));
79
+ const second = await maybeGzipResponse(reqWith("gzip"), markGzipReusable(json(big), "W/\"k1\""));
80
+ expect(gzipCacheStats).toEqual({ hits: 1, misses: 1 });
81
+ const a = new Uint8Array(await first!.arrayBuffer());
82
+ const b = new Uint8Array(await second!.arrayBuffer());
83
+ expect(new TextDecoder().decode(Bun.gunzipSync(b))).toBe(big);
84
+ expect(Buffer.from(b).equals(Buffer.from(a))).toBe(true);
85
+ expect(Number(second!.headers.get("Content-Length"))).toBe(b.byteLength);
86
+ const other = big.replace("节点0", "节点X");
87
+ const third = await maybeGzipResponse(reqWith("gzip"), markGzipReusable(json(other), "W/\"k2\""));
88
+ expect(new TextDecoder().decode(Bun.gunzipSync(new Uint8Array(await third!.arrayBuffer())))).toBe(other);
89
+ await maybeGzipResponse(reqWith("gzip"), json(big));
90
+ expect(gzipCacheStats).toEqual({ hits: 1, misses: 2 });
91
+ // 没要 gzip 的客户端照旧拿原样正文,不碰缓存
92
+ const plain = markGzipReusable(json(big), "W/\"k1\"");
93
+ expect(await maybeGzipResponse(reqWith(), plain)).toBe(plain);
94
+ });
package/src/http-gzip.ts CHANGED
@@ -29,6 +29,56 @@ export function isCompressibleResponse(res: Response): boolean {
29
29
  return COMPRESSIBLE.test(ct);
30
30
  }
31
31
 
32
+ // 2026-09-30(连接较慢横幅):任务页每 15 s 轮询 ~1 MB 的需求表,每次都在事件循环上同步 gzip 一遍,
33
+ // 而两次轮询之间表多半没变。带内容哈希 ETag 的响应(conditionalJson)标一个键,同一份正文只压一次。
34
+ // 键就是正文的哈希,所以不同调用者拿到同一份压缩字节是安全的:字节相同 ⇔ 正文相同。
35
+ export const GZIP_CACHE_MAX_BYTES = 16 * 1024 * 1024;
36
+ const gzipKeys = new WeakMap<Response, string>();
37
+ const gzipCache = new Map<string, Uint8Array>();
38
+ let gzipCacheBytes = 0;
39
+ export const gzipCacheStats = { hits: 0, misses: 0 };
40
+
41
+ /** 标记这个响应的正文由 `key`(正文的内容哈希)唯一决定:压缩结果可以按键复用。 */
42
+ export function markGzipReusable(res: Response, key: string): Response {
43
+ gzipKeys.set(res, key);
44
+ return res;
45
+ }
46
+
47
+ function gzipCached(key: string | undefined, raw: () => Promise<Uint8Array>): Promise<{ raw: Uint8Array | null; gz: Uint8Array | null }> | { raw: null; gz: Uint8Array } {
48
+ if (key) {
49
+ const hit = gzipCache.get(key);
50
+ if (hit) {
51
+ gzipCacheStats.hits++;
52
+ gzipCache.delete(key);
53
+ gzipCache.set(key, hit); // 最近用过的挪到队尾(LRU)
54
+ return { raw: null, gz: hit };
55
+ }
56
+ }
57
+ return raw().then(bytes => {
58
+ if (bytes.byteLength < GZIP_MIN_BYTES) return { raw: bytes, gz: null };
59
+ const gz = Bun.gzipSync(bytes);
60
+ if (key) gzipCacheStats.misses++;
61
+ if (key && gz.byteLength <= GZIP_CACHE_MAX_BYTES / 4) {
62
+ gzipCache.set(key, gz);
63
+ gzipCacheBytes += gz.byteLength;
64
+ for (const [k, v] of gzipCache) {
65
+ if (gzipCacheBytes <= GZIP_CACHE_MAX_BYTES) break;
66
+ gzipCache.delete(k);
67
+ gzipCacheBytes -= v.byteLength;
68
+ }
69
+ }
70
+ return { raw: bytes, gz };
71
+ });
72
+ }
73
+
74
+ /** Test-only. */
75
+ export function __resetGzipCacheForTest(): void {
76
+ gzipCache.clear();
77
+ gzipCacheBytes = 0;
78
+ gzipCacheStats.hits = 0;
79
+ gzipCacheStats.misses = 0;
80
+ }
81
+
32
82
  /**
33
83
  * Gzip a finished response when the client asked for it and the body is worth it.
34
84
  * Reads the body once; callers must not have consumed it. Anything streamed or
@@ -37,11 +87,11 @@ export function isCompressibleResponse(res: Response): boolean {
37
87
  export async function maybeGzipResponse(req: Request, res: Response | undefined | void): Promise<Response | undefined> {
38
88
  if (!res) return res as undefined;
39
89
  if (req.method === "HEAD" || !wantsGzip(req) || !isCompressibleResponse(res)) return res;
40
- const raw = new Uint8Array(await res.arrayBuffer());
41
- if (raw.byteLength < GZIP_MIN_BYTES) {
42
- return new Response(raw, { status: res.status, statusText: res.statusText, headers: res.headers });
90
+ const got = await gzipCached(gzipKeys.get(res), async () => new Uint8Array(await res.arrayBuffer()));
91
+ if (!got.gz) {
92
+ return new Response(got.raw, { status: res.status, statusText: res.statusText, headers: res.headers });
43
93
  }
44
- const gz = Bun.gzipSync(raw);
94
+ const gz = got.gz;
45
95
  const headers = new Headers(res.headers);
46
96
  headers.set("Content-Encoding", "gzip");
47
97
  headers.set("Content-Length", String(gz.byteLength));
@@ -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"]);
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"]);
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,315 @@
1
+ // GET /api/requirements 省流(2026-09-30,App「连接较慢 · 数据可能稍有延迟」)—— HTTP 集成测试(真实 Bun.serve,临时库)。
2
+ // view=summary —— 不带 description / checklist,换成 has_description / checklist_count;不带 view = 与原来逐字相同。
3
+ // changes=1 —— updated_since 之后改过的卡(含归档的)+ deleted 墓碑 + server_time;受限成员只收到他看得见的卡的删除。
4
+ // 列表缓存 —— 表没变时复用上一次的正文 / ETag;任何写入(REST、MCP、项目删除)之后都不会拿旧的。
5
+ // /api/stats/routes —— 每个路由的耗时汇总,只给管理员。
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
+ import { gunzipSync } from "node:zlib";
11
+
12
+ const DIR = mkdtempSync(join(tmpdir(), "anet-requirements-slim-"));
13
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
14
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
15
+
16
+ const PW = "SlimListPassw0rd!x";
17
+ let BASE = "";
18
+ let hub: any = null;
19
+ let NET = "";
20
+ let admin = { token: "", id: "" };
21
+ let member = { token: "", id: "" };
22
+ let scoped = { token: "", id: "" };
23
+ let cache: { size: () => number; clear: () => void; generation: () => number };
24
+ let gzipStats: { hits: number; misses: number };
25
+
26
+ type R = { status: number; body: any; text: string; headers: Headers };
27
+ async function send(token: string, method: string, path: string, payload?: unknown, headers: Record<string, string> = {}): Promise<R> {
28
+ const res = await fetch(`${BASE}${path}`, {
29
+ method,
30
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json", ...headers },
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, text, headers: res.headers };
37
+ }
38
+ const list = (t: string, qs = "", headers: Record<string, string> = {}) => send(t, "GET", `/api/requirements?network_id=${NET}${qs}`, undefined, headers);
39
+ const create = async (t: string, card: Record<string, unknown>) => {
40
+ const r = await send(t, "POST", "/api/requirements", { network_id: NET, ...card });
41
+ expect(r.status).toBe(201);
42
+ return r.body.requirement as { id: string; updatedAt: string };
43
+ };
44
+ const patch = (t: string, id: string, p: Record<string, unknown>) => send(t, "PATCH", `/api/requirements/${id}?network_id=${NET}`, p);
45
+ const del = (t: string, id: string) => send(t, "DELETE", `/api/requirements/${id}?network_id=${NET}`);
46
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
47
+ const res = await fetch(`${BASE}/mcp`, {
48
+ method: "POST",
49
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
50
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
51
+ });
52
+ const raw = await res.text();
53
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
54
+ const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
55
+ return JSON.parse(payload.result.content[0].text);
56
+ }
57
+ const tick = () => new Promise(r => setTimeout(r, 5));
58
+
59
+ beforeAll(async () => {
60
+ process.env.HOST = "127.0.0.1";
61
+ const { register } = await import("./auth.js");
62
+ const { db } = await import("./db.js");
63
+ const a = register(`slim_admin_${Date.now()}`, PW, undefined, "Admin");
64
+ expect(a.ok).toBe(true);
65
+ admin = { token: a.token!, id: a.user!.user_id };
66
+ NET = a.network_id!;
67
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
68
+ const mod: any = await import("./server.js");
69
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
70
+ BASE = `http://127.0.0.1:${hub.port}`;
71
+ const mk = async (username: string) => {
72
+ const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
73
+ expect(r.status).toBe(200);
74
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
75
+ expect(login.status).toBe(200);
76
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
77
+ };
78
+ const stamp = Date.now();
79
+ member = await mk(`slim_member_${stamp}`);
80
+ scoped = await mk(`slim_scoped_${stamp}`);
81
+ // member:看全部任务、全部 Agent(可缓存);scoped:只看相关任务(不缓存,且只收到看得见的卡的删除)。
82
+ db.run("UPDATE network_members SET task_access = 'all', agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
83
+ db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, scoped.id]);
84
+ cache = (await import("./requirements.js")).__requirementsListCacheForTest();
85
+ gzipStats = (await import("./http-gzip.js")).gzipCacheStats;
86
+ }, 30_000);
87
+
88
+ afterAll(() => {
89
+ try { hub?.stop?.(true); } catch {}
90
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
91
+ });
92
+
93
+ describe("view=summary", () => {
94
+ test("drops description and checklist bodies, keeps everything else, adds has_description / checklist_count", async () => {
95
+ const card = await create(admin.token, {
96
+ name: "slim-summary",
97
+ description: "一段很长的描述 ".repeat(200),
98
+ checklist: [{ text: "第一项", done: true }, { text: "第二项", done: false }, { text: "第三项", done: true }],
99
+ });
100
+ await create(admin.token, { name: "slim-empty" });
101
+ const full = await list(admin.token);
102
+ const summary = await list(admin.token, "&view=summary");
103
+ expect(full.status).toBe(200);
104
+ expect(summary.status).toBe(200);
105
+ expect(summary.body.view).toBe("summary");
106
+ expect(full.body.view).toBeUndefined();
107
+ expect(summary.body.capabilities).toContain("list_summary");
108
+ expect(summary.body.capabilities).toContain("changes");
109
+ const f = full.body.requirements.find((r: any) => r.id === card.id);
110
+ const s = summary.body.requirements.find((r: any) => r.id === card.id);
111
+ expect(f.description.length).toBeGreaterThan(1000);
112
+ expect(f.checklist).toHaveLength(3);
113
+ expect("description" in s).toBe(false);
114
+ expect("checklist" in s).toBe(false);
115
+ expect(s.has_description).toBe(true);
116
+ expect(s.checklist_count).toEqual({ total: 3, done: 2 });
117
+ const { description: _d, checklist: _c, ...rest } = f;
118
+ const { has_description: _h, checklist_count: _cc, ...sRest } = s;
119
+ expect(sRest).toEqual(rest);
120
+ const empty = summary.body.requirements.find((r: any) => r.name === "slim-empty");
121
+ expect(empty.has_description).toBe(false);
122
+ expect(empty.checklist_count).toEqual({ total: 0, done: 0 });
123
+ // 同样的卡、同样的顺序;全文在单卡接口上照旧拿得到
124
+ expect(summary.body.requirements.map((r: any) => r.id)).toEqual(full.body.requirements.map((r: any) => r.id));
125
+ const one = await send(admin.token, "GET", `/api/requirements/${card.id}?network_id=${NET}`);
126
+ expect(one.body.requirement.description).toBe(f.description);
127
+ expect(summary.text.length).toBeLessThan(full.text.length / 2);
128
+ });
129
+
130
+ test("view=full is the plain list; an unknown view is a 400", async () => {
131
+ const plain = await list(admin.token);
132
+ const explicit = await list(admin.token, "&view=full");
133
+ expect(explicit.body.requirements).toEqual(plain.body.requirements);
134
+ expect((await list(admin.token, "&view=tiny")).status).toBe(400);
135
+ expect((await list(admin.token, "&view=tiny")).body.error).toBe("invalid_view");
136
+ });
137
+ });
138
+
139
+ describe("changes=1", () => {
140
+ test("requires updated_since", async () => {
141
+ const r = await list(admin.token, "&changes=1");
142
+ expect(r.status).toBe(400);
143
+ expect(r.body.error).toBe("updated_since_required");
144
+ });
145
+
146
+ test("returns only cards changed since, archived ones included, plus deleted ids and a server_time to continue from", async () => {
147
+ const keep = await create(admin.token, { name: "slim-keep" });
148
+ const edit = await create(admin.token, { name: "slim-edit" });
149
+ const arch = await create(admin.token, { name: "slim-arch" });
150
+ const gone = await create(admin.token, { name: "slim-gone" });
151
+ const first = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(new Date(Date.now() - 60_000).toISOString())}`);
152
+ expect(first.status).toBe(200);
153
+ const t0 = first.body.server_time as string;
154
+ expect(Date.parse(t0)).toBeGreaterThan(0);
155
+ expect(first.body.deleted).toEqual([]);
156
+ await tick();
157
+
158
+ expect((await patch(admin.token, edit.id, { name: "slim-edit-2" })).status).toBe(200);
159
+ expect((await patch(admin.token, arch.id, { archived: true })).status).toBe(200);
160
+ expect((await del(admin.token, gone.id)).status).toBe(200);
161
+
162
+ const delta = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
163
+ expect(delta.status).toBe(200);
164
+ const ids = delta.body.requirements.map((r: any) => r.id).sort();
165
+ expect(ids).toEqual([edit.id, arch.id].sort());
166
+ expect(delta.body.requirements.find((r: any) => r.id === arch.id).archived).toBe(true);
167
+ expect(delta.body.requirements.find((r: any) => r.id === edit.id).name).toBe("slim-edit-2");
168
+ expect(delta.body.deleted).toEqual([gone.id]);
169
+ expect(ids).not.toContain(keep.id);
170
+ expect(Date.parse(delta.body.tombstones_since)).toBeLessThan(Date.parse(t0));
171
+
172
+ // 接着读:什么都没变就是空的
173
+ const again = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(delta.body.server_time)}`);
174
+ expect(again.body.requirements).toEqual([]);
175
+ expect(again.body.deleted).toEqual([]);
176
+
177
+ // 与 view=summary 组合
178
+ const slim = await list(admin.token, `&changes=1&view=summary&updated_since=${encodeURIComponent(t0)}`);
179
+ expect(slim.body.requirements.every((r: any) => !("description" in r) && "checklist_count" in r)).toBe(true);
180
+
181
+ // 不带 changes 的 updated_since 与原来一样:归档的不回、没有 deleted
182
+ const legacy = await list(admin.token, `&updated_since=${encodeURIComponent(t0)}`);
183
+ expect(legacy.body.requirements.map((r: any) => r.id)).toEqual([edit.id]);
184
+ expect(legacy.body.deleted).toBeUndefined();
185
+ expect(legacy.body.server_time).toBeUndefined();
186
+ });
187
+
188
+ test("side effects that used to leave updated_at alone now show up: orphaned children, cleared project", async () => {
189
+ const parent = await create(admin.token, { name: "slim-parent" });
190
+ const child = await create(admin.token, { name: "slim-child", parent_id: parent.id });
191
+ const proj = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "slim-proj" });
192
+ expect(proj.status).toBe(201);
193
+ const inProj = await create(admin.token, { name: "slim-in-proj", project_id: proj.body.project.id });
194
+ await tick();
195
+ const t0 = new Date().toISOString();
196
+ await tick();
197
+ expect((await del(admin.token, parent.id)).status).toBe(200);
198
+ expect((await send(admin.token, "DELETE", `/api/requirements/projects/${proj.body.project.id}?network_id=${NET}`)).status).toBe(200);
199
+ const delta = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
200
+ const byId = new Map(delta.body.requirements.map((r: any) => [r.id, r]));
201
+ expect((byId.get(child.id) as any)?.parent_id).toBeNull();
202
+ expect((byId.get(inProj.id) as any)?.project_id).toBeNull();
203
+ expect(delta.body.deleted).toEqual([parent.id]);
204
+ });
205
+
206
+ test("a scoped member only hears about deletions of cards they could see", async () => {
207
+ const theirs = await create(admin.token, { name: "slim-theirs", owner: { kind: "user", id: scoped.id } });
208
+ const hidden = await create(admin.token, { name: "slim-hidden" });
209
+ const seen = await list(scoped.token);
210
+ expect(seen.body.requirements.map((r: any) => r.id)).toContain(theirs.id);
211
+ expect(seen.body.requirements.map((r: any) => r.id)).not.toContain(hidden.id);
212
+ await tick();
213
+ const t0 = new Date().toISOString();
214
+ await tick();
215
+ expect((await del(admin.token, theirs.id)).status).toBe(200);
216
+ expect((await del(admin.token, hidden.id)).status).toBe(200);
217
+ const mine = await list(scoped.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
218
+ expect(mine.body.deleted).toEqual([theirs.id]);
219
+ const all = await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(t0)}`);
220
+ expect(all.body.deleted.sort()).toEqual([theirs.id, hidden.id].sort());
221
+ });
222
+ });
223
+
224
+ describe("list cache", () => {
225
+ test("an unchanged table is served from the cache: same ETag, and If-None-Match still 304s", async () => {
226
+ cache.clear();
227
+ const a = await list(admin.token);
228
+ expect(cache.size()).toBe(1);
229
+ const b = await list(admin.token);
230
+ expect(b.headers.get("etag")).toBe(a.headers.get("etag"));
231
+ expect(b.text).toBe(a.text);
232
+ expect((await list(admin.token, "", { "If-None-Match": a.headers.get("etag")! })).status).toBe(304);
233
+ });
234
+
235
+ test("every kind of write invalidates it: REST patch, MCP update, checklist tick, delete", async () => {
236
+ const card = await create(admin.token, { name: "slim-cache", checklist: [{ text: "x", done: false }] });
237
+ const seen = async () => (await list(admin.token)).body.requirements.find((r: any) => r.id === card.id);
238
+ expect((await seen()).name).toBe("slim-cache");
239
+ await patch(admin.token, card.id, { name: "slim-cache-rest" });
240
+ expect((await seen()).name).toBe("slim-cache-rest");
241
+ const viaMcp = await mcp(admin.token, "requirements_update", { id: card.id, network_id: NET, name: "slim-cache-mcp" });
242
+ expect(viaMcp.ok).toBe(true);
243
+ expect((await seen()).name).toBe("slim-cache-mcp");
244
+ const item = (await seen()).checklist[0];
245
+ expect((await send(admin.token, "PATCH", `/api/requirements/${card.id}/checklist/${item.id}?network_id=${NET}`, { done: true })).status).toBe(200);
246
+ expect((await seen()).checklist[0].done).toBe(true);
247
+ await del(admin.token, card.id);
248
+ expect(await seen()).toBeUndefined();
249
+ });
250
+
251
+ test("a failed write still invalidates (the handler may have written before failing)", async () => {
252
+ const g = cache.generation();
253
+ await send(admin.token, "PATCH", `/api/requirements/does-not-exist?network_id=${NET}`, { name: "x" });
254
+ expect(cache.generation()).toBe(g + 1);
255
+ // 不是需求接口的写入不动代数
256
+ await send(admin.token, "POST", "/api/task", { alias: "nobody", task: "x" });
257
+ expect(cache.generation()).toBe(g + 1);
258
+ });
259
+
260
+ test("callers are cached separately; a member's access change is seen at once; scoped / agent-restricted members are never cached", async () => {
261
+ cache.clear();
262
+ await list(admin.token);
263
+ await list(member.token);
264
+ expect(cache.size()).toBe(2);
265
+ await list(scoped.token);
266
+ expect(cache.size()).toBe(2);
267
+ const { db } = await import("./db.js");
268
+ const before = await list(member.token);
269
+ db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
270
+ const after = await list(member.token);
271
+ expect(after.body.requirements.length).toBeLessThan(before.body.requirements.length);
272
+ db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
273
+ expect((await list(member.token)).body.requirements.length).toBe(before.body.requirements.length);
274
+ // Agent 受限(看得见哪些 Agent 取决于节点表)→ 不缓存
275
+ cache.clear();
276
+ db.run("UPDATE network_members SET agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
277
+ await list(member.token);
278
+ expect(cache.size()).toBe(0);
279
+ db.run("UPDATE network_members SET agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
280
+ });
281
+
282
+ test("changes=1 and search are not cached", async () => {
283
+ cache.clear();
284
+ await list(admin.token, `&changes=1&updated_since=${encodeURIComponent(new Date(0).toISOString())}`);
285
+ await list(admin.token, "&q=slim");
286
+ expect(cache.size()).toBe(0);
287
+ });
288
+
289
+ test("gzip of an unchanged list is computed once", async () => {
290
+ const before = { ...gzipStats };
291
+ const a = await fetch(`${BASE}/api/requirements?network_id=${NET}`, { headers: { Authorization: `Bearer ${admin.token}`, "Accept-Encoding": "gzip" }, decompress: false } as any);
292
+ const b = await fetch(`${BASE}/api/requirements?network_id=${NET}`, { headers: { Authorization: `Bearer ${admin.token}`, "Accept-Encoding": "gzip" }, decompress: false } as any);
293
+ expect(a.headers.get("content-encoding")).toBe("gzip");
294
+ const ga = new Uint8Array(await a.arrayBuffer());
295
+ const gb = new Uint8Array(await b.arrayBuffer());
296
+ expect(Buffer.from(gunzipSync(gb)).toString()).toBe(Buffer.from(gunzipSync(ga)).toString());
297
+ expect(gzipStats.hits).toBeGreaterThanOrEqual(before.hits + 1);
298
+ expect(JSON.parse(Buffer.from(gunzipSync(gb)).toString()).ok).toBe(true);
299
+ });
300
+ });
301
+
302
+ describe("GET /api/stats/routes", () => {
303
+ test("admin sees per-route timings with ids collapsed; a member gets 403", async () => {
304
+ await list(admin.token, "&view=summary");
305
+ const r = await send(admin.token, "GET", "/api/stats/routes?minutes=5");
306
+ expect(r.status).toBe(200);
307
+ const routes = r.body.routes.map((x: any) => x.route);
308
+ expect(routes).toContain("GET /api/requirements?view=summary");
309
+ expect(routes.some((x: string) => x.startsWith("PATCH /api/requirements/:id"))).toBe(true);
310
+ const row = r.body.routes.find((x: any) => x.route === "GET /api/requirements");
311
+ expect(row.count).toBeGreaterThan(0);
312
+ expect(row.max_ms).toBeGreaterThanOrEqual(row.avg_ms);
313
+ expect((await send(member.token, "GET", "/api/stats/routes")).status).toBe(403);
314
+ });
315
+ });
@@ -115,6 +115,21 @@ describe("requirements MCP tools", () => {
115
115
  expect(page.requirements.length).toBe(1);
116
116
  expect(page.has_more).toBe(true);
117
117
  expect((await s.call("requirements_list", { limit: 1, include_archived: true, cursor: page.next_cursor })).requirements[0].id).not.toBe(page.requirements[0].id);
118
+ // view / changes 透传(Hub capability list_summary / changes):精简行没有正文;增量带 deleted 与 server_time
119
+ const slim = await s.call("requirements_list", { view: "summary", include_archived: true });
120
+ const slimRow = slim.requirements.find((r: any) => r.id === id);
121
+ expect(slim.view).toBe("summary");
122
+ expect("description" in slimRow).toBe(false);
123
+ expect(typeof slimRow.checklist_count.total).toBe("number");
124
+ expect(typeof slimRow.has_description).toBe("boolean");
125
+ expect((await s.call("requirements_list", { view: "tiny" })).mcpError).toBe(true);
126
+ expect((await s.call("requirements_list", { changes: true })).error).toBe("updated_since_required");
127
+ const since = new Date(Date.now() - 1000).toISOString();
128
+ await s.call("requirements_update", { id: child.requirement.id, name: "子需求改名" });
129
+ const delta = await s.call("requirements_list", { changes: true, updated_since: since, view: "summary" });
130
+ expect(delta.requirements.map((r: any) => r.id)).toContain(child.requirement.id);
131
+ expect(Array.isArray(delta.deleted)).toBe(true);
132
+ expect(typeof delta.server_time).toBe("string");
118
133
  } finally { await s.close(); }
119
134
  });
120
135
 
@@ -59,6 +59,27 @@ export function ensureNetworkTags(database: DbAdapter): void {
59
59
  `);
60
60
  }
61
61
 
62
+ /**
63
+ * 删掉的卡留一条墓碑,给 GET /api/requirements?changes=1 报「这些 id 没了」(按 updated_since 增量同步的客户端
64
+ * 否则永远不知道一张卡被删了)。连同判断可见性要用的列一起存:受限成员只收到他本来看得见的卡的删除。
65
+ * 保留 30 天(requirements.ts TOMBSTONE_RETENTION_MS,写入时顺手清)。IF NOT EXISTS:重复执行无副作用。
66
+ */
67
+ export function ensureRequirementTombstones(database: DbAdapter): void {
68
+ database.exec(`
69
+ CREATE TABLE IF NOT EXISTS requirement_tombstones (
70
+ requirement_id TEXT PRIMARY KEY,
71
+ network_id TEXT NOT NULL,
72
+ deleted_at TEXT NOT NULL,
73
+ project_id TEXT,
74
+ owner_json TEXT,
75
+ participants_json TEXT,
76
+ created_by TEXT,
77
+ created_by_json TEXT
78
+ );
79
+ `);
80
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirement_tombstones_network ON requirement_tombstones(network_id, deleted_at)");
81
+ }
82
+
62
83
  /** external_ref 在同一网络里唯一(只管有值的行)。IF NOT EXISTS:重复执行无副作用。 */
63
84
  export function ensureRequirementIndexes(database: DbAdapter): void {
64
85
  database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_external_ref ON requirements(network_id, external_ref) WHERE external_ref IS NOT NULL");
@@ -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
+ });
@@ -3,15 +3,16 @@
3
3
  import { createHash } from "node:crypto";
4
4
  import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
5
5
  import { db, logAudit } from "./db.js";
6
+ import { markGzipReusable } from "./http-gzip.js";
6
7
  import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
7
8
  import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
8
9
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
9
- import { isAgentRestricted, visibleAgents } from "./agent-access.js";
10
+ import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
10
11
  import {
11
12
  addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject,
12
13
  isTaskScoped, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
13
14
  } from "./task-access.js";
14
- import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
15
+ import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
15
16
 
16
17
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
17
18
  // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
@@ -22,6 +23,7 @@ ensureRequirementIndexes(db);
22
23
  ensureRequirementSeq(db);
23
24
  ensureRequirementCompletedAt(db);
24
25
  ensureNetworkTags(db);
26
+ ensureRequirementTombstones(db);
25
27
 
26
28
  type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
27
29
 
@@ -282,7 +284,8 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
282
284
  if (!current || current.network_id !== networkId) return jsonError("project_not_found", 404);
283
285
  if (req.method === "DELETE") {
284
286
  // 先把卡片上的引用置空,再删项目:卡片一张不少。
285
- db.run("UPDATE requirements SET project_id = NULL WHERE project_id = ?1", [current.project_id]);
287
+ // 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()]);
286
289
  db.run("DELETE FROM requirement_projects WHERE project_id = ?1", [current.project_id]);
287
290
  deleteTaskGrantsForProject(current.project_id);
288
291
  return Response.json({ ok: true });
@@ -553,11 +556,19 @@ function operationOf(req: Request, url: URL): string {
553
556
  */
554
557
  export function conditionalJson(req: Request, payload: unknown): Response {
555
558
  const body = JSON.stringify(payload);
556
- const etag = `W/"${createHash("sha256").update(body).digest("base64url").slice(0, 27)}"`;
559
+ return conditionalBody(req, body, bodyEtag(body));
560
+ }
561
+
562
+ function bodyEtag(body: string): string {
563
+ return `W/"${createHash("sha256").update(body).digest("base64url").slice(0, 27)}"`;
564
+ }
565
+
566
+ function conditionalBody(req: Request, body: string, etag: string): Response {
557
567
  // private:按用户可见范围生成,不能被共享缓存复用;no-cache:每次都要回来验证,不会拿旧表当新的。
558
568
  const headers = { ETag: etag, "Cache-Control": "private, no-cache" };
559
569
  if (ifNoneMatchHits(req.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers });
560
- return new Response(body, { headers: { ...headers, "Content-Type": "application/json;charset=utf-8" } });
570
+ // ETag 就是正文的哈希:同一份正文的 gzip 结果可以复用(http-gzip.ts)。
571
+ return markGzipReusable(new Response(body, { headers: { ...headers, "Content-Type": "application/json;charset=utf-8" } }), etag);
561
572
  }
562
573
 
563
574
  function ifNoneMatchHits(header: string | null, etag: string): boolean {
@@ -568,7 +579,9 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
568
579
 
569
580
  // tag_ops:有 POST /api/requirements/tags/ops(标签改名 / 合并 / 删除 / 颜色),GET /api/requirements/tags 带 counts / colors / can_manage。
570
581
  // search:GET 认 q=(服务端搜索,语义同 App 的任务搜索);paging:认 limit / cursor,响应带 has_more / next_cursor。
571
- 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"] as const;
582
+ // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
583
+ // (改过的卡含归档的,加上 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;
572
585
 
573
586
  // ── 子需求 ──
574
587
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -929,8 +942,9 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
929
942
  if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
930
943
  if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
931
944
  // Explicit archived-only wins over the legacy include-all switch.
945
+ // changes=1 看得见「被归档」这一改动:默认连归档的一起回(行上 archived: true),客户端据此把它移出看板。
932
946
  if (q.get("archived") === "true") sql += " AND COALESCE(archived, 0) = 1";
933
- else if (q.get("include_archived") !== "1") sql += " AND COALESCE(archived, 0) = 0";
947
+ else if (q.get("include_archived") !== "1" && q.get("changes") !== "1") sql += " AND COALESCE(archived, 0) = 0";
934
948
  return sql;
935
949
  }
936
950
 
@@ -993,9 +1007,106 @@ function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string):
993
1007
  return false;
994
1008
  }
995
1009
 
1010
+ // ── 列表省流(2026-09-30,App「连接较慢 · 数据可能稍有延迟」)──
1011
+ // 生产:425 张卡、1447 条子任务,整张表 1 MB(gzip 240 KB),任务页每 15 s 读一次;中国经 RELAY 的链路上
1012
+ // 一次要好几秒。三件事,都是加法,旧 App 不带新参数就与原来逐字相同:
1013
+ // 1. view=summary:不带描述正文和子任务条目(打开一张卡再 GET /api/requirements/:id 读全文);
1014
+ // 2. changes=1 + updated_since:只回这之后改过的卡(含归档的),加上删掉的卡的 id 和下次用的 server_time;
1015
+ // 3. 表没变时不重算:按「调用者 + 查询」缓存上一次的正文和 ETag,需求表一有写入就作废(见 requirementsGeneration)。
1016
+
1017
+ /** view=summary 的一行:去掉 description / checklist,换成 has_description 和 checklist_count。 */
1018
+ function toSummary<T extends { description: string; checklist: ChecklistItem[] }>(pub: T): Omit<T, "description" | "checklist"> & { has_description: boolean; checklist_count: { total: number; done: number } } {
1019
+ const { description, checklist, ...rest } = pub;
1020
+ return { ...rest, has_description: !!description, checklist_count: { total: checklist.length, done: checklist.filter(item => item.done).length } };
1021
+ }
1022
+
1023
+ /** 墓碑保留多久。changes 的 updated_since 早于这之前,删除就可能漏报 —— 响应里的 tombstones_since 告诉客户端这个下限。 */
1024
+ export const TOMBSTONE_RETENTION_MS = 30 * 86_400_000;
1025
+
1026
+ /** 删卡时留一条墓碑(连同判断「谁看得见」要用的列,受限成员只收到他本来看得见的卡的删除)。 */
1027
+ function recordTombstone(row: Row, deletedAt: string): void {
1028
+ db.run(
1029
+ `INSERT INTO requirement_tombstones (requirement_id, network_id, deleted_at, project_id, owner_json, participants_json, created_by, created_by_json)
1030
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1031
+ ON CONFLICT(requirement_id) DO UPDATE SET deleted_at = excluded.deleted_at`,
1032
+ [row.requirement_id, row.network_id, deletedAt, row.project_id || null, row.owner_json || null, row.participants_json || null, row.created_by || null, row.created_by_json || null],
1033
+ );
1034
+ db.run("DELETE FROM requirement_tombstones WHERE deleted_at < ?1", [new Date(Date.parse(deletedAt) - TOMBSTONE_RETENTION_MS).toISOString()]);
1035
+ }
1036
+
1037
+ type TombstoneRow = { requirement_id: string; network_id: string; deleted_at: string; project_id: string | null; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null };
1038
+
1039
+ /** updated_since 之后删掉的、调用者看得见的卡。同 id 又出现在表里的(不该有)不算删除。 */
1040
+ function deletedSince(ctx: RequirementsRequestContext, sinceIso: string): string[] {
1041
+ const params: unknown[] = [sinceIso];
1042
+ let sql = "SELECT requirement_id, network_id, deleted_at, project_id, owner_json, participants_json, created_by, created_by_json FROM requirement_tombstones WHERE deleted_at >= ?1";
1043
+ sql = addHumanNetworkScope(sql, params, ctx.scope);
1044
+ sql += " AND requirement_id NOT IN (SELECT requirement_id FROM requirements) ORDER BY deleted_at, requirement_id";
1045
+ const caller = taskCaller(ctx);
1046
+ return db.all<TombstoneRow>(sql, ...params).filter(row => canSeeTask(caller, row)).map(row => row.requirement_id);
1047
+ }
1048
+
1049
+ /**
1050
+ * 需求表的写入代数:每个经过 handleRequirementsRequest 的非 GET 请求处理完就 +1(REST 和 MCP 的写入都走它,
1051
+ * tools.ts 调的是同一个处理函数)。列表缓存只在代数没变时复用。进程重启 = 缓存清空。
1052
+ */
1053
+ let requirementsGeneration = 0;
1054
+ /** 缓存条目最长信任这么久:兜住不经过处理函数的改动(启动迁移、运维脚本直接改库)。 */
1055
+ export const LIST_CACHE_MAX_AGE_MS = 60_000;
1056
+ /** 只缓存不带搜索词的列表(搜索每打一个字就是一个新键),总共最多这么多字符(条目按最近使用淘汰)。 */
1057
+ const LIST_CACHE_MAX_CHARS = 16 * 1024 * 1024;
1058
+ type ListCacheEntry = { generation: number; acl: string; at: number; body: string; etag: string };
1059
+ const listCache = new Map<string, ListCacheEntry>();
1060
+ let listCacheChars = 0;
1061
+ function rememberList(key: string, entry: ListCacheEntry): void {
1062
+ const old = listCache.get(key);
1063
+ if (old) { listCache.delete(key); listCacheChars -= old.body.length; }
1064
+ if (entry.body.length > LIST_CACHE_MAX_CHARS / 4) return;
1065
+ listCache.set(key, entry);
1066
+ listCacheChars += entry.body.length;
1067
+ for (const [k, v] of listCache) {
1068
+ if (listCacheChars <= LIST_CACHE_MAX_CHARS) break;
1069
+ listCache.delete(k);
1070
+ listCacheChars -= v.body.length;
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * 缓存能不能用、以及调用者权限的指纹。null = 这个调用者不缓存:受限成员(看得见哪些 Agent 取决于节点表)
1076
+ * 和「只看相关任务」的成员(看得见哪些卡取决于项目授权)每次照旧现算。
1077
+ * 其余调用者的输出只取决于需求表本身 + 他的角色 / 成员行:这些都放进指纹,变了就不命中。
1078
+ */
1079
+ function listCacheAcl(ctx: RequirementsRequestContext): string | null {
1080
+ const caller = taskCaller(ctx);
1081
+ if (!caller) return `open:${ctx.isAdmin ? 1 : 0}:${ctx.isNodeToken ? 1 : 0}`;
1082
+ if (restrictedNetworkIds(caller.userId).length > 0) return null;
1083
+ const members = db.all<Record<string, unknown>>("SELECT * FROM network_members WHERE user_id = ?1 ORDER BY network_id", caller.userId);
1084
+ const networks = members.map(m => String(m.network_id));
1085
+ if (networks.some(networkId => isTaskScoped(caller.userId, networkId))) return null;
1086
+ const role = db.get<{ role: string | null }>("SELECT role FROM users WHERE user_id = ?1", caller.userId)?.role ?? null;
1087
+ return JSON.stringify({ role, members });
1088
+ }
1089
+
1090
+ function listCacheKey(ctx: RequirementsRequestContext): string {
1091
+ const sorted = [...ctx.url.searchParams.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
1092
+ return JSON.stringify({ u: ctx.auth?.userId ?? null, n: ctx.auth?.networkId ?? null, a: ctx.isAdmin, t: ctx.isNodeToken, s: ctx.scope, q: sorted });
1093
+ }
1094
+
1095
+ /** Test-only. */
1096
+ export function __requirementsListCacheForTest() {
1097
+ return { size: () => listCache.size, clear: () => { listCache.clear(); listCacheChars = 0; }, generation: () => requirementsGeneration };
1098
+ }
1099
+
996
1100
  export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
997
- const { req, url } = ctx;
1101
+ const { url } = ctx;
998
1102
  if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
1103
+ if (ctx.req.method === "GET") return handleRequirementsRequestInner(ctx);
1104
+ // 写完(成功与否)才 +1:之后的 GET 一定看得见这次写入。
1105
+ try { return await handleRequirementsRequestInner(ctx); } finally { requirementsGeneration++; }
1106
+ }
1107
+
1108
+ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext): Promise<Response | null> {
1109
+ const { req, url } = ctx;
999
1110
  // 节点令牌只能做 NODE_TOKEN_OPERATIONS 里的事(读 / 建 / 改 / 勾子任务 / upsert / 读项目),
1000
1111
  // 而且只在它绑定的网络里 —— 范围由 scope 强制,写入再由 canWrite 核一次。
1001
1112
  if (ctx.isNodeToken && !NODE_TOKEN_OPERATIONS.has(operationOf(req, url))) return jsonError("user_token_required", 403);
@@ -1044,12 +1155,16 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
1044
1155
  if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
1045
1156
  const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
1046
1157
  if (!networkId) return jsonError('network_id_required', 400);
1047
- const users = db.all<{ id: string; name: string }>(
1048
- "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,
1158
+ // name = 显示用的名字(没设 display_name 时回落到用户名 / alias,旧 app 只读它);display_name 单独给出、没设为 ""
1159
+ // —— 分享图等对外场景据此判断「只有用户名」,不把 admin 之类的账号名印出去。
1160
+ // 成员的 display_name 等于用户名也算「没设」:register() 在没给显示名时存的就是 username(auth.ts),
1161
+ // 否则没设过显示名的账号(比如 admin)照样把账号名当显示名送出去。
1162
+ const users = db.all<{ id: string; name: string; display_name: string }>(
1163
+ "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,
1049
1164
  );
1050
1165
  const hidden = hiddenNodeFilter(ctx, networkId);
1051
- const nodes = db.all<{ id: string; name: string }>(
1052
- "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,
1166
+ const nodes = db.all<{ id: string; name: string; display_name: string }>(
1167
+ "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,
1053
1168
  ).filter(row => !hidden?.(row.id));
1054
1169
  return Response.json({ ok: true, people: [...users.map(row => ({ ...row, kind: 'user', networkId })), ...nodes.map(row => ({ ...row, kind: 'node', networkId }))] });
1055
1170
  }
@@ -1065,10 +1180,37 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
1065
1180
  // q= / limit / cursor(requirements-search.ts)。都不带 = 旧行为:最新 500 张、同样的顺序。
1066
1181
  const lq = parseListQuery(url.searchParams);
1067
1182
  if ("error" in lq) return jsonError(lq.error, 400);
1183
+ const view = url.searchParams.get("view");
1184
+ if (view !== null && view !== "full" && view !== "summary") return jsonError("invalid_view", 400);
1185
+ const changes = url.searchParams.get("changes") === "1";
1186
+ if (changes && url.searchParams.get("updated_since") === null) return jsonError("updated_since_required", 400);
1187
+ // changes 模式:server_time 取在读表之前,下次拿它当 updated_since,读表期间的写入不会漏(>= 会重复回一次,客户端按 id 覆盖)。
1188
+ const serverTime = new Date().toISOString();
1189
+ const cacheKey = changes || url.searchParams.get("q") ? null : listCacheKey(ctx);
1190
+ const acl = cacheKey ? listCacheAcl(ctx) : null;
1191
+ const generation = requirementsGeneration;
1192
+ const cached = cacheKey && acl !== null ? listCache.get(cacheKey) : undefined;
1193
+ if (cached && cached.generation === generation && cached.acl === acl && Date.now() - cached.at < LIST_CACHE_MAX_AGE_MS) {
1194
+ listCache.delete(cacheKey!);
1195
+ listCache.set(cacheKey!, cached);
1196
+ return conditionalBody(ctx.req, cached.body, cached.etag);
1197
+ }
1068
1198
  const page = listPage(ctx, filtered, params, lq);
1199
+ const rows = page.rows.map(row => toPublicFor(ctx, row));
1069
1200
  // capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
1070
1201
  // has_more / next_cursor:后面还有没有(带 cursor=next_cursor 再读一页)。旧客户端不认识,忽略即可。
1071
- return conditionalJson(ctx.req, { ok: true, requirements: page.rows.map(row => toPublicFor(ctx, row)), capabilities: REQUIREMENT_CAPABILITIES, has_more: page.hasMore, next_cursor: page.nextCursor });
1202
+ 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 };
1203
+ if (view === "summary") payload.view = "summary";
1204
+ if (changes) {
1205
+ const since = new Date(Date.parse(url.searchParams.get("updated_since")!)).toISOString();
1206
+ payload.deleted = deletedSince(ctx, since);
1207
+ payload.server_time = serverTime;
1208
+ payload.tombstones_since = new Date(Date.parse(serverTime) - TOMBSTONE_RETENTION_MS).toISOString();
1209
+ }
1210
+ const body = JSON.stringify(payload);
1211
+ const etag = bodyEtag(body);
1212
+ if (cacheKey && acl !== null) rememberList(cacheKey, { generation, acl, at: Date.now(), body, etag });
1213
+ return conditionalBody(ctx.req, body, etag);
1072
1214
  }
1073
1215
 
1074
1216
  if (url.pathname === "/api/requirements" && req.method === "POST") {
@@ -1139,8 +1281,10 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
1139
1281
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1140
1282
  if (!canDeleteTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_delete_denied");
1141
1283
  // 子需求不跟着删:先解挂(变成顶层),再删父卡。
1142
- db.run("UPDATE requirements SET parent_id = NULL WHERE parent_id = ?1", [row.requirement_id]);
1284
+ const deletedAt = new Date().toISOString();
1285
+ db.run("UPDATE requirements SET parent_id = NULL, updated_at = ?2 WHERE parent_id = ?1", [row.requirement_id, deletedAt]);
1143
1286
  db.run("DELETE FROM requirements WHERE requirement_id = ?1", [row.requirement_id]);
1287
+ recordTombstone(row, deletedAt);
1144
1288
  // 硬删除以前不留痕(RFC-038 §9.1):记下是谁删了哪张(标题 + 短号),卡本身已经没了。
1145
1289
  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);
1146
1290
  return Response.json({ ok: true, deleted: row.requirement_id });
@@ -0,0 +1,40 @@
1
+ import { expect, test } from "bun:test";
2
+ import { __resetRouteTimingForTest, recordRouteTiming, routeKey, routeStats, ROUTE_RING_SIZE } from "./route-timing";
3
+
4
+ const u = (s: string) => new URL(`http://hub${s}`);
5
+
6
+ test("routeKey collapses ids, aliases and file names, keeps payload-shaping params", () => {
7
+ expect(routeKey("GET", u("/api/requirements?network_id=net_1&limit=500"))).toBe("GET /api/requirements");
8
+ expect(routeKey("GET", u("/api/requirements?view=summary&network_id=n"))).toBe("GET /api/requirements?view=summary");
9
+ expect(routeKey("PATCH", u("/api/requirements/req_3f9a/checklist/c1"))).toBe("PATCH /api/requirements/:id/checklist/:id");
10
+ expect(routeKey("GET", u("/api/requirements/stats"))).toBe("GET /api/requirements/stats");
11
+ expect(routeKey("GET", u("/api/status?light=1"))).toBe("GET /api/status?light=1");
12
+ expect(routeKey("GET", u(`/api/nodes/${encodeURIComponent("通信牛")}/config`))).toBe("GET /api/nodes/:id/config");
13
+ expect(routeKey("GET", u("/api/messages?scope=user&limit=50"))).toBe("GET /api/messages?scope=user");
14
+ });
15
+
16
+ test("routeStats groups the window, sorts by total time, and the ring keeps only the newest samples", () => {
17
+ __resetRouteTimingForTest();
18
+ const now = 1_000_000;
19
+ const req = (path: string) => new Request(`http://hub${path}`);
20
+ const res = (status = 200, len?: number) => new Response("x", { status, headers: len === undefined ? {} : { "Content-Length": String(len) } });
21
+ const at = (ms: number) => performance.now() - ms;
22
+ recordRouteTiming(req("/api/requirements"), at(30), res(200, 1000), now);
23
+ recordRouteTiming(req("/api/requirements"), at(10), res(200, 500), now);
24
+ recordRouteTiming(req("/api/status?light=1"), at(5), res(503), now);
25
+ recordRouteTiming(req("/api/status?light=1"), at(100), res(), now - 10 * 60_000); // 窗口外
26
+ expect(recordRouteTiming(req("/ws"), 0, undefined, now)).toBeUndefined();
27
+ const stats = routeStats(60_000, now);
28
+ expect(stats.samples).toBe(3);
29
+ expect(stats.routes.map(r => r.route)).toEqual(["GET /api/requirements", "GET /api/status?light=1"]);
30
+ const r0 = stats.routes[0];
31
+ expect(r0.count).toBe(2);
32
+ expect(r0.bytes).toBe(1500);
33
+ expect(r0.max_ms).toBeGreaterThanOrEqual(29);
34
+ expect(r0.total_ms).toBeGreaterThanOrEqual(39);
35
+ expect(stats.routes[1].errors).toBe(1);
36
+ for (let i = 0; i < ROUTE_RING_SIZE + 10; i++) recordRouteTiming(req("/api/tasks"), at(1), res(), now);
37
+ const after = routeStats(60_000, now);
38
+ expect(after.samples).toBe(ROUTE_RING_SIZE);
39
+ expect(after.routes.map(r => r.route)).toEqual(["GET /api/tasks"]);
40
+ });
@@ -0,0 +1,70 @@
1
+ // 每个路由的耗时环(2026-09-30):生产 hub 平均 ~24% CPU,但没有任何按路由的计时,也没有访问日志,
2
+ // 说不清是谁在吃 CPU。这里在 fetch 入口记最近 ROUTE_RING_SIZE 个请求(路由形状、耗时、状态、字节),
3
+ // GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query 值、不记调用者。
4
+ export const ROUTE_RING_SIZE = 4096;
5
+
6
+ type Sample = { at: number; route: string; ms: number; status: number; bytes: number | null };
7
+ const ring: Sample[] = [];
8
+ let next = 0;
9
+
10
+ /** 路由的形状:id / 别名 / 文件名这类段换成 :id,几个会改变载荷形状的参数保留(light / view / scope / changes)。 */
11
+ const SHAPE_PARAMS = ["changes", "light", "scope", "view"];
12
+ export function routeKey(method: string, url: URL): string {
13
+ const segments = url.pathname.split("/").map((seg, i) => {
14
+ if (i < 2 || !seg) return seg;
15
+ return /[0-9%]/.test(seg) || /[^\x20-\x7e]/.test(seg) || seg.length > 24 ? ":id" : seg;
16
+ });
17
+ const shape = SHAPE_PARAMS.filter(k => url.searchParams.has(k)).map(k => `${k}=${url.searchParams.get(k)}`).join("&");
18
+ return `${method} ${segments.join("/")}${shape ? `?${shape}` : ""}`;
19
+ }
20
+
21
+ /** 记一次请求;原样返回 res,方便套在 fetch 处理器的 return 上。WebSocket 升级(res 为空)不记。 */
22
+ export function recordRouteTiming<T extends Response | undefined>(req: Request, started: number, res: T, now: number = Date.now()): T {
23
+ if (!res) return res;
24
+ let url: URL;
25
+ try { url = new URL(req.url); } catch { return res; }
26
+ const length = Number(res.headers.get("content-length"));
27
+ const sample: Sample = { at: now, route: routeKey(req.method, url), ms: performance.now() - started, status: res.status, bytes: Number.isFinite(length) && res.headers.has("content-length") ? length : null };
28
+ if (ring.length < ROUTE_RING_SIZE) ring.push(sample);
29
+ else ring[next] = sample;
30
+ next = (next + 1) % ROUTE_RING_SIZE;
31
+ return res;
32
+ }
33
+
34
+ export type RouteStat = { route: string; count: number; total_ms: number; avg_ms: number; p95_ms: number; max_ms: number; bytes: number; errors: number };
35
+
36
+ /** 最近 windowMs 内的样本按路由汇总,按总耗时降序。 */
37
+ export function routeStats(windowMs: number, now: number = Date.now()): { window_ms: number; samples: number; oldest_at: string | null; routes: RouteStat[] } {
38
+ const since = now - windowMs;
39
+ const groups = new Map<string, Sample[]>();
40
+ let oldest: number | null = null;
41
+ for (const s of ring) {
42
+ if (s.at < since) continue;
43
+ if (oldest === null || s.at < oldest) oldest = s.at;
44
+ let g = groups.get(s.route);
45
+ if (!g) groups.set(s.route, g = []);
46
+ g.push(s);
47
+ }
48
+ const round = (n: number) => Math.round(n * 10) / 10;
49
+ const routes = [...groups].map(([route, samples]): RouteStat => {
50
+ const ms = samples.map(s => s.ms).sort((a, b) => a - b);
51
+ const total = ms.reduce((a, b) => a + b, 0);
52
+ return {
53
+ route,
54
+ count: samples.length,
55
+ total_ms: round(total),
56
+ avg_ms: round(total / samples.length),
57
+ p95_ms: round(ms[Math.min(ms.length - 1, Math.floor(ms.length * 0.95))]),
58
+ max_ms: round(ms[ms.length - 1]),
59
+ bytes: samples.reduce((n, s) => n + (s.bytes ?? 0), 0),
60
+ errors: samples.filter(s => s.status >= 500).length,
61
+ };
62
+ }).sort((a, b) => b.total_ms - a.total_ms);
63
+ return { window_ms: windowMs, samples: routes.reduce((n, r) => n + r.count, 0), oldest_at: oldest === null ? null : new Date(oldest).toISOString(), routes };
64
+ }
65
+
66
+ /** Test-only. */
67
+ export function __resetRouteTimingForTest(): void {
68
+ ring.length = 0;
69
+ next = 0;
70
+ }
package/src/server.ts CHANGED
@@ -61,6 +61,7 @@ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js"
61
61
  import { diagnoseTask } from "./task-diagnostic.js";
62
62
  import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
63
63
  import { handleRequirementsRequest } from "./requirements.js";
64
+ import { recordRouteTiming, routeStats } from "./route-timing.js";
64
65
  import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
65
66
  import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
66
67
  import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
@@ -903,7 +904,8 @@ return Bun.serve({
903
904
 
904
905
  async fetch(req, server) {
905
906
  // 2026-09-16 —— 所有 JSON/文本响应在客户端要求时 gzip(见 http-gzip.ts);SSE/流式/二进制原样。
906
- return maybeGzipResponse(req, explainExpiredSession(req, await (async (): Promise<Response | undefined> => {
907
+ // 2026-09-30 —— 每个请求的路由形状 + 耗时进内存环(route-timing.ts),GET /api/stats/routes 看谁在吃 CPU。
908
+ return recordRouteTiming(req, performance.now(), await maybeGzipResponse(req, explainExpiredSession(req, await (async (): Promise<Response | undefined> => {
907
909
  const url = new URL(req.url, `http://localhost:${PORT}`);
908
910
 
909
911
  // ── CORS preflight ──
@@ -2126,6 +2128,16 @@ return Bun.serve({
2126
2128
  });
2127
2129
  if (requirementsResponse) return withCors(req, requirementsResponse);
2128
2130
 
2131
+ // ── REST: per-route timing (ops-only, same gate as /api/stats/sse) ──
2132
+ // ?minutes=N(默认 15,最多 1440):最近 N 分钟里每个路由的次数、总耗时、p95、最大、字节。
2133
+ if (url.pathname === "/api/stats/routes" && req.method === "GET") {
2134
+ if (restAuth && !isAdmin) {
2135
+ return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
2136
+ }
2137
+ const minutes = Math.min(Math.max(Number(url.searchParams.get("minutes")) || 15, 1), 1440);
2138
+ return withCors(req, Response.json({ ok: true, ...routeStats(minutes * 60_000) }));
2139
+ }
2140
+
2129
2141
  // ── #473 REST: SSE connection detail (ops-only) ──
2130
2142
  // The per-key breakdown /health used to expose anonymously: keys are
2131
2143
  // `{networkId}:{alias}` (+ `\0netobs:{networkId}` observer keys via
@@ -4235,7 +4247,7 @@ Security: ${SECURITY_LABEL}
4235
4247
  `,
4236
4248
  { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } } // #426
4237
4249
  ));
4238
- })()));
4250
+ })())));
4239
4251
  },
4240
4252
 
4241
4253
  // ── WebSocket handler for tmux terminal streaming ──
package/src/tools.ts CHANGED
@@ -5825,7 +5825,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5825
5825
 
5826
5826
  server.tool(
5827
5827
  "requirements_list",
5828
- "List requirement tasks in your network. Filters: seq (the per-network short number shown as #N), status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived; q = search title / description / owner, agent and participant names / project name / tags (space-separated terms are ANDed). Newest first, 500 per page by default (limit ≤ 1000); has_more + next_cursor → pass cursor for the next page. Each task carries children {total, done}.",
5828
+ "List requirement tasks in your network. Filters: seq (the per-network short number shown as #N), status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived; q = search title / description / owner, agent and participant names / project name / tags (space-separated terms are ANDed). Newest first, 500 per page by default (limit ≤ 1000); has_more + next_cursor → pass cursor for the next page. Each task carries children {total, done}. view='summary' drops description and checklist bodies (adds has_description, checklist_count) — much smaller; use requirements_get for one task's full text. changes=true (needs updated_since) returns only tasks changed since then, archived ones included, plus deleted ids and server_time to pass as the next updated_since.",
5829
5829
  {
5830
5830
  network_id: z.string().max(200).optional(),
5831
5831
  seq: z.number().int().positive().optional().describe("short number (#N) of one task in this network"),
@@ -5838,12 +5838,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5838
5838
  include_archived: z.boolean().optional(),
5839
5839
  parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5840
5840
  top_level: z.boolean().optional(), q: z.string().max(200).optional(), limit: z.number().int().min(1).max(1000).optional(), cursor: z.string().max(600).optional(),
5841
+ view: z.enum(["full", "summary"]).optional().describe("summary = no description / checklist bodies (has_description, checklist_count instead)"),
5842
+ changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included) + deleted ids + server_time"),
5841
5843
  },
5842
5844
  async (args) => {
5843
5845
  const q = new URLSearchParams();
5844
- for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id", "q", "limit", "cursor"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5846
+ for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id", "q", "limit", "cursor", "view"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5845
5847
  if (args.include_archived) q.set("include_archived", "1");
5846
5848
  if (args.top_level) q.set("top_level", "1");
5849
+ if (args.changes) q.set("changes", "1");
5847
5850
  return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
5848
5851
  },
5849
5852
  );