@sleep2agi/commhub-server 0.9.0-preview.84 → 0.9.0-preview.86

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.84",
3
+ "version": "0.9.0-preview.86",
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,279 @@
1
+ // #460(#448 子项)—— 降级节点拒收新任务。
2
+ //
3
+ // 钉住:
4
+ // 1. 新鲜健康报告说某层坏了(app_server.ok=false / tui.ok=false / model_auth revoked|expired)
5
+ // → REST /api/task 409、MCP send_task / retry_task / reassign_task 返回 node_degraded,带层、原因、修法;
6
+ // 定时任务的这一次 run 记 failed + error_code=node_degraded,不建任务行。
7
+ // 2. 不知道 = 放行:没有报告(老 agent-node)、报告过期(> TTL)、各层都 ok、某层没报 —— 与改动前逐字相同的成功路径。
8
+ // 3. 逃生口 force=true 只对用户令牌生效;节点令牌带 force 照样被拒(不让 agent 绕过对另一个 agent 的健康判断)。
9
+ // 4. 老客户端请求形状(不带 force)在健康 / 未知节点上完全照旧 —— 兼容回放。
10
+ //
11
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/node-health-dispatch-http.test.ts
12
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
+ import { mkdtempSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
19
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
+ import { createNetworkTokenForNode, register } from "./auth.js";
21
+ import { db } from "./db.js";
22
+ import { registerTools } from "./tools.js";
23
+ import { clearNodeHealthStore, NODE_HEALTH_TTL_MS, recordNodeHealth } from "./node-health-store.js";
24
+ import { degradedLayers } from "./node-health-guard.js";
25
+ import { runDueScheduledTasks } from "./scheduled-tasks.js";
26
+
27
+ const dir = mkdtempSync(join(tmpdir(), "anet-health-dispatch-"));
28
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
29
+ if (!activeDbPath) throw new Error("node-health-dispatch requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
30
+
31
+ let server: any;
32
+ let base = "";
33
+ let ownerToken = "", ownerId = "", networkId = "";
34
+ let peerNodeToken = "", peerNodeTokenId = "";
35
+ const stamp = Date.now();
36
+ const TARGET = `hd-target-${stamp}`;
37
+ const TARGET_NODE = `n_hd_target_${stamp}`;
38
+ const PEER = `hd-peer-${stamp}`;
39
+ const PEER_NODE = `n_hd_peer_${stamp}`;
40
+ let seq = 0;
41
+ const text = (what: string) => `${what} #${++seq}`; // distinct content: the 5-minute dedup must not interfere
42
+
43
+ async function api(token: string, path: string, init?: RequestInit) {
44
+ const res = await fetch(`${base}${path}`, {
45
+ ...init,
46
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
47
+ });
48
+ return { status: res.status, body: await res.json() as any };
49
+ }
50
+ const postTask = (token: string, extra: Record<string, unknown> = {}) =>
51
+ api(token, "/api/task", { method: "POST", body: JSON.stringify({ alias: TARGET, task: text("rest"), network_id: networkId, ...extra }) });
52
+
53
+ async function mcpAs(identity: { net: string | null; user: string; alias: string; isNetworkToken: boolean; tokenId: string }) {
54
+ const s = new McpServer({ name: "health-dispatch", version: "1" });
55
+ registerTools(s, undefined, identity.net, identity.user, identity.alias, identity.isNetworkToken, identity.tokenId);
56
+ const client = new Client({ name: "health-dispatch-client", version: "1" });
57
+ const [ct, st] = InMemoryTransport.createLinkedPair();
58
+ await s.connect(st);
59
+ await client.connect(ct);
60
+ const call = async (name: string, args: Record<string, unknown>) => {
61
+ const r: any = await client.callTool({ name, arguments: args });
62
+ const raw = r.content?.[0]?.text ?? "null";
63
+ try { return JSON.parse(raw); } catch { return { raw }; }
64
+ };
65
+ return { call, close: async () => { await client.close(); await s.close(); } };
66
+ }
67
+
68
+ const DOWN_APP = { bridge: "ok" as const, app_server: { ok: false, rtt_ms: null, last_error: "connect ECONNREFUSED 127.0.0.1:4500" }, model_auth: "ok" as const };
69
+ const HEALTHY = { bridge: "ok" as const, app_server: { ok: true, rtt_ms: 3, last_error: null }, tui: { ok: true, reason: "running" }, model_auth: "ok" as const };
70
+ const setHealth = (h: any, at?: number) => recordNodeHealth(networkId, TARGET, h, at);
71
+ const taskCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE to_name = ?1 AND network_id = ?2", TARGET, networkId)!.n;
72
+
73
+ beforeAll(async () => {
74
+ process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
75
+ process.env.HOST = "127.0.0.1";
76
+ const owner = register(`hd_owner_${stamp}`, "HealthDispatchOwner123!", undefined, "seed");
77
+ ownerToken = owner.token!; ownerId = owner.user!.user_id; networkId = owner.network_id!;
78
+ for (const [nodeId, alias] of [[TARGET_NODE, TARGET], [PEER_NODE, PEER]]) {
79
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex-app-server', ?3, ?4)`, [nodeId, alias, networkId, ownerId]);
80
+ db.run(`INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)`, [`r_${nodeId}`, alias, nodeId, networkId]);
81
+ }
82
+ const minted = createNetworkTokenForNode(ownerId, networkId, PEER, PEER_NODE);
83
+ peerNodeToken = minted.token!; peerNodeTokenId = minted.token_id!;
84
+ const mod: any = await import("./server.js");
85
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
86
+ base = `http://127.0.0.1:${server.port}`;
87
+ }, 30_000);
88
+ afterAll(() => { server?.stop?.(true); clearNodeHealthStore(); });
89
+ beforeEach(() => clearNodeHealthStore());
90
+
91
+ describe("#460 which layers count as degraded", () => {
92
+ test("each failing layer is named; ok / missing layers are not", () => {
93
+ expect(degradedLayers(null)).toEqual([]);
94
+ expect(degradedLayers(HEALTHY)).toEqual([]);
95
+ expect(degradedLayers({ bridge: "ok", model_auth: "unknown" })).toEqual([]);
96
+ expect(degradedLayers(DOWN_APP).map(l => [l.layer, l.label])).toEqual([["app_server", "App Server 断开"]]);
97
+ expect(degradedLayers({ tui: { ok: false, reason: "sleep-placeholder" } }).map(l => [l.layer, l.reason, l.label])).toEqual([["tui", "sleep-placeholder", "TUI 被占位(sleep)"]]);
98
+ expect(degradedLayers({ model_auth: "revoked" }).map(l => l.label)).toEqual(["需要重新登录"]);
99
+ expect(degradedLayers({ model_auth: "expired" }).map(l => l.label)).toEqual(["登录已过期"]);
100
+ // 修法只指向本节点重新登录,不教人拷别人的凭据
101
+ expect(degradedLayers({ model_auth: "revoked" })[0].hint).toContain("不要拷别的节点的 auth.json");
102
+ });
103
+ });
104
+
105
+ describe("#460 REST /api/task", () => {
106
+ test("unknown health (old agent-node) → dispatched exactly as before", async () => {
107
+ const before = taskCount();
108
+ const r = await postTask(ownerToken);
109
+ expect(r.status).toBe(200);
110
+ expect(r.body.ok).toBe(true);
111
+ expect(taskCount()).toBe(before + 1);
112
+ });
113
+
114
+ test("healthy report → dispatched", async () => {
115
+ setHealth(HEALTHY);
116
+ expect((await postTask(ownerToken)).status).toBe(200);
117
+ });
118
+
119
+ test("App Server down → 409 node_degraded with layer, reason and hint; no task row", async () => {
120
+ setHealth(DOWN_APP);
121
+ const before = taskCount();
122
+ const r = await postTask(ownerToken);
123
+ expect(r.status).toBe(409);
124
+ expect(r.body.ok).toBe(false);
125
+ expect(r.body.error).toBe("node_degraded");
126
+ expect(r.body.alias).toBe(TARGET);
127
+ expect(r.body.layers.map((l: any) => l.layer)).toEqual(["app_server"]);
128
+ expect(r.body.layers[0].reason).toContain("ECONNREFUSED");
129
+ expect(r.body.message).toContain("App Server 断开");
130
+ expect(r.body.hint).toContain("force=true");
131
+ expect(r.body.force_allowed).toBe(true);
132
+ expect(typeof r.body.health_observed_ms_ago).toBe("number");
133
+ expect(taskCount()).toBe(before);
134
+ });
135
+
136
+ test("TUI gone and login revoked are refused too, all layers listed", async () => {
137
+ setHealth({ bridge: "ok", app_server: { ok: true, rtt_ms: 2, last_error: null }, tui: { ok: false, reason: "session-missing" }, model_auth: "revoked" });
138
+ const r = await postTask(ownerToken);
139
+ expect(r.status).toBe(409);
140
+ expect(r.body.layers.map((l: any) => l.layer)).toEqual(["tui", "model_auth"]);
141
+ });
142
+
143
+ test("expired login alone is refused", async () => {
144
+ setHealth({ bridge: "ok", model_auth: "expired" });
145
+ expect((await postTask(ownerToken)).body.error).toBe("node_degraded");
146
+ });
147
+
148
+ test("stale report (older than the TTL) = unknown → dispatched", async () => {
149
+ setHealth(DOWN_APP, Date.now() - NODE_HEALTH_TTL_MS - 1_000);
150
+ expect((await postTask(ownerToken)).status).toBe(200);
151
+ });
152
+
153
+ test("owner escape hatch: force=true with a user token dispatches", async () => {
154
+ setHealth(DOWN_APP);
155
+ const before = taskCount();
156
+ const r = await postTask(ownerToken, { force: true });
157
+ expect(r.status).toBe(200);
158
+ expect(taskCount()).toBe(before + 1);
159
+ });
160
+
161
+ test("a node token cannot force past another node's health", async () => {
162
+ setHealth(DOWN_APP);
163
+ const r = await api(peerNodeToken, "/api/task", { method: "POST", body: JSON.stringify({ alias: TARGET, task: text("node-force"), force: true }) });
164
+ expect(r.status).toBe(409);
165
+ expect(r.body.error).toBe("node_degraded");
166
+ expect(r.body.force_allowed).toBe(false);
167
+ expect(r.body.hint).not.toContain("force=true");
168
+ });
169
+
170
+ test("old-client request shapes are untouched on a healthy node (compat replay)", async () => {
171
+ setHealth(HEALTHY);
172
+ // the app's send (meta + client_request_id), a plain anet send, and a parent-linked dispatch
173
+ const shapes = [
174
+ { alias: TARGET, task: text("app"), network_id: networkId, priority: "normal", meta: { client_request_id: `cr_${stamp}_${seq}` } },
175
+ { alias: TARGET, task: text("anet"), network_id: networkId },
176
+ { alias: TARGET, task: text("ttl"), network_id: networkId, priority: "high", ttl_seconds: 600 },
177
+ ];
178
+ for (const shape of shapes) {
179
+ const r = await api(ownerToken, "/api/task", { method: "POST", body: JSON.stringify(shape) });
180
+ expect(r.status).toBe(200);
181
+ expect(r.body.ok).toBe(true);
182
+ }
183
+ });
184
+ });
185
+
186
+ describe("#460 MCP send_task / retry_task / reassign_task", () => {
187
+ const asOwner = () => ({ net: null, user: ownerId, alias: `hd_owner_${stamp}`, isNetworkToken: false, tokenId: "tok_hd_owner_login" });
188
+ const asPeerNode = () => ({ net: networkId, user: ownerId, alias: PEER, isNetworkToken: true, tokenId: peerNodeTokenId });
189
+
190
+ test("send_task from an agent to a degraded node → node_degraded (no queueing); force ignored for node tokens", async () => {
191
+ setHealth(DOWN_APP);
192
+ const peer = await mcpAs(asPeerNode());
193
+ try {
194
+ const before = taskCount();
195
+ const r = await peer.call("send_task", { alias: TARGET, task: text("mcp"), force: true });
196
+ expect(r.ok).toBe(false);
197
+ expect(r.error).toBe("node_degraded");
198
+ expect(r.force_allowed).toBe(false);
199
+ expect(taskCount()).toBe(before);
200
+ } finally { await peer.close(); }
201
+ });
202
+
203
+ test("send_task: unknown health → ok; owner force → ok", async () => {
204
+ const owner = await mcpAs(asOwner());
205
+ try {
206
+ expect((await owner.call("send_task", { alias: TARGET, task: text("mcp-unknown"), network_id: networkId })).ok).toBe(true);
207
+ setHealth({ bridge: "ok", model_auth: "revoked" });
208
+ expect((await owner.call("send_task", { alias: TARGET, task: text("mcp-refused"), network_id: networkId })).error).toBe("node_degraded");
209
+ expect((await owner.call("send_task", { alias: TARGET, task: text("mcp-forced"), network_id: networkId, force: true })).ok).toBe(true);
210
+ } finally { await owner.close(); }
211
+ });
212
+
213
+ test("retry_task and reassign_task onto a degraded node are refused", async () => {
214
+ const owner = await mcpAs(asOwner());
215
+ try {
216
+ const sent = await owner.call("send_task", { alias: PEER, task: text("to-peer"), network_id: networkId });
217
+ expect(sent.ok).toBe(true);
218
+ setHealth(DOWN_APP);
219
+ const re = await owner.call("reassign_task", { task_id: sent.task_id ?? sent.message_id, new_alias: TARGET, network_id: networkId });
220
+ expect(re.error).toBe("node_degraded");
221
+ const failed = await owner.call("send_task", { alias: TARGET, task: text("to-fail"), network_id: networkId, force: true });
222
+ const failedId = failed.task_id ?? failed.message_id;
223
+ db.run("UPDATE tasks SET status = 'failed' WHERE task_id = ?1", [failedId]);
224
+ const retry = await owner.call("retry_task", { task_id: failedId, network_id: networkId });
225
+ expect(retry.error).toBe("node_degraded");
226
+ clearNodeHealthStore();
227
+ expect((await owner.call("retry_task", { task_id: failedId, network_id: networkId })).ok).toBe(true);
228
+ } finally { await owner.close(); }
229
+ });
230
+ });
231
+
232
+ describe("#460 scheduled tasks", () => {
233
+ test("a due run on a degraded node fails with node_degraded and creates no task", async () => {
234
+ const created = await api(ownerToken, "/api/scheduled-tasks", {
235
+ method: "POST",
236
+ body: JSON.stringify({ network_id: networkId, name: `hd-schedule-${stamp}`, target_node_id: TARGET_NODE, task: text("scheduled"), timezone: "UTC", schedule: { type: "interval", every_seconds: 60 } }),
237
+ });
238
+ expect(created.status).toBe(201);
239
+ const scheduleId = created.body.schedule.schedule_id;
240
+ setHealth({ bridge: "ok", tui: { ok: false, reason: "pane-dead" }, model_auth: "ok" });
241
+ const before = taskCount();
242
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 60_000).toISOString(), scheduleId]);
243
+ runDueScheduledTasks();
244
+ const run = db.get<{ status: string; error_code: string | null; error_message: string | null; task_id: string | null }>(
245
+ "SELECT status, error_code, error_message, task_id FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY created_at DESC LIMIT 1", scheduleId);
246
+ expect(run?.status).toBe("failed");
247
+ expect(run?.error_code).toBe("node_degraded");
248
+ expect(run?.error_message).toContain("TUI 已退出");
249
+ expect(run?.task_id ?? null).toBeNull();
250
+ expect(taskCount()).toBe(before);
251
+ });
252
+ });
253
+
254
+ describe("#460 /api/status carries `degraded` only for degraded nodes", () => {
255
+ const row = async (light: boolean) => {
256
+ const r = await api(ownerToken, `/api/status?network_id=${encodeURIComponent(networkId)}${light ? "&light=1" : ""}`);
257
+ expect(r.status).toBe(200);
258
+ return r.body.sessions.find((x: any) => x.alias === TARGET);
259
+ };
260
+ test("unknown / healthy → no `degraded` key at all (light response bytes unchanged)", async () => {
261
+ expect("degraded" in (await row(true))).toBe(false);
262
+ setHealth(HEALTHY);
263
+ expect("degraded" in (await row(true))).toBe(false);
264
+ expect("degraded" in (await row(false))).toBe(false);
265
+ });
266
+ test("degraded → light and full rows list the failing layers with the app's labels", async () => {
267
+ setHealth({ bridge: "ok", app_server: { ok: false, rtt_ms: null, last_error: "refused" }, model_auth: "revoked" });
268
+ const want = [
269
+ { layer: "app_server", label: "App Server 断开", reason: "refused" },
270
+ { layer: "model_auth", label: "需要重新登录", reason: "revoked" },
271
+ ];
272
+ expect((await row(true)).degraded).toEqual(want);
273
+ expect((await row(false)).degraded).toEqual(want);
274
+ });
275
+ test("stale → no `degraded`", async () => {
276
+ setHealth(DOWN_APP, Date.now() - NODE_HEALTH_TTL_MS - 1_000);
277
+ expect("degraded" in (await row(true))).toBe(false);
278
+ });
279
+ });
@@ -0,0 +1,105 @@
1
+ // #460(#448 的子项)—— 降级节点拒收新任务。
2
+ //
3
+ // 现场:节点在 Hub 上 idle / in_flight=0,实际 app-server 端口没人听、TUI 变成 `sleep infinity`、
4
+ // 或登录态 revoked —— 派过去的任务**静默排队**,谁都不知道它永远不会被执行。agent-node .94 起
5
+ // 把这几层报上来(report_status.health,node-health-store.ts 只存内存、10 分钟 TTL);这里在**派发**
6
+ // 那一刻读它:新鲜的健康报告说某一层坏了,就直接拒,带上是哪一层、怎么修。
7
+ //
8
+ // 🔴 只拒「确知坏了」的:没有报告(老 agent-node / claude-code 节点)、报告已过期(>TTL)、
9
+ // 或某层没报(比如非共存节点没有 tui)= 不知道 = 放行。宁可像以前一样排队,
10
+ // 也不能因为缺数据把一台正常的老节点挡在外面。
11
+ // 🔴 只管**新任务派发**(send_task / REST /api/task / 定时任务 / retry / reassign / broadcast)。
12
+ // 回复、send_message、ack 不经过这里 —— 降级节点发出的回复照常送达,发给它的普通消息也照常入库。
13
+ // 逃生口:用户(不是节点令牌)可以带 force=true 强制派发 —— 比如明知 TUI 不在、但 bridge 能干活。
14
+ // 节点令牌不能 force:不让 agent 绕过对另一个 agent 的健康判断。
15
+
16
+ import { readNodeHealth, type NodeHealth } from "./node-health-store.js";
17
+
18
+ export type DegradedLayer = {
19
+ layer: "app_server" | "tui" | "model_auth";
20
+ /** 机器可读的原因:app_server 的错误摘要 / tui 的 reason / model_auth 的状态。 */
21
+ reason: string;
22
+ /** 给人看的一句(中文,与 app 的「降级」徽标同一套措辞)。 */
23
+ label: string;
24
+ hint: string;
25
+ };
26
+
27
+ const TUI_LABEL: Record<string, string> = {
28
+ "session-missing": "TUI 会话不在",
29
+ "pane-dead": "TUI 已退出",
30
+ "sleep-placeholder": "TUI 被占位(sleep)",
31
+ "tmux-unavailable": "tmux 不可用",
32
+ };
33
+
34
+ /** 一份健康报告里确知坏掉的层;没报的层不算。 */
35
+ export function degradedLayers(health: NodeHealth | null | undefined): DegradedLayer[] {
36
+ if (!health) return [];
37
+ const out: DegradedLayer[] = [];
38
+ if (health.app_server && health.app_server.ok === false) {
39
+ out.push({
40
+ layer: "app_server",
41
+ reason: (health.app_server.last_error || "unreachable").slice(0, 200),
42
+ label: "App Server 断开",
43
+ hint: "节点的 codex app-server 端口没有响应:在节点所在机器上重启该节点(anet node restart <别名>)",
44
+ });
45
+ }
46
+ if (health.tui && health.tui.ok === false) {
47
+ out.push({
48
+ layer: "tui",
49
+ reason: health.tui.reason,
50
+ label: TUI_LABEL[health.tui.reason] ?? "TUI 不可用",
51
+ hint: "共存 TUI 不在运行:在节点所在机器上重新拉起该节点的 TUI(anet node restart <别名>)",
52
+ });
53
+ }
54
+ if (health.model_auth === "revoked" || health.model_auth === "expired") {
55
+ out.push({
56
+ layer: "model_auth",
57
+ reason: health.model_auth,
58
+ label: health.model_auth === "revoked" ? "需要重新登录" : "登录已过期",
59
+ hint: "该节点自己的 CODEX_HOME 需要重新登录(在那台机器上:CODEX_HOME=<节点目录>/codex-home codex login);不要拷别的节点的 auth.json",
60
+ });
61
+ }
62
+ return out;
63
+ }
64
+
65
+ export type HealthGuardResult =
66
+ | { ok: true; forced?: true }
67
+ | {
68
+ ok: false;
69
+ error: "node_degraded";
70
+ alias: string;
71
+ layers: DegradedLayer[];
72
+ message: string;
73
+ hint: string;
74
+ health_observed_ms_ago: number;
75
+ /** 能不能用 force=true 绕过(只有用户令牌能)。 */
76
+ force_allowed: boolean;
77
+ };
78
+
79
+ /**
80
+ * 派发前的健康门。networkId 用与 report_status 写入时同一个键(sessionNetId = 网络 id ?? "default")。
81
+ * force 只在 forceAllowed(用户令牌)时生效。
82
+ */
83
+ export function assertNodeHealthy(
84
+ alias: string,
85
+ networkId: string | null | undefined,
86
+ opts: { force?: boolean; forceAllowed: boolean; now?: number },
87
+ ): HealthGuardResult {
88
+ if (!alias) return { ok: true };
89
+ const hit = readNodeHealth(networkId ?? "default", alias, opts.now);
90
+ if (!hit) return { ok: true };
91
+ const layers = degradedLayers(hit.health);
92
+ if (!layers.length) return { ok: true };
93
+ if (opts.force && opts.forceAllowed) return { ok: true, forced: true };
94
+ const labels = layers.map(l => l.label).join("、");
95
+ return {
96
+ ok: false,
97
+ error: "node_degraded",
98
+ alias,
99
+ layers,
100
+ message: `节点 ${alias} 处于降级状态(${labels}),任务不会被执行,已拒绝派发。`,
101
+ hint: layers[0].hint + (opts.forceAllowed ? "。确需强制派发可带 force=true。" : ""),
102
+ health_observed_ms_ago: hit.observed_ms_ago,
103
+ force_allowed: opts.forceAllowed,
104
+ };
105
+ }
@@ -1,5 +1,6 @@
1
1
  import { db, logTaskEvent } from "./db.js";
2
2
  import { assertNodeActive } from "./lifecycle-guard.js";
3
+ import { assertNodeHealthy } from "./node-health-guard.js";
3
4
  import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
4
5
  import { canMessageAgent } from "./agent-access.js";
5
6
  import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
@@ -290,6 +291,19 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
290
291
  return;
291
292
  }
292
293
 
294
+ // #460 — the target reports a degraded layer (App Server down / TUI gone / login revoked): the run fails
295
+ // with that reason instead of queueing a task nobody will execute. Unknown / stale health runs as before.
296
+ const health = assertNodeHealthy(node.alias, row.network_id, { forceAllowed: false });
297
+ if (!health.ok) {
298
+ finalStatus = "failed";
299
+ db.run(
300
+ "UPDATE scheduled_task_runs SET status = 'failed', error_code = 'node_degraded', error_message = ?1, completed_at = datetime('now') WHERE run_id = ?2",
301
+ [health.message.slice(0, 500), runId],
302
+ );
303
+ if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
304
+ return;
305
+ }
306
+
293
307
  const taskId = crypto.randomUUID();
294
308
  // claude-code 裸节点的 node-server 上报 report_status 不带 node_id,它的 sessions 行
295
309
  // node_id 永远是 NULL —— 只按 node_id 找会话,这类节点每一次都判成 queued、门铃不响,
package/src/server.ts CHANGED
@@ -2,6 +2,8 @@ import { buildServeErrorResponse } from "./serve-error.js";
2
2
  import { maybeGzipResponse, trimLightTask } from "./http-gzip";
3
3
  import { statusAliasResolverRead } from "./status-alias-resolver.js";
4
4
  import { readNodeHealth } from "./node-health-store.js";
5
+ import { assertNodeHealthy, degradedLayers } from "./node-health-guard.js";
6
+ import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setMemberDepartment, updateDepartment } from "./departments.js";
5
7
  import { redactMessageRow } from "./redact-tokens.js";
6
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
9
  import { parseDbTimestampMs } from "./db-timestamp.js";
@@ -749,8 +751,18 @@ const TaskSchema = z.object({
749
751
  // stays unified regardless of transport (REST or MCP).
750
752
  attachments: z.any().optional(),
751
753
  meta: z.any().optional(),
754
+ // #460 — dispatch even when the target reports a degraded health layer. Honoured for user
755
+ // tokens only (a node token cannot override another node's health). Old clients never send it.
756
+ force: z.boolean().optional(),
752
757
  });
753
758
 
759
+ /** #460 — `{ degraded: [{ layer, label, reason }] }` when the node's fresh health says a layer is down, else `{}`. */
760
+ function degradedField(networkId: string | null | undefined, alias: string): { degraded?: Array<{ layer: string; label: string; reason: string }> } {
761
+ const h = readNodeHealth(networkId ?? "default", alias);
762
+ const layers = degradedLayers(h?.health);
763
+ return layers.length ? { degraded: layers.map(l => ({ layer: l.layer, label: l.label, reason: l.reason })) } : {};
764
+ }
765
+
754
766
  const BroadcastSchema = z.object({
755
767
  message: z.string().min(1).max(10000),
756
768
  filter_server: z.string().max(200).optional(),
@@ -1708,6 +1720,60 @@ return Bun.serve({
1708
1720
  return withCors(req, Response.json({ ok: true, network_id: netId, humans: humans.map((h) => ({ ...h, ...presence.get(h.user_id)! })) }));
1709
1721
  }
1710
1722
 
1723
+ // ── 组织架构(board #419):部门 + 成员所在部门。读:网络任何成员 / 本网络的节点令牌 / Hub 管理员;
1724
+ // 写:网络 owner / admin、Hub 管理员(节点令牌不能写)。规则在 departments.ts。 ──
1725
+ const deptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments(?:\/([^/]+))?$/);
1726
+ const memberDeptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/department$/);
1727
+ if (deptMatch || memberDeptMatch) {
1728
+ const token = requestToken(req, { allowQueryToken: false });
1729
+ const resolved = token ? resolveToken(token) : null;
1730
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1731
+ const netId = decodeURIComponent((deptMatch ?? memberDeptMatch)![1]);
1732
+ if (resolved.networkId && resolved.networkId !== netId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1733
+ const hubAdmin = isHubAdminCredential(resolved);
1734
+ const role = resolved.networkId ? null : getUserNetworkRole(resolved.user.user_id, netId);
1735
+ if (!hubAdmin && !role && !resolved.networkId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1736
+ 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 }));
1737
+ if (deptMatch && !deptMatch[2] && req.method === "GET") {
1738
+ return withCors(req, Response.json({ ok: true, network_id: netId, ...listDepartments(netId) }));
1739
+ }
1740
+ const canManage = hubAdmin || (!resolved.networkId && (role === "owner" || role === "admin"));
1741
+ if (!["POST", "PATCH", "PUT", "DELETE"].includes(req.method)) return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1742
+ if (!canManage) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1743
+ let body: Record<string, unknown> = {};
1744
+ if (req.method !== "DELETE") {
1745
+ 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 })); }
1746
+ }
1747
+ const reply = (r: { ok: boolean; status?: number } & Record<string, unknown>, okStatus = 200) => {
1748
+ const { status, ...rest } = r;
1749
+ return withCors(req, Response.json(rest, { status: r.ok ? okStatus : status ?? 400 }));
1750
+ };
1751
+ if (memberDeptMatch) {
1752
+ if (req.method !== "PUT") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1753
+ const targetUid = decodeURIComponent(memberDeptMatch[2]);
1754
+ const r = setMemberDepartment(netId, targetUid, body.department_id);
1755
+ if (r.ok) auditDepartment(resolved.user, "member_department_set", netId, `${targetUid} → ${r.department_id ?? "(unassigned)"}`);
1756
+ return reply(r);
1757
+ }
1758
+ const deptId = deptMatch![2] ? decodeURIComponent(deptMatch![2]) : null;
1759
+ if (!deptId && req.method === "POST") {
1760
+ const r = createDepartment(netId, resolved.user.user_id, body);
1761
+ 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 }));
1762
+ return reply(r, 201);
1763
+ }
1764
+ if (deptId && req.method === "PATCH") {
1765
+ const r = updateDepartment(netId, deptId, body);
1766
+ if (r.ok) auditDepartment(resolved.user, "department_updated", netId, JSON.stringify({ id: deptId, ...body }));
1767
+ return reply(r);
1768
+ }
1769
+ if (deptId && req.method === "DELETE") {
1770
+ const r = deleteDepartment(netId, deptId);
1771
+ if (r.ok) auditDepartment(resolved.user, "department_deleted", netId, deptId);
1772
+ return reply(r as { ok: boolean; status?: number } & Record<string, unknown>);
1773
+ }
1774
+ return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1775
+ }
1776
+
1711
1777
  // ── V3.13: Network members + invites ──
1712
1778
  const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
1713
1779
  if (membersMatch) {
@@ -2262,6 +2328,10 @@ return Bun.serve({
2262
2328
  // Only when filtering by node_id (or for the old alias resolver), so the unfiltered light response
2263
2329
  // keeps its exact bytes.
2264
2330
  ...(withNodeId ? { node_id: s.node_id ?? null } : {}),
2331
+ // #460 — the app's list reads the light projection; a node whose fresh health says a layer is down
2332
+ // carries the failing layers here (the same ones dispatch refuses with node_degraded). Absent for every
2333
+ // healthy / unknown node, so the response is byte-identical unless something is actually degraded.
2334
+ ...degradedField(s.network_id, s.alias),
2265
2335
  };
2266
2336
  }
2267
2337
  const externalSchedules = (() => {
@@ -2304,6 +2374,7 @@ return Bun.serve({
2304
2374
  const h = readNodeHealth(s.network_id, s.alias);
2305
2375
  return { health: h?.health ?? null, health_observed_ms_ago: h?.observed_ms_ago ?? null };
2306
2376
  })(),
2377
+ ...degradedField(s.network_id, s.alias),
2307
2378
  };
2308
2379
  });
2309
2380
  const summary = sessions.reduce((acc: any, session: any) => {
@@ -3156,6 +3227,12 @@ return Bun.serve({
3156
3227
  return withCors(req, Response.json(lc, { status: 409 }));
3157
3228
  }
3158
3229
  }
3230
+ // #460 — refuse dispatch to a node whose fresh health says a layer is down (409, same as the
3231
+ // lifecycle guard above). Unknown / stale health = allowed: old nodes keep working.
3232
+ {
3233
+ const hg = assertNodeHealthy(targetAlias, taskNetId ?? null, { force: body.force === true, forceAllowed: !restAuth?.networkId });
3234
+ if (!hg.ok) return withCors(req, Response.json(hg, { status: 409 }));
3235
+ }
3159
3236
  // Mirror send_task MCP: write inbox + tasks rows in a single
3160
3237
  // transaction so the dispatch is visible to dashboard's Tasks page
3161
3238
  // and the parent_task_id lineage chain. Previously this endpoint
package/src/tools.ts CHANGED
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { parseDbTimestampMs } from "./db-timestamp.js";
3
3
  import { z } from "zod/v4";
4
4
  import { nodeHealthSchema, recordNodeHealth } from "./node-health-store.js";
5
+ import { assertNodeHealthy } from "./node-health-guard.js";
5
6
  import { parseAliasFilter } from "./alias-filter.js";
6
7
  import { createHash } from "node:crypto";
7
8
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
@@ -1632,8 +1633,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1632
1633
  network_id: z.string().max(200).optional().describe("Network scope"),
1633
1634
  parent_task_id: z.string().max(200).optional().describe("Parent task this dispatch is on behalf of. When the child task replies the hub will auto-chain the answer to the parent task's originator, so the user sees the final result even if the intermediate session ends."),
1634
1635
  meta: z.any().optional().describe("Optional structured task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
1636
+ force: z.boolean().optional().describe("#460 — dispatch even when the target reports a degraded health layer (user tokens only; ignored for node tokens)"),
1635
1637
  },
1636
- async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1638
+ async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1637
1639
  const effectiveNetId = getNetworkId(netId);
1638
1640
  const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
1639
1641
  ? "node"
@@ -1787,6 +1789,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1787
1789
  const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1788
1790
  if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1789
1791
  }
1792
+ // #460 — a node whose fresh health says a layer is down will not run the task: refuse instead of queueing silently.
1793
+ {
1794
+ const hg = assertNodeHealthy(targetAlias, effectiveNetId ?? null, { force, forceAllowed: !callerTokenIsNetwork });
1795
+ if (!hg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(hg) }] };
1796
+ }
1790
1797
 
1791
1798
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
1792
1799
  const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
@@ -2346,8 +2353,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2346
2353
  task_id: z.string().min(1).max(200).describe("Task ID to retry"),
2347
2354
  from_session: z.string().max(200).optional(),
2348
2355
  network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
2356
+ force: z.boolean().optional().describe("#460 — dispatch even when the target reports a degraded health layer (user tokens only; ignored for node tokens)"),
2349
2357
  },
2350
- async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2358
+ async ({ task_id, from_session: _fromIn, network_id: netId, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2351
2359
  const effectiveNetId = getNetworkId(netId);
2352
2360
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
2353
2361
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
@@ -2367,6 +2375,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2367
2375
  const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
2368
2376
  if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2369
2377
  }
2378
+ // #460 — a node whose fresh health says a layer is down will not run the task: refuse instead of queueing silently.
2379
+ {
2380
+ const hg = assertNodeHealthy(task.to_name, effectiveNetId ?? task.network_id ?? null, { force, forceAllowed: !callerTokenIsNetwork });
2381
+ if (!hg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(hg) }] };
2382
+ }
2370
2383
  db.transaction(() => {
2371
2384
  // Reset task status
2372
2385
  const updateParams: any[] = [task_id];
@@ -2559,8 +2572,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2559
2572
  new_alias: z.string().min(1).max(200).describe("Target agent alias"),
2560
2573
  from_session: z.string().max(200).optional(),
2561
2574
  network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
2575
+ force: z.boolean().optional().describe("#460 — dispatch even when the target reports a degraded health layer (user tokens only; ignored for node tokens)"),
2562
2576
  },
2563
- async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2577
+ async ({ task_id, new_alias, from_session: _fromIn, network_id: netId, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2564
2578
  const effectiveNetId = getNetworkId(netId);
2565
2579
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
2566
2580
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
@@ -2582,6 +2596,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2582
2596
  const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
2583
2597
  if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2584
2598
  }
2599
+ // #460 — a node whose fresh health says a layer is down will not run the task: refuse instead of queueing silently.
2600
+ {
2601
+ const hg = assertNodeHealthy(reassignedAlias, effectiveNetId ?? task.network_id ?? null, { force, forceAllowed: !callerTokenIsNetwork });
2602
+ if (!hg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(hg) }] };
2603
+ }
2585
2604
  db.transaction(() => {
2586
2605
  // Ack old inbox to prevent original agent from picking it up
2587
2606
  const inboxParams: any[] = [task_id];