@sleep2agi/commhub-server 0.9.0-preview.88 → 0.9.0-preview.90

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.88",
3
+ "version": "0.9.0-preview.90",
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",
@@ -480,4 +480,8 @@ export const RESTRICTED_MEMBER_TOOLS: ReadonlySet<string> = new Set([
480
480
  "projects_create",
481
481
  "projects_update",
482
482
  "requirements_events",
483
+ // #474:评论走同一个 REST 处理 —— 看不见的任务 404、只读角色 403;受限成员的评论和他的其他任务写一样按可见性准入。
484
+ "requirements_comment",
485
+ // #473:通讯录同 /api/requirements/people 的可见范围 —— 受限成员看不见的 Agent 不出现(hiddenNodeFilter)。
486
+ "requirements_people",
483
487
  ]);
@@ -56,11 +56,12 @@ export type EventCard = {
56
56
  archived: number | null;
57
57
  };
58
58
 
59
- export type EventKind = "created" | "changed" | "deleted";
59
+ export type EventKind = "created" | "changed" | "deleted" | "comment";
60
60
  /**
61
61
  * field(kind = changed):column / title / priority / due / start / assignee / owner / agent_owner / participants /
62
62
  * tags / checklist_item(勾选或取消一项:{id,text,done})/ checklist(增删改条目:{total,done})/
63
63
  * description(只记字数,不存正文)/ project / parent / archived。
64
+ * kind = comment(#474):field 为 null,new = { text }(评论正文,≤ 4000 字),只追加,不对应任何字段的改动。
64
65
  */
65
66
  export type EventDraft = { kind: EventKind; field: string | null; old: unknown; new: unknown };
66
67
 
@@ -0,0 +1,165 @@
1
+ // #474(MCP 任务生命周期测试报告问题 5)—— 评论 / 进展:只追加,进「动态」(requirement_events kind=comment),
2
+ // 不改任务本身。钉住:
3
+ // - Agent(节点令牌)经 MCP、人经 REST 都能评论,署名是调用者;description / updated_at 一个字节都不动;
4
+ // - requirements_events 里是 kind=comment、new.text 全文,单任务的动态体积可控;
5
+ // - 看不见这张卡的人评论 = 404(和读一样),只读角色 = 403,空 / 超长 = 400 + 提示;
6
+ // - 只追加:没有改 / 删评论的路由。
7
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "fs";
9
+ import { join } from "path";
10
+ import { tmpdir } from "os";
11
+ import { register, createNetworkTokenForNode } from "./auth.js";
12
+ import { db } from "./db.js";
13
+ import { COMMENT_MAX_CHARS } from "./requirements.js";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-comment-"));
16
+ let BASE = "";
17
+ let hub: any = null;
18
+ const PW = "ReqCommentPassw0rd!xyz";
19
+ let NET = "";
20
+ const U: Record<string, { token: string; id: string }> = {};
21
+ let card = { id: "", seq: 0 };
22
+
23
+ async function rest(token: string, method: string, path: string, body?: unknown) {
24
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
25
+ const text = await res.text();
26
+ let json: any = null;
27
+ try { json = JSON.parse(text); } catch {}
28
+ return { status: res.status, body: json, bytes: text.length };
29
+ }
30
+ async function rpc(token: string, name: string, args: Record<string, unknown>): Promise<{ data: any; raw: any; bytes: number }> {
31
+ const res = await fetch(`${BASE}/mcp`, {
32
+ method: "POST",
33
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
34
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
35
+ });
36
+ const raw = await res.text();
37
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
38
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
39
+ const text = out.result?.content?.[0]?.text ?? "";
40
+ let data: any = null;
41
+ try { data = JSON.parse(text); } catch { data = text; }
42
+ return { data, raw: out, bytes: text.length };
43
+ }
44
+ const mcp = async (token: string, name: string, args: Record<string, unknown>) => (await rpc(token, name, args)).data;
45
+ const cardRow = (id: string) => db.get<{ description: string | null; updated_at: string | null; updated_by_json: string | null }>("SELECT description, updated_at, updated_by_json FROM requirements WHERE requirement_id = ?1", id)!;
46
+
47
+ beforeAll(async () => {
48
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
49
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
50
+ process.env.HOST = "127.0.0.1";
51
+ const a = register(`cmt_owner_${Date.now()}`, PW, undefined, "Owner");
52
+ U.owner = { token: a.token!, id: a.user!.user_id };
53
+ NET = a.network_id!;
54
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
55
+ const mod: any = await import("./server.js");
56
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
57
+ BASE = `http://127.0.0.1:${hub.port}`;
58
+ const mk = async (username: string, role: string) => {
59
+ expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
60
+ const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
61
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
62
+ };
63
+ const stamp = Date.now();
64
+ U.member = await mk(`cmt_member_${stamp}`, "member");
65
+ U.viewer = await mk(`cmt_viewer_${stamp}`, "viewer");
66
+ U.scoped = await mk(`cmt_scoped_${stamp}`, "member");
67
+ expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.scoped.id}/task-grants`, { task_access: "scoped", project_grants: [] })).status).toBe(200);
68
+ U.nodeA = { token: createNetworkTokenForNode(U.owner.id, NET, "示例-甲", "node_cmt_a").token!, id: "node_cmt_a" };
69
+ const created = (await mcp(U.nodeA.token, "requirements_create", { name: "示例任务:评论", description: "## 背景\n原文。" })).requirement;
70
+ card = { id: created.id, seq: created.seq };
71
+ }, 30_000);
72
+
73
+ afterAll(() => {
74
+ try { hub?.stop?.(true); } catch {}
75
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
76
+ });
77
+
78
+ describe("requirements_comment", () => {
79
+ test("an Agent comments over MCP (#N): one comment event, attributed to the node; the task itself is untouched", async () => {
80
+ const before = cardRow(card.id);
81
+ const r = await mcp(U.nodeA.token, "requirements_comment", { id: `#${card.seq}`, text: "进展:示例草稿已写完,等人看。" });
82
+ expect(r.ok).toBe(true);
83
+ expect(r.event.kind).toBe("comment");
84
+ expect(r.event.field).toBeNull();
85
+ expect(r.event.new).toEqual({ text: "进展:示例草稿已写完,等人看。" });
86
+ expect(r.event.actor).toEqual({ kind: "node", id: "node_cmt_a" });
87
+ expect(r.event.requirement_id).toBe(card.id);
88
+ const after = cardRow(card.id);
89
+ expect(after).toEqual(before); // description, updated_at, updated_by all unchanged
90
+ });
91
+
92
+ test("a person comments over REST; both show in requirements_events with the full text, newest first", async () => {
93
+ const r = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: " 好的,我下午看。 " });
94
+ expect(r.status).toBe(201);
95
+ expect(r.body.event.new.text).toBe("好的,我下午看。"); // trimmed
96
+ expect(r.body.event.actor).toEqual({ kind: "user", id: U.member.id });
97
+ const ev = await rpc(U.nodeA.token, "requirements_events", { requirement_id: `#${card.seq}` });
98
+ const comments = ev.data.events.filter((e: any) => e.kind === "comment");
99
+ expect(comments.map((e: any) => e.new.text)).toEqual(["好的,我下午看。", "进展:示例草稿已写完,等人看。"]);
100
+ expect(ev.data.events.at(-1).kind).toBe("created");
101
+ });
102
+
103
+ test("full text up to the cap, and the timeline stays small", async () => {
104
+ const long = "长".repeat(COMMENT_MAX_CHARS);
105
+ const r = await mcp(U.nodeA.token, "requirements_comment", { id: card.id, text: long });
106
+ expect(r.ok).toBe(true);
107
+ expect(r.event.new.text.length).toBe(COMMENT_MAX_CHARS);
108
+ const ev = await rpc(U.nodeA.token, "requirements_events", { requirement_id: card.id });
109
+ // 1 created + 3 comments (one of them 4000 chars): well under 20 KB.
110
+ expect(ev.bytes).toBeLessThan(20_000);
111
+ expect(ev.data.events.some((e: any) => e.kind === "comment" && e.new.text === long)).toBe(true);
112
+ });
113
+
114
+ test("bad input: empty → invalid_comment, over the cap → comment_too_long (REST), MCP schema → -32602; both with hints", async () => {
115
+ const empty = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: " " });
116
+ expect(empty.status).toBe(400);
117
+ expect(empty.body.error).toBe("invalid_comment");
118
+ expect(empty.body.field).toBe("text");
119
+ const tooLong = await rest(U.member.token, "POST", `/api/requirements/${card.id}/comments`, { text: "x".repeat(COMMENT_MAX_CHARS + 1) });
120
+ expect(tooLong.status).toBe(400);
121
+ expect(tooLong.body.error).toBe("comment_too_long");
122
+ expect(tooLong.body.hint).toContain("Split");
123
+ const viaMcp = await rpc(U.nodeA.token, "requirements_comment", { id: card.id, text: "x".repeat(COMMENT_MAX_CHARS + 1) });
124
+ expect(String(viaMcp.raw.error?.code ?? viaMcp.raw.result?.content?.[0]?.text)).toMatch(/-32602|too big|Too big/i);
125
+ });
126
+
127
+ test("permissions: a non-reader gets 404 (like reading), a read-only role 403; neither writes anything", async () => {
128
+ const count = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_events WHERE requirement_id = ?1 AND kind = 'comment'", card.id)!.n;
129
+ const n = count();
130
+ // scoped member with no grant cannot see the task → cannot read it → cannot comment on it.
131
+ expect((await rest(U.scoped.token, "GET", `/api/requirements/${card.id}`)).status).toBe(404);
132
+ const scoped = await rest(U.scoped.token, "POST", `/api/requirements/${card.id}/comments`, { text: "看不见也想评论" });
133
+ expect(scoped.status).toBe(404);
134
+ expect(scoped.body.error).toBe("requirement_not_found");
135
+ const viewer = await rest(U.viewer.token, "POST", `/api/requirements/${card.id}/comments`, { text: "只读角色" });
136
+ expect(viewer.status).toBe(403);
137
+ expect(viewer.body.error).toBe("permission_denied");
138
+ expect(count()).toBe(n);
139
+ // …and the scoped member does not see the comments in the network timeline either.
140
+ const ev = await rest(U.scoped.token, "GET", `/api/requirements/events?network_id=${NET}`);
141
+ expect((ev.body.events ?? []).some((e: any) => e.requirement_id === card.id)).toBe(false);
142
+ });
143
+
144
+ test("append-only: no route edits or deletes a comment; unknown task → requirement_not_found with a hint", async () => {
145
+ expect((await rest(U.owner.token, "PATCH", `/api/requirements/${card.id}/comments`, { text: "改" })).status).toBe(404);
146
+ expect((await rest(U.owner.token, "DELETE", `/api/requirements/${card.id}/comments`)).status).toBe(404);
147
+ const missing = await mcp(U.nodeA.token, "requirements_comment", { id: "#9999", text: "没有这张卡" });
148
+ expect(missing.error).toBe("requirement_not_found");
149
+ expect(missing.hint).toContain("requirements_list");
150
+ });
151
+
152
+ test("discoverable: tools/list carries requirements_comment, and requirements_events mentions comments", async () => {
153
+ const res = await fetch(`${BASE}/mcp`, {
154
+ method: "POST",
155
+ headers: { Authorization: `Bearer ${U.nodeA.token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
156
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
157
+ });
158
+ const raw = await res.text();
159
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
160
+ const tools = (lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw)).result.tools as any[];
161
+ const byName = new Map(tools.map(t => [t.name, t]));
162
+ expect(String(byName.get("requirements_comment")?.description)).toContain("append-only");
163
+ expect(String(byName.get("requirements_events")?.description)).toContain("kind=comment");
164
+ });
165
+ });
@@ -0,0 +1,164 @@
1
+ // #472(MCP 任务生命周期测试报告问题 4)—— 任务 / 项目接口的错误带上 field / message / hint,`error` 原样。
2
+ // 每条提示一个测试(经 MCP,以 Agent / 人的身份),外加一道取集门:requirements.ts 里返回 / 抛出的每个错误码都在
3
+ // requirements-errors.ts 的登记表里,新加的错误码没登记 ⇒ 红。
4
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
5
+ import { mkdtempSync, readFileSync, rmSync } from "fs";
6
+ import { join } from "path";
7
+ import { tmpdir } from "os";
8
+ import { register, createNetworkTokenForNode } from "./auth.js";
9
+ import { db } from "./db.js";
10
+ import { __documentedErrorCodes, errorBody } from "./requirements-errors.js";
11
+
12
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-errors-"));
13
+ let BASE = "";
14
+ let hub: any = null;
15
+ const PW = "ReqErrorsPassw0rd!xyz";
16
+ let NET = "", ownerToken = "", ownerId = "", nodeToken = "", memberToken = "", cardId = "";
17
+
18
+ async function rest(token: string, method: string, path: string, body?: unknown) {
19
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
20
+ const text = await res.text();
21
+ let json: any = null;
22
+ try { json = JSON.parse(text); } catch {}
23
+ return { status: res.status, body: json };
24
+ }
25
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
26
+ const res = await fetch(`${BASE}/mcp`, {
27
+ method: "POST",
28
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
29
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
30
+ });
31
+ const raw = await res.text();
32
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
33
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
34
+ return JSON.parse(out.result.content[0].text);
35
+ }
36
+
37
+ beforeAll(async () => {
38
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
39
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
40
+ process.env.HOST = "127.0.0.1";
41
+ const a = register(`reqerr_owner_${Date.now()}`, PW, undefined, "Owner");
42
+ ownerToken = a.token!; ownerId = a.user!.user_id; NET = a.network_id!;
43
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [ownerId]);
44
+ nodeToken = createNetworkTokenForNode(ownerId, NET, "示例-甲", "node_reqerr_a").token!;
45
+ const mod: any = await import("./server.js");
46
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
47
+ BASE = `http://127.0.0.1:${hub.port}`;
48
+ const username = `reqerr_member_${Date.now()}`;
49
+ expect((await rest(ownerToken, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" })).status).toBe(200);
50
+ memberToken = (await rest("", "POST", "/api/auth/login", { username, password: PW })).body.token;
51
+ cardId = (await mcp(nodeToken, "requirements_create", { name: "错误提示测试" })).requirement.id;
52
+ }, 30_000);
53
+
54
+ afterAll(() => {
55
+ try { hub?.stop?.(true); } catch {}
56
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
57
+ });
58
+
59
+ describe("each hint, over MCP", () => {
60
+ test("invalid_due: field due, the accepted formats", async () => {
61
+ for (const due of ["下周五", "2026/10/10"]) {
62
+ const r = await mcp(nodeToken, "requirements_update", { id: cardId, due });
63
+ expect(r.error).toBe("invalid_due");
64
+ expect(r.status).toBe(400);
65
+ expect(r.field).toBe("due");
66
+ expect(r.hint).toContain("YYYY-MM-DD");
67
+ expect(r.hint).toContain("ISO 8601");
68
+ }
69
+ expect((await mcp(nodeToken, "requirements_update", { id: cardId, start: "明天" })).field).toBe("start");
70
+ });
71
+
72
+ test("person_not_in_network: names the field, explains node_id vs user_id and where to find them", async () => {
73
+ const byAlias = await mcp(nodeToken, "requirements_update", { id: cardId, agent_owner: { kind: "node", id: "示例-甲" } });
74
+ expect(byAlias.error).toBe("person_not_in_network");
75
+ expect(byAlias.field).toBe("agent_owner");
76
+ // #473:人员字段也收名字 —— 提示改为指向 requirements_people,并说明 alias / username 的写法。
77
+ expect(byAlias.hint).toContain("requirements_people");
78
+ expect(byAlias.hint).toContain("{kind:'node', alias}");
79
+ expect((await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "user", id: "u_nobody" } })).field).toBe("owner");
80
+ expect((await mcp(nodeToken, "requirements_update", { id: cardId, participants: [{ kind: "node", id: "node_reqerr_a" }, { kind: "user", id: "u_nobody" }] })).field).toBe("participants");
81
+ });
82
+
83
+ test("project_not_in_network: a name is recognised as a name; both point to projects_list", async () => {
84
+ const byName = await mcp(nodeToken, "requirements_update", { id: cardId, project_id: "示例项目" });
85
+ expect(byName.error).toBe("project_not_in_network");
86
+ expect(byName.field).toBe("project_id");
87
+ expect(byName.message).toContain("looks like a project name");
88
+ expect(byName.hint).toContain("projects_list");
89
+ const byId = await mcp(nodeToken, "requirements_update", { id: cardId, project_id: "proj_missing" });
90
+ expect(byId.error).toBe("project_not_in_network");
91
+ expect(byId.message).not.toContain("project name");
92
+ expect(byId.hint).toContain("projects_list");
93
+ });
94
+
95
+ test("empty_patch: lists the writable fields; sending status says to use column", async () => {
96
+ // The MCP tool schema strips keys it does not know, so `status` arrives as nothing; REST sees the raw body.
97
+ const viaRest = await rest(nodeToken, "PATCH", `/api/requirements/${cardId}`, { status: "doing" });
98
+ expect(viaRest.status).toBe(400);
99
+ expect(viaRest.body.error).toBe("empty_patch");
100
+ expect(viaRest.body.hint).toContain("use column");
101
+ expect(viaRest.body.hint).toContain("Writable fields: column");
102
+ expect(viaRest.body.message).toContain("status");
103
+ const viaMcp = await mcp(nodeToken, "requirements_update", { id: cardId });
104
+ expect(viaMcp.error).toBe("empty_patch");
105
+ expect(viaMcp.hint).toContain("Writable fields:");
106
+ expect(viaMcp.hint).not.toContain("use column");
107
+ });
108
+
109
+ test("user_token_required: says projects are managed by people and what to do instead", async () => {
110
+ const r = await mcp(nodeToken, "projects_create", { name: "节点建项目" });
111
+ expect(r.error).toBe("user_token_required");
112
+ expect(r.status).toBe(403);
113
+ expect(r.message).toContain("node token");
114
+ expect(r.hint).toContain("projects_list");
115
+ });
116
+
117
+ test("network_id_required: field network_id and how to fix it", async () => {
118
+ const r = await mcp(memberToken, "projects_create", { name: "成员建项目" });
119
+ expect(r.error).toBe("network_id_required");
120
+ expect(r.field).toBe("network_id");
121
+ expect(r.hint).toContain("network_id");
122
+ });
123
+
124
+ test("swept codes carry hints too: checklist_item_not_found, external_ref_exists (existing_id kept), owner_must_be_human, parent_cycle", async () => {
125
+ const ck = await mcp(nodeToken, "requirements_checklist_toggle", { id: cardId, item_id: "nope", done: true });
126
+ expect(ck.error).toBe("checklist_item_not_found");
127
+ expect(ck.hint).toContain("requirements_get");
128
+ await mcp(nodeToken, "requirements_create", { name: "外部", external_ref: "github:example/repo#472" });
129
+ const dup = await mcp(nodeToken, "requirements_create", { name: "外部又一次", external_ref: "github:example/repo#472" });
130
+ expect(dup.error).toBe("external_ref_exists");
131
+ expect(typeof dup.existing_id).toBe("string");
132
+ expect(dup.hint).toContain("requirements_upsert_by_external_ref");
133
+ const own = await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "node", id: "node_reqerr_a" } });
134
+ expect(own.error).toBe("owner_must_be_human");
135
+ expect(own.hint).toContain("agent_owner");
136
+ const cyc = await mcp(nodeToken, "requirements_update", { id: cardId, parent_id: cardId });
137
+ expect(cyc.error).toBe("parent_cycle");
138
+ expect(cyc.field).toBe("parent_id");
139
+ });
140
+ });
141
+
142
+ describe("compatibility", () => {
143
+ test("REST keeps the exact error strings the app matches on; the new keys are additive", async () => {
144
+ const r = await rest(ownerToken, "PATCH", `/api/requirements/${cardId}`, { due: "下周五" });
145
+ expect(r.status).toBe(400);
146
+ expect(r.body.ok).toBe(false);
147
+ expect(r.body.error).toBe("invalid_due");
148
+ expect(Object.keys(r.body).sort()).toEqual(["error", "field", "hint", "message", "ok"]);
149
+ // A code without an entry is exactly the old shape.
150
+ expect(errorBody("some_future_code")).toEqual({ ok: false, error: "some_future_code" });
151
+ });
152
+
153
+ test("every error code requirements.ts returns or throws has an entry (a new code must be documented)", () => {
154
+ const src = readFileSync(join(import.meta.dir, "requirements.ts"), "utf8");
155
+ const codes = new Set<string>();
156
+ for (const m of src.matchAll(/jsonError\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
157
+ for (const m of src.matchAll(/(?:new (?:Error|RequirementFieldError))\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
158
+ for (const m of src.matchAll(/errorBody\(\s*["']([a-z_]+)["']/g)) codes.add(m[1]);
159
+ for (const m of src.matchAll(/return\s+["'](parent_[a-z_]+)["']/g)) codes.add(m[1]);
160
+ expect(codes.size).toBeGreaterThan(40); // the scan really found the codes (not an empty pass)
161
+ const documented = new Set(__documentedErrorCodes());
162
+ expect([...codes].filter(c => !documented.has(c)).sort()).toEqual([]);
163
+ });
164
+ });
@@ -0,0 +1,138 @@
1
+ // #472(MCP 任务生命周期测试报告问题 4)—— 任务 / 项目接口的错误不再只有一个代码。
2
+ //
3
+ // 以前回 `{ ok:false, error:"invalid_due" }`:app 按代码自己出文案,能用;但 MCP 上的 Agent 拿到的就只有这个词,
4
+ // 不知道哪个字段错了、正确的格式是什么、下一步该调哪个工具。这里给每个代码配上:
5
+ // field —— 出错的字段(有的话),
6
+ // message —— 一句英文说明(MCP 工具描述都是英文,读的是 LLM;app 不显示它),
7
+ // hint —— 怎么改 / 下一步调什么。
8
+ // 🔴 `error` 字符串原样不变:app、测试、脚本都按它匹配。新增的三个键旧客户端不认识,直接忽略。
9
+ // 只有一个出口:requirements.ts 的 jsonError() 和抛错转换都走 errorBody(),不在调用点各写各的。
10
+
11
+ export type ErrorContext = {
12
+ /** 出错的字段(调用点知道时传;不传就用登记表里的默认字段)。 */
13
+ field?: string;
14
+ /** 请求里实际带了哪些键(empty_patch 用来指出「你传了 status,该用 column」)。 */
15
+ sent?: readonly string[];
16
+ /** 可写字段(empty_patch 列出来)。 */
17
+ writable?: readonly string[];
18
+ /** 出错的那个值(project_not_in_network 用来判断是不是传了项目名)。 */
19
+ value?: unknown;
20
+ };
21
+
22
+ type Detail = { field?: string; message: string; hint?: string };
23
+ type Entry = Detail | ((ctx: ErrorContext) => Detail);
24
+
25
+ const DUE_FORMATS = 'YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (e.g. 2026-10-10T18:00:00+08:00); "" clears it';
26
+ // #473 —— 人员字段也收名字({kind:'user', username} / {kind:'node', alias}),只在任务所在的网络里解析;查人用 requirements_people。
27
+ const PERSON_IDS = "Look people up with requirements_people (q= filters by name): it returns each member's user_id and username and the node_id and alias of their Agents. Pass {kind:'user', id} or {kind:'user', username}, {kind:'node', id} or {kind:'node', alias}; names resolve only in the task's own network.";
28
+
29
+ const ENTRIES: Record<string, Entry> = {
30
+ // ── 定位 / 范围 ──
31
+ network_id_required: { field: 'network_id', message: 'This token can see more than one network (or is not bound to one), so the network must be named.', hint: 'Pass network_id (the network the task or project belongs to).' },
32
+ ambiguous_seq: { field: 'id', message: 'That #N exists in more than one network you can see.', hint: 'Pass network_id, or use the req_… id.' },
33
+ requirement_not_found: { field: 'id', message: 'No task with that id or #N in this network (or you cannot see it).', hint: 'Find it with requirements_list (q= searches titles; include_archived=true also lists archived tasks).' },
34
+ not_found: { message: 'No such task, project or route.', hint: 'Check the id; requirements_list / projects_list return the ids you can use.' },
35
+ project_not_found: { field: 'id', message: 'No project with that id in this network.', hint: 'Call projects_list to get project ids (proj_…).' },
36
+ tag_not_found: { field: 'tag', message: 'No task in this network carries that tag.', hint: 'Tags are free text on tasks; list the ones in use from requirements_list (view=summary shows each task’s tags).' },
37
+ checklist_item_not_found: { field: 'item_id', message: 'This task has no checklist item with that id.', hint: 'Item ids (ck_…) are in requirements_get → checklist[].id.' },
38
+ // ── 权限 ──
39
+ user_token_required: { message: 'This operation is for people only: a node token can read projects and read / create / update tasks, but cannot manage projects or delete tasks.', hint: 'Use an existing project (projects_list), or ask a person to create / change the project in the app (任务 → 管理项目).' },
40
+ permission_denied: { message: 'Your role in this network cannot do this (read-only, or task-scoped to other projects).', hint: 'Ask a network admin for write access, or work on tasks you can see.' },
41
+ upsert_not_allowed: { message: 'Task-scoped members cannot upsert by external_ref.', hint: 'Use requirements_create / requirements_update on tasks you can see.' },
42
+ external_ref_not_allowed: { field: 'external_ref', message: 'Task-scoped members cannot set external_ref.', hint: 'Leave external_ref out.' },
43
+ // ── 人员 ──
44
+ invalid_person: (ctx) => ({ field: ctx.field ?? 'owner', message: 'A person must be an object {kind:"user", id | username} or {kind:"node", id | alias}.', hint: PERSON_IDS }),
45
+ person_not_in_network: (ctx) => ({ field: ctx.field ?? 'owner', message: `${ctx.field ?? 'owner'} names nobody in this network (unknown id / username / alias, wrong kind, or a node you cannot see).`, hint: PERSON_IDS }),
46
+ person_ambiguous: (ctx) => ({ field: ctx.field ?? 'agent_owner', message: 'More than one Agent in this network has that alias.', hint: 'Call requirements_people (q=<alias>) and pass the node_id instead.' }),
47
+ owner_must_be_human: { field: 'owner', message: 'owner (负责人) must be a person.', hint: "Use owner: {kind:'user', id}. To assign a node / Agent, use agent_owner: {kind:'node', id}." },
48
+ agent_owner_must_be_agent: { field: 'agent_owner', message: 'agent_owner (负责 Agent) must be a node.', hint: "Use agent_owner: {kind:'node', id}. To assign a person, use owner: {kind:'user', id}." },
49
+ agent_owner_not_granted: { field: 'agent_owner', message: 'The current responsible Agent is one you are not granted, so you cannot replace or clear it.', hint: 'Leave agent_owner out of the patch.' },
50
+ invalid_participants: { field: 'participants', message: 'participants must be an array of up to 100 persons {kind, id}; it replaces the whole list.', hint: 'Send the full list you want (read it first with requirements_get).' },
51
+ invalid_participants_delta: { field: 'participants_add', message: 'participants_add / participants_remove must each be an array of up to 100 persons ({kind, id}, {kind:"user", username} or {kind:"node", alias}).', hint: PERSON_IDS },
52
+ participants_conflict: { field: 'participants', message: 'participants (replace the whole list) cannot be combined with participants_add / participants_remove.', hint: 'Use participants_add / participants_remove to change a few people, or participants alone to replace the list.' },
53
+ participants_add_remove_overlap: { field: 'participants_remove', message: 'The same person is in both participants_add and participants_remove.', hint: 'Send each person in only one of the two lists.' },
54
+ too_many_participants: { field: 'participants_add', message: 'A task can have at most 100 participants.', hint: 'Remove some with participants_remove first.' },
55
+ status_conflicts_with_column: { field: 'status', message: 'status is an alias of column, and the two values sent differ.', hint: 'Send only one of them (pool, doing or done).' },
56
+ invalid_assignee: { field: 'assignee', message: 'assignee must be a string (legacy free-text field).', hint: 'Prefer owner / agent_owner.' },
57
+ // ── 项目 ──
58
+ invalid_project: { field: 'project_id', message: 'project_id must be a project id string or null.', hint: 'Call projects_list to get project ids (proj_…); null removes the task from its project.' },
59
+ project_not_in_network: (ctx) => {
60
+ const looksLikeName = typeof ctx.value === 'string' && !/^proj_/.test(ctx.value);
61
+ return {
62
+ field: ctx.field ?? 'project_id',
63
+ message: looksLikeName ? `"${String(ctx.value).slice(0, 40)}" is not a project id — it looks like a project name.` : 'No project with that id in this network.',
64
+ hint: 'Call projects_list and pass the project’s id (proj_…), not its name.',
65
+ };
66
+ },
67
+ project_archived: { field: 'project_id', message: 'That project is archived.', hint: 'Pick an active project from projects_list, or ask a person to unarchive it.' },
68
+ project_name_taken: { field: 'name', message: 'A project with that name already exists in this network.', hint: 'Use the existing one (projects_list) or choose another name.' },
69
+ invalid_project_name: { field: 'name', message: 'Project name must be 1–40 characters.' },
70
+ invalid_project_color: { field: 'color', message: 'color must be #RRGGBB.' },
71
+ invalid_project_sort: { field: 'sort', message: 'sort must be an integer.' },
72
+ invalid_project_archived: { field: 'archived', message: 'archived must be true or false.' },
73
+ too_many_projects: { message: 'This network has reached the project limit (200).', hint: 'Reuse an existing project (projects_list).' },
74
+ // ── 任务字段 ──
75
+ empty_patch: (ctx) => {
76
+ const sent = ctx.sent ?? [];
77
+ const writable = ctx.writable?.length ? ctx.writable.join(', ') : 'name, column, priority, due, start, description, checklist, tags, owner, agent_owner, participants, project_id, parent_id, external_ref, external_url, archived';
78
+ const statusHint = sent.includes('status') ? 'To move a task between pool / doing / done, use column (status is the name of the list filter). ' : '';
79
+ return { message: sent.length ? `None of the keys sent (${sent.join(', ')}) is a writable field.` : 'The patch changes nothing.', hint: `${statusHint}Writable fields: ${writable}.` };
80
+ },
81
+ invalid_json: { message: 'The request body must be a JSON object.' },
82
+ invalid_name: { field: 'name', message: 'name must be 1–80 characters.' },
83
+ invalid_description: { field: 'description', message: 'description must be a string (markdown) of at most 20 000 characters.' },
84
+ invalid_column: { field: 'column', message: 'column must be pool, doing or done.' },
85
+ invalid_status: { field: 'status', message: 'status (list filter) must be pool, doing or done.' },
86
+ invalid_priority: { field: 'priority', message: 'priority must be high (P0), normal (P1), low (P2) or lowest (P3).' },
87
+ invalid_due: { field: 'due', message: 'due is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
88
+ invalid_start: { field: 'start', message: 'start is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
89
+ invalid_tags: { field: 'tags', message: 'tags must be an array of at most 10 strings, each 1–20 characters.', hint: 'Omit tags to keep them, [] to clear.' },
90
+ invalid_checklist: { field: 'checklist', message: 'checklist must be an array of up to 100 items {text (1–500 chars), done?, id?}; it replaces the whole list.', hint: 'To tick one item use requirements_checklist_toggle (item ids from requirements_get).' },
91
+ invalid_done: { field: 'done', message: 'done must be true or false.' },
92
+ invalid_parent_id: { field: 'parent_id', message: 'parent_id must be a task id in this network (or null to detach).', hint: 'Get the parent’s req_… id from requirements_list / requirements_get.' },
93
+ parent_cycle: { field: 'parent_id', message: 'That parent would make the task its own ancestor.', hint: 'Pick a parent that is not this task or one of its children.' },
94
+ parent_too_deep: { field: 'parent_id', message: 'Tasks nest at most 5 levels.', hint: 'Attach it to a higher-level task.' },
95
+ parent_not_found: { field: 'parent_id', message: 'No task with that parent_id in this network (or you cannot see it).', hint: 'Get the parent’s req_… id from requirements_list / requirements_get.' },
96
+ invalid_external_ref: { field: 'external_ref', message: 'external_ref must start with a letter or digit and use only letters, digits and _ . : / # @ + - (≤ 200 characters).', hint: 'e.g. github:owner/repo#123' },
97
+ external_ref_required: { field: 'external_ref', message: 'external_ref is required for upsert.' },
98
+ external_ref_exists: { field: 'external_ref', message: 'A task with that external_ref already exists in this network (existing_id).', hint: 'Use requirements_upsert_by_external_ref to sync, or requirements_update on existing_id.' },
99
+ invalid_external_url: { field: 'external_url', message: 'external_url must be an http(s) URL of at most 500 characters, or null.' },
100
+ invalid_issues: { field: 'issues', message: 'issues must be an array of issue references.' },
101
+ invalid_archived: { field: 'archived', message: 'archived must be true or false.' },
102
+ invalid_seq: { field: 'seq', message: 'seq must be a positive integer (the #N shown for a task).' },
103
+ invalid_client_id: { field: 'client_id', message: 'client_id must be 1–80 characters of letters, digits, ., _ or -.' },
104
+ client_id_taken: { field: 'client_id', message: 'That client_id was already used for another task.', hint: 'Generate a new client_id for each new task.' },
105
+ // ── 列表 / 事件 ──
106
+ invalid_view: { field: 'view', message: 'view must be full or summary.' },
107
+ invalid_limit: { field: 'limit', message: 'limit is out of range.' },
108
+ invalid_cursor: { field: 'cursor', message: 'cursor is not one this hub issued.', hint: 'Pass next_cursor from the previous page unchanged, or start again without cursor.' },
109
+ invalid_updated_since: { field: 'updated_since', message: 'updated_since must be an ISO 8601 time.', hint: 'Use server_time from the previous changes=true response.' },
110
+ updated_since_required: { field: 'updated_since', message: 'changes=true needs updated_since.', hint: 'Pass updated_since (ISO time; server_time from the last call).' },
111
+ invalid_since: { field: 'since', message: 'since must be an ISO 8601 time.', hint: 'Use server_time from the previous requirements_events response.' },
112
+ invalid_comment: { field: 'text', message: 'A comment needs non-empty text.', hint: 'Pass text (markdown), e.g. a progress note or a conclusion.' },
113
+ comment_too_long: { field: 'text', message: 'A comment can be at most 4000 characters.', hint: 'Split it into several comments, or put the long part in the description.' },
114
+ insert_failed: { message: 'The hub could not save the task.', hint: 'Retry once; if it fails again, report it.' },
115
+ };
116
+
117
+ /** 错误响应体:`error` 原样,补上 field / message / hint(登记表里没有的代码就只有 error,和以前一样)。 */
118
+ export function errorBody(error: string, ctx: ErrorContext = {}): { ok: false; error: string; field?: string; message?: string; hint?: string } {
119
+ const entry = ENTRIES[error];
120
+ if (!entry) return { ok: false, error };
121
+ const d = typeof entry === 'function' ? entry(ctx) : entry;
122
+ const field = ctx.field ?? d.field;
123
+ return { ok: false, error, ...(field ? { field } : {}), message: d.message, ...(d.hint ? { hint: d.hint } : {}) };
124
+ }
125
+
126
+ /** 带字段的抛错:assignments / projectRef 里抛,调用点的 catch 用 errorFrom() 转成响应。 */
127
+ export class RequirementFieldError extends Error {
128
+ constructor(code: string, readonly field?: string, readonly value?: unknown) { super(code); }
129
+ }
130
+
131
+ export function errorFrom(e: unknown, status = 400): Response {
132
+ const code = (e as Error)?.message ?? 'invalid_json';
133
+ const fe = e instanceof RequirementFieldError ? e : null;
134
+ return Response.json(errorBody(code, { field: fe?.field, value: fe?.value }), { status });
135
+ }
136
+
137
+ /** Test-only: every code with an entry. */
138
+ export const __documentedErrorCodes = (): string[] => Object.keys(ENTRIES);