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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ // #471(#469 问题 3)—— MCP requirements_list 默认省流 + 严格参数。
2
+ //
3
+ // 钉住:
4
+ // 1. MCP 默认 = summary 视图 + 50 张一页:120 张有正文 / 检查项 / 标签 / 参与人的卡,第一页 50 张、has_more、
5
+ // next_cursor 接着翻,两页不重叠;一页的线上字节有上限(见 SUMMARY_PAGE_MAX_BYTES)。
6
+ // 2. view='full' 照旧给全文(description / checklist);limit 可显式放大。
7
+ // 3. 不认识的参数(以前的 tag 写错成 tags / 任何拼错)→ -32602,错误里列出能用的参数;不会回整张表。
8
+ // 4. tag= 精确筛(区分大小写),和 limit / cursor 一起翻页。
9
+ // 5. REST GET /api/requirements 不变:不带参数仍是 full + 最多 500 张(App 靠它)。
10
+ //
11
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/requirements-list-mcp-slim-http.test.ts
12
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
13
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { register } from "./auth.js";
18
+ import { registerTools } from "./tools.js";
19
+
20
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
21
+ if (!activeDbPath) throw new Error("requirements-list-mcp-slim requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
22
+
23
+ const TASKS = 120;
24
+ /**
25
+ * 一页 summary(50 张;每张中文标题、3 个标签、负责人 + 参与人、8 条检查项、1.5 KB 正文)的字节上限。
26
+ * 这批数据实测 39.7 KB(≈ 795 B / 张,中文 3 字节 / 字);生产 122 张 summary 81 KB ≈ 670 B / 张 → 50 张约 33 KB。
27
+ * 上限 45 KB。同一批数据走旧默认(full、500 张)是整张表 120 张全文。
28
+ */
29
+ const SUMMARY_PAGE_MAX_BYTES = 45_000;
30
+
31
+ let server: any;
32
+ let base = "";
33
+ let token = "", userId = "", netId = "";
34
+ let call: (args: Record<string, unknown>) => Promise<{ isError: boolean; text: string }>;
35
+ let closeMcp: () => Promise<void>;
36
+
37
+ const DESCRIPTION = "背景:".padEnd(1500, "这是一段很长的任务描述,用来模拟真实看板上的正文。");
38
+ const checklist = Array.from({ length: 8 }, (_, i) => ({ text: `检查项 ${i + 1}:确认这一步已经完成并且有记录` }));
39
+
40
+ beforeAll(async () => {
41
+ const stamp = Date.now();
42
+ const u = register(`slim_${stamp}`, "SlimList123!", undefined, "seed");
43
+ expect(u.ok).toBe(true);
44
+ token = u.token!; userId = u.user!.user_id; netId = u.network_id!;
45
+ const mod: any = await import("./server.js");
46
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
47
+ base = `http://127.0.0.1:${server.port}`;
48
+
49
+ const s = new McpServer({ name: "slim", version: "1" });
50
+ registerTools(s, undefined, null, userId, u.user!.username, false, "tok_user");
51
+ const client = new Client({ name: "slim-client", version: "1" });
52
+ const [ct, st] = InMemoryTransport.createLinkedPair();
53
+ await s.connect(st);
54
+ await client.connect(ct);
55
+ const raw = async (name: string, args: Record<string, unknown>) => {
56
+ const r: any = await client.callTool({ name, arguments: args });
57
+ return { isError: r.isError === true, text: String(r.content?.[0]?.text ?? "") };
58
+ };
59
+ call = (args) => raw("requirements_list", { network_id: netId, ...args });
60
+ closeMcp = async () => { await client.close(); await s.close(); };
61
+
62
+ for (let i = 0; i < TASKS; i++) {
63
+ const tags = ["bug", "后端", `批次${i % 4}`];
64
+ if (i % 3 === 0) tags[0] = "Bug"; // 大小写不同的另一个标签
65
+ const r = await raw("requirements_create", {
66
+ network_id: netId, name: `任务 ${i}:修复列表接口返回过大的问题`, description: DESCRIPTION, checklist, tags,
67
+ owner: { kind: "user", id: userId }, participants: [{ kind: "user", id: userId }],
68
+ priority: "normal", column: i % 2 ? "doing" : "pool",
69
+ });
70
+ expect(r.isError).toBe(false);
71
+ }
72
+ }, 120_000);
73
+
74
+ afterAll(async () => { await closeMcp?.(); server?.stop?.(true); });
75
+
76
+ describe("#471 MCP requirements_list defaults", () => {
77
+ test("default = summary, 50 per page, under the byte bound; cursor pages without overlap", async () => {
78
+ const first = await call({});
79
+ expect(first.isError).toBe(false);
80
+ const bytes = Buffer.byteLength(first.text, "utf8");
81
+ const body = JSON.parse(first.text);
82
+ expect(body.view).toBe("summary");
83
+ expect(body.requirements.length).toBe(50);
84
+ expect(body.has_more).toBe(true);
85
+ expect(typeof body.next_cursor).toBe("string");
86
+ for (const r of body.requirements) {
87
+ expect(r.description).toBeUndefined();
88
+ expect(r.checklist).toBeUndefined();
89
+ expect(r.has_description).toBe(true);
90
+ expect(r.checklist_count).toEqual({ total: 8, done: 0 });
91
+ }
92
+ console.log(`summary page of 50: ${bytes} B`);
93
+ expect(bytes).toBeLessThan(SUMMARY_PAGE_MAX_BYTES);
94
+
95
+ const second = JSON.parse((await call({ cursor: body.next_cursor })).text);
96
+ expect(second.requirements.length).toBe(50);
97
+ const third = JSON.parse((await call({ cursor: second.next_cursor })).text);
98
+ expect(third.requirements.length).toBe(TASKS - 100);
99
+ expect(third.has_more).toBe(false);
100
+ const ids = [...body.requirements, ...second.requirements, ...third.requirements].map((r: any) => r.id);
101
+ expect(new Set(ids).size).toBe(TASKS);
102
+ });
103
+
104
+ test("view='full' still returns description and checklist; limit is honoured", async () => {
105
+ const r = JSON.parse((await call({ view: "full", limit: 3 })).text);
106
+ expect(r.view).toBeUndefined();
107
+ expect(r.requirements.length).toBe(3);
108
+ expect(r.requirements[0].description).toBe(DESCRIPTION);
109
+ expect(r.requirements[0].checklist.length).toBe(8);
110
+ });
111
+
112
+ test("unknown parameters → -32602 listing the valid ones; never the whole table", async () => {
113
+ for (const bad of [{ tags: "bug" }, { lable: "x" }, { status: "doing", foo: 1 }]) {
114
+ const r = await call(bad);
115
+ expect(r.isError).toBe(true);
116
+ expect(r.text).toContain("-32602");
117
+ expect(r.text).toContain(`unknown parameter(s): ${Object.keys(bad).filter(k => k !== "status").join(", ")}`);
118
+ expect(r.text).toContain("valid parameters: network_id, seq, status, project_id, owner, agent_owner, tag,");
119
+ expect(r.text).not.toContain('"requirements":[');
120
+ }
121
+ });
122
+
123
+ test("tag = exact label match (case-sensitive), pages with limit/cursor", async () => {
124
+ const lower = JSON.parse((await call({ tag: "bug", limit: 100 })).text);
125
+ expect(lower.requirements.length).toBe(TASKS - TASKS / 3);
126
+ expect(lower.requirements.every((r: any) => r.tags.includes("bug"))).toBe(true);
127
+ const upper = JSON.parse((await call({ tag: "Bug", limit: 100 })).text);
128
+ expect(upper.requirements.length).toBe(TASKS / 3);
129
+ expect(upper.requirements.every((r: any) => r.tags.includes("Bug") && !r.tags.includes("bug"))).toBe(true);
130
+ const p1 = JSON.parse((await call({ tag: "批次1", limit: 20 })).text);
131
+ expect(p1.requirements.length).toBe(20);
132
+ expect(p1.has_more).toBe(true);
133
+ const p2 = JSON.parse((await call({ tag: "批次1", limit: 20, cursor: p1.next_cursor })).text);
134
+ expect(p2.requirements.length).toBe(TASKS / 4 - 20);
135
+ expect(p2.has_more).toBe(false);
136
+ expect((await call({ tag: "nope" })).text).toContain('"requirements":[]');
137
+ // tag + q 组合:两个条件都要满足
138
+ const both = JSON.parse((await call({ tag: "批次2", q: "任务 10", limit: 100 })).text);
139
+ expect(both.requirements.map((r: any) => r.name)).toContain("任务 10:修复列表接口返回过大的问题");
140
+ expect(both.requirements.every((r: any) => r.tags.includes("批次2") && /任务 \d*10/.test(r.name))).toBe(true);
141
+ });
142
+
143
+ test("REST GET /api/requirements is unchanged: no params = full view, up to 500", async () => {
144
+ const res = await fetch(`${base}/api/requirements?network_id=${netId}`, { headers: { Authorization: `Bearer ${token}` } });
145
+ expect(res.status).toBe(200);
146
+ const body = await res.json() as any;
147
+ expect(body.view).toBeUndefined();
148
+ expect(body.requirements.length).toBe(TASKS);
149
+ expect(body.requirements[0].description).toBe(DESCRIPTION);
150
+ // REST 的 tag= 是新增的可选参数:不合法 → 400,与其它筛选参数同一形状
151
+ const bad = await fetch(`${base}/api/requirements?network_id=${netId}&tag=${"x".repeat(21)}`, { headers: { Authorization: `Bearer ${token}` } });
152
+ expect(bad.status).toBe(400);
153
+ expect(((await bad.json()) as any).error).toBe("invalid_tag");
154
+ });
155
+ });
@@ -0,0 +1,145 @@
1
+ // #470(MCP 任务生命周期测试报告问题 1)—— Agent 经 MCP 把负责人设成节点:以前走旧 App 的兼容改写,
2
+ // 人类负责人被静默清空、负责 Agent 被换掉,响应还显示 owner=节点。现在:
3
+ // - MCP 上 owner 传节点(建 / 改 / upsert,节点令牌和用户令牌都一样)→ 400 owner_must_be_human + hint,什么都不写;
4
+ // - MCP 写入的响应就是落库的那一行(和紧接着的 requirements_get 一字不差);
5
+ // - REST 上旧 App ≤ 0.2.142 的兼容路径不变(requirements-http.test.ts「old-app compat」那条钉着)。
6
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "fs";
8
+ import { join } from "path";
9
+ import { tmpdir } from "os";
10
+ import { register, createNetworkTokenForNode } from "./auth.js";
11
+ import { db } from "./db.js";
12
+
13
+ const DIR = mkdtempSync(join(tmpdir(), "anet-owner-strict-mcp-"));
14
+ let BASE = "";
15
+ let hub: any = null;
16
+ const PW = "OwnerStrictMcpPassw0rd!x";
17
+ let NET = "", ownerToken = "", ownerId = "", nodeToken = "";
18
+ const human = () => ({ kind: "user", id: ownerId });
19
+ const nodeA = { kind: "node", id: "node_strict_a" };
20
+ const nodeB = { kind: "node", id: "node_strict_b" };
21
+
22
+ async function rest(token: string, method: string, path: string, body?: unknown) {
23
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
24
+ const text = await res.text();
25
+ let json: any = null;
26
+ try { json = JSON.parse(text); } catch {}
27
+ return { status: res.status, body: json };
28
+ }
29
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
30
+ const res = await fetch(`${BASE}/mcp`, {
31
+ method: "POST",
32
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
33
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
34
+ });
35
+ const raw = await res.text();
36
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
37
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
38
+ return JSON.parse(out.result.content[0].text);
39
+ }
40
+ const stored = (id: string) => db.get<{ owner_json: string | null; agent_owner_json: string | null }>("SELECT owner_json, agent_owner_json FROM requirements WHERE requirement_id = ?1", id)!;
41
+
42
+ beforeAll(async () => {
43
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
44
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
45
+ process.env.HOST = "127.0.0.1";
46
+ const a = register(`strict_owner_${Date.now()}`, PW, undefined, "Owner");
47
+ ownerToken = a.token!; ownerId = a.user!.user_id; NET = a.network_id!;
48
+ nodeToken = createNetworkTokenForNode(ownerId, NET, "strict-a", "node_strict_a").token!;
49
+ createNetworkTokenForNode(ownerId, NET, "strict-b", "node_strict_b");
50
+ const mod: any = await import("./server.js");
51
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
52
+ BASE = `http://127.0.0.1:${hub.port}`;
53
+ }, 30_000);
54
+
55
+ afterAll(() => {
56
+ try { hub?.stop?.(true); } catch {}
57
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
58
+ });
59
+
60
+ describe("MCP: owner must be a person", () => {
61
+ test("update owner=node → 400 owner_must_be_human + hint; the human owner and the agent owner are untouched", async () => {
62
+ const card = (await mcp(nodeToken, "requirements_create", { name: "负责人保护", owner: human(), agent_owner: nodeB })).requirement;
63
+ expect(card.owner).toEqual(human());
64
+ const r = await mcp(nodeToken, "requirements_update", { id: card.id, owner: nodeA });
65
+ expect(r.ok).toBe(false);
66
+ expect(r.error).toBe("owner_must_be_human");
67
+ expect(r.status).toBe(400);
68
+ expect(r.hint).toContain("agent_owner");
69
+ expect(r.owner_coerced_to_agent_owner).toBeUndefined();
70
+ const row = stored(card.id);
71
+ expect(JSON.parse(row.owner_json!)).toEqual(human());
72
+ expect(JSON.parse(row.agent_owner_json!)).toEqual(nodeB);
73
+ const got = (await mcp(nodeToken, "requirements_get", { id: card.id })).requirement;
74
+ expect(got.owner).toEqual(human());
75
+ expect(got.agent_owner).toEqual(nodeB);
76
+ });
77
+
78
+ test("…the same for a user token over MCP, for create (nothing is written) and for upsert", async () => {
79
+ const r = await mcp(ownerToken, "requirements_update", { network_id: NET, id: (await mcp(ownerToken, "requirements_create", { network_id: NET, name: "用户令牌", owner: human() })).requirement.id, owner: nodeA });
80
+ expect(r.error).toBe("owner_must_be_human");
81
+ const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET)!.n;
82
+ const c = await mcp(nodeToken, "requirements_create", { name: "建时就传节点", owner: nodeA });
83
+ expect(c.error).toBe("owner_must_be_human");
84
+ expect(c.hint).toContain("agent_owner");
85
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET)!.n).toBe(before);
86
+ const u = await mcp(nodeToken, "requirements_upsert_by_external_ref", { external_ref: "github:example/repo#470", name: "upsert", owner: nodeA });
87
+ expect(u.error).toBe("owner_must_be_human");
88
+ expect(db.get("SELECT 1 FROM requirements WHERE external_ref = 'github:example/repo#470'")).toBeNull();
89
+ });
90
+
91
+ test("the right way still works: agent_owner = node, owner = person, owner = null", async () => {
92
+ const card = (await mcp(nodeToken, "requirements_create", { name: "正确的写法", owner: human() })).requirement;
93
+ const r = await mcp(nodeToken, "requirements_update", { id: card.id, agent_owner: nodeA });
94
+ expect(r.ok).toBe(true);
95
+ expect(r.requirement.owner).toEqual(human());
96
+ expect(r.requirement.agent_owner).toEqual(nodeA);
97
+ const cleared = await mcp(nodeToken, "requirements_update", { id: card.id, owner: null });
98
+ expect(cleared.requirement.owner).toBeNull();
99
+ // agent_owner given a person is refused the other way round, with its own hint.
100
+ const wrong = await mcp(nodeToken, "requirements_update", { id: card.id, agent_owner: human() });
101
+ expect(wrong.error).toBe("agent_owner_must_be_agent");
102
+ expect(wrong.hint).toContain("owner");
103
+ });
104
+
105
+ test("every MCP write's response equals a fresh requirements_get", async () => {
106
+ const card = (await mcp(nodeToken, "requirements_create", { name: "响应即落库", owner: human(), checklist: [{ text: "一" }] })).requirement;
107
+ expect((await mcp(nodeToken, "requirements_get", { id: card.id })).requirement).toEqual(card);
108
+ const patches: Record<string, unknown>[] = [
109
+ { column: "doing" }, { agent_owner: nodeA }, { participants: [nodeB, human()] }, { owner: null }, { owner: human(), priority: "high" },
110
+ { agent_owner: null }, { due: "2026-10-10" }, { archived: true }, { archived: false },
111
+ ];
112
+ for (const p of patches) {
113
+ const r = await mcp(nodeToken, "requirements_update", { id: card.id, ...p });
114
+ expect(r.ok).toBe(true);
115
+ expect(r.owner_coerced_to_agent_owner).toBeUndefined();
116
+ expect(r.requirement).toEqual((await mcp(nodeToken, "requirements_get", { id: card.id })).requirement);
117
+ }
118
+ });
119
+ });
120
+
121
+ describe("REST", () => {
122
+ test("new clients' responses equal a fresh GET (the old-app compat echo is the only exception, pinned in requirements-http.test.ts)", async () => {
123
+ const card = (await rest(ownerToken, "POST", "/api/requirements", { network_id: NET, name: "REST 响应", owner: human() })).body.requirement;
124
+ for (const p of [{ agent_owner: nodeA }, { owner: human(), agent_owner: nodeB }, { participants: [nodeA] }]) {
125
+ const r = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, p);
126
+ expect(r.status).toBe(200);
127
+ expect(r.body.requirement).toEqual((await rest(ownerToken, "GET", `/api/requirements/${card.id}`)).body.requirement);
128
+ }
129
+ // A new client (sends agent_owner) passing a node as owner: still strict, now with the hint.
130
+ const strict = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, { owner: nodeA, agent_owner: nodeB });
131
+ expect(strict.status).toBe(400);
132
+ expect(strict.body.error).toBe("owner_must_be_human");
133
+ expect(strict.body.hint).toContain("agent_owner");
134
+ });
135
+
136
+ test("old-app compat on REST is unchanged: owner=node without agent_owner is still coerced (200 + flag)", async () => {
137
+ const card = (await rest(ownerToken, "POST", "/api/requirements", { network_id: NET, name: "旧 App", owner: human() })).body.requirement;
138
+ const old = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, { owner: nodeA });
139
+ expect(old.status).toBe(200);
140
+ expect(old.body.owner_coerced_to_agent_owner).toBe(true);
141
+ const row = stored(card.id);
142
+ expect(row.owner_json).toBeNull();
143
+ expect(JSON.parse(row.agent_owner_json!)).toEqual(nodeA);
144
+ });
145
+ });
@@ -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
+ }