@sleep2agi/commhub-server 0.9.0-preview.89 → 0.9.0-preview.91

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.
@@ -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
+ });
@@ -2,6 +2,7 @@
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
3
  import { errorBody, errorFrom, RequirementFieldError, type ErrorContext } from "./requirements-errors.js";
4
4
  import { createHash } from "node:crypto";
5
+ import { parsePeopleQuery, peopleDirectory, resolvePersonName } from "./requirements-people.js";
5
6
  import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
6
7
  import { db, logAudit } from "./db.js";
7
8
  import { markGzipReusable } from "./http-gzip.js";
@@ -10,10 +11,11 @@ import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-s
10
11
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
11
12
  import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
12
13
  import {
13
- addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject, projectUseResolver,
14
+ addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, deletesAsHeadOnly, headOnlyEditScope, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject, projectUseResolver,
14
15
  isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
15
16
  } from "./task-access.js";
16
- import { notifyParticipantChange } from "./requirement-notify.js";
17
+ import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-notify.js";
18
+ import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
17
19
  import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
18
20
  import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
19
21
 
@@ -523,7 +525,15 @@ type PersonRef = { kind: 'user' | 'node'; id: string };
523
525
  function personRef(value: unknown, networkId: string, hidden: HiddenNode = null, field = 'owner'): PersonRef {
524
526
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RequirementFieldError('invalid_person', field);
525
527
  const row = value as Record<string, unknown>;
526
- if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new RequirementFieldError('invalid_person', field);
528
+ if (row.kind !== 'user' && row.kind !== 'node') throw new RequirementFieldError('invalid_person', field);
529
+ // #473 —— 没给 id 时可以按名字:人 {kind:'user', username},Agent {kind:'node', alias}。
530
+ // 只在 networkId(任务所在的网络)里找,绝不跨网络;存下来的仍是 {kind, id}。给了 id 就只看 id(与以前逐字相同)。
531
+ if (typeof row.id !== 'string' || !row.id.trim()) {
532
+ const name = row.kind === 'user' ? row.username : row.alias;
533
+ if (row.id !== undefined || typeof name !== 'string' || !name.trim()) throw new RequirementFieldError('invalid_person', field);
534
+ try { return { kind: row.kind, id: resolvePersonName(row.kind, name.trim(), networkId, hidden) }; }
535
+ catch (e) { throw new RequirementFieldError((e as Error).message, field); }
536
+ }
527
537
  const exists = row.kind === 'user'
528
538
  ? 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)
529
539
  : db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
@@ -532,6 +542,18 @@ function personRef(value: unknown, networkId: string, hidden: HiddenNode = null,
532
542
  return { kind: row.kind, id: row.id };
533
543
  }
534
544
 
545
+ // participants_remove 的一项 → "kind:id"。给了 id 就直接按 id 比(已经离开网络的人也能从列表里减掉,不查在不在网络里);
546
+ // 没给 id 的形状交给 personRef 解析。受限成员看不见的节点与不存在同一个错误(不给他留探测差异)。
547
+ function participantKey(value: unknown, networkId: string, hidden: HiddenNode, field: string): string {
548
+ const row = value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
549
+ if (row && (row.kind === 'user' || row.kind === 'node') && typeof row.id === 'string' && row.id.trim()) {
550
+ if (row.kind === 'node' && hidden?.(row.id)) throw new RequirementFieldError('person_not_in_network', field);
551
+ return `${row.kind}:${row.id}`;
552
+ }
553
+ const ref = personRef(value, networkId, hidden, field);
554
+ return `${ref.kind}:${ref.id}`;
555
+ }
556
+
535
557
  // 负责人只能是人类,负责 Agent 只能是节点;参与人两种都行。库里旧的节点负责人照样读出来(启动迁移会挪走),
536
558
  // 只有这次请求显式写了 owner / agent_owner 才校验种类。
537
559
  // 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
@@ -544,7 +566,7 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
544
566
  // 受限成员看不见的负责 Agent,他也不能换掉或清空(他读到的是 null,写回 null 不该抹掉别人的指派)。
545
567
  const agentOwnerLocked = isHiddenRef(agentOwner, hidden);
546
568
  if ('owner' in body) {
547
- owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
569
+ owner = body.owner === null ? null : personRef(body.owner, networkId, hidden, 'owner');
548
570
  if (owner && owner.kind !== 'user') {
549
571
  if (strictOwner || 'agent_owner' in body) throw new Error('owner_must_be_human');
550
572
  coerced = owner;
@@ -566,6 +588,26 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
566
588
  const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
567
589
  participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
568
590
  }
591
+ // #475 —— 增量改参与人:participants_add / participants_remove 在当前列表上加 / 减(同步读改写,中间不让出,
592
+ // 并发的另一个请求插不进来),不碰别的参与人。加已有的人 = 不变;减不在列表里的人 = 不变。
593
+ // 和整体替换的 participants 不能同时用;同一个人既加又减 = 400,不猜先后。
594
+ if ('participants_add' in body || 'participants_remove' in body) {
595
+ if ('participants' in body) throw new RequirementFieldError('participants_conflict', 'participants');
596
+ const list = (key: string): unknown[] => {
597
+ const value = body[key];
598
+ if (value === undefined) return [];
599
+ if (!Array.isArray(value) || value.length > 100) throw new RequirementFieldError('invalid_participants_delta', key);
600
+ return value;
601
+ };
602
+ const add: PersonRef[] = list('participants_add').map(value => personRef(value, networkId, hidden, 'participants_add'));
603
+ const remove = new Set(list('participants_remove').map(value => participantKey(value, networkId, hidden, 'participants_remove')));
604
+ if (add.some(ref => remove.has(`${ref.kind}:${ref.id}`))) throw new RequirementFieldError('participants_add_remove_overlap', 'participants_remove');
605
+ const next = new Map<string, PersonRef>(participants.map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref]));
606
+ for (const ref of add) if (!next.has(`${ref.kind}:${ref.id}`)) next.set(`${ref.kind}:${ref.id}`, ref);
607
+ for (const key of remove) next.delete(key);
608
+ if (next.size > 100) throw new RequirementFieldError('too_many_participants', 'participants_add');
609
+ participants = [...next.values()];
610
+ }
569
611
  return {
570
612
  coerced,
571
613
  ownerJson: owner === null ? null : JSON.stringify(owner),
@@ -578,12 +620,16 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
578
620
  * 节点令牌(Agent,比如把 GitHub issue 同步进任务的 TM 节点)可用的操作 —— 只在它自己的网络里
579
621
  * (令牌绑定的网络,范围由 resolveRestNetworkScope 强制)。删除卡片、管理项目仍然只给人。
580
622
  */
581
- const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "upsert", "projects_read"]);
623
+ /** 一条评论最多这么多字(去掉首尾空白后);进展 / 结论写得下,又不至于让动态列表里一条撑爆。 */
624
+ export const COMMENT_MAX_CHARS = 4000;
625
+
626
+ const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "comment", "upsert", "projects_read"]);
582
627
  function operationOf(req: Request, url: URL): string {
583
628
  if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
584
629
  if (url.pathname === "/api/requirements/tags/ops") return "tags_write";
585
630
  if (req.method === "GET") return "read";
586
631
  if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
632
+ if (/^\/api\/requirements\/[^/]+\/comments$/.test(url.pathname)) return "comment";
587
633
  if (url.pathname === "/api/requirements/upsert") return "upsert";
588
634
  if (req.method === "DELETE") return "delete";
589
635
  if (req.method === "PATCH") return "patch";
@@ -811,7 +857,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
811
857
  }
812
858
 
813
859
  // ── 修改(省略的字段保留原值) ──
814
- 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"];
860
+ 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"];
815
861
  function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
816
862
  if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
817
863
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
@@ -865,6 +911,15 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
865
911
  }
866
912
  let people;
867
913
  try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
914
+ // RFC-040:只凭负责人身份改这张卡时,负责人 / 负责 Agent 只能交给本部门的人(或自己)/ 本部门成员的 Agent,
915
+ // 不能把卡推出本部门(推出去等于替别的部门派活)。清空(null)不限制。
916
+ const headOnly = (has("owner") || has("agent_owner")) ? headOnlyEditScope(caller, row) : null;
917
+ if (headOnly) {
918
+ const owner = people.ownerJson ? JSON.parse(people.ownerJson) as PersonRef : null;
919
+ const agent = people.agentOwnerJson ? JSON.parse(people.agentOwnerJson) as PersonRef : null;
920
+ if (has("owner") && owner && owner.id !== caller!.userId && !headOnly.users.has(owner.id)) return jsonError("department_scope_denied", 403, { field: "owner" });
921
+ if (has("agent_owner") && agent && !headOnly.nodes.has(agent.id)) return jsonError("department_scope_denied", 403, { field: "agent_owner" });
922
+ }
868
923
  let projectId = row.project_id;
869
924
  if (has("project_id")) {
870
925
  try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return errorFrom(e); }
@@ -998,6 +1053,23 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
998
1053
  if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
999
1054
  const parent = q.get("parent_id");
1000
1055
  if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
1056
+ // RFC-040:department_id= —— 负责人是这个部门(含下级)的成员、或负责 Agent 归这些成员所有的卡。可见范围照旧由上面的
1057
+ // addTaskVisibilityScope 管,这里只再收窄。部门按调用者看得见的网络找(部门 id 只在网络内唯一)。
1058
+ const dept = q.get("department_id");
1059
+ if (dept !== null) {
1060
+ if (!dept || !/^[A-Za-z0-9_-]{1,64}$/.test(dept)) return jsonError("department_not_found", 400);
1061
+ const networks = ctx ? departmentNetworks(ctx, dept) : [];
1062
+ if (!networks.length) return jsonError("department_not_found", 400);
1063
+ const parts: string[] = [];
1064
+ for (const networkId of networks) {
1065
+ const cards = departmentCardsFor(networkId, departmentSubtree(networkId, dept));
1066
+ const ors: string[] = [];
1067
+ if (cards.users.size) ors.push(`owner_json IN (${[...cards.users].map((id) => `?${params.push(JSON.stringify({ kind: "user", id }))}`).join(", ")})`);
1068
+ if (cards.nodes.size) ors.push(`agent_owner_json IN (${[...cards.nodes].map((id) => `?${params.push(JSON.stringify({ kind: "node", id }))}`).join(", ")})`);
1069
+ if (ors.length) parts.push(`(network_id = ?${params.push(networkId)} AND (${ors.join(" OR ")}))`);
1070
+ }
1071
+ sql += parts.length ? ` AND (${parts.join(" OR ")})` : " AND 1=0";
1072
+ }
1001
1073
  if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
1002
1074
  // Explicit archived-only wins over the legacy include-all switch.
1003
1075
  // changes=1 看得见「被归档」这一改动:默认连归档的一起回(行上 archived: true),客户端据此把它移出看板。
@@ -1006,6 +1078,13 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
1006
1078
  return sql;
1007
1079
  }
1008
1080
 
1081
+ /** department_id 筛选:调用者作用域里有这个部门的网络。 */
1082
+ function departmentNetworks(ctx: RequirementsRequestContext, deptId: string): string[] {
1083
+ const params: unknown[] = [deptId];
1084
+ const sql = addHumanNetworkScope("SELECT DISTINCT network_id FROM network_departments WHERE department_id = ?1", params, ctx.scope);
1085
+ return db.all<{ network_id: string }>(sql, ...params).map((r) => r.network_id);
1086
+ }
1087
+
1009
1088
  const LIST_ORDER = " ORDER BY created_at DESC, requirement_id DESC";
1010
1089
  type LightRow = { requirement_id: string; seq?: number | null; network_id: string; created_at: string; title: string; description: string | null; assignee: string | null; tags_json: string | null; project_id: string | null; owner_json: string | null; agent_owner_json: string | null; participants_json: string | null };
1011
1090
  const parseRef = (json: string | null) => { try { return json ? JSON.parse(json) : null; } catch { return null; } };
@@ -1115,7 +1194,7 @@ function deletedSince(ctx: RequirementsRequestContext, sinceIso: string): string
1115
1194
  const EVENTS_PAGE_DEFAULT = 200;
1116
1195
  const EVENTS_PAGE_MAX = 500;
1117
1196
  const EVENTS_SCAN_MAX = 5000;
1118
- type VisibilityRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null };
1197
+ type VisibilityRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null; agent_owner_json?: string | null };
1119
1198
 
1120
1199
  function listEvents(ctx: RequirementsRequestContext): Response {
1121
1200
  const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
@@ -1142,7 +1221,8 @@ function listEvents(ctx: RequirementsRequestContext): Response {
1142
1221
  let ok = seen.get(id);
1143
1222
  if (ok === undefined) {
1144
1223
  const cols = "network_id, owner_json, participants_json, created_by, created_by_json, project_id";
1145
- const row = db.get<VisibilityRow>(`SELECT ${cols} FROM requirements WHERE requirement_id = ?1`, id)
1224
+ // agent_owner_json:负责人按「负责 Agent 归本部门成员」也认得这张卡(RFC-040);墓碑表没有这一列。
1225
+ const row = db.get<VisibilityRow>(`SELECT ${cols}, agent_owner_json FROM requirements WHERE requirement_id = ?1`, id)
1146
1226
  ?? db.get<VisibilityRow>(`SELECT ${cols} FROM requirement_tombstones WHERE requirement_id = ?1`, id);
1147
1227
  seen.set(id, ok = !!row && row.network_id === networkId && canSeeTask(caller, row));
1148
1228
  }
@@ -1294,6 +1374,15 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1294
1374
 
1295
1375
  if (url.pathname === "/api/requirements/events" && req.method === "GET") return listEvents(ctx);
1296
1376
 
1377
+ // #473 —— MCP requirements_people:紧凑通讯录(用户名、角色、部门、名下 Agent),q / limit / offset。
1378
+ if (url.pathname === '/api/requirements/people/directory' && req.method === 'GET') {
1379
+ const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
1380
+ if (!networkId) return jsonError('network_id_required', 400);
1381
+ const pq = parsePeopleQuery(url.searchParams);
1382
+ if ('error' in pq) return jsonError(pq.error, 400);
1383
+ return Response.json(peopleDirectory(networkId, hiddenNodeFilter(ctx, networkId), pq));
1384
+ }
1385
+
1297
1386
  if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
1298
1387
  const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
1299
1388
  if (!networkId) return jsonError('network_id_required', 400);
@@ -1388,6 +1477,28 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1388
1477
  return Response.json({ ...data, created: false });
1389
1478
  }
1390
1479
 
1480
+ // #474 评论 / 进展(MCP 任务生命周期测试报告问题 5):以前 Agent 只能读全文再整段改写 description,两次调用、
1481
+ // 并发会覆盖别人刚写的。评论是一条只追加的动态(requirement_events kind=comment,正文存在 new.text),
1482
+ // 不碰 requirements 表的任何一列;谁能看见这张卡谁就能读到,能写这个网络(非只读角色)又看得见它的人能发。
1483
+ const commentMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/comments$/);
1484
+ if (commentMatch) {
1485
+ if (req.method !== "POST") return jsonError("not_found", 404);
1486
+ let body: Record<string, unknown>;
1487
+ try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
1488
+ const text = typeof body.text === "string" ? body.text.trim() : "";
1489
+ if (!text) return jsonError("invalid_comment", 400);
1490
+ if (text.length > COMMENT_MAX_CHARS) return jsonError("comment_too_long", 400);
1491
+ const row = scopedRow(ctx, decodeURIComponent(commentMatch[1]));
1492
+ if (row instanceof Response) return row;
1493
+ if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1494
+ const at = new Date().toISOString();
1495
+ const event = db.transaction(() => {
1496
+ recordRequirementEvents(db, row, JSON.stringify(actorOf(ctx)), [{ kind: "comment", field: null, old: null, new: { text } }], at);
1497
+ 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)!;
1498
+ });
1499
+ return Response.json({ ok: true, event: eventPublic(event) }, { status: 201 });
1500
+ }
1501
+
1391
1502
  const itemMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/checklist\/([^/]+)$/);
1392
1503
  if (itemMatch) {
1393
1504
  if (req.method !== "PATCH") return jsonError("not_found", 404);
@@ -1430,6 +1541,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1430
1541
  if (row instanceof Response) return row;
1431
1542
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1432
1543
  if (!canDeleteTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_delete_denied");
1544
+ const byLeader = deletesAsHeadOnly(taskCaller(ctx), row);
1433
1545
  // 子需求不跟着删:先解挂(变成顶层),再删父卡。
1434
1546
  const deletedAt = new Date().toISOString();
1435
1547
  db.transaction(() => {
@@ -1441,6 +1553,11 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1441
1553
  });
1442
1554
  // 硬删除以前不留痕(RFC-038 §9.1):记下是谁删了哪张(标题 + 短号),卡本身已经没了。
1443
1555
  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);
1556
+ // RFC-040 Q4:只凭部门负责人身份删的,另记一条并私信卡的负责人(人;没有就私信负责 Agent 的主人)。
1557
+ if (byLeader && ctx.auth) {
1558
+ const notified = notifyLeaderDeleted({ card: row, actorUserId: ctx.auth.userId });
1559
+ logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_deleted_by_leader", "requirement", row.requirement_id, JSON.stringify({ title: row.title, seq: row.seq ?? null, owner: row.owner_json ? JSON.parse(row.owner_json) : null, agent_owner: row.agent_owner_json ? JSON.parse(row.agent_owner_json) : null, notified }).slice(0, 1000), undefined, row.network_id);
1560
+ }
1444
1561
  return Response.json({ ok: true, deleted: row.requirement_id });
1445
1562
  }
1446
1563
  if (req.method !== "PATCH") return jsonError("not_found", 404);
@@ -32,6 +32,8 @@ const GOLDEN_RESPONSE_KEYS = {
32
32
  authMeNetwork: [
33
33
  "network_id", "network_name", "owner_id", "description", "settings",
34
34
  "created_at", "updated_at", "visibility", "max_members", "member_role", "agent_access", "task_access",
35
+ // RFC-040(#455):不是列,是每次请求现算的「我负责的部门 + 全部下级」(department-heads.ts)。
36
+ "managed_department_ids",
35
37
  ],
36
38
  networkDetail: [
37
39
  "network_id", "network_name", "owner_id", "description", "settings",