@sleep2agi/commhub-server 0.9.0-preview.89 → 0.9.0-preview.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-access.ts +4 -0
- package/src/auth.ts +40 -6
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments.ts +7 -1
- package/src/mcp-context-cost-http.test.ts +117 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/observer-avatar-http.test.ts +7 -5
- package/src/requirement-events.ts +2 -1
- package/src/requirement-notify.ts +31 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-errors-mcp-http.test.ts +3 -2
- package/src/requirements-errors.ts +14 -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 +125 -8
- package/src/rest-explicit-columns-http.test.ts +2 -0
- package/src/server.ts +131 -37
- package/src/task-access.ts +68 -10
- package/src/tools.ts +109 -40
|
@@ -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,20 @@ 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
|
+
department_scope_denied: { field: 'owner', message: 'As a department head you can hand this task only to people in your department (or yourself) and to their Agents.', hint: 'Pick someone from your department, or ask an owner / admin to move it to another department.' },
|
|
52
|
+
department_not_found: { field: 'department_id', message: 'No department with that id in your network(s).', hint: 'Department ids come from GET /api/networks/{id}/departments.' },
|
|
53
|
+
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 },
|
|
54
|
+
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.' },
|
|
55
|
+
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.' },
|
|
56
|
+
too_many_participants: { field: 'participants_add', message: 'A task can have at most 100 participants.', hint: 'Remove some with participants_remove first.' },
|
|
57
|
+
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
58
|
invalid_assignee: { field: 'assignee', message: 'assignee must be a string (legacy free-text field).', hint: 'Prefer owner / agent_owner.' },
|
|
50
59
|
// ── 项目 ──
|
|
51
60
|
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 +111,8 @@ const ENTRIES: Record<string, Entry> = {
|
|
|
102
111
|
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
112
|
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
113
|
invalid_since: { field: 'since', message: 'since must be an ISO 8601 time.', hint: 'Use server_time from the previous requirements_events response.' },
|
|
114
|
+
invalid_comment: { field: 'text', message: 'A comment needs non-empty text.', hint: 'Pass text (markdown), e.g. a progress note or a conclusion.' },
|
|
115
|
+
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
116
|
insert_failed: { message: 'The hub could not save the task.', hint: 'Retry once; if it fails again, report it.' },
|
|
106
117
|
};
|
|
107
118
|
|
|
@@ -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
|
+
}
|