@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 +1 -1
- package/src/http-gzip.test.ts +21 -0
- package/src/http-gzip.ts +54 -4
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +15 -0
- package/src/requirements-migrate.ts +21 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements.ts +158 -14
- package/src/route-timing.test.ts +40 -0
- package/src/route-timing.ts +70 -0
- package/src/server.ts +14 -2
- package/src/tools.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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",
|
package/src/http-gzip.test.ts
CHANGED
|
@@ -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
|
|
41
|
-
if (
|
|
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 =
|
|
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
|
+
});
|
package/src/requirements.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|