@sleep2agi/commhub-server 0.9.0-preview.82 → 0.9.0-preview.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-access.ts +5 -0
- package/src/mcp-projects-events-http.test.ts +162 -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 +7 -0
- package/src/tools.ts +71 -2
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.84",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/agent-access.ts
CHANGED
|
@@ -475,4 +475,9 @@ export const RESTRICTED_MEMBER_TOOLS: ReadonlySet<string> = new Set([
|
|
|
475
475
|
"requirements_checklist_toggle",
|
|
476
476
|
"requirements_upsert_by_external_ref",
|
|
477
477
|
"projects_list",
|
|
478
|
+
// 2026-10-02(app 任务页审计 M3):和 app「管理项目」/「动态」同一个 REST 处理,权限也同一套 —— 项目管理对仅相关任务的
|
|
479
|
+
// 成员和 viewer 一律 403;动态按任务可见性过滤,受限成员看不见的节点在里面隐去(listEvents 的 hiddenNodeFilter)。
|
|
480
|
+
"projects_create",
|
|
481
|
+
"projects_update",
|
|
482
|
+
"requirements_events",
|
|
478
483
|
]);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// MCP projects_create / projects_update / requirements_events(app 任务页审计 2026-10-02 M3:Agent 能做人能做的)。
|
|
2
|
+
// HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。三个工具都走 app 用的同一套 REST 处理(handleRequirementsRequest),
|
|
3
|
+
// 所以这里不另抄一份权限规则:对每类调用者(网络 owner、全部任务成员、仅相关任务成员、viewer、节点令牌)都同时调
|
|
4
|
+
// MCP 和 REST,断言状态码 / 结果逐项一致。再钉住正向行为(建 / 改名 / 归档 / 取消归档、读流水、"#N")和工具出现在 tools/list 里。
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
7
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { tmpdir } from "os";
|
|
10
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
11
|
+
import { db } from "./db.js";
|
|
12
|
+
|
|
13
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-mcp-projects-events-"));
|
|
14
|
+
let BASE = "";
|
|
15
|
+
let hub: any = null;
|
|
16
|
+
const PW = "McpProjectsEventsPassw0rd!x";
|
|
17
|
+
let NET = "";
|
|
18
|
+
const U: Record<string, { token: string; id: string }> = {};
|
|
19
|
+
|
|
20
|
+
async function rest(token: string, method: string, path: string, body?: unknown) {
|
|
21
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
22
|
+
const text = await res.text();
|
|
23
|
+
let json: any = null;
|
|
24
|
+
try { json = JSON.parse(text); } catch {}
|
|
25
|
+
return { status: res.status, body: json };
|
|
26
|
+
}
|
|
27
|
+
async function rpc(token: string, method: string, params?: unknown): Promise<any> {
|
|
28
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
31
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, ...(params === undefined ? {} : { params }) }),
|
|
32
|
+
});
|
|
33
|
+
const raw = await res.text();
|
|
34
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
35
|
+
return lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
38
|
+
const out = await rpc(token, "tools/call", { name, arguments: args });
|
|
39
|
+
return JSON.parse(out.result.content[0].text);
|
|
40
|
+
}
|
|
41
|
+
// MCP error bodies from the REST handler carry its status (requirementsCall). Anything else that isn't ok (e.g. a tool-level
|
|
42
|
+
// gate) is NOT a success — a body without `status` must never be read as the 2xx (that hid a gate in the first draft).
|
|
43
|
+
const mcpStatus = (r: any, okStatus: number) => (r?.ok === true || (r && r.ok === undefined && (r.project || r.events)) ? okStatus : typeof r?.status === "number" ? r.status : -1);
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
47
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
48
|
+
process.env.HOST = "127.0.0.1";
|
|
49
|
+
const a = register(`mpe_owner_${Date.now()}`, PW, undefined, "Owner");
|
|
50
|
+
expect(a.ok).toBe(true);
|
|
51
|
+
U.owner = { token: a.token!, id: a.user!.user_id };
|
|
52
|
+
NET = a.network_id!;
|
|
53
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
|
|
54
|
+
const mod: any = await import("./server.js");
|
|
55
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
56
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
57
|
+
const mk = async (username: string, role: string) => {
|
|
58
|
+
expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
59
|
+
const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
|
|
60
|
+
expect(login.status).toBe(200);
|
|
61
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
62
|
+
};
|
|
63
|
+
const stamp = Date.now();
|
|
64
|
+
U.member = await mk(`mpe_member_${stamp}`, "member");
|
|
65
|
+
U.scoped = await mk(`mpe_scoped_${stamp}`, "member");
|
|
66
|
+
U.viewer = await mk(`mpe_viewer_${stamp}`, "viewer");
|
|
67
|
+
db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, U.member.id]);
|
|
68
|
+
expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.scoped.id}/task-grants`, { task_access: "scoped", project_grants: [] })).status).toBe(200);
|
|
69
|
+
const node = createNetworkTokenForNode(U.owner.id, NET, "mpe-node", "node_mpe");
|
|
70
|
+
U.node = { token: node.token!, id: "node_mpe" };
|
|
71
|
+
}, 30_000);
|
|
72
|
+
|
|
73
|
+
afterAll(() => {
|
|
74
|
+
try { hub?.stop?.(true); } catch {}
|
|
75
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("discoverable", () => {
|
|
79
|
+
test("tools/list carries the three tools with descriptions", async () => {
|
|
80
|
+
const list = await rpc(U.owner.token, "tools/list");
|
|
81
|
+
const byName = new Map((list.result.tools as any[]).map(t => [t.name, t]));
|
|
82
|
+
for (const n of ["projects_create", "projects_update", "requirements_events"]) {
|
|
83
|
+
expect(byName.has(n)).toBe(true);
|
|
84
|
+
expect(String(byName.get(n).description).length).toBeGreaterThan(40);
|
|
85
|
+
}
|
|
86
|
+
expect(String(byName.get("projects_list").description)).toContain("projects_create");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("projects_create / projects_update", () => {
|
|
91
|
+
test("owner: create, rename, recolour, archive, unarchive — same rows the app sees", async () => {
|
|
92
|
+
const created = await mcp(U.owner.token, "projects_create", { network_id: NET, name: "mpe-项目甲" });
|
|
93
|
+
expect(created.ok).toBe(true);
|
|
94
|
+
const id = created.project.id as string;
|
|
95
|
+
expect(created.project.name).toBe("mpe-项目甲");
|
|
96
|
+
const renamed = await mcp(U.owner.token, "projects_update", { network_id: NET, id, name: "mpe-项目乙", color: "#16a34a" });
|
|
97
|
+
expect(renamed.project.name).toBe("mpe-项目乙");
|
|
98
|
+
expect(renamed.project.color).toBe("#16a34a");
|
|
99
|
+
expect((await mcp(U.owner.token, "projects_update", { network_id: NET, id, archived: true })).project.archived).toBe(true);
|
|
100
|
+
const seen = await rest(U.owner.token, "GET", `/api/requirements/projects?network_id=${NET}`);
|
|
101
|
+
expect(seen.body.projects.find((p: any) => p.id === id).archived).toBe(true);
|
|
102
|
+
expect((await mcp(U.owner.token, "projects_update", { network_id: NET, id, archived: false })).project.archived).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("validation comes from the REST handler: duplicate name 409, unknown id 404", async () => {
|
|
106
|
+
await mcp(U.owner.token, "projects_create", { network_id: NET, name: "mpe-重名" });
|
|
107
|
+
const dup = await mcp(U.owner.token, "projects_create", { network_id: NET, name: "mpe-重名" });
|
|
108
|
+
expect(dup.ok).toBe(false);
|
|
109
|
+
expect(dup.status).toBe(409);
|
|
110
|
+
const missing = await mcp(U.owner.token, "projects_update", { network_id: NET, id: "proj_nobody", name: "x" });
|
|
111
|
+
expect(missing.status).toBe(404);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("permission parity with the app's REST path, for every kind of caller", async () => {
|
|
115
|
+
const results: Record<string, { mcp: number; rest: number }> = {};
|
|
116
|
+
for (const who of ["owner", "member", "scoped", "viewer", "node"]) {
|
|
117
|
+
const name = `mpe-parity-${who}`;
|
|
118
|
+
const viaMcp = await mcp(U[who].token, "projects_create", { network_id: NET, name: `${name}-mcp` });
|
|
119
|
+
const viaRest = await rest(U[who].token, "POST", `/api/requirements/projects?network_id=${NET}`, { name: `${name}-rest` });
|
|
120
|
+
results[who] = { mcp: mcpStatus(viaMcp, 201), rest: viaRest.status };
|
|
121
|
+
}
|
|
122
|
+
for (const r of Object.values(results)) expect(r.mcp).toBe(r.rest);
|
|
123
|
+
// the rule itself (RFC-038 §9): task-scoped members and viewers can't manage projects; the others can
|
|
124
|
+
expect(results.owner.rest).toBe(201);
|
|
125
|
+
expect(results.member.rest).toBe(201);
|
|
126
|
+
expect(results.scoped.rest).toBe(403);
|
|
127
|
+
expect(results.viewer.rest).toBe(403);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("requirements_events", () => {
|
|
132
|
+
test("one task's timeline by id and by #N, same rows as GET /api/requirements/events", async () => {
|
|
133
|
+
const card = await rest(U.owner.token, "POST", "/api/requirements", { network_id: NET, name: "mpe-流水示例" });
|
|
134
|
+
expect(card.status).toBe(201);
|
|
135
|
+
const id = card.body.requirement.id as string, seq = card.body.requirement.seq as number;
|
|
136
|
+
expect((await rest(U.owner.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { priority: "high" })).status).toBe(200);
|
|
137
|
+
const viaMcp = await mcp(U.owner.token, "requirements_events", { network_id: NET, requirement_id: id });
|
|
138
|
+
const viaRest = await rest(U.owner.token, "GET", `/api/requirements/events?network_id=${NET}&requirement_id=${id}`);
|
|
139
|
+
expect(viaMcp.ok).toBe(true);
|
|
140
|
+
expect(viaMcp.events.length).toBeGreaterThan(0);
|
|
141
|
+
expect(viaMcp.events.map((e: any) => e.id)).toEqual(viaRest.body.events.map((e: any) => e.id));
|
|
142
|
+
expect(viaMcp.events.every((e: any) => e.requirement_id === id)).toBe(true);
|
|
143
|
+
const bySeq = await mcp(U.owner.token, "requirements_events", { network_id: NET, requirement_id: `#${seq}` });
|
|
144
|
+
expect(bySeq.events.map((e: any) => e.id)).toEqual(viaMcp.events.map((e: any) => e.id));
|
|
145
|
+
// paging / bad input come from the REST handler too
|
|
146
|
+
expect((await mcp(U.owner.token, "requirements_events", { network_id: NET, since: "not-a-date" })).status).toBe(400);
|
|
147
|
+
expect((await mcp(U.owner.token, "requirements_events", { network_id: NET, requirement_id: "#999999" })).status).toBe(404);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("visibility parity: a task-scoped member doesn't get events for a task they can't see", async () => {
|
|
151
|
+
const hidden = await rest(U.owner.token, "POST", "/api/requirements", { network_id: NET, name: "mpe-看不见的" });
|
|
152
|
+
const id = hidden.body.requirement.id as string;
|
|
153
|
+
await rest(U.owner.token, "PATCH", `/api/requirements/${id}?network_id=${NET}`, { priority: "low" });
|
|
154
|
+
for (const who of ["scoped", "member", "node"]) {
|
|
155
|
+
const viaMcp = await mcp(U[who].token, "requirements_events", { network_id: NET, requirement_id: id });
|
|
156
|
+
const viaRest = await rest(U[who].token, "GET", `/api/requirements/events?network_id=${NET}&requirement_id=${id}`);
|
|
157
|
+
expect((viaMcp.events ?? []).map((e: any) => e.id)).toEqual((viaRest.body?.events ?? []).map((e: any) => e.id));
|
|
158
|
+
}
|
|
159
|
+
expect((await mcp(U.scoped.token, "requirements_events", { network_id: NET, requirement_id: id })).events ?? []).toHaveLength(0);
|
|
160
|
+
expect((await mcp(U.member.token, "requirements_events", { network_id: NET, requirement_id: id })).events.length).toBeGreaterThan(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -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,7 @@
|
|
|
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";
|
|
4
5
|
import { redactMessageRow } from "./redact-tokens.js";
|
|
5
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
7
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
@@ -2297,6 +2298,12 @@ return Bun.serve({
|
|
|
2297
2298
|
uptime_seconds: s.process_uptime_seconds ?? null,
|
|
2298
2299
|
in_flight_count: s.process_in_flight_count ?? null,
|
|
2299
2300
|
},
|
|
2301
|
+
// #448 — latest layered health the node reported (memory-only, 10 min TTL);
|
|
2302
|
+
// null = not reported / stale / older agent-node — never "healthy".
|
|
2303
|
+
...(() => {
|
|
2304
|
+
const h = readNodeHealth(s.network_id, s.alias);
|
|
2305
|
+
return { health: h?.health ?? null, health_observed_ms_ago: h?.observed_ms_ago ?? null };
|
|
2306
|
+
})(),
|
|
2300
2307
|
};
|
|
2301
2308
|
});
|
|
2302
2309
|
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
|
|
@@ -5888,10 +5900,67 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5888
5900
|
|
|
5889
5901
|
server.tool(
|
|
5890
5902
|
"projects_list",
|
|
5891
|
-
"List requirement projects in your network (id, name, color, sort, archived).",
|
|
5903
|
+
"List requirement projects in your network (id, name, color, sort, archived). Manage them with projects_create / projects_update.",
|
|
5892
5904
|
{ network_id: z.string().max(200).optional() },
|
|
5893
5905
|
async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
|
|
5894
5906
|
);
|
|
5907
|
+
|
|
5908
|
+
// Agent parity (app task audit 2026-10-02 M3): what people do under 管理项目 and the task 动态 timeline. Both go
|
|
5909
|
+
// through the same REST handlers as the app, so every permission check is the app's own: project management needs
|
|
5910
|
+
// write access and is refused to 「仅相关任务」(task-scoped) members; events use the task list's visibility.
|
|
5911
|
+
server.tool(
|
|
5912
|
+
"projects_create",
|
|
5913
|
+
"Create a requirement project in your network (same as 管理项目 → 新建 in the app). name 1–40 characters, unique in the network; color #RRGGBB (default: next palette colour); sort integer. Returns { project }. Refused (403) for task-scoped members and read-only roles.",
|
|
5914
|
+
{
|
|
5915
|
+
network_id: z.string().max(200).optional(),
|
|
5916
|
+
name: z.string().min(1).max(40),
|
|
5917
|
+
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
5918
|
+
sort: z.number().int().optional(),
|
|
5919
|
+
},
|
|
5920
|
+
async (args) => requirementsCall("POST", "/api/requirements/projects", args.network_id, pick(args, ["name", "color", "sort"])),
|
|
5921
|
+
);
|
|
5922
|
+
|
|
5923
|
+
server.tool(
|
|
5924
|
+
"projects_update",
|
|
5925
|
+
"Rename, recolour, reorder, archive or unarchive a requirement project (same as 管理项目 in the app). Only the fields you pass change. archived=true hides it from pickers (its tasks keep project_id); archived=false brings it back. Returns { project }. Refused (403) for task-scoped members and read-only roles.",
|
|
5926
|
+
{
|
|
5927
|
+
network_id: z.string().max(200).optional(),
|
|
5928
|
+
id: z.string().min(1).max(200).describe("project id (proj_…), from projects_list"),
|
|
5929
|
+
name: z.string().min(1).max(40).optional(),
|
|
5930
|
+
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
5931
|
+
sort: z.number().int().optional(),
|
|
5932
|
+
archived: z.boolean().optional(),
|
|
5933
|
+
},
|
|
5934
|
+
async (args) => requirementsCall("PATCH", `/api/requirements/projects/${encodeURIComponent(args.id)}`, args.network_id, pick(args, ["name", "color", "sort", "archived"])),
|
|
5935
|
+
);
|
|
5936
|
+
|
|
5937
|
+
server.tool(
|
|
5938
|
+
"requirements_events",
|
|
5939
|
+
"Read the task activity timeline (the app's 动态): field-level changes — who, when, old → new — newest first. Pass requirement_id (req_… or \"#N\") for one task, or omit it for the whole network. since (ISO) = only changes after it; limit 1–500 (default 200); next_cursor → pass as cursor for the next page; server_time → use as the next since. Same visibility as requirements_list: tasks you can't see (and hidden Agents) are left out.",
|
|
5940
|
+
{
|
|
5941
|
+
network_id: z.string().max(200).optional(),
|
|
5942
|
+
requirement_id: z.string().min(1).max(200).optional(),
|
|
5943
|
+
since: z.string().max(40).optional(),
|
|
5944
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
5945
|
+
cursor: z.string().max(20).optional(),
|
|
5946
|
+
},
|
|
5947
|
+
async (args) => {
|
|
5948
|
+
let card = args.requirement_id;
|
|
5949
|
+
// "#N" → the card's id, resolved exactly like requirements_get (same scope; a card you can't see is a 404 here too).
|
|
5950
|
+
if (card && /^#\d+$/.test(card)) {
|
|
5951
|
+
const got = await requirementsCall("GET", `/api/requirements/${encodeURIComponent(card)}`, args.network_id);
|
|
5952
|
+
const parsed = JSON.parse(got.content[0].text) as { requirement?: { id?: string } };
|
|
5953
|
+
if (!parsed.requirement?.id) return got;
|
|
5954
|
+
card = parsed.requirement.id;
|
|
5955
|
+
}
|
|
5956
|
+
const q = new URLSearchParams();
|
|
5957
|
+
if (card) q.set("requirement_id", card);
|
|
5958
|
+
if (args.since) q.set("since", args.since);
|
|
5959
|
+
if (args.limit !== undefined) q.set("limit", String(args.limit));
|
|
5960
|
+
if (args.cursor) q.set("cursor", args.cursor);
|
|
5961
|
+
return requirementsCall("GET", `/api/requirements/events${q.size ? `?${q}` : ""}`, args.network_id);
|
|
5962
|
+
},
|
|
5963
|
+
);
|
|
5895
5964
|
}
|
|
5896
5965
|
|
|
5897
5966
|
// ────────────────────────────────────────────────────────────────────
|