@sleep2agi/commhub-server 0.9.0-preview.83 → 0.9.0-preview.85
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/auth.ts +6 -3
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +233 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +57 -0
- package/src/rest-explicit-columns-http.test.ts +2 -0
- package/src/server.ts +62 -0
- package/src/tools.ts +13 -1
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.85",
|
|
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/auth.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* V3 Auth module — user registration, login, token management
|
|
3
3
|
*/
|
|
4
|
-
import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
|
|
4
|
+
import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js"; import { deleteDepartmentsForNetwork } from "./departments.js"; // #419: also ensures network_members.department_id exists (same line on purpose: docs pin auth.ts line numbers)
|
|
5
5
|
import { WEAK_PASSWORDS } from "./password-dict.js";
|
|
6
6
|
import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
|
|
7
7
|
import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, NEW_MEMBER_TASK_ACCESS, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
|
|
@@ -445,7 +445,7 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
|
|
|
445
445
|
// single-network user to "ambiguous" or auto-resolve writes INTO the
|
|
446
446
|
// deleted network. Remove memberships with the network.
|
|
447
447
|
db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
|
|
448
|
-
deleteNetworkAgentGroups(networkId); // #2144
|
|
448
|
+
deleteNetworkAgentGroups(networkId); deleteDepartmentsForNetwork(networkId); // #2144, #419 (one line: docs pin auth.ts line numbers)
|
|
449
449
|
});
|
|
450
450
|
return { ok: true };
|
|
451
451
|
}
|
|
@@ -588,7 +588,8 @@ export function getNetworkMembers(networkId: string) {
|
|
|
588
588
|
(SELECT COUNT(*) FROM network_member_agent_grants g WHERE g.network_id = nm.network_id AND g.user_id = nm.user_id) AS agent_grant_count,
|
|
589
589
|
CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.task_access = 'all' THEN 'all' ELSE 'scoped' END AS task_access,
|
|
590
590
|
(SELECT COUNT(*) FROM network_member_project_grants pg WHERE pg.network_id = nm.network_id AND pg.user_id = nm.user_id) AS task_project_count,
|
|
591
|
-
(SELECT COUNT(*) FROM network_member_group_grants gg WHERE gg.network_id = nm.network_id AND gg.user_id = nm.user_id) AS agent_group_count
|
|
591
|
+
(SELECT COUNT(*) FROM network_member_group_grants gg WHERE gg.network_id = nm.network_id AND gg.user_id = nm.user_id) AS agent_group_count,
|
|
592
|
+
nm.department_id
|
|
592
593
|
FROM network_members nm JOIN users u ON nm.user_id = u.user_id
|
|
593
594
|
WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
|
|
594
595
|
networkId);
|
|
@@ -836,6 +837,8 @@ const NETWORK_CLEANUP_TABLES = [
|
|
|
836
837
|
"task_events", "task_terminal_events", "scheduled_task_runs", "external_schedule_edits",
|
|
837
838
|
// 标签颜色:网络本身的元数据(标签本体在 requirements 行里,那张表是阻断项)。
|
|
838
839
|
"network_tags",
|
|
840
|
+
// 组织架构(board #419):部门是网络本身的元数据。
|
|
841
|
+
"network_departments",
|
|
839
842
|
];
|
|
840
843
|
|
|
841
844
|
function tableExists(name: string): boolean {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// 组织架构(board #419):GET/POST/PATCH/DELETE /api/networks/:id/departments、PUT …/members/:uid/department。
|
|
2
|
+
// HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。钉住:谁能读 / 谁能写、部门规则(同级不重名、不成环、最多 10 层、
|
|
3
|
+
// 只删空部门、负责人必须是成员)、成员所在部门,以及只增不改(/humans、/members 只多一个 department_id)。
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
6
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
10
|
+
import { db } from "./db.js";
|
|
11
|
+
|
|
12
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-departments-"));
|
|
13
|
+
let BASE = "";
|
|
14
|
+
let hub: any = null;
|
|
15
|
+
const PW = "DepartmentsPassw0rd!x";
|
|
16
|
+
let NET = "", OTHER_NET = "";
|
|
17
|
+
const U: Record<string, { token: string; id: string }> = {};
|
|
18
|
+
|
|
19
|
+
async function send(token: string, method: string, path: string, body?: unknown) {
|
|
20
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
21
|
+
const text = await res.text();
|
|
22
|
+
let json: any = null;
|
|
23
|
+
try { json = JSON.parse(text); } catch {}
|
|
24
|
+
return { status: res.status, body: json };
|
|
25
|
+
}
|
|
26
|
+
const D = () => `/api/networks/${NET}/departments`;
|
|
27
|
+
const list = async (who = "owner") => (await send(U[who].token, "GET", D())).body;
|
|
28
|
+
const mk = async (name: string, extra: Record<string, unknown> = {}) => {
|
|
29
|
+
const r = await send(U.owner.token, "POST", D(), { name, ...extra });
|
|
30
|
+
expect(r.status).toBe(201);
|
|
31
|
+
return r.body.department as { id: string; name: string; parent_id: string | null };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
36
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
37
|
+
process.env.HOST = "127.0.0.1";
|
|
38
|
+
const a = register(`dep_owner_${Date.now()}`, PW, undefined, "Owner");
|
|
39
|
+
expect(a.ok).toBe(true);
|
|
40
|
+
U.owner = { token: a.token!, id: a.user!.user_id };
|
|
41
|
+
NET = a.network_id!;
|
|
42
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
|
|
43
|
+
const mod: any = await import("./server.js");
|
|
44
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
45
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
46
|
+
const add = async (username: string, role: string) => {
|
|
47
|
+
expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
48
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
49
|
+
expect(login.status).toBe(200);
|
|
50
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
51
|
+
};
|
|
52
|
+
const stamp = Date.now();
|
|
53
|
+
U.admin = await add(`dep_admin_${stamp}`, "admin");
|
|
54
|
+
U.member = await add(`dep_member_${stamp}`, "member");
|
|
55
|
+
U.viewer = await add(`dep_viewer_${stamp}`, "viewer");
|
|
56
|
+
U.node = { token: createNetworkTokenForNode(U.owner.id, NET, "dep-node", "node_dep").token!, id: "node_dep" };
|
|
57
|
+
const o = register(`dep_outsider_${stamp}`, PW);
|
|
58
|
+
U.outsider = { token: o.token!, id: o.user!.user_id };
|
|
59
|
+
OTHER_NET = o.network_id!;
|
|
60
|
+
}, 30_000);
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
try { hub?.stop?.(true); } catch {}
|
|
64
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("who can read / write", () => {
|
|
68
|
+
test("every member, a node token of this network and a hub admin can read; outsiders and other networks' nodes can't", async () => {
|
|
69
|
+
for (const who of ["owner", "admin", "member", "viewer", "node"]) expect((await send(U[who].token, "GET", D())).status).toBe(200);
|
|
70
|
+
expect((await send(U.outsider.token, "GET", D())).status).toBe(403);
|
|
71
|
+
expect((await send("", "GET", D())).status).toBe(401);
|
|
72
|
+
const otherNode = createNetworkTokenForNode(U.outsider.id, OTHER_NET, "dep-other", "node_dep_other").token!;
|
|
73
|
+
expect((await send(otherNode, "GET", D())).status).toBe(403);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("only network owner / admin (and hub admin) can write", async () => {
|
|
77
|
+
expect((await send(U.admin.token, "POST", D(), { name: "管理员建的" })).status).toBe(201);
|
|
78
|
+
for (const who of ["member", "viewer", "node", "outsider"]) {
|
|
79
|
+
expect((await send(U[who].token, "POST", D(), { name: `不该建-${who}` })).status).toBe(403);
|
|
80
|
+
}
|
|
81
|
+
const anyDept = (await list()).departments[0].id;
|
|
82
|
+
expect((await send(U.member.token, "PATCH", `${D()}/${anyDept}`, { name: "x" })).status).toBe(403);
|
|
83
|
+
expect((await send(U.member.token, "DELETE", `${D()}/${anyDept}`)).status).toBe(403);
|
|
84
|
+
expect((await send(U.member.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/department`, { department_id: anyDept })).status).toBe(403);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("departments", () => {
|
|
89
|
+
test("create: top level and child, auto id and custom id, sibling names unique, validation", async () => {
|
|
90
|
+
const top = await mk("军团基建");
|
|
91
|
+
expect(top.id).toMatch(/^dept_/);
|
|
92
|
+
expect(top.parent_id).toBeNull();
|
|
93
|
+
const child = await mk("前端组", { parent_id: top.id, id: "fe-team" });
|
|
94
|
+
expect(child.id).toBe("fe-team");
|
|
95
|
+
expect(child.parent_id).toBe(top.id);
|
|
96
|
+
expect((await send(U.owner.token, "POST", D(), { name: "前端组", parent_id: top.id })).status).toBe(409);
|
|
97
|
+
expect((await send(U.owner.token, "POST", D(), { name: "前端组" })).status).toBe(201); // same name under another parent is fine
|
|
98
|
+
expect((await send(U.owner.token, "POST", D(), { name: "另一个", id: "fe-team" })).body.error).toBe("department_id_taken");
|
|
99
|
+
expect((await send(U.owner.token, "POST", D(), { name: " " })).body.error).toBe("invalid_department_name");
|
|
100
|
+
expect((await send(U.owner.token, "POST", D(), { name: "x".repeat(41) })).body.error).toBe("invalid_department_name");
|
|
101
|
+
expect((await send(U.owner.token, "POST", D(), { name: "坏 id", id: "has space" })).body.error).toBe("invalid_department_id");
|
|
102
|
+
expect((await send(U.owner.token, "POST", D(), { name: "孤儿", parent_id: "nope" })).body.error).toBe("parent_not_found");
|
|
103
|
+
expect((await send(U.owner.token, "POST", D(), { name: "外人负责", leader_user_id: U.outsider.id })).body.error).toBe("leader_not_member");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("rename, move (no cycles, depth ≤ 10), set / clear leader, sort", async () => {
|
|
107
|
+
const a = await mk("移动甲");
|
|
108
|
+
const b = await mk("移动乙", { parent_id: a.id });
|
|
109
|
+
const c = await mk("移动丙", { parent_id: b.id });
|
|
110
|
+
expect((await send(U.owner.token, "PATCH", `${D()}/${a.id}`, { parent_id: c.id })).body.error).toBe("department_cycle");
|
|
111
|
+
expect((await send(U.owner.token, "PATCH", `${D()}/${a.id}`, { parent_id: a.id })).body.error).toBe("department_cycle");
|
|
112
|
+
const moved = await send(U.owner.token, "PATCH", `${D()}/${c.id}`, { parent_id: null, name: "移动丙(顶层)" });
|
|
113
|
+
expect(moved.status).toBe(200);
|
|
114
|
+
expect(moved.body.department.parent_id).toBeNull();
|
|
115
|
+
expect(moved.body.department.name).toBe("移动丙(顶层)");
|
|
116
|
+
const led = await send(U.owner.token, "PATCH", `${D()}/${b.id}`, { leader_user_id: U.member.id, sort: 5 });
|
|
117
|
+
expect(led.body.department.leader_user_id).toBe(U.member.id);
|
|
118
|
+
expect(led.body.department.sort).toBe(5);
|
|
119
|
+
expect((await send(U.owner.token, "PATCH", `${D()}/${b.id}`, { leader_user_id: null })).body.department.leader_user_id).toBeNull();
|
|
120
|
+
expect((await send(U.owner.token, "PATCH", `${D()}/${b.id}`, {})).body.error).toBe("empty_patch");
|
|
121
|
+
expect((await send(U.owner.token, "PATCH", `${D()}/missing`, { name: "x" })).status).toBe(404);
|
|
122
|
+
// depth: a chain of 10 is fine, the 11th level is refused (create and move)
|
|
123
|
+
let parent: string | null = null;
|
|
124
|
+
const chain: string[] = [];
|
|
125
|
+
for (let i = 0; i < 10; i++) { const d = await mk(`层${i}`, parent ? { parent_id: parent } : {}); chain.push(d.id); parent = d.id; }
|
|
126
|
+
expect((await send(U.owner.token, "POST", D(), { name: "第11层", parent_id: parent })).body.error).toBe("department_too_deep");
|
|
127
|
+
const two = await mk("两层");
|
|
128
|
+
await mk("两层的子", { parent_id: two.id });
|
|
129
|
+
expect((await send(U.owner.token, "PATCH", `${D()}/${two.id}`, { parent_id: chain[8] })).body.error).toBe("department_too_deep");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("members: put into a department, counts, unassign; /members (admin list) carries department_id, /humans unchanged", async () => {
|
|
133
|
+
const team = await mk("成员测试部");
|
|
134
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/department`, { department_id: team.id })).body.department_id).toBe(team.id);
|
|
135
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.viewer.id}/department`, { department_id: team.id })).status).toBe(200);
|
|
136
|
+
const l = await list("member");
|
|
137
|
+
expect(l.departments.find((d: any) => d.id === team.id).member_count).toBe(2);
|
|
138
|
+
expect(l.members.find((m: any) => m.user_id === U.member.id).department_id).toBe(team.id);
|
|
139
|
+
expect(l.members.find((m: any) => m.user_id === U.owner.id).department_id).toBeNull();
|
|
140
|
+
// /humans stays identity-only (its key set is pinned by member-presence / agent-acl tests): who sits where comes
|
|
141
|
+
// from GET …/departments (members[]), which every member can read.
|
|
142
|
+
const humans = await send(U.viewer.token, "GET", `/api/networks/${NET}/humans`);
|
|
143
|
+
expect(humans.body.humans.find((h: any) => h.user_id === U.member.id).department_id).toBeUndefined();
|
|
144
|
+
const members = await send(U.owner.token, "GET", `/api/networks/${NET}/members`);
|
|
145
|
+
const row = members.body.members.find((m: any) => m.user_id === U.member.id);
|
|
146
|
+
expect(row.department_id).toBe(team.id);
|
|
147
|
+
for (const k of ["user_id", "role", "username", "agent_access", "task_access"]) expect(row).toHaveProperty(k); // old fields unchanged
|
|
148
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.outsider.id}/department`, { department_id: team.id })).status).toBe(404);
|
|
149
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/department`, { department_id: "nope" })).body.error).toBe("department_not_found");
|
|
150
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.viewer.id}/department`, { department_id: null })).body.department_id).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("delete only when empty (no sub-departments, no members), with the counts", async () => {
|
|
154
|
+
const parent = await mk("删除测试部");
|
|
155
|
+
const kid = await mk("删除测试子部", { parent_id: parent.id });
|
|
156
|
+
await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.admin.id}/department`, { department_id: parent.id });
|
|
157
|
+
const blocked = await send(U.owner.token, "DELETE", `${D()}/${parent.id}`);
|
|
158
|
+
expect(blocked.status).toBe(409);
|
|
159
|
+
expect(blocked.body).toMatchObject({ error: "department_not_empty", children: 1, members: 1 });
|
|
160
|
+
expect((await send(U.owner.token, "DELETE", `${D()}/${kid.id}`)).status).toBe(200);
|
|
161
|
+
await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.admin.id}/department`, { department_id: null });
|
|
162
|
+
expect((await send(U.owner.token, "DELETE", `${D()}/${parent.id}`)).status).toBe(200);
|
|
163
|
+
expect((await list()).departments.some((d: any) => d.id === parent.id)).toBe(false);
|
|
164
|
+
expect((await send(U.owner.token, "DELETE", `${D()}/${parent.id}`)).status).toBe(404);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a leader who leaves the network reads as no leader", async () => {
|
|
168
|
+
const stamp = Date.now();
|
|
169
|
+
expect((await send(U.owner.token, "POST", "/api/admin/users", { username: `dep_leaver_${stamp}`, password: PW, network_id: NET, role: "member" })).status).toBe(200);
|
|
170
|
+
const leaver = (await send("", "POST", "/api/auth/login", { username: `dep_leaver_${stamp}`, password: PW })).body.user.user_id as string;
|
|
171
|
+
const d = await mk("负责人会走", { leader_user_id: leaver });
|
|
172
|
+
expect(d).toBeTruthy();
|
|
173
|
+
expect((await send(U.owner.token, "DELETE", `/api/networks/${NET}/members/${leaver}`)).status).toBe(200);
|
|
174
|
+
expect((await list()).departments.find((x: any) => x.id === d.id).leader_user_id).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("deleting a network removes its departments", async () => {
|
|
178
|
+
const r = register(`dep_gone_${Date.now()}`, PW);
|
|
179
|
+
const net = r.network_id!;
|
|
180
|
+
expect((await send(r.token!, "POST", `/api/networks/${net}/departments`, { name: "会被删" })).status).toBe(201);
|
|
181
|
+
expect(db.get("SELECT 1 AS x FROM network_departments WHERE network_id = ?1", net)).toBeTruthy();
|
|
182
|
+
expect((await send(r.token!, "DELETE", `/api/networks/${net}`)).status).toBe(200);
|
|
183
|
+
expect(db.get("SELECT 1 AS x FROM network_departments WHERE network_id = ?1", net)).toBeFalsy();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// 人类成员的组织架构(board #419):部门(多层)+ 每个成员归一个部门 + 部门负责人。
|
|
2
|
+
//
|
|
3
|
+
// 只增不改:新表 network_departments,network_members 多一列 department_id(默认 NULL = 未分配)。
|
|
4
|
+
// 旧 app 不读这两样,行为不变。网络本身就是根(parent_id = NULL 的部门挂在网络下面)。
|
|
5
|
+
//
|
|
6
|
+
// 权限:网络里任何成员(含 viewer、受限成员)都能读组织架构 —— 和 /humans 通讯录同一可见范围;
|
|
7
|
+
// 写(建 / 改名 / 移动 / 删除部门、设负责人、调人)只给网络 owner / admin 和 Hub 管理员。节点令牌只能读自己网络的。
|
|
8
|
+
//
|
|
9
|
+
// /humans 通讯录保持只有身份字段(它的键集合被测试钉着);谁在哪个部门从 GET …/departments 的 members[] 读。
|
|
10
|
+
// 规则:同一上级下部门不重名;移动不能挂到自己或自己的下级下面;最多 MAX_DEPTH 层;
|
|
11
|
+
// 只有空部门(没有子部门、没有成员)能删;负责人必须是本网络成员;人离开网络后,作为负责人的引用读出来是 null。
|
|
12
|
+
|
|
13
|
+
import { db, logAudit } from "./db.js";
|
|
14
|
+
|
|
15
|
+
try { db.exec("ALTER TABLE network_members ADD COLUMN department_id TEXT"); } catch {}
|
|
16
|
+
db.exec(`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS network_departments (
|
|
18
|
+
network_id TEXT NOT NULL,
|
|
19
|
+
department_id TEXT NOT NULL,
|
|
20
|
+
name TEXT NOT NULL,
|
|
21
|
+
parent_id TEXT,
|
|
22
|
+
leader_user_id TEXT,
|
|
23
|
+
sort INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
created_by TEXT,
|
|
25
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
26
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
27
|
+
PRIMARY KEY (network_id, department_id)
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_network_departments_parent ON network_departments(network_id, parent_id);
|
|
30
|
+
`);
|
|
31
|
+
|
|
32
|
+
export const MAX_DEPTH = 10;
|
|
33
|
+
export const MAX_DEPARTMENTS = 500;
|
|
34
|
+
const NAME_MAX = 40;
|
|
35
|
+
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
36
|
+
|
|
37
|
+
type DeptRow = { network_id: string; department_id: string; name: string; parent_id: string | null; leader_user_id: string | null; sort: number; created_at: string; updated_at: string };
|
|
38
|
+
export type Department = { id: string; name: string; parent_id: string | null; leader_user_id: string | null; sort: number; member_count: number; created_at: string; updated_at: string };
|
|
39
|
+
|
|
40
|
+
type Fail = { ok: false; status: number; error: string };
|
|
41
|
+
type Ok<T> = { ok: true } & T;
|
|
42
|
+
|
|
43
|
+
const fail = (status: number, error: string): Fail => ({ ok: false, status, error });
|
|
44
|
+
|
|
45
|
+
function rows(networkId: string): DeptRow[] {
|
|
46
|
+
return db.all<DeptRow>(
|
|
47
|
+
"SELECT network_id, department_id, name, parent_id, leader_user_id, sort, created_at, updated_at FROM network_departments WHERE network_id = ?1 ORDER BY sort, created_at, department_id",
|
|
48
|
+
networkId,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
function one(networkId: string, id: string): DeptRow | null {
|
|
52
|
+
return db.get<DeptRow>(
|
|
53
|
+
"SELECT network_id, department_id, name, parent_id, leader_user_id, sort, created_at, updated_at FROM network_departments WHERE network_id = ?1 AND department_id = ?2",
|
|
54
|
+
networkId, id,
|
|
55
|
+
) ?? null;
|
|
56
|
+
}
|
|
57
|
+
function isMember(networkId: string, userId: string): boolean {
|
|
58
|
+
return !!db.get<{ user_id: string }>("SELECT user_id FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 读:部门(带直属人数)+ 每个成员在哪个部门。负责人不在本网络了 → null。 */
|
|
62
|
+
export function listDepartments(networkId: string): { departments: Department[]; members: Array<{ user_id: string; department_id: string | null }> } {
|
|
63
|
+
const members = db.all<{ user_id: string; department_id: string | null }>(
|
|
64
|
+
"SELECT user_id, department_id FROM network_members WHERE network_id = ?1 ORDER BY joined_at, user_id",
|
|
65
|
+
networkId,
|
|
66
|
+
);
|
|
67
|
+
const memberIds = new Set(members.map((m) => m.user_id));
|
|
68
|
+
const all = rows(networkId);
|
|
69
|
+
const ids = new Set(all.map((d) => d.department_id));
|
|
70
|
+
// 成员行指着一个已经不存在的部门(理论上删除时已清空;防御)→ 当未分配。
|
|
71
|
+
const placed = members.map((m) => ({ user_id: m.user_id, department_id: m.department_id && ids.has(m.department_id) ? m.department_id : null }));
|
|
72
|
+
const counts = new Map<string, number>();
|
|
73
|
+
for (const m of placed) if (m.department_id) counts.set(m.department_id, (counts.get(m.department_id) ?? 0) + 1);
|
|
74
|
+
return {
|
|
75
|
+
departments: all.map((d) => ({
|
|
76
|
+
id: d.department_id,
|
|
77
|
+
name: d.name,
|
|
78
|
+
parent_id: d.parent_id,
|
|
79
|
+
leader_user_id: d.leader_user_id && memberIds.has(d.leader_user_id) ? d.leader_user_id : null,
|
|
80
|
+
sort: d.sort,
|
|
81
|
+
member_count: counts.get(d.department_id) ?? 0,
|
|
82
|
+
created_at: d.created_at,
|
|
83
|
+
updated_at: d.updated_at,
|
|
84
|
+
})),
|
|
85
|
+
members: placed,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const cleanName = (raw: unknown): string | null => {
|
|
90
|
+
if (typeof raw !== "string") return null;
|
|
91
|
+
const name = raw.trim();
|
|
92
|
+
return name && [...name].length <= NAME_MAX ? name : null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
function depthOf(networkId: string, id: string | null): number {
|
|
96
|
+
let depth = 0;
|
|
97
|
+
let cur = id;
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
while (cur) {
|
|
100
|
+
if (seen.has(cur)) return Infinity;
|
|
101
|
+
seen.add(cur);
|
|
102
|
+
depth += 1;
|
|
103
|
+
cur = one(networkId, cur)?.parent_id ?? null;
|
|
104
|
+
}
|
|
105
|
+
return depth;
|
|
106
|
+
}
|
|
107
|
+
/** 以 id 为根的子树最深有几层(含自己)。 */
|
|
108
|
+
function subtreeHeight(networkId: string, id: string): number {
|
|
109
|
+
const children = db.all<{ department_id: string }>("SELECT department_id FROM network_departments WHERE network_id = ?1 AND parent_id = ?2", networkId, id);
|
|
110
|
+
return 1 + children.reduce((m, c) => Math.max(m, subtreeHeight(networkId, c.department_id)), 0);
|
|
111
|
+
}
|
|
112
|
+
function isDescendant(networkId: string, ancestor: string, id: string | null): boolean {
|
|
113
|
+
let cur = id;
|
|
114
|
+
const seen = new Set<string>();
|
|
115
|
+
while (cur) {
|
|
116
|
+
if (cur === ancestor) return true;
|
|
117
|
+
if (seen.has(cur)) return true;
|
|
118
|
+
seen.add(cur);
|
|
119
|
+
cur = one(networkId, cur)?.parent_id ?? null;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
function siblingNameTaken(networkId: string, parentId: string | null, name: string, exceptId?: string): boolean {
|
|
124
|
+
const sql = parentId === null
|
|
125
|
+
? "SELECT department_id FROM network_departments WHERE network_id = ?1 AND parent_id IS NULL AND name = ?2"
|
|
126
|
+
: "SELECT department_id FROM network_departments WHERE network_id = ?1 AND parent_id = ?3 AND name = ?2";
|
|
127
|
+
const hits = parentId === null ? db.all<{ department_id: string }>(sql, networkId, name) : db.all<{ department_id: string }>(sql, networkId, name, parentId);
|
|
128
|
+
return hits.some((h) => h.department_id !== exceptId);
|
|
129
|
+
}
|
|
130
|
+
function publicOne(networkId: string, id: string): Department {
|
|
131
|
+
return listDepartments(networkId).departments.find((d) => d.id === id)!;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createDepartment(networkId: string, actor: string, body: Record<string, unknown>): Ok<{ department: Department }> | Fail {
|
|
135
|
+
const name = cleanName(body.name);
|
|
136
|
+
if (!name) return fail(400, "invalid_department_name");
|
|
137
|
+
let id: string;
|
|
138
|
+
if (body.id !== undefined && body.id !== null && body.id !== "") {
|
|
139
|
+
if (typeof body.id !== "string" || !ID_RE.test(body.id)) return fail(400, "invalid_department_id");
|
|
140
|
+
if (one(networkId, body.id)) return fail(409, "department_id_taken");
|
|
141
|
+
id = body.id;
|
|
142
|
+
} else {
|
|
143
|
+
id = `dept_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
144
|
+
}
|
|
145
|
+
const parentId = body.parent_id === undefined || body.parent_id === null || body.parent_id === "" ? null : body.parent_id;
|
|
146
|
+
if (parentId !== null && (typeof parentId !== "string" || !one(networkId, parentId))) return fail(400, "parent_not_found");
|
|
147
|
+
if (depthOf(networkId, parentId as string | null) + 1 > MAX_DEPTH) return fail(400, "department_too_deep");
|
|
148
|
+
if (siblingNameTaken(networkId, parentId as string | null, name)) return fail(409, "department_name_taken");
|
|
149
|
+
const leader = body.leader_user_id === undefined || body.leader_user_id === null || body.leader_user_id === "" ? null : body.leader_user_id;
|
|
150
|
+
if (leader !== null && (typeof leader !== "string" || !isMember(networkId, leader))) return fail(400, "leader_not_member");
|
|
151
|
+
if (body.sort !== undefined && !Number.isInteger(body.sort)) return fail(400, "invalid_sort");
|
|
152
|
+
const count = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1", networkId)?.n ?? 0;
|
|
153
|
+
if (count >= MAX_DEPARTMENTS) return fail(400, "too_many_departments");
|
|
154
|
+
const sort = body.sort === undefined
|
|
155
|
+
? (db.get<{ n: number }>(
|
|
156
|
+
parentId === null
|
|
157
|
+
? "SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1 AND parent_id IS NULL"
|
|
158
|
+
: "SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1 AND parent_id = ?2",
|
|
159
|
+
...(parentId === null ? [networkId] : [networkId, parentId]),
|
|
160
|
+
)?.n ?? 0)
|
|
161
|
+
: body.sort as number;
|
|
162
|
+
db.run(
|
|
163
|
+
"INSERT INTO network_departments (network_id, department_id, name, parent_id, leader_user_id, sort, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
164
|
+
[networkId, id, name, parentId, leader, sort, actor],
|
|
165
|
+
);
|
|
166
|
+
return { ok: true, department: publicOne(networkId, id) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function updateDepartment(networkId: string, id: string, body: Record<string, unknown>): Ok<{ department: Department }> | Fail {
|
|
170
|
+
const cur = one(networkId, id);
|
|
171
|
+
if (!cur) return fail(404, "department_not_found");
|
|
172
|
+
let name = cur.name;
|
|
173
|
+
let parentId = cur.parent_id;
|
|
174
|
+
let leader = cur.leader_user_id;
|
|
175
|
+
let sort = cur.sort;
|
|
176
|
+
let touched = false;
|
|
177
|
+
if (body.name !== undefined) {
|
|
178
|
+
const n = cleanName(body.name);
|
|
179
|
+
if (!n) return fail(400, "invalid_department_name");
|
|
180
|
+
name = n; touched = true;
|
|
181
|
+
}
|
|
182
|
+
if (body.parent_id !== undefined) {
|
|
183
|
+
const p = body.parent_id === null || body.parent_id === "" ? null : body.parent_id;
|
|
184
|
+
if (p !== null && (typeof p !== "string" || !one(networkId, p))) return fail(400, "parent_not_found");
|
|
185
|
+
if (p !== null && isDescendant(networkId, id, p as string)) return fail(400, "department_cycle");
|
|
186
|
+
if (depthOf(networkId, p as string | null) + subtreeHeight(networkId, id) > MAX_DEPTH) return fail(400, "department_too_deep");
|
|
187
|
+
parentId = p as string | null; touched = true;
|
|
188
|
+
}
|
|
189
|
+
if (body.leader_user_id !== undefined) {
|
|
190
|
+
const l = body.leader_user_id === null || body.leader_user_id === "" ? null : body.leader_user_id;
|
|
191
|
+
if (l !== null && (typeof l !== "string" || !isMember(networkId, l))) return fail(400, "leader_not_member");
|
|
192
|
+
leader = l as string | null; touched = true;
|
|
193
|
+
}
|
|
194
|
+
if (body.sort !== undefined) {
|
|
195
|
+
if (!Number.isInteger(body.sort)) return fail(400, "invalid_sort");
|
|
196
|
+
sort = body.sort as number; touched = true;
|
|
197
|
+
}
|
|
198
|
+
if (!touched) return fail(400, "empty_patch");
|
|
199
|
+
if ((name !== cur.name || parentId !== cur.parent_id) && siblingNameTaken(networkId, parentId, name, id)) return fail(409, "department_name_taken");
|
|
200
|
+
db.run(
|
|
201
|
+
"UPDATE network_departments SET name = ?3, parent_id = ?4, leader_user_id = ?5, sort = ?6, updated_at = datetime('now') WHERE network_id = ?1 AND department_id = ?2",
|
|
202
|
+
[networkId, id, name, parentId, leader, sort],
|
|
203
|
+
);
|
|
204
|
+
return { ok: true, department: publicOne(networkId, id) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 只删空部门:有子部门 / 有成员 → 409(带数字,界面照着说)。 */
|
|
208
|
+
export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: string }> | (Fail & { children?: number; members?: number }) {
|
|
209
|
+
if (!one(networkId, id)) return fail(404, "department_not_found");
|
|
210
|
+
const children = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1 AND parent_id = ?2", networkId, id)?.n ?? 0;
|
|
211
|
+
const members = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_members WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
|
|
212
|
+
if (children || members) return { ...fail(409, "department_not_empty"), children, members };
|
|
213
|
+
db.run("DELETE FROM network_departments WHERE network_id = ?1 AND department_id = ?2", [networkId, id]);
|
|
214
|
+
return { ok: true, deleted: id };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 把成员放进一个部门(null = 未分配)。 */
|
|
218
|
+
export function setMemberDepartment(networkId: string, userId: string, departmentId: unknown): Ok<{ user_id: string; department_id: string | null }> | Fail {
|
|
219
|
+
if (!isMember(networkId, userId)) return fail(404, "member_not_found");
|
|
220
|
+
const dept = departmentId === null || departmentId === "" ? null : departmentId;
|
|
221
|
+
if (dept !== null && (typeof dept !== "string" || !one(networkId, dept))) return fail(400, "department_not_found");
|
|
222
|
+
db.run("UPDATE network_members SET department_id = ?3 WHERE network_id = ?1 AND user_id = ?2", [networkId, userId, dept]);
|
|
223
|
+
return { ok: true, user_id: userId, department_id: dept as string | null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 网络删掉时一起清(和项目授权同一时机调用)。 */
|
|
227
|
+
export function deleteDepartmentsForNetwork(networkId: string): void {
|
|
228
|
+
db.run("DELETE FROM network_departments WHERE network_id = ?1", [networkId]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function auditDepartment(user: { user_id: string; username: string }, action: string, networkId: string, detail: string): void {
|
|
232
|
+
logAudit(user.user_id, user.username, action, "network", networkId, detail.slice(0, 2000), undefined, networkId);
|
|
233
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// #448 — report_status.health passthrough (layered node health, report-only).
|
|
2
|
+
//
|
|
3
|
+
// Hub-side contract verified here:
|
|
4
|
+
// 1. A well-formed `health` from the node's own token is kept (memory-only) and readable.
|
|
5
|
+
// 2. Malformed `health` never rejects the report — the node stays online, health reads as null.
|
|
6
|
+
// 3. Another node / a user login cannot speak for this node's health.
|
|
7
|
+
// 4. Stale (> TTL) health reads as null, never as "healthy".
|
|
8
|
+
//
|
|
9
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/node-health.db bun test src/node-health-passthrough.test.ts
|
|
10
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
12
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { db } from "./db.js";
|
|
15
|
+
import { registerTools } from "./tools.js";
|
|
16
|
+
import { clearNodeHealthStore, NODE_HEALTH_TTL_MS, readNodeHealth, recordNodeHealth } from "./node-health-store.js";
|
|
17
|
+
|
|
18
|
+
const NET = "net_health_t";
|
|
19
|
+
const OWNER = "u_health_owner";
|
|
20
|
+
const NODE_A = "health-node-a";
|
|
21
|
+
const NODE_B = "health-node-b";
|
|
22
|
+
const NODE_A_ID = "node_health_a";
|
|
23
|
+
const NODE_B_ID = "node_health_b";
|
|
24
|
+
const T_A = "tok_health_node_a";
|
|
25
|
+
const T_B = "tok_health_node_b";
|
|
26
|
+
|
|
27
|
+
function cleanup() {
|
|
28
|
+
for (const t of ["sessions", "nodes", "api_tokens", "network_members", "networks"]) {
|
|
29
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
30
|
+
}
|
|
31
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [OWNER]); } catch {}
|
|
32
|
+
clearNodeHealthStore();
|
|
33
|
+
}
|
|
34
|
+
function seed() {
|
|
35
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [OWNER, OWNER]);
|
|
36
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, OWNER]);
|
|
37
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [OWNER, NET]);
|
|
38
|
+
for (const [id, alias, tok] of [[NODE_A_ID, NODE_A, T_A], [NODE_B_ID, NODE_B, T_B]]) {
|
|
39
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`, [id, alias, alias, NET, OWNER]);
|
|
40
|
+
db.run(`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`, [tok, OWNER, NET, `node:${alias}`, `hash_${tok}`, id]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function connectAs(alias: string, tokenId: string) {
|
|
45
|
+
const server = new McpServer({ name: "health-test", version: "1" });
|
|
46
|
+
registerTools(server, undefined, NET, OWNER, alias, true, tokenId);
|
|
47
|
+
const client = new Client({ name: "health-client", version: "1" });
|
|
48
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
49
|
+
await server.connect(st);
|
|
50
|
+
await client.connect(ct);
|
|
51
|
+
const call = async (args: Record<string, unknown>) => {
|
|
52
|
+
const r: any = await client.callTool({ name: "report_status", arguments: args });
|
|
53
|
+
return { isError: r.isError === true, body: JSON.parse(r.content?.[0]?.text ?? "null") };
|
|
54
|
+
};
|
|
55
|
+
return { call, close: async () => { await client.close(); await server.close(); } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const HEALTH = {
|
|
59
|
+
bridge: "ok",
|
|
60
|
+
app_server: { ok: false, rtt_ms: null, last_error: "connection refused" },
|
|
61
|
+
tui: { ok: false, reason: "sleep-placeholder" },
|
|
62
|
+
model_auth: "revoked",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
66
|
+
afterAll(cleanup);
|
|
67
|
+
|
|
68
|
+
describe("#448 report_status.health passthrough", () => {
|
|
69
|
+
test("node's own token: stored and readable as reported", async () => {
|
|
70
|
+
const a = await connectAs(NODE_A, T_A);
|
|
71
|
+
try {
|
|
72
|
+
const r = await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: HEALTH });
|
|
73
|
+
expect(r.isError).toBe(false);
|
|
74
|
+
expect(r.body.ok).not.toBe(false);
|
|
75
|
+
} finally { await a.close(); }
|
|
76
|
+
expect(readNodeHealth(NET, NODE_A)?.health).toEqual(HEALTH as any);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("malformed health never rejects the report (node stays online); it reads as not reported", async () => {
|
|
80
|
+
const a = await connectAs(NODE_A, T_A);
|
|
81
|
+
try {
|
|
82
|
+
for (const bad of [42, "ok", { app_server: { ok: "yes" }, model_auth: "weird", tui: [] }, { app_server: { ok: true, rtt_ms: NaN, last_error: 5 } }]) {
|
|
83
|
+
const r = await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: bad });
|
|
84
|
+
expect(r.isError).toBe(false);
|
|
85
|
+
}
|
|
86
|
+
} finally { await a.close(); }
|
|
87
|
+
expect(db.get<{ s: string }>("SELECT status AS s FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_A, NET)?.s).toBe("idle");
|
|
88
|
+
// 最后一份里 app_server.ok 合法、其余两格降级成 null —— 留下的是能证实的部分,不是编出来的。
|
|
89
|
+
expect(readNodeHealth(NET, NODE_A)?.health.app_server).toEqual({ ok: true, rtt_ms: null, last_error: null });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("another node's token cannot speak for this node", async () => {
|
|
93
|
+
const b = await connectAs(NODE_B, T_B);
|
|
94
|
+
try {
|
|
95
|
+
await b.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: HEALTH });
|
|
96
|
+
} finally { await b.close(); }
|
|
97
|
+
expect(readNodeHealth(NET, NODE_A)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("stale health reads as null, never as healthy", () => {
|
|
101
|
+
const t0 = 1_000_000;
|
|
102
|
+
recordNodeHealth(NET, NODE_A, { bridge: "ok", model_auth: "ok" }, t0);
|
|
103
|
+
expect(readNodeHealth(NET, NODE_A, t0 + 1_000)?.observed_ms_ago).toBe(1_000);
|
|
104
|
+
expect(readNodeHealth(NET, NODE_A, t0 + NODE_HEALTH_TTL_MS + 1)).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a report without health leaves the last one (heartbeats from older code paths do not erase it)", async () => {
|
|
108
|
+
const a = await connectAs(NODE_A, T_A);
|
|
109
|
+
try {
|
|
110
|
+
await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: HEALTH });
|
|
111
|
+
await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "working", node_id: NODE_A_ID });
|
|
112
|
+
} finally { await a.close(); }
|
|
113
|
+
expect(readNodeHealth(NET, NODE_A)?.health.model_auth).toBe("revoked");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// #448 —— agent-node 上报的分层健康(`report_status.health`)的 Hub 侧透传。
|
|
2
|
+
//
|
|
3
|
+
// 「在线」只说明桥进程还活着:现场 ~40 台 codex 共存节点里,有的在 Hub 上 idle / in_flight=0,
|
|
4
|
+
// 实际 app-server 端口没人监听、TUI pane 变成 `sleep infinity`、或登录态 "refresh token revoked"。
|
|
5
|
+
// 节点把这几层分开报上来;这里只负责**收下、按节点存最新一份、在 /api/status 和 status_update 里带出去**。
|
|
6
|
+
//
|
|
7
|
+
// 刻意只放内存,不加列:它是**几秒钟一变的观测值**,不是需要跨重启保留的状态 —— Hub 重启后下一次
|
|
8
|
+
// 心跳(≤3 分钟,翻转时立即)就会补上;持久化一份旧观测值反而会在节点死掉后继续说「app_server ok」。
|
|
9
|
+
// 同理,超过 TTL 的那份按「没报」处理(null),不按「健康」处理。
|
|
10
|
+
|
|
11
|
+
import { z } from "zod/v4";
|
|
12
|
+
|
|
13
|
+
export const NODE_HEALTH_TTL_MS = 10 * 60_000;
|
|
14
|
+
|
|
15
|
+
// 🔴 `.catch(undefined)`:这一格是纯诊断信息,**任何形状都不许拒掉整份 report_status**
|
|
16
|
+
// (zod 对象里一个已知字段验证失败 = 整份被拒 = 节点在 Hub 上失联,#1225 的形状)。
|
|
17
|
+
// 合法的透传,其余一律当没报。子对象不 strict:新节点多报的键丢掉即可。
|
|
18
|
+
export const nodeHealthSchema = z.object({
|
|
19
|
+
bridge: z.literal("ok").optional(),
|
|
20
|
+
app_server: z.object({
|
|
21
|
+
ok: z.boolean(),
|
|
22
|
+
rtt_ms: z.number().nullable().catch(null),
|
|
23
|
+
last_error: z.string().max(200).nullable().catch(null),
|
|
24
|
+
}).optional().catch(undefined),
|
|
25
|
+
tui: z.object({
|
|
26
|
+
ok: z.boolean(),
|
|
27
|
+
reason: z.string().max(40),
|
|
28
|
+
}).optional().catch(undefined),
|
|
29
|
+
model_auth: z.enum(["ok", "revoked", "expired", "unknown"]).optional().catch(undefined),
|
|
30
|
+
}).optional().catch(undefined);
|
|
31
|
+
|
|
32
|
+
export type NodeHealth = NonNullable<z.infer<typeof nodeHealthSchema>>;
|
|
33
|
+
|
|
34
|
+
const store = new Map<string, { health: NodeHealth; at: number }>();
|
|
35
|
+
const keyOf = (networkId: string | null | undefined, alias: string) => `${networkId ?? "default"}\0${alias}`;
|
|
36
|
+
|
|
37
|
+
export function recordNodeHealth(networkId: string | null | undefined, alias: string, health: NodeHealth, now = Date.now()): void {
|
|
38
|
+
store.set(keyOf(networkId, alias), { health, at: now });
|
|
39
|
+
// 有界:按 TTL 顺手清掉过期的,别让改过名/删掉的节点在内存里攒着。
|
|
40
|
+
if (store.size > 256) {
|
|
41
|
+
for (const [k, v] of store) if (now - v.at > NODE_HEALTH_TTL_MS) store.delete(k);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 最新一份;没有或已过 TTL → null(读的人必须把它当「不知道」,而不是「健康」)。 */
|
|
46
|
+
export function readNodeHealth(
|
|
47
|
+
networkId: string | null | undefined, alias: string, now = Date.now(),
|
|
48
|
+
): { health: NodeHealth; observed_ms_ago: number } | null {
|
|
49
|
+
const hit = store.get(keyOf(networkId, alias));
|
|
50
|
+
if (!hit) return null;
|
|
51
|
+
const age = now - hit.at;
|
|
52
|
+
if (age > NODE_HEALTH_TTL_MS) { store.delete(keyOf(networkId, alias)); return null; }
|
|
53
|
+
return { health: hit.health, observed_ms_ago: Math.max(0, age) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 测试用。 */
|
|
57
|
+
export function clearNodeHealthStore(): void { store.clear(); }
|
|
@@ -46,6 +46,8 @@ const GOLDEN_RESPONSE_KEYS = {
|
|
|
46
46
|
"process_in_flight_count", "network_id", "registered_at", "updated_at",
|
|
47
47
|
"node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
|
|
48
48
|
"external_schedules", "rules_file_capable", "skills_capable", "files_capable", "logs_capable", "runtime", "host", "process_telemetry",
|
|
49
|
+
// #448 — layered node health (memory-only; null when not reported or stale).
|
|
50
|
+
"health", "health_observed_ms_ago",
|
|
49
51
|
],
|
|
50
52
|
task: [
|
|
51
53
|
"task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { buildServeErrorResponse } from "./serve-error.js";
|
|
2
2
|
import { maybeGzipResponse, trimLightTask } from "./http-gzip";
|
|
3
3
|
import { statusAliasResolverRead } from "./status-alias-resolver.js";
|
|
4
|
+
import { readNodeHealth } from "./node-health-store.js";
|
|
5
|
+
import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setMemberDepartment, updateDepartment } from "./departments.js";
|
|
4
6
|
import { redactMessageRow } from "./redact-tokens.js";
|
|
5
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
8
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
@@ -1707,6 +1709,60 @@ return Bun.serve({
|
|
|
1707
1709
|
return withCors(req, Response.json({ ok: true, network_id: netId, humans: humans.map((h) => ({ ...h, ...presence.get(h.user_id)! })) }));
|
|
1708
1710
|
}
|
|
1709
1711
|
|
|
1712
|
+
// ── 组织架构(board #419):部门 + 成员所在部门。读:网络任何成员 / 本网络的节点令牌 / Hub 管理员;
|
|
1713
|
+
// 写:网络 owner / admin、Hub 管理员(节点令牌不能写)。规则在 departments.ts。 ──
|
|
1714
|
+
const deptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments(?:\/([^/]+))?$/);
|
|
1715
|
+
const memberDeptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/department$/);
|
|
1716
|
+
if (deptMatch || memberDeptMatch) {
|
|
1717
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1718
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1719
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1720
|
+
const netId = decodeURIComponent((deptMatch ?? memberDeptMatch)![1]);
|
|
1721
|
+
if (resolved.networkId && resolved.networkId !== netId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1722
|
+
const hubAdmin = isHubAdminCredential(resolved);
|
|
1723
|
+
const role = resolved.networkId ? null : getUserNetworkRole(resolved.user.user_id, netId);
|
|
1724
|
+
if (!hubAdmin && !role && !resolved.networkId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1725
|
+
if (!db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)) return withCors(req, Response.json({ ok: false, error: "network_not_found" }, { status: 404 }));
|
|
1726
|
+
if (deptMatch && !deptMatch[2] && req.method === "GET") {
|
|
1727
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, ...listDepartments(netId) }));
|
|
1728
|
+
}
|
|
1729
|
+
const canManage = hubAdmin || (!resolved.networkId && (role === "owner" || role === "admin"));
|
|
1730
|
+
if (!["POST", "PATCH", "PUT", "DELETE"].includes(req.method)) return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1731
|
+
if (!canManage) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1732
|
+
let body: Record<string, unknown> = {};
|
|
1733
|
+
if (req.method !== "DELETE") {
|
|
1734
|
+
try { const b = await req.json(); body = b && typeof b === "object" && !Array.isArray(b) ? b as Record<string, unknown> : {}; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1735
|
+
}
|
|
1736
|
+
const reply = (r: { ok: boolean; status?: number } & Record<string, unknown>, okStatus = 200) => {
|
|
1737
|
+
const { status, ...rest } = r;
|
|
1738
|
+
return withCors(req, Response.json(rest, { status: r.ok ? okStatus : status ?? 400 }));
|
|
1739
|
+
};
|
|
1740
|
+
if (memberDeptMatch) {
|
|
1741
|
+
if (req.method !== "PUT") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1742
|
+
const targetUid = decodeURIComponent(memberDeptMatch[2]);
|
|
1743
|
+
const r = setMemberDepartment(netId, targetUid, body.department_id);
|
|
1744
|
+
if (r.ok) auditDepartment(resolved.user, "member_department_set", netId, `${targetUid} → ${r.department_id ?? "(unassigned)"}`);
|
|
1745
|
+
return reply(r);
|
|
1746
|
+
}
|
|
1747
|
+
const deptId = deptMatch![2] ? decodeURIComponent(deptMatch![2]) : null;
|
|
1748
|
+
if (!deptId && req.method === "POST") {
|
|
1749
|
+
const r = createDepartment(netId, resolved.user.user_id, body);
|
|
1750
|
+
if (r.ok) auditDepartment(resolved.user, "department_created", netId, JSON.stringify({ id: r.department.id, name: r.department.name, parent_id: r.department.parent_id }));
|
|
1751
|
+
return reply(r, 201);
|
|
1752
|
+
}
|
|
1753
|
+
if (deptId && req.method === "PATCH") {
|
|
1754
|
+
const r = updateDepartment(netId, deptId, body);
|
|
1755
|
+
if (r.ok) auditDepartment(resolved.user, "department_updated", netId, JSON.stringify({ id: deptId, ...body }));
|
|
1756
|
+
return reply(r);
|
|
1757
|
+
}
|
|
1758
|
+
if (deptId && req.method === "DELETE") {
|
|
1759
|
+
const r = deleteDepartment(netId, deptId);
|
|
1760
|
+
if (r.ok) auditDepartment(resolved.user, "department_deleted", netId, deptId);
|
|
1761
|
+
return reply(r as { ok: boolean; status?: number } & Record<string, unknown>);
|
|
1762
|
+
}
|
|
1763
|
+
return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1710
1766
|
// ── V3.13: Network members + invites ──
|
|
1711
1767
|
const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
|
|
1712
1768
|
if (membersMatch) {
|
|
@@ -2297,6 +2353,12 @@ return Bun.serve({
|
|
|
2297
2353
|
uptime_seconds: s.process_uptime_seconds ?? null,
|
|
2298
2354
|
in_flight_count: s.process_in_flight_count ?? null,
|
|
2299
2355
|
},
|
|
2356
|
+
// #448 — latest layered health the node reported (memory-only, 10 min TTL);
|
|
2357
|
+
// null = not reported / stale / older agent-node — never "healthy".
|
|
2358
|
+
...(() => {
|
|
2359
|
+
const h = readNodeHealth(s.network_id, s.alias);
|
|
2360
|
+
return { health: h?.health ?? null, health_observed_ms_ago: h?.observed_ms_ago ?? null };
|
|
2361
|
+
})(),
|
|
2300
2362
|
};
|
|
2301
2363
|
});
|
|
2302
2364
|
const summary = sessions.reduce((acc: any, session: any) => {
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
|
+
import { nodeHealthSchema, recordNodeHealth } from "./node-health-store.js";
|
|
4
5
|
import { parseAliasFilter } from "./alias-filter.js";
|
|
5
6
|
import { createHash } from "node:crypto";
|
|
6
7
|
import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
|
|
@@ -827,8 +828,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
827
828
|
files_capable: z.literal(true).optional(),
|
|
828
829
|
// Node run-log view — same doorbell, op logs_tail.
|
|
829
830
|
logs_capable: z.literal(true).optional(),
|
|
831
|
+
// #448 — layered node health (bridge / app_server / tui / model_auth).
|
|
832
|
+
// Report-only, memory-only; any malformed shape degrades to "not reported"
|
|
833
|
+
// instead of rejecting the whole report (see node-health-store.ts).
|
|
834
|
+
health: nodeHealthSchema,
|
|
830
835
|
},
|
|
831
|
-
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable, logs_capable: logsCapable }) => {
|
|
836
|
+
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable, logs_capable: logsCapable, health: nodeHealth }) => {
|
|
832
837
|
const effectiveNetId = getNetworkId(netId);
|
|
833
838
|
const sessionNetId = effectiveNetId ?? "default";
|
|
834
839
|
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
@@ -1051,6 +1056,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1051
1056
|
);
|
|
1052
1057
|
}
|
|
1053
1058
|
});
|
|
1059
|
+
// #448 — only the node token bound to this alias may speak for its health
|
|
1060
|
+
// (same rule as the *_capable flags above).
|
|
1061
|
+
const acceptedHealth = nodeHealth && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias
|
|
1062
|
+
? nodeHealth
|
|
1063
|
+
: undefined;
|
|
1064
|
+
if (acceptedHealth) recordNodeHealth(sessionNetId, effectiveAlias, acceptedHealth);
|
|
1054
1065
|
pushEvent(effectiveAlias, {
|
|
1055
1066
|
type: "status_update",
|
|
1056
1067
|
alias: effectiveAlias,
|
|
@@ -1059,6 +1070,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1059
1070
|
progress: progress ?? null,
|
|
1060
1071
|
host: statusHostTelemetry,
|
|
1061
1072
|
process_telemetry: statusProcessTelemetry,
|
|
1073
|
+
...(acceptedHealth ? { health: acceptedHealth } : {}),
|
|
1062
1074
|
}, sessionNetId);
|
|
1063
1075
|
|
|
1064
1076
|
// V2: sync tasks table — report_status(working) → tasks.running
|