@sleep2agi/commhub-server 0.9.0-preview.89 → 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 +1 -1
- package/src/agent-access.ts +4 -0
- package/src/requirement-events.ts +2 -1
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-errors-mcp-http.test.ts +3 -2
- package/src/requirements-errors.ts +12 -3
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-status-participants-http.test.ts +216 -0
- package/src/requirements.ts +80 -4
- package/src/tools.ts +72 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.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",
|
package/src/agent-access.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -73,8 +73,9 @@ describe("each hint, over MCP", () => {
|
|
|
73
73
|
const byAlias = await mcp(nodeToken, "requirements_update", { id: cardId, agent_owner: { kind: "node", id: "示例-甲" } });
|
|
74
74
|
expect(byAlias.error).toBe("person_not_in_network");
|
|
75
75
|
expect(byAlias.field).toBe("agent_owner");
|
|
76
|
-
|
|
77
|
-
expect(byAlias.hint).toContain("
|
|
76
|
+
// #473:人员字段也收名字 —— 提示改为指向 requirements_people,并说明 alias / username 的写法。
|
|
77
|
+
expect(byAlias.hint).toContain("requirements_people");
|
|
78
|
+
expect(byAlias.hint).toContain("{kind:'node', alias}");
|
|
78
79
|
expect((await mcp(nodeToken, "requirements_update", { id: cardId, owner: { kind: "user", id: "u_nobody" } })).field).toBe("owner");
|
|
79
80
|
expect((await mcp(nodeToken, "requirements_update", { id: cardId, participants: [{ kind: "node", id: "node_reqerr_a" }, { kind: "user", id: "u_nobody" }] })).field).toBe("participants");
|
|
80
81
|
});
|
|
@@ -23,7 +23,8 @@ type Detail = { field?: string; message: string; hint?: string };
|
|
|
23
23
|
type Entry = Detail | ((ctx: ErrorContext) => Detail);
|
|
24
24
|
|
|
25
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
|
-
|
|
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.";
|
|
27
28
|
|
|
28
29
|
const ENTRIES: Record<string, Entry> = {
|
|
29
30
|
// ── 定位 / 范围 ──
|
|
@@ -40,12 +41,18 @@ const ENTRIES: Record<string, Entry> = {
|
|
|
40
41
|
upsert_not_allowed: { message: 'Task-scoped members cannot upsert by external_ref.', hint: 'Use requirements_create / requirements_update on tasks you can see.' },
|
|
41
42
|
external_ref_not_allowed: { field: 'external_ref', message: 'Task-scoped members cannot set external_ref.', hint: 'Leave external_ref out.' },
|
|
42
43
|
// ── 人员 ──
|
|
43
|
-
invalid_person: (ctx) => ({ field: ctx.field ?? 'owner', message: 'A person must be an object {kind:"user"|"node", id}.', hint: PERSON_IDS }),
|
|
44
|
-
person_not_in_network: (ctx) => ({ field: ctx.field ?? 'owner', message: `${ctx.field ?? 'owner'} names nobody in this network (unknown id, wrong kind, or a node you cannot see).`, hint: PERSON_IDS }),
|
|
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.' }),
|
|
45
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}." },
|
|
46
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}." },
|
|
47
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.' },
|
|
48
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).' },
|
|
49
56
|
invalid_assignee: { field: 'assignee', message: 'assignee must be a string (legacy free-text field).', hint: 'Prefer owner / agent_owner.' },
|
|
50
57
|
// ── 项目 ──
|
|
51
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.' },
|
|
@@ -102,6 +109,8 @@ const ENTRIES: Record<string, Entry> = {
|
|
|
102
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.' },
|
|
103
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).' },
|
|
104
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.' },
|
|
105
114
|
insert_failed: { message: 'The hub could not save the task.', hint: 'Retry once; if it fails again, report it.' },
|
|
106
115
|
};
|
|
107
116
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// #473(#469 问题 2)—— Agent 查人 + 人员字段按名字写。
|
|
2
|
+
//
|
|
3
|
+
// 钉住:
|
|
4
|
+
// 1. MCP requirements_people:紧凑行(user_id / username / display_name / role / department / agents),
|
|
5
|
+
// 没主人的 Agent 单列;q 按用户名 / 显示名 / 部门 / Agent 别名筛;50 人一页、offset 翻页;一页有字节上限。
|
|
6
|
+
// 2. 严格参数:不认识的参数 → -32602,列出能用的参数。
|
|
7
|
+
// 3. requirements_create / update / upsert 的 owner / agent_owner / participants 收 {kind:'user', username} 与
|
|
8
|
+
// {kind:'node', alias},在任务所在的网络里解析,存下来仍是 {kind, id}。
|
|
9
|
+
// 4. 别名对上多个 Agent → person_ambiguous;找不到 → person_not_in_network;都带 field 和指向 requirements_people 的 hint。
|
|
10
|
+
// 5. 绝不跨网络:别的网络的用户名 / 别名都是「找不到」。受限成员看不见的 Agent:通讯录里没有,按别名写也是「找不到」。
|
|
11
|
+
// 6. REST GET /api/requirements/people 的形状不变。
|
|
12
|
+
//
|
|
13
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/requirements-people-mcp-http.test.ts
|
|
14
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
16
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
18
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
|
+
import { addNetworkMember, register } from "./auth.js";
|
|
20
|
+
import { db } from "./db.js";
|
|
21
|
+
import { createDepartment, setMemberDepartment } from "./departments.js";
|
|
22
|
+
import { registerTools } from "./tools.js";
|
|
23
|
+
|
|
24
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
25
|
+
if (!activeDbPath) throw new Error("requirements-people-mcp requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
26
|
+
|
|
27
|
+
const stamp = Date.now();
|
|
28
|
+
const PW = "PeopleLookup123!";
|
|
29
|
+
/** 一页 50 人(每人带部门、1 个 Agent)的字节上限。实测约 12.6 KB(≈ 250 B / 人);上限 16 KB。 */
|
|
30
|
+
const PAGE_MAX_BYTES = 16_000;
|
|
31
|
+
const EXTRA_MEMBERS = 60;
|
|
32
|
+
|
|
33
|
+
let server: any;
|
|
34
|
+
let base = "";
|
|
35
|
+
let netA = "", netB = "";
|
|
36
|
+
let boss: { id: string; token: string; username: string };
|
|
37
|
+
let alice: { id: string; username: string };
|
|
38
|
+
let bob: { id: string; username: string };
|
|
39
|
+
let carol: { id: string; username: string };
|
|
40
|
+
let ruth: { id: string; username: string };
|
|
41
|
+
let deptId = "";
|
|
42
|
+
const node = (suffix: string) => `n_pl_${suffix}_${stamp}`;
|
|
43
|
+
const ALIAS = { alice: `alice-bot-${stamp}`, dup: `dup-bot-${stamp}`, free: `free-bot-${stamp}`, far: `far-bot-${stamp}` };
|
|
44
|
+
|
|
45
|
+
type Mcp = { call: (name: string, args: Record<string, unknown>) => Promise<any>; close: () => Promise<void> };
|
|
46
|
+
async function connect(userId: string, username: string): Promise<Mcp> {
|
|
47
|
+
const s = new McpServer({ name: "people", version: "1" });
|
|
48
|
+
registerTools(s, undefined, null, userId, username, false, "tok_user");
|
|
49
|
+
const client = new Client({ name: "people-client", version: "1" });
|
|
50
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
51
|
+
await s.connect(st);
|
|
52
|
+
await client.connect(ct);
|
|
53
|
+
return {
|
|
54
|
+
call: async (name, args) => {
|
|
55
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
56
|
+
const text = String(r.content?.[0]?.text ?? "");
|
|
57
|
+
if (r.isError) return { mcpError: true, text };
|
|
58
|
+
return { ...JSON.parse(text), _bytes: Buffer.byteLength(text, "utf8") };
|
|
59
|
+
},
|
|
60
|
+
close: async () => { await client.close(); await s.close(); },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
let asBoss: Mcp, asRuth: Mcp;
|
|
64
|
+
|
|
65
|
+
function mk(name: string) {
|
|
66
|
+
const r = register(`${name}_${stamp}`, PW, undefined, name === "alice" ? "爱丽丝" : undefined);
|
|
67
|
+
expect(r.ok).toBe(true);
|
|
68
|
+
return { id: r.user!.user_id, token: r.token!, username: r.user!.username, net: r.network_id! };
|
|
69
|
+
}
|
|
70
|
+
const insertNode = (id: string, alias: string, net: string, owner: string | null) =>
|
|
71
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)", [id, alias, net, owner]);
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
const b = mk("boss"); boss = { id: b.id, token: b.token, username: b.username }; netA = b.net;
|
|
75
|
+
const a = mk("alice"); alice = { id: a.id, username: a.username };
|
|
76
|
+
const o = mk("bob"); bob = { id: o.id, username: o.username };
|
|
77
|
+
const c = mk("carol"); carol = { id: c.id, username: c.username }; netB = c.net;
|
|
78
|
+
const r = mk("ruth"); ruth = { id: r.id, username: r.username };
|
|
79
|
+
expect(addNetworkMember(netA, alice.id, "member", boss.id, { agentAccess: "all" }).ok).toBe(true);
|
|
80
|
+
expect(addNetworkMember(netA, bob.id, "member", boss.id, { agentAccess: "all" }).ok).toBe(true);
|
|
81
|
+
// ruth:受限成员(只看授权的 Agent),一个都没授权
|
|
82
|
+
expect(addNetworkMember(netA, ruth.id, "member", boss.id, { agentAccess: "granted" }).ok).toBe(true);
|
|
83
|
+
const dept = createDepartment(netA, boss.id, { name: `研发部${stamp % 1000}` });
|
|
84
|
+
expect(dept.ok).toBe(true);
|
|
85
|
+
deptId = (dept as any).department.id;
|
|
86
|
+
expect(setMemberDepartment(netA, alice.id, deptId).ok).toBe(true);
|
|
87
|
+
insertNode(node("alice"), ALIAS.alice, netA, alice.id);
|
|
88
|
+
insertNode(node("dup1"), ALIAS.dup, netA, null);
|
|
89
|
+
insertNode(node("dup2"), ALIAS.dup, netA, null);
|
|
90
|
+
insertNode(node("free"), ALIAS.free, netA, null);
|
|
91
|
+
insertNode(node("far"), ALIAS.far, netB, carol.id);
|
|
92
|
+
for (let i = 0; i < EXTRA_MEMBERS; i++) {
|
|
93
|
+
const m = register(`pl_m${String(i).padStart(2, "0")}_${stamp}`, PW, undefined, `成员${i}`);
|
|
94
|
+
expect(addNetworkMember(netA, m.user!.user_id, "member", boss.id, { agentAccess: "all" }).ok).toBe(true);
|
|
95
|
+
setMemberDepartment(netA, m.user!.user_id, deptId);
|
|
96
|
+
insertNode(node(`m${i}`), `m${i}-bot-${stamp}`, netA, m.user!.user_id);
|
|
97
|
+
}
|
|
98
|
+
const mod: any = await import("./server.js");
|
|
99
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
100
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
101
|
+
asBoss = await connect(boss.id, boss.username);
|
|
102
|
+
asRuth = await connect(ruth.id, ruth.username);
|
|
103
|
+
}, 120_000);
|
|
104
|
+
afterAll(async () => { await asBoss?.close(); await asRuth?.close(); server?.stop?.(true); });
|
|
105
|
+
|
|
106
|
+
describe("#473 requirements_people", () => {
|
|
107
|
+
test("compact rows: username, display_name, role, department, owned agents; unowned agents listed once", async () => {
|
|
108
|
+
const r = await asBoss.call("requirements_people", { network_id: netA, q: "alice" });
|
|
109
|
+
expect(r.ok).toBe(true);
|
|
110
|
+
expect(r.people).toEqual([{
|
|
111
|
+
user_id: alice.id, username: alice.username, display_name: "爱丽丝", role: "member",
|
|
112
|
+
department: { id: deptId, name: `研发部${stamp % 1000}` }, agents: [{ node_id: node("alice"), alias: ALIAS.alice }],
|
|
113
|
+
}]);
|
|
114
|
+
const all = await asBoss.call("requirements_people", { network_id: netA, q: String(stamp) });
|
|
115
|
+
expect(all.agents_without_owner.map((a: any) => a.alias).sort()).toEqual([ALIAS.dup, ALIAS.dup, ALIAS.free].sort());
|
|
116
|
+
const bossRow = (await asBoss.call("requirements_people", { network_id: netA, q: boss.username })).people[0];
|
|
117
|
+
expect(bossRow.role).toBe("owner");
|
|
118
|
+
expect(bossRow.display_name).toBe("");
|
|
119
|
+
expect(bossRow.department).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("q matches username, display name, department and Agent alias", async () => {
|
|
123
|
+
expect((await asBoss.call("requirements_people", { network_id: netA, q: "爱丽" })).people.map((p: any) => p.user_id)).toEqual([alice.id]);
|
|
124
|
+
expect((await asBoss.call("requirements_people", { network_id: netA, q: ALIAS.alice.toUpperCase() })).people.map((p: any) => p.user_id)).toEqual([alice.id]);
|
|
125
|
+
const dept = await asBoss.call("requirements_people", { network_id: netA, q: `研发部${stamp % 1000}`, limit: 200 });
|
|
126
|
+
expect(dept.total).toBe(EXTRA_MEMBERS + 1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("default page = 50 people under the byte bound; offset pages the rest", async () => {
|
|
130
|
+
const p1 = await asBoss.call("requirements_people", { network_id: netA });
|
|
131
|
+
expect(p1.people.length).toBe(50);
|
|
132
|
+
expect(p1.has_more).toBe(true);
|
|
133
|
+
console.log(`people page of 50: ${p1._bytes} B`);
|
|
134
|
+
expect(p1._bytes).toBeLessThan(PAGE_MAX_BYTES);
|
|
135
|
+
const p2 = await asBoss.call("requirements_people", { network_id: netA, offset: p1.next_offset });
|
|
136
|
+
expect(p2.people.length).toBe(p1.total - 50);
|
|
137
|
+
expect(p2.agents_without_owner).toBeUndefined();
|
|
138
|
+
expect(new Set([...p1.people, ...p2.people].map((p: any) => p.user_id)).size).toBe(p1.total);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("strict params: unknown key → -32602 listing the valid ones", async () => {
|
|
142
|
+
const r = await asBoss.call("requirements_people", { network_id: netA, name: "alice" });
|
|
143
|
+
expect(r.mcpError).toBe(true);
|
|
144
|
+
expect(r.text).toContain("-32602");
|
|
145
|
+
expect(r.text).toContain("unknown parameter(s): name; valid parameters: network_id, q, limit, offset");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("another network is not readable; a restricted member sees no ungranted Agents", async () => {
|
|
149
|
+
const other = await asBoss.call("requirements_people", { network_id: netB });
|
|
150
|
+
expect(other.ok).toBe(false);
|
|
151
|
+
const r = await asRuth.call("requirements_people", { network_id: netA, q: String(stamp), limit: 200 });
|
|
152
|
+
expect(r.people.every((p: any) => p.agents.length === 0)).toBe(true);
|
|
153
|
+
expect(r.agents_without_owner).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("#473 person fields by name", () => {
|
|
158
|
+
test("create / update / upsert accept username and alias; stored as ids in this network", async () => {
|
|
159
|
+
const made = await asBoss.call("requirements_create", {
|
|
160
|
+
network_id: netA, name: `by name ${stamp}`,
|
|
161
|
+
owner: { kind: "user", username: alice.username },
|
|
162
|
+
agent_owner: { kind: "node", alias: ALIAS.alice },
|
|
163
|
+
participants: [{ kind: "user", username: bob.username }, { kind: "node", alias: ALIAS.free }],
|
|
164
|
+
});
|
|
165
|
+
expect(made.ok).toBe(true);
|
|
166
|
+
const req = made.requirement;
|
|
167
|
+
expect(req.owner).toEqual({ kind: "user", id: alice.id });
|
|
168
|
+
expect(req.agent_owner).toEqual({ kind: "node", id: node("alice") });
|
|
169
|
+
expect(req.participants).toEqual([{ kind: "user", id: bob.id }, { kind: "node", id: node("free") }]);
|
|
170
|
+
|
|
171
|
+
const upd = await asBoss.call("requirements_update", { id: req.id, network_id: netA, owner: { kind: "user", username: bob.username } });
|
|
172
|
+
expect(upd.requirement.owner).toEqual({ kind: "user", id: bob.id });
|
|
173
|
+
|
|
174
|
+
const ups = await asBoss.call("requirements_upsert_by_external_ref", {
|
|
175
|
+
network_id: netA, external_ref: `test:people#${stamp}`, name: "upserted", agent_owner: { kind: "node", alias: ALIAS.alice },
|
|
176
|
+
});
|
|
177
|
+
expect(ups.requirement.agent_owner).toEqual({ kind: "node", id: node("alice") });
|
|
178
|
+
// id 照旧可用,且优先于名字
|
|
179
|
+
const byId = await asBoss.call("requirements_update", { id: req.id, network_id: netA, owner: { kind: "user", id: alice.id, username: bob.username } });
|
|
180
|
+
expect(byId.requirement.owner).toEqual({ kind: "user", id: alice.id });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("ambiguous alias → person_ambiguous with field and a requirements_people hint", async () => {
|
|
184
|
+
const r = await asBoss.call("requirements_create", { network_id: netA, name: "amb", agent_owner: { kind: "node", alias: ALIAS.dup } });
|
|
185
|
+
expect(r.ok).toBe(false);
|
|
186
|
+
expect(r.error).toBe("person_ambiguous");
|
|
187
|
+
expect(r.field).toBe("agent_owner");
|
|
188
|
+
expect(r.hint).toContain("requirements_people");
|
|
189
|
+
expect(r.hint).toContain("node_id");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("unknown name → person_not_in_network naming the field and requirements_people", async () => {
|
|
193
|
+
const r = await asBoss.call("requirements_create", { network_id: netA, name: "nope", participants: [{ kind: "user", username: `ghost_${stamp}` }] });
|
|
194
|
+
expect(r.error).toBe("person_not_in_network");
|
|
195
|
+
expect(r.field).toBe("participants");
|
|
196
|
+
expect(r.hint).toContain("requirements_people");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("never across networks: another network's username / alias is not found", async () => {
|
|
200
|
+
const u = await asBoss.call("requirements_create", { network_id: netA, name: "x", owner: { kind: "user", username: carol.username } });
|
|
201
|
+
expect(u.error).toBe("person_not_in_network");
|
|
202
|
+
const n = await asBoss.call("requirements_create", { network_id: netA, name: "x", agent_owner: { kind: "node", alias: ALIAS.far } });
|
|
203
|
+
expect(n.error).toBe("person_not_in_network");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("wrong-kind or missing identifier → invalid_person", async () => {
|
|
207
|
+
for (const bad of [{ kind: "user", alias: ALIAS.alice }, { kind: "node", username: alice.username }, { kind: "user" }]) {
|
|
208
|
+
const r = await asBoss.call("requirements_create", { network_id: netA, name: "bad", participants: [bad] });
|
|
209
|
+
expect(r.error).toBe("invalid_person");
|
|
210
|
+
expect(r.hint).toContain("requirements_people");
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a restricted member cannot resolve an Agent alias they are not granted", async () => {
|
|
215
|
+
const r = await asRuth.call("requirements_create", { network_id: netA, name: "hidden", agent_owner: { kind: "node", alias: ALIAS.alice } });
|
|
216
|
+
expect(r.error).toBe("person_not_in_network");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("REST GET /api/requirements/people keeps its shape", async () => {
|
|
220
|
+
const res = await fetch(`${base}/api/requirements/people?network_id=${netA}`, { headers: { Authorization: `Bearer ${boss.token}` } });
|
|
221
|
+
const body = await res.json() as any;
|
|
222
|
+
expect(res.status).toBe(200);
|
|
223
|
+
for (const p of body.people) expect(Object.keys(p).sort()).toEqual(["display_name", "id", "kind", "name", "networkId"]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// #473(#469 问题 2)—— Agent 查人。
|
|
2
|
+
//
|
|
3
|
+
// 现场:Agent 想把任务派给「张三」或者某个节点,手里只有用户名 / 别名,而任务的人员字段只收 id;
|
|
4
|
+
// GET /api/requirements/people 只给显示名,没有用户名、角色、部门,也看不出哪个 Agent 是谁的。
|
|
5
|
+
// 这里给 MCP requirements_people 一份紧凑的通讯录:每个成员一行(user_id、username、display_name、网络角色、
|
|
6
|
+
// 部门),带上他名下的 Agent(node_id、alias);没有主人(或主人不在本网络)的 Agent 单列。
|
|
7
|
+
// 只在一个网络里查,受限成员看不见的 Agent 一律不出现(与 /people 同一个 hidden 判据)。
|
|
8
|
+
//
|
|
9
|
+
// 人员字段按名字写(owner {kind:'user', username} / agent_owner {kind:'node', alias})的解析在
|
|
10
|
+
// requirements.ts personRef():同样只在任务所在的那个网络里找,看不见的节点等同不存在。
|
|
11
|
+
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
import "./departments.js"; // network_departments 表 + network_members.department_id 列(导入即建,只增)
|
|
14
|
+
|
|
15
|
+
type HiddenNode = ((nodeId: string) => boolean) | null;
|
|
16
|
+
|
|
17
|
+
export const PEOPLE_DEFAULT_LIMIT = 50;
|
|
18
|
+
export const PEOPLE_MAX_LIMIT = 200;
|
|
19
|
+
|
|
20
|
+
export type DirectoryAgent = { node_id: string; alias: string };
|
|
21
|
+
export type DirectoryPerson = {
|
|
22
|
+
user_id: string;
|
|
23
|
+
username: string;
|
|
24
|
+
/** 没设(或等于用户名)为 ""(与 /people 同一规则)。 */
|
|
25
|
+
display_name: string;
|
|
26
|
+
role: string;
|
|
27
|
+
department: { id: string; name: string } | null;
|
|
28
|
+
agents: DirectoryAgent[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** q / limit / offset 解析;不合法 → 错误码(400)。 */
|
|
32
|
+
export function parsePeopleQuery(params: URLSearchParams): { q: string; limit: number; offset: number } | { error: string } {
|
|
33
|
+
const q = (params.get("q") ?? "").trim();
|
|
34
|
+
if (q.length > 100) return { error: "invalid_q" };
|
|
35
|
+
let limit = PEOPLE_DEFAULT_LIMIT;
|
|
36
|
+
const rawLimit = params.get("limit");
|
|
37
|
+
if (rawLimit !== null) {
|
|
38
|
+
if (!/^\d{1,4}$/.test(rawLimit)) return { error: "invalid_limit" };
|
|
39
|
+
limit = Number(rawLimit);
|
|
40
|
+
if (limit < 1 || limit > PEOPLE_MAX_LIMIT) return { error: "invalid_limit" };
|
|
41
|
+
}
|
|
42
|
+
let offset = 0;
|
|
43
|
+
const rawOffset = params.get("offset");
|
|
44
|
+
if (rawOffset !== null) {
|
|
45
|
+
if (!/^\d{1,6}$/.test(rawOffset)) return { error: "invalid_offset" };
|
|
46
|
+
offset = Number(rawOffset);
|
|
47
|
+
}
|
|
48
|
+
return { q, limit, offset };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 一个网络的通讯录。q = 不区分大小写的子串,匹配用户名 / 显示名 / 部门名 / 名下 Agent 的别名;
|
|
53
|
+
* 成员按 username 排序、offset 翻页;没主人的 Agent 只在第一页给(按 q 过滤)。
|
|
54
|
+
*/
|
|
55
|
+
export function peopleDirectory(networkId: string, hidden: HiddenNode, query: { q: string; limit: number; offset: number }) {
|
|
56
|
+
const members = db.all<{ user_id: string; username: string; display_name: string | null; role: string | null; department_id: string | null; department_name: string | null }>(
|
|
57
|
+
`SELECT u.user_id, u.username, u.display_name, m.role, d.department_id, d.name AS department_name
|
|
58
|
+
FROM network_members m
|
|
59
|
+
JOIN users u ON u.user_id = m.user_id
|
|
60
|
+
LEFT JOIN network_departments d ON d.network_id = m.network_id AND d.department_id = m.department_id
|
|
61
|
+
WHERE m.network_id = ?1
|
|
62
|
+
ORDER BY u.username, u.user_id`,
|
|
63
|
+
networkId,
|
|
64
|
+
);
|
|
65
|
+
const nodes = db.all<{ node_id: string; alias: string | null; node_name: string; owner_user_id: string | null }>(
|
|
66
|
+
"SELECT node_id, alias, node_name, owner_user_id FROM nodes WHERE network_id = ?1 ORDER BY COALESCE(alias, node_name), node_id",
|
|
67
|
+
networkId,
|
|
68
|
+
).filter(n => !hidden?.(n.node_id));
|
|
69
|
+
const memberIds = new Set(members.map(m => m.user_id));
|
|
70
|
+
const byOwner = new Map<string, DirectoryAgent[]>();
|
|
71
|
+
const unowned: DirectoryAgent[] = [];
|
|
72
|
+
for (const n of nodes) {
|
|
73
|
+
const agent = { node_id: n.node_id, alias: n.alias || n.node_name };
|
|
74
|
+
if (n.owner_user_id && memberIds.has(n.owner_user_id)) {
|
|
75
|
+
const list = byOwner.get(n.owner_user_id) ?? [];
|
|
76
|
+
list.push(agent);
|
|
77
|
+
byOwner.set(n.owner_user_id, list);
|
|
78
|
+
} else unowned.push(agent);
|
|
79
|
+
}
|
|
80
|
+
const needle = query.q.toLowerCase();
|
|
81
|
+
const hit = (s: string | null | undefined) => !needle || (s ?? "").toLowerCase().includes(needle);
|
|
82
|
+
const people: DirectoryPerson[] = [];
|
|
83
|
+
for (const m of members) {
|
|
84
|
+
const displayName = !m.display_name || m.display_name === m.username ? "" : m.display_name;
|
|
85
|
+
const agents = byOwner.get(m.user_id) ?? [];
|
|
86
|
+
if (!(hit(m.username) || (displayName && hit(displayName)) || (m.department_name && hit(m.department_name)) || agents.some(a => hit(a.alias)))) continue;
|
|
87
|
+
people.push({
|
|
88
|
+
user_id: m.user_id,
|
|
89
|
+
username: m.username,
|
|
90
|
+
display_name: displayName,
|
|
91
|
+
role: m.role ?? "member",
|
|
92
|
+
department: m.department_id && m.department_name ? { id: m.department_id, name: m.department_name } : null,
|
|
93
|
+
agents,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const page = people.slice(query.offset, query.offset + query.limit);
|
|
97
|
+
const hasMore = query.offset + query.limit < people.length;
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
network_id: networkId,
|
|
101
|
+
total: people.length,
|
|
102
|
+
people: page,
|
|
103
|
+
...(query.offset === 0 ? { agents_without_owner: unowned.filter(a => hit(a.alias)) } : {}),
|
|
104
|
+
has_more: hasMore,
|
|
105
|
+
next_offset: hasMore ? query.offset + query.limit : null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 按名字找人(personRef 用):只在 networkId 里找,hidden 的节点等同不存在。
|
|
111
|
+
* 返回 id;找不到 → person_not_in_network;同一个别名对上多个节点 → person_ambiguous。
|
|
112
|
+
*/
|
|
113
|
+
export function resolvePersonName(kind: "user" | "node", name: string, networkId: string, hidden: HiddenNode): string {
|
|
114
|
+
if (kind === "user") {
|
|
115
|
+
const row = db.get<{ user_id: string }>(
|
|
116
|
+
"SELECT u.user_id FROM network_members m JOIN users u ON u.user_id = m.user_id WHERE m.network_id = ?1 AND u.username = ?2",
|
|
117
|
+
networkId, name,
|
|
118
|
+
);
|
|
119
|
+
if (!row) throw new Error("person_not_in_network");
|
|
120
|
+
return row.user_id;
|
|
121
|
+
}
|
|
122
|
+
const rows = db.all<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", networkId, name)
|
|
123
|
+
.filter(r => !hidden?.(r.node_id));
|
|
124
|
+
if (rows.length === 0) throw new Error("person_not_in_network");
|
|
125
|
+
if (rows.length > 1) throw new Error("person_ambiguous");
|
|
126
|
+
return rows[0].node_id;
|
|
127
|
+
}
|
|
@@ -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
|
+
});
|
package/src/requirements.ts
CHANGED
|
@@ -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";
|
|
@@ -523,7 +524,15 @@ type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
|
523
524
|
function personRef(value: unknown, networkId: string, hidden: HiddenNode = null, field = 'owner'): PersonRef {
|
|
524
525
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RequirementFieldError('invalid_person', field);
|
|
525
526
|
const row = value as Record<string, unknown>;
|
|
526
|
-
if (
|
|
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
|
+
}
|
|
527
536
|
const exists = row.kind === 'user'
|
|
528
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)
|
|
529
538
|
: db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
|
|
@@ -532,6 +541,18 @@ function personRef(value: unknown, networkId: string, hidden: HiddenNode = null,
|
|
|
532
541
|
return { kind: row.kind, id: row.id };
|
|
533
542
|
}
|
|
534
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
|
+
|
|
535
556
|
// 负责人只能是人类,负责 Agent 只能是节点;参与人两种都行。库里旧的节点负责人照样读出来(启动迁移会挪走),
|
|
536
557
|
// 只有这次请求显式写了 owner / agent_owner 才校验种类。
|
|
537
558
|
// 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
|
|
@@ -544,7 +565,7 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
|
|
|
544
565
|
// 受限成员看不见的负责 Agent,他也不能换掉或清空(他读到的是 null,写回 null 不该抹掉别人的指派)。
|
|
545
566
|
const agentOwnerLocked = isHiddenRef(agentOwner, hidden);
|
|
546
567
|
if ('owner' in body) {
|
|
547
|
-
owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
|
|
568
|
+
owner = body.owner === null ? null : personRef(body.owner, networkId, hidden, 'owner');
|
|
548
569
|
if (owner && owner.kind !== 'user') {
|
|
549
570
|
if (strictOwner || 'agent_owner' in body) throw new Error('owner_must_be_human');
|
|
550
571
|
coerced = owner;
|
|
@@ -566,6 +587,26 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
|
|
|
566
587
|
const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
|
|
567
588
|
participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
|
|
568
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
|
+
}
|
|
569
610
|
return {
|
|
570
611
|
coerced,
|
|
571
612
|
ownerJson: owner === null ? null : JSON.stringify(owner),
|
|
@@ -578,12 +619,16 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
|
|
|
578
619
|
* 节点令牌(Agent,比如把 GitHub issue 同步进任务的 TM 节点)可用的操作 —— 只在它自己的网络里
|
|
579
620
|
* (令牌绑定的网络,范围由 resolveRestNetworkScope 强制)。删除卡片、管理项目仍然只给人。
|
|
580
621
|
*/
|
|
581
|
-
|
|
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"]);
|
|
582
626
|
function operationOf(req: Request, url: URL): string {
|
|
583
627
|
if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
|
|
584
628
|
if (url.pathname === "/api/requirements/tags/ops") return "tags_write";
|
|
585
629
|
if (req.method === "GET") return "read";
|
|
586
630
|
if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
|
|
631
|
+
if (/^\/api\/requirements\/[^/]+\/comments$/.test(url.pathname)) return "comment";
|
|
587
632
|
if (url.pathname === "/api/requirements/upsert") return "upsert";
|
|
588
633
|
if (req.method === "DELETE") return "delete";
|
|
589
634
|
if (req.method === "PATCH") return "patch";
|
|
@@ -811,7 +856,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
811
856
|
}
|
|
812
857
|
|
|
813
858
|
// ── 修改(省略的字段保留原值) ──
|
|
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"];
|
|
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"];
|
|
815
860
|
function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
|
|
816
861
|
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
|
|
817
862
|
const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
|
|
@@ -1294,6 +1339,15 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1294
1339
|
|
|
1295
1340
|
if (url.pathname === "/api/requirements/events" && req.method === "GET") return listEvents(ctx);
|
|
1296
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
|
+
|
|
1297
1351
|
if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
|
|
1298
1352
|
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
1299
1353
|
if (!networkId) return jsonError('network_id_required', 400);
|
|
@@ -1388,6 +1442,28 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1388
1442
|
return Response.json({ ...data, created: false });
|
|
1389
1443
|
}
|
|
1390
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
|
+
|
|
1391
1467
|
const itemMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/checklist\/([^/]+)$/);
|
|
1392
1468
|
if (itemMatch) {
|
|
1393
1469
|
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
package/src/tools.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
|
14
14
|
import { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
15
15
|
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
|
|
16
16
|
import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
17
|
+
import { errorBody } from "./requirements-errors.js";
|
|
17
18
|
import { handleRequirementsRequest } from "./requirements.js";
|
|
18
19
|
import {
|
|
19
20
|
buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
|
|
@@ -5839,7 +5840,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5839
5840
|
const data = res ? await res.json() : { ok: false, error: "not_found" };
|
|
5840
5841
|
return { content: [{ type: "text" as const, text: JSON.stringify(res && !res.ok && data && typeof data === "object" ? { ...data, status: res.status } : data) }] };
|
|
5841
5842
|
};
|
|
5842
|
-
|
|
5843
|
+
// #473 —— 人员字段除了 id,也可以按名字:{kind:'user', username} / {kind:'node', alias},在任务所在的网络里解析
|
|
5844
|
+
// (requirements.ts personRef;找不到 / 别名对上多个 → 400 + hint)。给了 id 就只看 id。
|
|
5845
|
+
const reqPerson = z.object({
|
|
5846
|
+
kind: z.enum(["user", "node"]),
|
|
5847
|
+
id: z.string().min(1).max(200).optional().describe("user_id / node_id (from requirements_people)"),
|
|
5848
|
+
username: z.string().min(1).max(100).optional().describe("kind 'user' only: the member's username, resolved in the task's network"),
|
|
5849
|
+
alias: z.string().min(1).max(200).optional().describe("kind 'node' only: the Agent's alias, resolved in the task's network"),
|
|
5850
|
+
});
|
|
5843
5851
|
const reqChecklistItem = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/).optional(), text: z.string().min(1).max(500), done: z.boolean().optional() });
|
|
5844
5852
|
const reqFields = {
|
|
5845
5853
|
tags: z.array(z.string()).max(10).optional().describe("Task labels; each 1–20 Unicode characters. Omit to preserve, [] to clear."),
|
|
@@ -5851,14 +5859,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5851
5859
|
description: z.string().max(20_000).optional().describe("markdown"),
|
|
5852
5860
|
checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
|
|
5853
5861
|
project_id: z.string().max(200).nullable().optional(),
|
|
5854
|
-
owner: reqPerson.nullable().optional().describe("负责人 (a person):
|
|
5855
|
-
agent_owner: reqPerson.nullable().optional().describe("负责 Agent:
|
|
5856
|
-
participants: z.array(reqPerson).max(100).optional(),
|
|
5862
|
+
owner: reqPerson.nullable().optional().describe("负责人 (a person): {kind:'user', id} or {kind:'user', username}; a node here is rejected with 400 owner_must_be_human — use agent_owner for nodes"),
|
|
5863
|
+
agent_owner: reqPerson.nullable().optional().describe("负责 Agent: {kind:'node', id} or {kind:'node', alias}"),
|
|
5864
|
+
participants: z.array(reqPerson).max(100).optional().describe("REPLACES the whole participant list ({kind, id}, {kind:'user', username} or {kind:'node', alias}) (omitted people are removed; [] clears it). To add or remove a few people without touching the others, use participants_add / participants_remove on requirements_update."),
|
|
5857
5865
|
external_url: z.string().max(500).nullable().optional(),
|
|
5858
5866
|
parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
|
|
5859
5867
|
};
|
|
5860
5868
|
const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
|
|
5861
5869
|
const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
|
|
5870
|
+
// #475 —— status 是 column 的别名(列表的筛选参数就叫 status,Agent 写任务时也常这么写)。只在 MCP 这层换名,REST 不变;
|
|
5871
|
+
// 两个都给且不同 → 400 status_conflicts_with_column,不替调用方挑一个。
|
|
5872
|
+
const reqStatus = z.enum(["pool", "doing", "done"]).optional().describe("alias of column (same values); if both are sent they must match");
|
|
5873
|
+
const withColumn = (args: Record<string, unknown>, call: (args: Record<string, unknown>) => Promise<{ content: { type: "text"; text: string }[] }>) => {
|
|
5874
|
+
if (args.status === undefined) return call(args);
|
|
5875
|
+
if (args.column !== undefined && args.column !== args.status) return Promise.resolve({ content: [{ type: "text" as const, text: JSON.stringify({ ...errorBody("status_conflicts_with_column"), status: 400 }) }] });
|
|
5876
|
+
return call({ ...args, column: args.status });
|
|
5877
|
+
};
|
|
5878
|
+
const reqPeopleDelta = (verb: string) => z.array(reqPerson).max(100).optional().describe(`${verb} these people ${verb === "add" ? "to" : "from"} the participant list, keeping everyone else (atomic; ${verb === "add" ? "already present = no change" : "not present = no change"}). Cannot be combined with participants.`);
|
|
5862
5879
|
const REQ_ID_DESC = "requirement id (req_…) or its short number \"#N\" (e.g. \"#42\", per network; a user token that sees several networks must also pass network_id, else 409 ambiguous_seq)";
|
|
5863
5880
|
|
|
5864
5881
|
// #471 —— MCP 的列表默认省流:summary 视图 + 50 张一页(REST 的默认 full + 500 不变,App 靠它)。
|
|
@@ -5907,6 +5924,31 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5907
5924
|
},
|
|
5908
5925
|
);
|
|
5909
5926
|
|
|
5927
|
+
// #473 —— Agent 查人:紧凑通讯录(requirements-people.ts)。严格参数,同 requirements_list(#471)。
|
|
5928
|
+
const reqPeopleShape = {
|
|
5929
|
+
network_id: z.string().max(200).optional(),
|
|
5930
|
+
q: z.string().min(1).max(100).optional().describe("case-insensitive substring of username, display name, department or Agent alias"),
|
|
5931
|
+
limit: z.number().int().min(1).max(200).optional().describe("people per page, default 50"),
|
|
5932
|
+
offset: z.number().int().min(0).optional().describe("next_offset from the previous page"),
|
|
5933
|
+
};
|
|
5934
|
+
const reqPeopleKeys = Object.keys(reqPeopleShape);
|
|
5935
|
+
server.registerTool(
|
|
5936
|
+
"requirements_people",
|
|
5937
|
+
{
|
|
5938
|
+
description: "Look up the people in your network and the Agents they own, to fill task person fields. Each row: user_id, username, display_name (\"\" if not set), role, department {id, name} or null, agents [{node_id, alias}]; Agents with no owner in this network are listed in agents_without_owner (first page only). q filters by username / display name / department / Agent alias (case-insensitive substring). 50 people per page (limit ≤ 200); has_more + next_offset → pass offset. Agents you are not granted are left out. Person fields on requirements_create / requirements_update / requirements_upsert_by_external_ref accept {kind:'user', id} or {kind:'user', username} (owner, participants) and {kind:'node', id} or {kind:'node', alias} (agent_owner, participants), resolved in the task's own network only.",
|
|
5939
|
+
inputSchema: z.strictObject(reqPeopleShape, {
|
|
5940
|
+
error: (iss: any) => iss.code === "unrecognized_keys"
|
|
5941
|
+
? `unknown parameter(s): ${(iss.keys ?? []).join(", ")}; valid parameters: ${reqPeopleKeys.join(", ")}`
|
|
5942
|
+
: undefined,
|
|
5943
|
+
}) as any,
|
|
5944
|
+
},
|
|
5945
|
+
async (args: any) => {
|
|
5946
|
+
const q = new URLSearchParams();
|
|
5947
|
+
for (const k of ["q", "limit", "offset"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
|
|
5948
|
+
return requirementsCall("GET", `/api/requirements/people/directory${q.size ? `?${q}` : ""}`, args.network_id);
|
|
5949
|
+
},
|
|
5950
|
+
);
|
|
5951
|
+
|
|
5910
5952
|
server.tool(
|
|
5911
5953
|
"requirements_get",
|
|
5912
5954
|
"Get one requirement task by id or by its short number \"#N\" (with description, checklist, owners, project, external_ref, seq).",
|
|
@@ -5916,16 +5958,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5916
5958
|
|
|
5917
5959
|
server.tool(
|
|
5918
5960
|
"requirements_create",
|
|
5919
|
-
"Create a requirement task. name is required. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
|
|
5920
|
-
{ network_id: z.string().max(200).optional(), ...reqFields, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
|
|
5921
|
-
async (args) => requirementsCall("POST", "/api/requirements", args.network_id, pick(
|
|
5961
|
+
"Create a requirement task. name is required. column (or its alias status) = pool / doing / done. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
|
|
5962
|
+
{ network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
|
|
5963
|
+
async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements", args.network_id, pick(a, REQ_WRITE_KEYS))),
|
|
5922
5964
|
);
|
|
5923
5965
|
|
|
5924
5966
|
server.tool(
|
|
5925
5967
|
"requirements_update",
|
|
5926
|
-
"Patch a requirement task (id or \"#N\"); omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
|
|
5927
|
-
{ id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
|
|
5928
|
-
async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(
|
|
5968
|
+
"Patch a requirement task (id or \"#N\"); omitted fields keep their value. column (or its alias status) moves it between pool / doing / done. participants REPLACES the whole list; to add or remove people without dropping the others use participants_add / participants_remove. archived=true hides it from the default list (agents cannot delete).",
|
|
5969
|
+
{ id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, participants_add: reqPeopleDelta("add"), participants_remove: reqPeopleDelta("remove"), external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
|
|
5970
|
+
async (args) => withColumn(args, a => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(a, [...REQ_WRITE_KEYS, "participants_add", "participants_remove"]))),
|
|
5929
5971
|
);
|
|
5930
5972
|
|
|
5931
5973
|
server.tool(
|
|
@@ -5937,14 +5979,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5937
5979
|
|
|
5938
5980
|
server.tool(
|
|
5939
5981
|
"requirements_upsert_by_external_ref",
|
|
5940
|
-
"Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or, if it already exists in the network, patch it (omitted fields — including status — are kept). Returns { requirement, created }.",
|
|
5941
|
-
{ network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, archived: z.boolean().optional() },
|
|
5942
|
-
async (args) => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(
|
|
5982
|
+
"Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or, if it already exists in the network, patch it (omitted fields — including column / status — are kept). Returns { requirement, created }.",
|
|
5983
|
+
{ network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, status: reqStatus, archived: z.boolean().optional() },
|
|
5984
|
+
async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(a, REQ_WRITE_KEYS))),
|
|
5943
5985
|
);
|
|
5944
5986
|
|
|
5945
5987
|
server.tool(
|
|
5946
5988
|
"projects_list",
|
|
5947
|
-
"List requirement projects in your network (id, name, color, sort, archived).
|
|
5989
|
+
"List requirement projects in your network (id, name, color, sort, archived). Node (Agent) tokens can only read projects; creating / changing them (projects_create / projects_update) needs a person's token.",
|
|
5948
5990
|
{ network_id: z.string().max(200).optional() },
|
|
5949
5991
|
async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
|
|
5950
5992
|
);
|
|
@@ -5954,7 +5996,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5954
5996
|
// write access and is refused to 「仅相关任务」(task-scoped) members; events use the task list's visibility.
|
|
5955
5997
|
server.tool(
|
|
5956
5998
|
"projects_create",
|
|
5957
|
-
"Create a requirement project in your network (same as 管理项目 → 新建 in the app). name 1–40 characters, unique in the network; color #RRGGBB (default: next palette colour); sort integer. Returns { project }.
|
|
5999
|
+
"Create a requirement project in your network (same as 管理项目 → 新建 in the app). name 1–40 characters, unique in the network; color #RRGGBB (default: next palette colour); sort integer. Returns { project }. Needs a person's token: node (Agent) tokens can only read projects (403 user_token_required). Also refused (403) for task-scoped members and read-only roles.",
|
|
5958
6000
|
{
|
|
5959
6001
|
network_id: z.string().max(200).optional(),
|
|
5960
6002
|
name: z.string().min(1).max(40),
|
|
@@ -5966,7 +6008,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5966
6008
|
|
|
5967
6009
|
server.tool(
|
|
5968
6010
|
"projects_update",
|
|
5969
|
-
"Rename, recolour, reorder, archive or unarchive a requirement project (same as 管理项目 in the app). Only the fields you pass change. archived=true hides it from pickers (its tasks keep project_id); archived=false brings it back. Returns { project }.
|
|
6011
|
+
"Rename, recolour, reorder, archive or unarchive a requirement project (same as 管理项目 in the app). Only the fields you pass change. archived=true hides it from pickers (its tasks keep project_id); archived=false brings it back. Returns { project }. Needs a person's token: node (Agent) tokens can only read projects (403 user_token_required). Also refused (403) for task-scoped members and read-only roles.",
|
|
5970
6012
|
{
|
|
5971
6013
|
network_id: z.string().max(200).optional(),
|
|
5972
6014
|
id: z.string().min(1).max(200).describe("project id (proj_…), from projects_list"),
|
|
@@ -5980,7 +6022,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5980
6022
|
|
|
5981
6023
|
server.tool(
|
|
5982
6024
|
"requirements_events",
|
|
5983
|
-
"Read the task activity timeline (the app's 动态): field-level changes — who, when, old → new — newest first. Pass requirement_id (req_… or \"#N\") for one task, or omit it for the whole network. since (ISO) = only changes after it; limit 1–500 (default 200); next_cursor → pass as cursor for the next page; server_time → use as the next since. Same visibility as requirements_list: tasks you can't see (and hidden Agents) are left out.",
|
|
6025
|
+
"Read the task activity timeline (the app's 动态): field-level changes — who, when, old → new — and comments (kind=comment, text in new.text; add one with requirements_comment), newest first. Pass requirement_id (req_… or \"#N\") for one task, or omit it for the whole network. since (ISO) = only changes after it; limit 1–500 (default 200); next_cursor → pass as cursor for the next page; server_time → use as the next since. Same visibility as requirements_list: tasks you can't see (and hidden Agents) are left out.",
|
|
5984
6026
|
{
|
|
5985
6027
|
network_id: z.string().max(200).optional(),
|
|
5986
6028
|
requirement_id: z.string().min(1).max(200).optional(),
|
|
@@ -6005,6 +6047,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6005
6047
|
return requirementsCall("GET", `/api/requirements/events${q.size ? `?${q}` : ""}`, args.network_id);
|
|
6006
6048
|
},
|
|
6007
6049
|
);
|
|
6050
|
+
|
|
6051
|
+
// #474(MCP 任务生命周期测试报告问题 5):评论 / 进展。以前只能 requirements_get 读全文、再整段改写 description ——
|
|
6052
|
+
// 两次调用,并发时覆盖别人刚写的描述。这条只追加,进「动态」(requirements_events kind=comment),不改任务本身。
|
|
6053
|
+
server.tool(
|
|
6054
|
+
"requirements_comment",
|
|
6055
|
+
"Add a comment / progress note to a task (append-only; never rewrites the description). It shows in the task's activity timeline (the app's 动态, requirements_events kind=comment, text in new.text) under your name. text: markdown, 1–4000 characters. Anyone who can see the task and can write in the network (not read-only) may comment. Returns { event }.",
|
|
6056
|
+
{
|
|
6057
|
+
id: z.string().min(1).max(200).describe(REQ_ID_DESC),
|
|
6058
|
+
text: z.string().min(1).max(4000).describe("the comment (markdown), 1–4000 characters"),
|
|
6059
|
+
network_id: z.string().max(200).optional(),
|
|
6060
|
+
},
|
|
6061
|
+
async ({ id, text, network_id }) => requirementsCall("POST", `/api/requirements/${encodeURIComponent(id)}/comments`, network_id, { text }),
|
|
6062
|
+
);
|
|
6008
6063
|
}
|
|
6009
6064
|
|
|
6010
6065
|
// ────────────────────────────────────────────────────────────────────
|