@sleep2agi/commhub-server 0.9.0-preview.46 → 0.9.0-preview.48

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.46",
3
+ "version": "0.9.0-preview.48",
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",
@@ -0,0 +1,122 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ // #1548 —— 一个活着、能收发任务的节点可以在名册里永远显示 `blocked`:
7
+ // 只有 report_completion 会把 sessions.status 拉回 idle,而现在大多数 agent 用
8
+ // send_reply(终态)结束任务、用 send_task 派活 —— 这两条路径此前都不碰 sessions.status。
9
+ // 「它刚回了我一条终态消息 / 刚派了一条任务」本身就是最强的活性证据。
10
+ // 只出 `blocked`;`working` 可能真在忙别的任务,不碰(保守)。
11
+ const NET_ID = "net_1548_blocked";
12
+ const USER_ID = "u_1548";
13
+ const NODE_ID = "node_1548_agent";
14
+ const AGENT_ALIAS = "agent-1548";
15
+ const DISPATCHER_NODE_ID = "node_1548_dispatcher";
16
+ const DISPATCHER_ALIAS = "dispatcher-1548";
17
+ const AGENT_TOK_ID = "tok_1548";
18
+
19
+ type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
20
+
21
+ function cleanup() {
22
+ for (const [sql, params] of [
23
+ ["DELETE FROM tasks WHERE network_id = ?1", [NET_ID]],
24
+ ["DELETE FROM inbox WHERE network_id = ?1", [NET_ID]],
25
+ ["DELETE FROM task_events WHERE network_id = ?1", [NET_ID]],
26
+ ["DELETE FROM sessions WHERE network_id = ?1", [NET_ID]],
27
+ ["DELETE FROM nodes WHERE network_id = ?1", [NET_ID]],
28
+ ["DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]],
29
+ ["DELETE FROM network_members WHERE network_id = ?1", [NET_ID]],
30
+ ["DELETE FROM networks WHERE network_id = ?1", [NET_ID]],
31
+ ["DELETE FROM users WHERE user_id = ?1", [USER_ID]],
32
+ ] as const) {
33
+ try { db.run(sql, params as any); } catch {}
34
+ }
35
+ }
36
+ beforeEach(cleanup);
37
+ afterAll(cleanup);
38
+
39
+ function seed(agentStatus: string) {
40
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
41
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET_ID, NET_ID, USER_ID]);
42
+ db.run(
43
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
44
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
45
+ [USER_ID, NET_ID],
46
+ );
47
+ for (const [nodeId, alias] of [[DISPATCHER_NODE_ID, DISPATCHER_ALIAS], [NODE_ID, AGENT_ALIAS]] as const) {
48
+ db.run(
49
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
50
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
51
+ [nodeId, alias, alias, NET_ID, `host-${alias}`],
52
+ );
53
+ }
54
+ db.run(
55
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
56
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
57
+ [`res_${DISPATCHER_ALIAS}`, DISPATCHER_ALIAS, DISPATCHER_NODE_ID, NET_ID],
58
+ );
59
+ db.run(
60
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
61
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`,
62
+ [`res_${AGENT_ALIAS}`, AGENT_ALIAS, agentStatus, NODE_ID, NET_ID],
63
+ );
64
+ db.run(
65
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
66
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
67
+ [AGENT_TOK_ID, USER_ID, NET_ID, `node:${AGENT_ALIAS}`, `hash_${AGENT_TOK_ID}`],
68
+ );
69
+ }
70
+ function makeTask(): string {
71
+ const id = "t_" + Math.random().toString(36).slice(2, 14);
72
+ db.run(
73
+ `INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, network_id)
74
+ VALUES (?1, ?2, ?3, ?4, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?5)`,
75
+ [id, DISPATCHER_ALIAS, AGENT_ALIAS, NODE_ID, NET_ID],
76
+ );
77
+ return id;
78
+ }
79
+ function buildHandlers(): Record<string, ToolHandler> {
80
+ const server = new McpServer({ name: "test", version: "0" }) as any;
81
+ const tools: Record<string, ToolHandler> = {};
82
+ const origTool = server.tool.bind(server);
83
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => { tools[name] = handler; return origTool(name, _desc, _schema, handler); };
84
+ const origRegisterTool = server.registerTool?.bind(server);
85
+ if (origRegisterTool) {
86
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => { tools[name] = handler; return origRegisterTool(name, _cfg, handler); };
87
+ }
88
+ registerTools(server, undefined, NET_ID, USER_ID, AGENT_ALIAS, true, AGENT_TOK_ID);
89
+ return tools;
90
+ }
91
+ async function call(handler: ToolHandler, args: any): Promise<any> {
92
+ const r = await handler(args);
93
+ return JSON.parse(r.content[0]!.text);
94
+ }
95
+ function agentStatus(): string {
96
+ return (db.get("SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2", [AGENT_ALIAS, NET_ID]) as { status: string }).status;
97
+ }
98
+
99
+ describe("#1548 blocked has an exit: a terminal reply or an outbound task proves the agent is alive", () => {
100
+ test("terminal send_reply from a blocked agent puts it back to idle", async () => {
101
+ seed("blocked");
102
+ const tools = buildHandlers();
103
+ const taskId = makeTask();
104
+ const r = await call(tools.send_reply!, { text: "done", in_reply_to: taskId, status: "replied", from_session: AGENT_ALIAS, network_id: NET_ID });
105
+ expect(r.ok, JSON.stringify(r)).toBe(true);
106
+ expect(agentStatus()).toBe("idle");
107
+ });
108
+ test("send_task from a blocked agent puts it back to idle", async () => {
109
+ seed("blocked");
110
+ const tools = buildHandlers();
111
+ const r = await call(tools.send_task!, { alias: DISPATCHER_ALIAS, task: "ping", priority: "normal", from_session: AGENT_ALIAS, network_id: NET_ID });
112
+ expect(r.ok, JSON.stringify(r)).toBe(true);
113
+ expect(agentStatus()).toBe("idle");
114
+ });
115
+ test("a working agent is left alone (it may be busy with another task)", async () => {
116
+ seed("working");
117
+ const tools = buildHandlers();
118
+ const taskId = makeTask();
119
+ await call(tools.send_reply!, { text: "done", in_reply_to: taskId, status: "replied", from_session: AGENT_ALIAS, network_id: NET_ID });
120
+ expect(agentStatus()).toBe("working");
121
+ });
122
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildServeErrorResponse, serveErrorBody } from "./serve-error.js";
3
+
4
+ // 注:「没有 error 回调时 Bun 回默认非 JSON 500」这一半没法在 bun test 里当用例:
5
+ // 运行器会把 fetch 里抛出的异常当成未处理错误直接判这条用例失败(实测),证据在 #695 末评。
6
+ describe("#695 fetch exceptions become JSON with the exception's own message", () => {
7
+ test("body carries THE message, both JSON-RPC-shaped and REST-shaped", () => {
8
+ const b = serveErrorBody(new Error("boom-695: sqlite is locked"));
9
+ expect(b.error.code).toBe(-32603);
10
+ expect(b.error.message).toBe("boom-695: sqlite is locked");
11
+ expect(b.message).toBe("boom-695: sqlite is locked");
12
+ expect(b.ok).toBe(false);
13
+ // 不是固定 JSON:换一个异常,message 跟着变
14
+ expect(serveErrorBody(new Error("other")).error.message).toBe("other");
15
+ expect(serveErrorBody("plain string").error.message).toBe("plain string");
16
+ expect(serveErrorBody(new Error("")).error.message).toBe("internal error");
17
+ });
18
+
19
+ test("real Bun.serve: a throwing fetch answers 500 + application/json + the message (was a non-JSON default page)", async () => {
20
+ const server = Bun.serve({
21
+ port: 0,
22
+ fetch() { throw new Error("boom-695-live"); },
23
+ error: (err) => buildServeErrorResponse(err),
24
+ });
25
+ try {
26
+ const res = await fetch(`http://127.0.0.1:${server.port}/mcp`, { method: "POST", body: "{}" });
27
+ expect(res.status).toBe(500);
28
+ expect(res.headers.get("content-type") ?? "").toContain("application/json");
29
+ const json = await res.json() as { error: { code: number; message: string }; ok: boolean };
30
+ expect(json.error.code).toBe(-32603);
31
+ expect(json.error.message).toBe("boom-695-live");
32
+ expect(json.ok).toBe(false);
33
+ } finally {
34
+ server.stop(true);
35
+ }
36
+ });
37
+
38
+ });
@@ -0,0 +1,27 @@
1
+ // #695 —— Bun.serve 没注册 `error` 回调时,fetch 处理器抛出的异常会变成 Bun 的默认 500,
2
+ // body 不是 JSON;MCP 客户端于是只能报一个泛化的 -32603,拿不到任何一句人话。
3
+ // 这里把异常变成一个带 message 的 JSON 响应:/mcp 的客户端按 JSON-RPC 形状读 error.message,
4
+ // REST 客户端按本仓其它路由的 { ok:false, error, message } 形状读。
5
+ // 🔴 判据(#695 末评):同一个异常,响应里要带**那个异常的 message**,不是一个固定 JSON。
6
+
7
+ export interface ServeErrorBody {
8
+ jsonrpc: "2.0";
9
+ id: null;
10
+ error: { code: -32603; message: string };
11
+ ok: false;
12
+ message: string;
13
+ }
14
+
15
+ export function serveErrorBody(err: unknown): ServeErrorBody {
16
+ const raw = err instanceof Error ? err.message : String(err);
17
+ const message = (raw && raw.trim()) ? raw.trim().slice(0, 500) : "internal error";
18
+ return { jsonrpc: "2.0", id: null, error: { code: -32603, message }, ok: false, message };
19
+ }
20
+
21
+ export function buildServeErrorResponse(err: unknown): Response {
22
+ const body = serveErrorBody(err);
23
+ return new Response(JSON.stringify(body), {
24
+ status: 500,
25
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
26
+ });
27
+ }
package/src/server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { buildServeErrorResponse } from "./serve-error.js";
1
2
  import { redactMessageRow } from "./redact-tokens.js";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { parseDbTimestampMs } from "./db-timestamp.js";
@@ -786,6 +787,9 @@ return Bun.serve({
786
787
  // route. Default Bun ceiling is 128 MiB which is too permissive for
787
788
  // a hub now exposed to the public internet.
788
789
  maxRequestBodySize: MAX_REQUEST_CONTENT_LENGTH,
790
+ // #695 —— fetch 处理器抛出时不要让 Bun 回默认的非 JSON 500;带上异常 message,
791
+ // /mcp 客户端按 JSON-RPC 读 error.message,REST 客户端按 { ok:false, message } 读。
792
+ error: (err) => buildServeErrorResponse(err),
789
793
 
790
794
  async fetch(req, server) {
791
795
  const url = new URL(req.url, `http://localhost:${PORT}`);
package/src/tools.ts CHANGED
@@ -362,6 +362,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
362
362
  return sql;
363
363
  };
364
364
 
365
+ /** #1548 —— 把一个还标着 blocked 的会话拉回 idle。只在「它刚发出终态回复 / 刚派了任务」这两个活性证据处调用。 */
366
+ const releaseBlockedSession = (alias: string | null | undefined, networkId: string | null | undefined): void => {
367
+ if (!alias) return;
368
+ const params: any[] = [alias];
369
+ let sql = "UPDATE sessions SET status = 'idle', updated_at = datetime('now') WHERE alias = ?1 AND status = 'blocked'";
370
+ sql = addScope(sql, params, networkId ?? null);
371
+ try {
372
+ db.run(sql, params);
373
+ } catch {
374
+ // 展示层的一次修正失败不能影响主路径。
375
+ }
376
+ };
377
+
365
378
  type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
366
379
 
367
380
  // Delegates to the shared membership query (network-scope.ts) — was a
@@ -1598,6 +1611,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1598
1611
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
1599
1612
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
1600
1613
  db.run(touchSql, touchParams);
1614
+ // #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
1615
+ releaseBlockedSession(from_session, effectiveNetId);
1601
1616
  });
1602
1617
  logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
1603
1618
  // Only stamp the dedup index after the inbox/tasks transaction
@@ -1938,6 +1953,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1938
1953
  throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
1939
1954
  }
1940
1955
  syncScheduledRunForTask(in_reply_to, effectiveNetId);
1956
+ // #1548 —— 终态回复是最强的活性证据:一个还标着 blocked 的发送方在此回到 idle。
1957
+ // 只出 blocked;working 可能真在忙别的任务,不碰。
1958
+ releaseBlockedSession(from_session, effectiveNetId);
1941
1959
  return { ok: true as const, replyLogged: true };
1942
1960
  }
1943
1961
  return { ok: true as const, replyLogged: false };