@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.
@@ -94,7 +94,7 @@ export function decodeCursor(raw: string): Cursor | null {
94
94
  }
95
95
  }
96
96
 
97
- export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string };
97
+ export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string; /** #471 —— 只要带这个标签的卡(精确、区分大小写);null = 不按标签筛。 */ tag: string | null };
98
98
 
99
99
  /**
100
100
  * 任务 ID(同 App 的 task-short-id.ts matchesTaskId):「#42」「42」精确对短号 seq;完整 id,或 8 位以上的
@@ -134,5 +134,12 @@ export function parseListQuery(params: URLSearchParams): ListQuery | { error: st
134
134
  const q = params.get("q") ?? "";
135
135
  if (q.length > MAX_QUERY_LENGTH) return { error: "invalid_q" };
136
136
  const terms = searchTerms(q);
137
- return { limit, cursor, terms, idQuery: terms.length ? taskIdQuery(q) : "" };
137
+ // #471 —— tag=:和卡片标签同一套规范(去首尾空白、1–20 个字符、不含控制字符);不带 = 旧行为。
138
+ const rawTag = params.get("tag");
139
+ let tag: string | null = null;
140
+ if (rawTag !== null) {
141
+ tag = rawTag.trim();
142
+ if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return { error: "invalid_tag" };
143
+ }
144
+ return { limit, cursor, terms, idQuery: terms.length ? taskIdQuery(q) : "", tag };
138
145
  }
@@ -0,0 +1,216 @@
1
+ // #475(MCP 任务生命周期测试报告问题 6)—— Agent 写任务时两处常见的坑:
2
+ // - status 当 column 用(列表的筛选参数就叫 status):MCP 的 create / update / upsert 把 status 当 column 的别名,
3
+ // 两个都给且不同 → 400 status_conflicts_with_column;REST 不变(仍是 empty_patch + 提示用 column)。
4
+ // - participants 是整体替换,Agent 想「加一个人」时把别人全删了:新增 participants_add / participants_remove,
5
+ // 在当前列表上加减、同一次同步读改写里完成;加已有 = 不变,减不在的 = 不变。
6
+ // 外加 #476 第 1 部分:项目工具的描述写明节点令牌只能读项目(行为本来就是 403 user_token_required)。
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
+
14
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-status-part-"));
15
+ let BASE = "";
16
+ let hub: any = null;
17
+ const PW = "ReqStatusPartPassw0rd!xyz";
18
+ let NET = "";
19
+ const U: Record<string, { token: string; id: string }> = {};
20
+
21
+ async function rest(token: string, method: string, path: string, body?: unknown) {
22
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
23
+ const text = await res.text();
24
+ let json: any = null;
25
+ try { json = JSON.parse(text); } catch {}
26
+ return { status: res.status, body: json };
27
+ }
28
+ async function rpc(token: string, method: string, params: Record<string, unknown>) {
29
+ const res = await fetch(`${BASE}/mcp`, {
30
+ method: "POST",
31
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
32
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
33
+ });
34
+ const raw = await res.text();
35
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
36
+ return lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
37
+ }
38
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
39
+ const out = await rpc(token, "tools/call", { name, arguments: args });
40
+ const text = out.result?.content?.[0]?.text ?? "";
41
+ try { return JSON.parse(text); } catch { return { raw: out, text }; }
42
+ }
43
+ const keys = (refs: { kind: string; id: string }[]) => refs.map(r => `${r.kind}:${r.id}`).sort();
44
+ const row = (id: string) => db.get<{ column_name: string; participants_json: string; updated_at: string }>("SELECT column_name, participants_json, updated_at FROM requirements WHERE requirement_id = ?1", id)!;
45
+ const participantEvents = (id: string) => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_events WHERE requirement_id = ?1 AND field = 'participants'", id)!.n;
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(`sp_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 username = `sp_member_${Date.now()}`;
59
+ expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" })).status).toBe(200);
60
+ const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
61
+ U.member = { token: login.body.token, id: login.body.user.user_id };
62
+ for (const [k, alias] of [["nodeA", "示例-甲"], ["nodeB", "示例-乙"], ["nodeC", "示例-丙"]] as const) {
63
+ const nodeId = `node_sp_${k}`;
64
+ U[k] = { token: createNetworkTokenForNode(U.owner.id, NET, alias, nodeId).token!, id: nodeId };
65
+ }
66
+ }, 30_000);
67
+
68
+ afterAll(() => {
69
+ try { hub?.stop?.(true); } catch {}
70
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
71
+ });
72
+
73
+ describe("status is an alias of column (MCP)", () => {
74
+ test("create / update / upsert with status land exactly where column would", async () => {
75
+ const viaStatus = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:status 建", status: "doing" })).requirement;
76
+ const viaColumn = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:column 建", column: "doing" })).requirement;
77
+ expect(viaStatus.column).toBe("doing");
78
+ expect(viaStatus.column).toBe(viaColumn.column);
79
+
80
+ const done = await mcp(U.nodeA.token, "requirements_update", { id: `#${viaStatus.seq}`, status: "done" });
81
+ expect(done.requirement.column).toBe("done");
82
+ expect(done.requirement.completedAt).toBeTruthy(); // same side effects as column=done
83
+ expect(row(viaStatus.id).column_name).toBe("done");
84
+
85
+ const same = await mcp(U.nodeA.token, "requirements_update", { id: viaColumn.id, status: "pool", column: "pool" });
86
+ expect(same.requirement.column).toBe("pool");
87
+
88
+ const up = await mcp(U.nodeA.token, "requirements_upsert_by_external_ref", { external_ref: "demo:status-alias-1", name: "示例:upsert", status: "doing" });
89
+ expect(up.created).toBe(true);
90
+ expect(up.requirement.column).toBe("doing");
91
+ });
92
+
93
+ test("status and column both sent and different → 400 status_conflicts_with_column with a hint; nothing written", async () => {
94
+ const card = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:冲突", column: "pool" })).requirement;
95
+ const before = row(card.id);
96
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: card.id, status: "doing", column: "done" });
97
+ expect(r.ok).toBe(false);
98
+ expect(r.status).toBe(400);
99
+ expect(r.error).toBe("status_conflicts_with_column");
100
+ expect(r.field).toBe("status");
101
+ expect(r.hint).toContain("only one");
102
+ expect(row(card.id)).toEqual(before);
103
+ const c = await mcp(U.nodeA.token, "requirements_create", { name: "示例:冲突建", status: "doing", column: "done" });
104
+ expect(c.error).toBe("status_conflicts_with_column");
105
+ });
106
+
107
+ test("a bad status value is a schema error (-32602), like a bad column", async () => {
108
+ const out = await rpc(U.nodeA.token, "tools/call", { name: "requirements_update", arguments: { id: "#1", status: "finished" } });
109
+ expect(String(out.error?.code ?? out.result?.content?.[0]?.text)).toMatch(/-32602|invalid/i);
110
+ });
111
+
112
+ test("REST is unchanged: status on PATCH is still not a writable field", async () => {
113
+ const card = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:REST", column: "pool" })).requirement;
114
+ const r = await rest(U.member.token, "PATCH", `/api/requirements/${card.id}`, { status: "doing" });
115
+ expect(r.status).toBe(400);
116
+ expect(r.body.error).toBe("empty_patch");
117
+ expect(row(card.id).column_name).toBe("pool");
118
+ });
119
+ });
120
+
121
+ describe("participants_add / participants_remove", () => {
122
+ let card = { id: "", seq: 0 };
123
+ const node = (k: string) => ({ kind: "node", id: U[k].id });
124
+ const user = (k: string) => ({ kind: "user", id: U[k].id });
125
+
126
+ test("add keeps the people already there", async () => {
127
+ card = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:参与人", participants: [user("member"), node("nodeA")] })).requirement;
128
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: `#${card.seq}`, participants_add: [node("nodeB")] });
129
+ expect(keys(r.requirement.participants)).toEqual(keys([user("member"), node("nodeA"), node("nodeB")]));
130
+ expect(keys(JSON.parse(row(card.id).participants_json))).toEqual(keys([user("member"), node("nodeA"), node("nodeB")]));
131
+ });
132
+
133
+ test("add is idempotent: someone already there → same list, no participants event", async () => {
134
+ const events = participantEvents(card.id);
135
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants_add: [node("nodeB"), node("nodeA")] });
136
+ expect(keys(r.requirement.participants)).toEqual(keys([user("member"), node("nodeA"), node("nodeB")]));
137
+ expect(participantEvents(card.id)).toBe(events);
138
+ });
139
+
140
+ test("remove drops only those people; removing someone absent (or no longer in the network) is a no-op", async () => {
141
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants_remove: [user("member"), { kind: "user", id: "u_left_the_network" }] });
142
+ expect(r.ok).toBe(true);
143
+ expect(keys(r.requirement.participants)).toEqual(keys([node("nodeA"), node("nodeB")]));
144
+ });
145
+
146
+ test("add and remove in one call are applied together", async () => {
147
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants_add: [node("nodeC"), user("member")], participants_remove: [node("nodeA")] });
148
+ expect(keys(r.requirement.participants)).toEqual(keys([node("nodeB"), node("nodeC"), user("member")]));
149
+ });
150
+
151
+ test("concurrent adds from two Agents both stick (no lost update)", async () => {
152
+ const fresh = (await mcp(U.owner.token, "requirements_create", { name: "示例:并发", participants: [user("owner")], network_id: NET })).requirement;
153
+ await Promise.all([
154
+ mcp(U.nodeA.token, "requirements_update", { id: fresh.id, participants_add: [node("nodeA")] }),
155
+ mcp(U.nodeB.token, "requirements_update", { id: fresh.id, participants_add: [node("nodeB")] }),
156
+ mcp(U.nodeC.token, "requirements_update", { id: fresh.id, participants_add: [node("nodeC")] }),
157
+ ]);
158
+ expect(keys(JSON.parse(row(fresh.id).participants_json))).toEqual(keys([user("owner"), node("nodeA"), node("nodeB"), node("nodeC")]));
159
+ });
160
+
161
+ test("name forms (#473): add by alias / username, remove by username", async () => {
162
+ const fresh = (await mcp(U.nodeA.token, "requirements_create", { name: "示例:按名字", participants: [node("nodeA")] })).requirement;
163
+ const memberName = db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", U.member.id)!.username;
164
+ const added = await mcp(U.nodeA.token, "requirements_update", { id: fresh.id, participants_add: [{ kind: "node", alias: "示例-乙" }, { kind: "user", username: memberName }] });
165
+ expect(keys(added.requirement.participants)).toEqual(keys([node("nodeA"), node("nodeB"), user("member")]));
166
+ const removed = await mcp(U.nodeA.token, "requirements_update", { id: fresh.id, participants_remove: [{ kind: "user", username: memberName }] });
167
+ expect(keys(removed.requirement.participants)).toEqual(keys([node("nodeA"), node("nodeB")]));
168
+ });
169
+
170
+ test("participants is still a full replace", async () => {
171
+ const r = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants: [node("nodeA")] });
172
+ expect(keys(r.requirement.participants)).toEqual(keys([node("nodeA")]));
173
+ });
174
+
175
+ test("bad combinations → 400 with hints, nothing written", async () => {
176
+ const before = row(card.id);
177
+ const both = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants: [], participants_add: [node("nodeB")] });
178
+ expect(both.status).toBe(400);
179
+ expect(both.error).toBe("participants_conflict");
180
+ expect(both.hint).toContain("participants_add");
181
+ const overlap = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants_add: [node("nodeB")], participants_remove: [node("nodeB")] });
182
+ expect(overlap.error).toBe("participants_add_remove_overlap");
183
+ const stranger = await mcp(U.nodeA.token, "requirements_update", { id: card.id, participants_add: [{ kind: "node", id: "node_not_here" }] });
184
+ expect(stranger.error).toBe("person_not_in_network");
185
+ expect(stranger.field).toBe("participants_add");
186
+ expect(row(card.id)).toEqual(before);
187
+ });
188
+
189
+ test("REST accepts the same keys (additive; old clients never send them)", async () => {
190
+ const r = await rest(U.member.token, "PATCH", `/api/requirements/${card.id}`, { participants_add: [user("member")] });
191
+ expect(r.status).toBe(200);
192
+ // the member has no grant on nodeA, so the response leaves it out — but the stored list keeps it
193
+ expect(keys(JSON.parse(row(card.id).participants_json))).toEqual(keys([node("nodeA"), user("member")]));
194
+ expect(keys(r.body.requirement.participants)).toEqual(keys([user("member")]));
195
+ // …and the member cannot remove an Agent they cannot see (same error as a node that does not exist)
196
+ const hidden = await rest(U.member.token, "PATCH", `/api/requirements/${card.id}`, { participants_remove: [node("nodeA")] });
197
+ expect(hidden.status).toBe(400);
198
+ expect(hidden.body.error).toBe("person_not_in_network");
199
+ expect(keys(JSON.parse(row(card.id).participants_json))).toEqual(keys([node("nodeA"), user("member")]));
200
+ });
201
+ });
202
+
203
+ describe("tool descriptions", () => {
204
+ test("participants says it replaces; projects tools say node tokens only read; node token project write → 403", async () => {
205
+ const tools = (await rpc(U.nodeA.token, "tools/list", {})).result.tools as any[];
206
+ const byName = new Map(tools.map(t => [t.name, t]));
207
+ const update = byName.get("requirements_update");
208
+ expect(update.description).toContain("REPLACES the whole list");
209
+ expect(update.inputSchema.properties.participants.description).toContain("REPLACES");
210
+ expect(Object.keys(update.inputSchema.properties)).toEqual(expect.arrayContaining(["status", "participants_add", "participants_remove"]));
211
+ for (const name of ["projects_list", "projects_create", "projects_update"]) expect(byName.get(name).description).toMatch(/node \(Agent\) tokens can only read projects/i);
212
+ const denied = await mcp(U.nodeA.token, "projects_create", { name: "示例项目" });
213
+ expect(denied.status).toBe(403);
214
+ expect(denied.error).toBe("user_token_required");
215
+ });
216
+ });
@@ -1,6 +1,8 @@
1
1
  // 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
+ import { errorBody, errorFrom, RequirementFieldError, type ErrorContext } from "./requirements-errors.js";
3
4
  import { createHash } from "node:crypto";
5
+ import { parsePeopleQuery, peopleDirectory, resolvePersonName } from "./requirements-people.js";
4
6
  import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
5
7
  import { db, logAudit } from "./db.js";
6
8
  import { markGzipReusable } from "./http-gzip.js";
@@ -37,6 +39,12 @@ export type RequirementsRequestContext = {
37
39
  isAdmin: boolean;
38
40
  isNodeToken: boolean;
39
41
  scope: RestNetworkScope;
42
+ /**
43
+ * #470 —— 负责人(owner)只收人,传节点一律 400 owner_must_be_human,不走旧 App 的「节点负责人 → 负责 Agent」兼容。
44
+ * MCP 工具(Agent 与人经 MCP 的调用)总是严格:那条兼容只为 App ≤ 0.2.142 的 REST 保存存在,Agent 走它会
45
+ * 静默清空人类负责人(task-lifecycle 测试报告问题 1)。REST 上带 agent_owner 的新客户端也严格(见 assignments)。
46
+ */
47
+ strictOwner?: boolean;
40
48
  };
41
49
 
42
50
  const COLUMNS = new Set(["pool", "doing", "done"]);
@@ -78,8 +86,9 @@ type Row = {
78
86
  completed_at_approx: number | null;
79
87
  };
80
88
 
81
- function jsonError(error: string, status: number): Response {
82
- return Response.json({ ok: false, error }, { status });
89
+ // #472:错误码原样,统一补 field / message / hint(requirements-errors.ts,唯一出口)。
90
+ function jsonError(error: string, status: number, ctx?: ErrorContext): Response {
91
+ return Response.json(errorBody(error, ctx), { status });
83
92
  }
84
93
 
85
94
  // ── 任务的人员权限(RFC-038 §9,判定在 task-access.ts) ──
@@ -261,10 +270,10 @@ function projectNameTaken(networkId: string, name: string, except?: string): boo
261
270
  /** 卡片上的 project_id:null 清空;字符串必须是同一网络、未归档的项目。 */
262
271
  function projectRef(value: unknown, networkId: string): string | null {
263
272
  if (value === null) return null;
264
- if (typeof value !== "string" || !value) throw new Error("invalid_project");
273
+ if (typeof value !== "string" || !value) throw new RequirementFieldError("invalid_project", "project_id", value);
265
274
  const row = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, value);
266
- if (!row || row.network_id !== networkId) throw new Error("project_not_in_network");
267
- if (row.archived) throw new Error("project_archived");
275
+ if (!row || row.network_id !== networkId) throw new RequirementFieldError("project_not_in_network", "project_id", value);
276
+ if (row.archived) throw new RequirementFieldError("project_archived", "project_id", value);
268
277
  return row.project_id;
269
278
  }
270
279
 
@@ -512,33 +521,53 @@ const SELECT = "requirement_id, network_id, title, column_name, priority, due_on
512
521
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
513
522
 
514
523
  type PersonRef = { kind: 'user' | 'node'; id: string };
515
- function personRef(value: unknown, networkId: string, hidden: HiddenNode = null): PersonRef {
516
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_person');
524
+ function personRef(value: unknown, networkId: string, hidden: HiddenNode = null, field = 'owner'): PersonRef {
525
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RequirementFieldError('invalid_person', field);
517
526
  const row = value as Record<string, unknown>;
518
- if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new Error('invalid_person');
527
+ if (row.kind !== 'user' && row.kind !== 'node') throw new RequirementFieldError('invalid_person', field);
528
+ // #473 —— 没给 id 时可以按名字:人 {kind:'user', username},Agent {kind:'node', alias}。
529
+ // 只在 networkId(任务所在的网络)里找,绝不跨网络;存下来的仍是 {kind, id}。给了 id 就只看 id(与以前逐字相同)。
530
+ if (typeof row.id !== 'string' || !row.id.trim()) {
531
+ const name = row.kind === 'user' ? row.username : row.alias;
532
+ if (row.id !== undefined || typeof name !== 'string' || !name.trim()) throw new RequirementFieldError('invalid_person', field);
533
+ try { return { kind: row.kind, id: resolvePersonName(row.kind, name.trim(), networkId, hidden) }; }
534
+ catch (e) { throw new RequirementFieldError((e as Error).message, field); }
535
+ }
519
536
  const exists = row.kind === 'user'
520
537
  ? db.get("SELECT 1 FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 AND m.user_id=?2", networkId, row.id)
521
538
  : db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
522
539
  // 没授权的节点与不存在的节点同一个错误,不给受限成员留 node_id 探测差异。
523
- if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new Error('person_not_in_network');
540
+ if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new RequirementFieldError('person_not_in_network', field);
524
541
  return { kind: row.kind, id: row.id };
525
542
  }
526
543
 
544
+ // participants_remove 的一项 → "kind:id"。给了 id 就直接按 id 比(已经离开网络的人也能从列表里减掉,不查在不在网络里);
545
+ // 没给 id 的形状交给 personRef 解析。受限成员看不见的节点与不存在同一个错误(不给他留探测差异)。
546
+ function participantKey(value: unknown, networkId: string, hidden: HiddenNode, field: string): string {
547
+ const row = value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
548
+ if (row && (row.kind === 'user' || row.kind === 'node') && typeof row.id === 'string' && row.id.trim()) {
549
+ if (row.kind === 'node' && hidden?.(row.id)) throw new RequirementFieldError('person_not_in_network', field);
550
+ return `${row.kind}:${row.id}`;
551
+ }
552
+ const ref = personRef(value, networkId, hidden, field);
553
+ return `${ref.kind}:${ref.id}`;
554
+ }
555
+
527
556
  // 负责人只能是人类,负责 Agent 只能是节点;参与人两种都行。库里旧的节点负责人照样读出来(启动迁移会挪走),
528
557
  // 只有这次请求显式写了 owner / agent_owner 才校验种类。
529
558
  // 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
530
559
  // → 当成设置负责 Agent,负责人清空,照常 200,并回 owner_coerced_to_agent_owner: true。
531
560
  // 带了 agent_owner 的就是新客户端,保持严格:owner 是节点 → 400 owner_must_be_human。
532
- function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null) {
561
+ function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null, strictOwner = false) {
533
562
  let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
534
563
  let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
535
564
  let coerced: PersonRef | null = null;
536
565
  // 受限成员看不见的负责 Agent,他也不能换掉或清空(他读到的是 null,写回 null 不该抹掉别人的指派)。
537
566
  const agentOwnerLocked = isHiddenRef(agentOwner, hidden);
538
567
  if ('owner' in body) {
539
- owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
568
+ owner = body.owner === null ? null : personRef(body.owner, networkId, hidden, 'owner');
540
569
  if (owner && owner.kind !== 'user') {
541
- if ('agent_owner' in body) throw new Error('owner_must_be_human');
570
+ if (strictOwner || 'agent_owner' in body) throw new Error('owner_must_be_human');
542
571
  coerced = owner;
543
572
  agentOwner = owner;
544
573
  owner = null;
@@ -547,17 +576,37 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
547
576
  if (coerced && agentOwnerLocked) throw new Error('agent_owner_not_granted');
548
577
  if ('agent_owner' in body) {
549
578
  if (agentOwnerLocked) throw new Error('agent_owner_not_granted');
550
- agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden);
579
+ agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden, 'agent_owner');
551
580
  if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
552
581
  }
553
582
  let participants = current ? JSON.parse(current.participants_json || '[]') : [];
554
583
  if ('participants' in body) {
555
584
  if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
556
- const refs = body.participants.map(value => personRef(value, networkId, hidden));
585
+ const refs = body.participants.map(value => personRef(value, networkId, hidden, 'participants'));
557
586
  // 参与人是整体替换:把受限成员看不见的那些节点原样留下,别让他「保存」时静默删掉。
558
587
  const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
559
588
  participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
560
589
  }
590
+ // #475 —— 增量改参与人:participants_add / participants_remove 在当前列表上加 / 减(同步读改写,中间不让出,
591
+ // 并发的另一个请求插不进来),不碰别的参与人。加已有的人 = 不变;减不在列表里的人 = 不变。
592
+ // 和整体替换的 participants 不能同时用;同一个人既加又减 = 400,不猜先后。
593
+ if ('participants_add' in body || 'participants_remove' in body) {
594
+ if ('participants' in body) throw new RequirementFieldError('participants_conflict', 'participants');
595
+ const list = (key: string): unknown[] => {
596
+ const value = body[key];
597
+ if (value === undefined) return [];
598
+ if (!Array.isArray(value) || value.length > 100) throw new RequirementFieldError('invalid_participants_delta', key);
599
+ return value;
600
+ };
601
+ const add: PersonRef[] = list('participants_add').map(value => personRef(value, networkId, hidden, 'participants_add'));
602
+ const remove = new Set(list('participants_remove').map(value => participantKey(value, networkId, hidden, 'participants_remove')));
603
+ if (add.some(ref => remove.has(`${ref.kind}:${ref.id}`))) throw new RequirementFieldError('participants_add_remove_overlap', 'participants_remove');
604
+ const next = new Map<string, PersonRef>(participants.map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref]));
605
+ for (const ref of add) if (!next.has(`${ref.kind}:${ref.id}`)) next.set(`${ref.kind}:${ref.id}`, ref);
606
+ for (const key of remove) next.delete(key);
607
+ if (next.size > 100) throw new RequirementFieldError('too_many_participants', 'participants_add');
608
+ participants = [...next.values()];
609
+ }
561
610
  return {
562
611
  coerced,
563
612
  ownerJson: owner === null ? null : JSON.stringify(owner),
@@ -570,12 +619,16 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
570
619
  * 节点令牌(Agent,比如把 GitHub issue 同步进任务的 TM 节点)可用的操作 —— 只在它自己的网络里
571
620
  * (令牌绑定的网络,范围由 resolveRestNetworkScope 强制)。删除卡片、管理项目仍然只给人。
572
621
  */
573
- const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "upsert", "projects_read"]);
622
+ /** 一条评论最多这么多字(去掉首尾空白后);进展 / 结论写得下,又不至于让动态列表里一条撑爆。 */
623
+ export const COMMENT_MAX_CHARS = 4000;
624
+
625
+ const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "comment", "upsert", "projects_read"]);
574
626
  function operationOf(req: Request, url: URL): string {
575
627
  if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
576
628
  if (url.pathname === "/api/requirements/tags/ops") return "tags_write";
577
629
  if (req.method === "GET") return "read";
578
630
  if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
631
+ if (/^\/api\/requirements\/[^/]+\/comments$/.test(url.pathname)) return "comment";
579
632
  if (url.pathname === "/api/requirements/upsert") return "upsert";
580
633
  if (req.method === "DELETE") return "delete";
581
634
  if (req.method === "PATCH") return "patch";
@@ -741,7 +794,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
741
794
  const scoped = !!caller && isTaskScoped(caller.userId, networkId);
742
795
  let projectId: string | null = null;
743
796
  if (body.project_id !== undefined) {
744
- try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
797
+ try { projectId = projectRef(body.project_id, networkId); } catch (e) { return errorFrom(e); }
745
798
  // scoped 成员只能建进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
746
799
  if (!canUseProject(caller, networkId, projectId)) return jsonError("project_not_in_network", 400);
747
800
  }
@@ -760,7 +813,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
760
813
  if (ref) {
761
814
  const existing = rowByExternalRef(networkId, ref);
762
815
  // 同一个外部条目再建一次 = 冲突,回已有的 id(同步方改用 upsert,或按这个 id PATCH)。
763
- if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
816
+ if (existing) return Response.json({ ...errorBody("external_ref_exists"), existing_id: existing.requirement_id }, { status: 409 });
764
817
  }
765
818
  if (clientId) {
766
819
  const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
@@ -770,7 +823,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
770
823
  const id = `req_${crypto.randomUUID()}`;
771
824
  const createdAt = new Date().toISOString();
772
825
  let people;
773
- try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
826
+ try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
774
827
  const actor = JSON.stringify(actorOf(ctx));
775
828
  try {
776
829
  // 领号、插入、记「新建」这条动态在同一个事务里:插入被唯一索引挡下时,号和动态一起回滚,不留空洞。
@@ -790,7 +843,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
790
843
  // 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
791
844
  if (ref) {
792
845
  const existing = rowByExternalRef(networkId, ref);
793
- if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
846
+ if (existing) return Response.json({ ...errorBody("external_ref_exists"), existing_id: existing.requirement_id }, { status: 409 });
794
847
  }
795
848
  if (!clientId) return jsonError("insert_failed", 500);
796
849
  const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
@@ -803,9 +856,9 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
803
856
  }
804
857
 
805
858
  // ── 修改(省略的字段保留原值) ──
806
- const PATCH_FIELDS = ["column", "issues", "tags", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id", "start"];
859
+ const PATCH_FIELDS = ["column", "issues", "tags", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "participants_add", "participants_remove", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id", "start"];
807
860
  function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
808
- if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
861
+ if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
809
862
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
810
863
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
811
864
  const caller = taskCaller(ctx);
@@ -856,10 +909,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
856
909
  assignee = body.assignee.trim().slice(0, 80);
857
910
  }
858
911
  let people;
859
- try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id)); } catch (e) { return jsonError((e as Error).message, 400); }
912
+ try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
860
913
  let projectId = row.project_id;
861
914
  if (has("project_id")) {
862
- try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
915
+ try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return errorFrom(e); }
863
916
  // scoped 成员只能挪进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
864
917
  if (projectId !== row.project_id && !canUseProject(caller, row.network_id, projectId)) return jsonError("project_not_in_network", 400);
865
918
  }
@@ -869,7 +922,7 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
869
922
  if (next === undefined) return jsonError("invalid_external_ref", 400);
870
923
  if (next && next !== row.external_ref) {
871
924
  const taken = rowByExternalRef(row.network_id, next);
872
- if (taken) return Response.json({ ok: false, error: "external_ref_exists", existing_id: taken.requirement_id }, { status: 409 });
925
+ if (taken) return Response.json({ ...errorBody("external_ref_exists"), existing_id: taken.requirement_id }, { status: 409 });
873
926
  }
874
927
  ref = next;
875
928
  }
@@ -950,7 +1003,7 @@ function scopedRow(ctx: RequirementsRequestContext, id: string): Row | Response
950
1003
  const rows = db.all<Row>(`${sql} LIMIT 2`, ...params);
951
1004
  if (rows.length > 1) {
952
1005
  const networks = rows.map(r => r.network_id);
953
- return Response.json({ ok: false, error: "ambiguous_seq", networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
1006
+ return Response.json({ ...errorBody("ambiguous_seq"), networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
954
1007
  }
955
1008
  return rows[0] ?? jsonError("requirement_not_found", 404);
956
1009
  }
@@ -1014,7 +1067,7 @@ function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unk
1014
1067
  where += ` AND (created_at < ?${a} OR (created_at = ?${a} AND requirement_id < ?${b}))`;
1015
1068
  }
1016
1069
  let rows: Row[];
1017
- if (!lq.terms.length) {
1070
+ if (!lq.terms.length && lq.tag === null) {
1018
1071
  rows = db.all<Row>(`SELECT ${SELECT} ${where}${LIST_ORDER} LIMIT ${lq.limit + 1}`, ...params);
1019
1072
  } else {
1020
1073
  // seq(任务短号,#2139):启动迁移 ensureRequirementSeq 在 SQLite / PostgreSQL 上都加这一列,SELECT 本来就带它。
@@ -1025,7 +1078,9 @@ function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unk
1025
1078
  let m = maps.get(r.network_id);
1026
1079
  if (!m) maps.set(r.network_id, m = searchNameMaps(ctx, r.network_id));
1027
1080
  const row = { name: r.title, description: r.description || "", assignee: r.assignee || "", tags: storedTags(r.tags_json), project_id: r.project_id, owner: parseRef(r.owner_json), agent_owner: parseRef(r.agent_owner_json), participants: parseRef(r.participants_json) ?? [] };
1028
- if (matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
1081
+ // #471 —— tag= 在这里按存好的标签数组精确比(JSON 列,SQLite / PostgreSQL 写法不同,所以不进 SQL)。
1082
+ if (lq.tag !== null && !row.tags.includes(lq.tag)) continue;
1083
+ if (!lq.terms.length || matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
1029
1084
  if (hits.length > lq.limit) break;
1030
1085
  }
1031
1086
  const byId = new Map(hits.length ? db.all<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id IN (${hits.map((_, i) => `?${i + 1}`).join(",")})`, ...hits).map(r => [r.requirement_id, r]) : []);
@@ -1284,6 +1339,15 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1284
1339
 
1285
1340
  if (url.pathname === "/api/requirements/events" && req.method === "GET") return listEvents(ctx);
1286
1341
 
1342
+ // #473 —— MCP requirements_people:紧凑通讯录(用户名、角色、部门、名下 Agent),q / limit / offset。
1343
+ if (url.pathname === '/api/requirements/people/directory' && req.method === 'GET') {
1344
+ const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
1345
+ if (!networkId) return jsonError('network_id_required', 400);
1346
+ const pq = parsePeopleQuery(url.searchParams);
1347
+ if ('error' in pq) return jsonError(pq.error, 400);
1348
+ return Response.json(peopleDirectory(networkId, hiddenNodeFilter(ctx, networkId), pq));
1349
+ }
1350
+
1287
1351
  if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
1288
1352
  const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
1289
1353
  if (!networkId) return jsonError('network_id_required', 400);
@@ -1378,6 +1442,28 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1378
1442
  return Response.json({ ...data, created: false });
1379
1443
  }
1380
1444
 
1445
+ // #474 评论 / 进展(MCP 任务生命周期测试报告问题 5):以前 Agent 只能读全文再整段改写 description,两次调用、
1446
+ // 并发会覆盖别人刚写的。评论是一条只追加的动态(requirement_events kind=comment,正文存在 new.text),
1447
+ // 不碰 requirements 表的任何一列;谁能看见这张卡谁就能读到,能写这个网络(非只读角色)又看得见它的人能发。
1448
+ const commentMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/comments$/);
1449
+ if (commentMatch) {
1450
+ if (req.method !== "POST") return jsonError("not_found", 404);
1451
+ let body: Record<string, unknown>;
1452
+ try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
1453
+ const text = typeof body.text === "string" ? body.text.trim() : "";
1454
+ if (!text) return jsonError("invalid_comment", 400);
1455
+ if (text.length > COMMENT_MAX_CHARS) return jsonError("comment_too_long", 400);
1456
+ const row = scopedRow(ctx, decodeURIComponent(commentMatch[1]));
1457
+ if (row instanceof Response) return row;
1458
+ if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1459
+ const at = new Date().toISOString();
1460
+ const event = db.transaction(() => {
1461
+ recordRequirementEvents(db, row, JSON.stringify(actorOf(ctx)), [{ kind: "comment", field: null, old: null, new: { text } }], at);
1462
+ return db.get<EventRow>("SELECT id, network_id, requirement_id, seq, title, actor_json, kind, field, old_json, new_json, created_at FROM requirement_events WHERE requirement_id = ?1 AND kind = 'comment' ORDER BY id DESC LIMIT 1", row.requirement_id)!;
1463
+ });
1464
+ return Response.json({ ok: true, event: eventPublic(event) }, { status: 201 });
1465
+ }
1466
+
1381
1467
  const itemMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/checklist\/([^/]+)$/);
1382
1468
  if (itemMatch) {
1383
1469
  if (req.method !== "PATCH") return jsonError("not_found", 404);
@@ -1436,7 +1522,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1436
1522
  if (req.method !== "PATCH") return jsonError("not_found", 404);
1437
1523
  let body: Record<string, unknown>;
1438
1524
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
1439
- if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
1525
+ if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
1440
1526
  const row = scopedRow(ctx, id);
1441
1527
  if (row instanceof Response) return row;
1442
1528
  return patchRequirement(ctx, row, body);