@sleep2agi/commhub-server 0.9.0-preview.38 → 0.9.0-preview.39

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.38",
3
+ "version": "0.9.0-preview.39",
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",
@@ -318,6 +318,8 @@ export function auditCreateNode(input: {
318
318
  // wider migration (see P1.1 issue).
319
319
  | "stop_node_dispatched"
320
320
  | "stop_node_completed"
321
+ | "start_node_dispatched"
322
+ | "start_node_completed"
321
323
  | "delete_node_dispatched"
322
324
  | "delete_node_completed"
323
325
  | "forced_stop_with_in_flight"
package/src/db.ts CHANGED
@@ -432,6 +432,36 @@ db.exec(`
432
432
  db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_daemon ON node_stop_requests(daemon_node_id, status)`);
433
433
  db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_child ON node_stop_requests(child_node_id)`);
434
434
 
435
+ // Daemon-mediated start request. Kept separate from node_stop_requests:
436
+ // the deployed stop table has a CHECK(action IN ('stop','delete')) and
437
+ // rebuilding that table in-place would be a risky migration for an additive
438
+ // lifecycle operation.
439
+ db.exec(`
440
+ CREATE TABLE IF NOT EXISTS node_start_requests (
441
+ request_id TEXT PRIMARY KEY,
442
+ network_id TEXT NOT NULL,
443
+ daemon_node_id TEXT NOT NULL,
444
+ child_node_id TEXT NOT NULL,
445
+ child_alias TEXT NOT NULL,
446
+ created_by_token TEXT NOT NULL,
447
+ status TEXT NOT NULL DEFAULT 'pending',
448
+ error TEXT,
449
+ child_pid INTEGER,
450
+ created_at INTEGER NOT NULL,
451
+ delivered_at INTEGER,
452
+ acked_at INTEGER
453
+ )
454
+ `);
455
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_start_req_daemon ON node_start_requests(daemon_node_id, status)`);
456
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_start_req_child ON node_start_requests(child_node_id)`);
457
+ try {
458
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_start_req_child_inflight
459
+ ON node_start_requests(child_node_id)
460
+ WHERE status IN ('pending', 'delivered')`);
461
+ } catch (e: any) {
462
+ if (!/unique constraint/i.test(e?.message || "")) throw e;
463
+ }
464
+
435
465
  // `node_config_updates` — pending + history. One row per dashboard write.
436
466
  // At most one row per node may be in a non-terminal state at a time
437
467
  // (single-flight enforced at the tool layer; this table just stores).
@@ -0,0 +1,310 @@
1
+ // SSE `inbox_count` contract for the message-shaped events.
2
+ //
3
+ // Background: `new_task` has shipped a real unacked count since #1304,
4
+ // but `new_message` / `new_reply` carried NO count at all and
5
+ // `broadcast` carried a hardcoded `inbox_count: 1`. A client that
6
+ // renders "N new" from the SSE payload therefore never moved for plain
7
+ // messages and replies, and showed a constant 1 for broadcasts.
8
+ //
9
+ // Gate discipline: every assertion below expects a count that is NOT 1
10
+ // (pre-seeded unacked rows push the expected value to 2 or 3). A
11
+ // hardcoded `1` — the exact defect being fixed — fails these tests
12
+ // rather than passing them. Asserting `=== 1` would have been a fake
13
+ // gate that the old code satisfied.
14
+ //
15
+ // Run: COMMHUB_DB=/tmp/inbox-count-sse.db bun test src/inbox-count-sse.test.ts
16
+
17
+ import { afterAll, beforeEach, afterEach, describe, expect, test } from "bun:test";
18
+ import { db, uuidv4 } from "./db.js";
19
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
+ import { registerTools } from "./tools.js";
21
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
22
+ import { pendingInboxCount } from "./inbox-count.js";
23
+
24
+ const NET = "net_inboxcount";
25
+ const OWNER = "u_inboxcount_owner";
26
+ const RECV = "peer-inboxcount-recv"; // receives messages/replies
27
+ const OTHER = "peer-inboxcount-other"; // second broadcast recipient
28
+ const SENDER = "peer-inboxcount-send";
29
+ // #1440 ① — a broadcast target the lifecycle guard skips.
30
+ const STOPPED = "peer-inboxcount-stopped";
31
+ const ALIASES = [RECV, OTHER, SENDER];
32
+
33
+ type ToolHandler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
34
+
35
+ function cleanup() {
36
+ for (const t of ["tasks", "inbox", "task_events"]) {
37
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
38
+ }
39
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
40
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
41
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
42
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
43
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [OWNER]); } catch {}
44
+ }
45
+
46
+ function seed() {
47
+ db.run(
48
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
49
+ VALUES (?1, ?1, 'x', 'user', datetime('now'))`,
50
+ [OWNER],
51
+ );
52
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET, OWNER]);
53
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [OWNER, NET]);
54
+ for (const alias of ALIASES) {
55
+ db.run(
56
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
57
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'), datetime('now'), 'active')`,
58
+ [`node_${alias}`, alias, NET, `host-${alias}`],
59
+ );
60
+ db.run(
61
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
62
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
63
+ [`res_${alias}`, alias, `node_${alias}`, NET],
64
+ );
65
+ }
66
+ // A session that broadcast will select as a target, but whose node is
67
+ // NOT active — assertNodeActive skips it, so it gets no inbox row.
68
+ db.run(
69
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
70
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'), datetime('now'), 'stopped')`,
71
+ [`node_${STOPPED}`, STOPPED, NET, `host-${STOPPED}`],
72
+ );
73
+ db.run(
74
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
75
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
76
+ [`res_${STOPPED}`, STOPPED, `node_${STOPPED}`, NET],
77
+ );
78
+ }
79
+
80
+ beforeEach(() => { cleanup(); seed(); });
81
+ afterEach(() => { __resetSSEClientsForTest(); });
82
+ afterAll(cleanup);
83
+
84
+ function buildHandlers(alias: string): Record<string, ToolHandler> {
85
+ const server = new McpServer({ name: "test", version: "0" }) as any;
86
+ const tools: Record<string, ToolHandler> = {};
87
+ const origTool = server.tool.bind(server);
88
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
89
+ tools[name] = handler;
90
+ return origTool(name, _desc, schema, handler);
91
+ };
92
+ registerTools(server, undefined, NET, OWNER, alias, true, null);
93
+ return tools;
94
+ }
95
+
96
+ async function call(handler: ToolHandler, args: any): Promise<Record<string, any>> {
97
+ const r = await handler(args);
98
+ return JSON.parse(r.content[0].text);
99
+ }
100
+
101
+ /** Read the next SSE data frame, with a hard timeout so a silent stream
102
+ * fails instead of hanging. Skips keepalive comment frames. */
103
+ async function readFrame(
104
+ reader: ReadableStreamDefaultReader<Uint8Array>,
105
+ timeoutMs = 2_000,
106
+ ): Promise<Record<string, any>> {
107
+ const decoder = new TextDecoder();
108
+ let buf = "";
109
+ const deadline = Date.now() + timeoutMs;
110
+ while (Date.now() < deadline) {
111
+ const result = await Promise.race([
112
+ reader.read(),
113
+ new Promise<never>((_, rej) =>
114
+ setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
115
+ ),
116
+ ]);
117
+ if (result.done) throw new Error("SSE stream ended unexpectedly");
118
+ buf += decoder.decode(result.value, { stream: true });
119
+ const sep = buf.indexOf("\n\n");
120
+ if (sep === -1) continue;
121
+ const rawFrame = buf.slice(0, sep);
122
+ buf = buf.slice(sep + 2);
123
+ const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
124
+ if (!dataLine) continue;
125
+ return JSON.parse(dataLine.slice(6));
126
+ }
127
+ throw new Error(`no SSE frame within ${timeoutMs}ms`);
128
+ }
129
+
130
+ /** Subscribe and swallow the initial `connected` frame. */
131
+ async function subscribe(alias: string) {
132
+ const reader = createSSEStream(alias, NET).body!.getReader();
133
+ await readFrame(reader);
134
+ return reader;
135
+ }
136
+
137
+ /** Pre-existing unacked inbox rows, so the expected count is never 1. */
138
+ function seedUnacked(alias: string, n: number, type = "message") {
139
+ for (let i = 0; i < n; i++) {
140
+ db.run(
141
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
142
+ VALUES (?1, ?2, ?3, ?4, 'normal', ?5, ?6, ?7)`,
143
+ [uuidv4(), alias, `node_${alias}`, type, `backlog ${i}`, SENDER, NET],
144
+ );
145
+ }
146
+ }
147
+
148
+ function makeTask(from: string, to: string, content: string, status = "delivered"): string {
149
+ const id = uuidv4();
150
+ db.run(
151
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
152
+ VALUES (?1, ?2, ?3, 'normal', ?4, ?5, 'reply', datetime('now'), ?6)`,
153
+ [id, from, to, status, content, NET],
154
+ );
155
+ return id;
156
+ }
157
+
158
+ describe("SSE inbox_count", () => {
159
+ test("new_message carries the recipient's real unacked count, not a hardcoded 1", async () => {
160
+ seedUnacked(RECV, 2);
161
+ const reader = await subscribe(RECV);
162
+
163
+ const tools = buildHandlers(SENDER);
164
+ const res = await call(tools.send_message, { alias: RECV, message: "hello", network_id: NET });
165
+ expect(res.ok ?? true).not.toBe(false);
166
+
167
+ const evt = await readFrame(reader);
168
+ expect(evt.type).toBe("new_message");
169
+ // 2 seeded + this one. A hardcoded 1 — or a missing field — fails here.
170
+ expect(evt.inbox_count).toBe(3);
171
+ expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
172
+ });
173
+
174
+ test("new_reply carries the reply target's real unacked count", async () => {
175
+ seedUnacked(RECV, 1);
176
+ const taskId = makeTask(RECV, SENDER, "please do the thing");
177
+ const reader = await subscribe(RECV);
178
+
179
+ const tools = buildHandlers(SENDER);
180
+ const res = await call(tools.send_reply, {
181
+ alias: RECV,
182
+ text: "done",
183
+ in_reply_to: taskId,
184
+ status: "completed",
185
+ network_id: NET,
186
+ });
187
+ expect(res.ok).toBe(true);
188
+
189
+ const evt = await readFrame(reader);
190
+ expect(evt.type).toBe("new_reply");
191
+ // 1 seeded + the reply row. Not 1, and not absent.
192
+ expect(evt.inbox_count).toBe(2);
193
+ expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
194
+ });
195
+
196
+ test("broadcast counts per recipient instead of shipping a constant 1", async () => {
197
+ // Asymmetric backlogs: if the payload were hardcoded, both
198
+ // recipients would report the same number. They must not.
199
+ seedUnacked(RECV, 2);
200
+ const recvReader = await subscribe(RECV);
201
+ const otherReader = await subscribe(OTHER);
202
+
203
+ const tools = buildHandlers(SENDER);
204
+ await call(tools.broadcast, { message: "all hands", network_id: NET });
205
+
206
+ const recvEvt = await readFrame(recvReader);
207
+ const otherEvt = await readFrame(otherReader);
208
+ expect(recvEvt.type).toBe("broadcast");
209
+ expect(otherEvt.type).toBe("broadcast");
210
+
211
+ // RECV: 2 seeded + the broadcast row. OTHER: only the broadcast row.
212
+ expect(recvEvt.inbox_count).toBe(3);
213
+ expect(otherEvt.inbox_count).toBe(1);
214
+ expect(recvEvt.inbox_count).not.toBe(otherEvt.inbox_count);
215
+ });
216
+
217
+ test("acked rows are excluded — the count tracks what is still unread", async () => {
218
+ seedUnacked(RECV, 3);
219
+ db.run("UPDATE inbox SET acked = 1 WHERE session_name = ?1 AND network_id = ?2", [RECV, NET]);
220
+ const reader = await subscribe(RECV);
221
+
222
+ const tools = buildHandlers(SENDER);
223
+ await call(tools.send_message, { alias: RECV, message: "after the catch-up", network_id: NET });
224
+
225
+ const evt = await readFrame(reader);
226
+ // 3 acked rows contribute nothing; only the new one is outstanding.
227
+ expect(evt.inbox_count).toBe(1);
228
+ });
229
+ });
230
+
231
+ // #1440 ② — `retry_task` and `reassign_task` re-queue a task and push
232
+ // `new_task`, but both shipped a hardcoded `inbox_count: 1` while the
233
+ // hub already knew the real number. Same defect family as the
234
+ // `broadcast` case above; same gate discipline — the expected counts
235
+ // here are 3, so a hardcoded 1 cannot satisfy them.
236
+ describe("#1440 new_task inbox_count on requeue paths", () => {
237
+ test("retry_task carries the real unacked count, not a hardcoded 1", async () => {
238
+ seedUnacked(RECV, 2);
239
+ const taskId = makeTask(SENDER, RECV, "flaky job", "failed");
240
+ const reader = await subscribe(RECV);
241
+
242
+ const tools = buildHandlers(SENDER);
243
+ const res = await call(tools.retry_task, { task_id: taskId, network_id: NET });
244
+ expect(res.ok).toBe(true);
245
+
246
+ const evt = await readFrame(reader);
247
+ expect(evt.type).toBe("new_task");
248
+ // 2 seeded + the re-queued task row.
249
+ expect(evt.inbox_count).toBe(3);
250
+ expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
251
+ });
252
+
253
+ test("reassign_task carries the NEW assignee's real unacked count", async () => {
254
+ seedUnacked(RECV, 2);
255
+ const taskId = makeTask(SENDER, OTHER, "moved job");
256
+ const reader = await subscribe(RECV);
257
+
258
+ const tools = buildHandlers(SENDER);
259
+ const res = await call(tools.reassign_task, { task_id: taskId, new_alias: RECV, network_id: NET });
260
+ expect(res.ok).toBe(true);
261
+ expect(res.reassigned_to).toBe(RECV);
262
+
263
+ const evt = await readFrame(reader);
264
+ expect(evt.type).toBe("new_task");
265
+ // 2 seeded on the new assignee + the row reassign just wrote.
266
+ expect(evt.inbox_count).toBe(3);
267
+ expect(evt.inbox_count).toBe(pendingInboxCount(RECV, NET));
268
+ });
269
+ });
270
+
271
+ // #1440 ① — the broadcast push loop walked every candidate target,
272
+ // including the ones assertNodeActive had just skipped. Those nodes
273
+ // were told "you have a broadcast" while their inbox got no row for it.
274
+ // Before #1439 the hardcoded `inbox_count: 1` hid this: everyone got
275
+ // the same digit, so a spurious event was indistinguishable from a real
276
+ // one.
277
+ describe("#1440 broadcast delivery set", () => {
278
+ test("a lifecycle-skipped node gets no inbox row AND no broadcast event", async () => {
279
+ const activeReader = await subscribe(OTHER);
280
+ const stoppedReader = await subscribe(STOPPED);
281
+
282
+ const tools = buildHandlers(SENDER);
283
+ const res = await call(tools.broadcast, { message: "all hands", network_id: NET });
284
+
285
+ // The active recipient is served — this proves the broadcast ran, so
286
+ // the absence checked below is "not sent", not "not yet sent"
287
+ // (pushEvent is synchronous inside the handler).
288
+ const activeEvt = await readFrame(activeReader);
289
+ expect(activeEvt.type).toBe("broadcast");
290
+
291
+ // The skipped node has no row...
292
+ const rows = db.get<{ cnt: number }>(
293
+ "SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND type = 'broadcast'",
294
+ STOPPED,
295
+ );
296
+ expect(rows?.cnt).toBe(0);
297
+ // ...and must therefore receive no event announcing one.
298
+ await expect(readFrame(stoppedReader, 300)).rejects.toThrow(/no SSE frame/);
299
+
300
+ // message_ids covers only the recipients that actually got a row:
301
+ // RECV + OTHER + SENDER, never STOPPED.
302
+ expect(res.message_ids).toHaveLength(3);
303
+ // #1440 ③ — `recipients` counted candidates, not deliveries, so it
304
+ // disagreed with the documented invariant
305
+ // ("message_ids.length === recipients", docs-site/docs/api/rest.md)
306
+ // exactly when a node was skipped. 4 sessions, 3 delivered.
307
+ expect(res.recipients).toBe(res.message_ids.length);
308
+ expect(res.recipients).toBe(3);
309
+ });
310
+ });
@@ -0,0 +1,29 @@
1
+ import { db } from "./db.js";
2
+
3
+ /**
4
+ * Unacked inbox rows for `alias` — the number a client should show as
5
+ * "new". This is the count `new_task` has shipped since #1304; it is
6
+ * lifted here verbatim so every SSE event that carries `inbox_count`
7
+ * derives it the same way instead of hardcoding a digit.
8
+ *
9
+ * The SQL and its network scoping are unchanged from the `new_task`
10
+ * sites (tools.ts / server.ts): falsy `networkId` means "no network
11
+ * filter", matching both `addScope()` and the inline `if (taskNetId)`
12
+ * guard those sites already use.
13
+ *
14
+ * The `?? 1` fallback only fires if the COUNT itself returns no row,
15
+ * which SQLite does not do. It is not a default count: it is the
16
+ * fail-closed direction for this gate. Every caller pushes right after
17
+ * inserting an inbox row, so the floor is genuinely 1, and under-
18
+ * reporting an unread (badge says 0, a message is sitting there) is the
19
+ * failure this event exists to prevent.
20
+ */
21
+ export function pendingInboxCount(alias: string, networkId?: string | null): number {
22
+ const params: any[] = [alias];
23
+ let sql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
24
+ if (networkId) {
25
+ sql += " AND network_id = ?2";
26
+ params.push(networkId);
27
+ }
28
+ return db.get<{ cnt: number }>(sql, ...params)?.cnt ?? 1;
29
+ }
@@ -0,0 +1,147 @@
1
+ // #1281 — 子节点生命周期工具的参数名统一(node_id ⇄ child_node_id)。
2
+ //
3
+ // 分叉现状(origin/main 2976adb2):stop_node / start_node / delete_node 用
4
+ // `child_node_id`,restart_node / update_node_config 用 `node_id`,指向同一个
5
+ // 子节点。调用方按一个工具的习惯给另一个传就吃 -32602。修复:5 个工具都同时
6
+ // 接受两个名字(canonical = node_id,child_node_id 兼容 alias),解析收敛到一个
7
+ // 模块级 helper resolveNodeIdArg。
8
+ //
9
+ // 两向 witnessed-red(本文件即判据):
10
+ // · schema 层:每个工具的**历史用名**始终 parse 成功(证明没破已发布契约),
11
+ // **新增别名**在修复前 parse 失败(缺必填字段)、修复后成功。
12
+ // · handler 层:resolveNodeIdArg 纯函数 —— 二选一至少一必填、都传须相等。
13
+ // · 真实 handler 路径:stop_node 用 node_id / child_node_id 都能穿到
14
+ // resolveDaemonForChild(不再被 node_id_required 挡),都传冲突 → node_id_conflict。
15
+ //
16
+ // 对 pre-fix 见红的证明方式(评审可复现):把 tools.ts 里任一工具的
17
+ // `...NODE_ID_ALIAS_FIELDS` 还原成原来的单名字段(如 stop_node 的
18
+ // `child_node_id: z.string()...regex(...)`),本文件对应工具的「新增别名 parse 成功」
19
+ // 断言立即变红 —— 证明该断言有判别力,不是恒真。
20
+
21
+ import { describe, expect, test, beforeAll } from "bun:test";
22
+ import { mkdtempSync } from "fs";
23
+ import { join } from "path";
24
+ import { tmpdir } from "os";
25
+
26
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || (mkdtempSync(join(tmpdir(), "anet-1281-")) + "/commhub.db");
27
+
28
+ import { z } from "zod/v4";
29
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
30
+ import { registerTools, resolveNodeIdArg } from "./tools.js";
31
+
32
+ type ToolHandler = (args: any) => Promise<{ content: { type: "text"; text: string }[] }>;
33
+
34
+ // Capture BOTH the raw zod shape and the handler for every tool, so the
35
+ // schema-layer assertions run against the exact object server.tool received.
36
+ function buildCaptured() {
37
+ const schemas: Record<string, any> = {};
38
+ const handlers: Record<string, ToolHandler> = {};
39
+ const server = new McpServer({ name: "t1281", version: "0" }) as any;
40
+ const origTool = server.tool.bind(server);
41
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
42
+ schemas[name] = schema; handlers[name] = handler;
43
+ return origTool(name, _desc, schema, handler);
44
+ };
45
+ const origRegisterTool = server.registerTool?.bind(server);
46
+ if (origRegisterTool) {
47
+ server.registerTool = (name: string, cfg: any, handler: ToolHandler) => {
48
+ schemas[name] = cfg?.inputSchema ?? cfg; handlers[name] = handler;
49
+ return origRegisterTool(name, cfg, handler);
50
+ };
51
+ }
52
+ registerTools(server, undefined, null, "user_1281", null, false, null);
53
+ return { schemas, handlers };
54
+ }
55
+
56
+ let SCHEMAS: Record<string, any> = {};
57
+ let HANDLERS: Record<string, ToolHandler> = {};
58
+ beforeAll(() => { const c = buildCaptured(); SCHEMAS = c.schemas; HANDLERS = c.handlers; });
59
+
60
+ async function call(name: string, args: any) {
61
+ const r = await HANDLERS[name](args);
62
+ return JSON.parse(r.content[0].text);
63
+ }
64
+
65
+ // Per tool: the historically-required id name, the newly-added alias, and
66
+ // the OTHER required fields the shape needs so a safeParse isolates the id
67
+ // field rather than tripping on an unrelated missing field.
68
+ const TOOLS = [
69
+ { name: "stop_node", historic: "child_node_id", added: "node_id", extra: {} },
70
+ { name: "start_node", historic: "child_node_id", added: "node_id", extra: {} },
71
+ { name: "delete_node", historic: "child_node_id", added: "node_id", extra: { confirm_alias: "some-alias" } },
72
+ { name: "restart_node", historic: "node_id", added: "child_node_id", extra: {} },
73
+ { name: "update_node_config", historic: "node_id", added: "child_node_id", extra: { base_revision: 0, patch: {} } },
74
+ ];
75
+
76
+ const VALID_ID = "node_test_1281";
77
+
78
+ describe("#1281 schema layer — every lifecycle tool accepts BOTH node_id and child_node_id", () => {
79
+ for (const t of TOOLS) {
80
+ test(`${t.name}: historic name '${t.historic}' still parses (published contract intact)`, () => {
81
+ const shape = z.object(SCHEMAS[t.name]);
82
+ const r = shape.safeParse({ [t.historic]: VALID_ID, ...t.extra });
83
+ expect(r.success).toBe(true);
84
+ });
85
+
86
+ test(`${t.name}: added alias '${t.added}' parses (was -32602 pre-#1281 → RED without the fix)`, () => {
87
+ const shape = z.object(SCHEMAS[t.name]);
88
+ const r = shape.safeParse({ [t.added]: VALID_ID, ...t.extra });
89
+ expect(r.success).toBe(true);
90
+ });
91
+
92
+ test(`${t.name}: both names present + equal parses (schema layer permissive; conflict caught in handler)`, () => {
93
+ const shape = z.object(SCHEMAS[t.name]);
94
+ const r = shape.safeParse({ node_id: VALID_ID, child_node_id: VALID_ID, ...t.extra });
95
+ expect(r.success).toBe(true);
96
+ });
97
+ }
98
+ });
99
+
100
+ describe("#1281 resolveNodeIdArg — the single resolution helper", () => {
101
+ test("node_id only → resolves to node_id", () => {
102
+ expect(resolveNodeIdArg({ node_id: "node_a" })).toEqual({ ok: true, node_id: "node_a" });
103
+ });
104
+ test("child_node_id only → resolves to it (back-compat)", () => {
105
+ expect(resolveNodeIdArg({ child_node_id: "node_b" })).toEqual({ ok: true, node_id: "node_b" });
106
+ });
107
+ test("both present + EQUAL → resolves (no conflict)", () => {
108
+ expect(resolveNodeIdArg({ node_id: "node_c", child_node_id: "node_c" })).toEqual({ ok: true, node_id: "node_c" });
109
+ });
110
+ test("both present + DIFFERENT → node_id_conflict (catches A/B fat-finger)", () => {
111
+ const r = resolveNodeIdArg({ node_id: "node_a", child_node_id: "node_b" });
112
+ expect(r.ok).toBe(false);
113
+ expect((r as any).error).toBe("node_id_conflict");
114
+ });
115
+ test("neither present → node_id_required", () => {
116
+ const r = resolveNodeIdArg({});
117
+ expect(r.ok).toBe(false);
118
+ expect((r as any).error).toBe("node_id_required");
119
+ });
120
+ });
121
+
122
+ describe("#1281 real handler path — stop_node routes both names to the same place", () => {
123
+ // stop_node calls resolveNodeIdArg FIRST, then resolveDaemonForChild. A
124
+ // fabricated id has no create-request row → daemon_not_resolvable. The
125
+ // point is that BOTH names get PAST the id gate to that same downstream
126
+ // error (proving the alias flows through), and the two error branches fire.
127
+ test("node_id (new name) flows through → daemon_not_resolvable, NOT node_id_required", async () => {
128
+ const r = await call("stop_node", { node_id: VALID_ID });
129
+ expect(r.ok).toBe(false);
130
+ expect(r.error).toBe("daemon_not_resolvable");
131
+ });
132
+ test("child_node_id (old name) flows through → daemon_not_resolvable (back-compat intact)", async () => {
133
+ const r = await call("stop_node", { child_node_id: VALID_ID });
134
+ expect(r.ok).toBe(false);
135
+ expect(r.error).toBe("daemon_not_resolvable");
136
+ });
137
+ test("neither → node_id_required (before any daemon resolution)", async () => {
138
+ const r = await call("stop_node", { force: true });
139
+ expect(r.ok).toBe(false);
140
+ expect(r.error).toBe("node_id_required");
141
+ });
142
+ test("both differ → node_id_conflict (before any daemon resolution)", async () => {
143
+ const r = await call("stop_node", { node_id: "node_a", child_node_id: "node_b" });
144
+ expect(r.ok).toBe(false);
145
+ expect(r.error).toBe("node_id_conflict");
146
+ });
147
+ });
package/src/server.ts CHANGED
@@ -9,6 +9,7 @@ import { registerTools } from "./tools.js";
9
9
  import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
10
10
  import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
11
11
  import { assertNodeActive } from "./lifecycle-guard.js";
12
+ import { pendingInboxCount } from "./inbox-count.js";
12
13
  import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
13
14
  import { validateAvatarUrl } from "./avatar-validate.js";
14
15
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
@@ -2549,6 +2550,9 @@ return Bun.serve({
2549
2550
  if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
2550
2551
  const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
2551
2552
  const ids: string[] = [];
2553
+ // #1440 ① — mirrors the MCP handler: push to the recipients that
2554
+ // actually got an inbox row, not to every candidate target.
2555
+ const delivered: Array<{ alias: string; netId: string | null }> = [];
2552
2556
  for (const t of targets) {
2553
2557
  // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
2554
2558
  // 2/2). Broadcast skips non-active recipients silently per its
@@ -2563,11 +2567,12 @@ return Bun.serve({
2563
2567
  [id, t.alias, t.node_id ?? null, body.message, t.network_id]
2564
2568
  );
2565
2569
  ids.push(id);
2570
+ delivered.push({ alias: t.alias, netId: t.network_id });
2566
2571
  }
2567
- for (const t of targets) {
2568
- pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, t.network_id);
2572
+ for (const d of delivered) {
2573
+ pushEvent(d.alias, { type: "broadcast", inbox_count: pendingInboxCount(d.alias, d.netId) }, d.netId);
2569
2574
  }
2570
- return withCors(req, Response.json({ ok: true, recipients: targets.length, message_ids: ids }));
2575
+ return withCors(req, Response.json({ ok: true, recipients: ids.length, message_ids: ids }));
2571
2576
  }
2572
2577
 
2573
2578
  // ── REST: tmux capture-pane ──
@@ -0,0 +1,52 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db } from "./db.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ const NET = "net_start_node"; const USER = "u_start_node";
7
+ const DAEMON = "node_start_daemon"; const DAEMON_ALIAS = "start-daemon";
8
+ const CHILD = "node_start_child"; const CHILD_ALIAS = "start-child";
9
+
10
+ function cleanup() {
11
+ for (const table of ["node_start_requests", "node_create_requests", "audit_log", "nodes", "api_tokens", "network_members", "networks"]) {
12
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
13
+ }
14
+ try { db.run("DELETE FROM users WHERE user_id=?1", USER); } catch {}
15
+ }
16
+ beforeEach(() => { cleanup();
17
+ db.run(`INSERT INTO users(user_id,username,password_hash,role,created_at) VALUES(?1,?1,'x','user',datetime('now'))`, USER);
18
+ db.run(`INSERT INTO networks(network_id,network_name,owner_id,created_at) VALUES(?1,?1,?2,datetime('now'))`, [NET, USER]);
19
+ db.run(`INSERT INTO network_members(user_id,network_id,role,joined_at) VALUES(?1,?2,'owner',datetime('now'))`, [USER, NET]);
20
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,config_snapshot,hostname,created_at,updated_at,lifecycle_state) VALUES(?1,?2,?2,?3,'{}','h',datetime('now'),datetime('now'),'active')`, [DAEMON, DAEMON_ALIAS, NET]);
21
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,config_snapshot,hostname,created_at,updated_at,lifecycle_state) VALUES(?1,?2,?2,?3,'{}','h',datetime('now'),datetime('now'),'stopped')`, [CHILD, CHILD_ALIAS, NET]);
22
+ db.run(`INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('tok_start_daemon',?1,?2,'network',?3,'h')`, [USER, NET, `node:${DAEMON_ALIAS}`]);
23
+ db.run(`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id) VALUES('cr_start_child',?1,?2,?3,'codex-sdk','x','{}','[]','succeeded',1,'t',?4)`, [DAEMON, CHILD_ALIAS, NET, CHILD]);
24
+ });
25
+ afterAll(cleanup);
26
+
27
+ function handlers(user: string | null, daemon = false) {
28
+ const s = new McpServer({ name: "t", version: "0" }) as any; const out: any = {};
29
+ const orig = s.tool.bind(s); s.tool = (n: string, d: string, schema: any, h: any) => { out[n] = h; return orig(n,d,schema,h); };
30
+ registerTools(s, undefined, daemon ? NET : null, user, null, daemon, daemon ? "tok_start_daemon" : null); return out;
31
+ }
32
+ async function call(h: any, args: any) { return JSON.parse((await h(args)).content[0].text); }
33
+
34
+ describe("start_node Hub -> daemon lifecycle", () => {
35
+ test("dispatch, authenticated pull, ack transitions stopped -> starting -> active", async () => {
36
+ const u = handlers(USER); const dispatch = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
37
+ expect(dispatch.ok).toBe(true); expect(dispatch.lifecycle_state).toBe("starting");
38
+ const d = handlers(null, true); const pulled = await call(d.get_start_request, { request_id: dispatch.request_id });
39
+ expect(pulled).toMatchObject({ ok: true, child_node_id: CHILD, child_alias: CHILD_ALIAS });
40
+ const ack = await call(d.ack_start_request, { request_id: dispatch.request_id, status: "started", child_pid: 4321 });
41
+ expect(ack.ok).toBe(true);
42
+ expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe("active");
43
+ expect(db.all<any>(`SELECT action FROM audit_log WHERE target_id=?1 ORDER BY id`, dispatch.request_id).map((x:any)=>x.action)).toEqual(["start_node_dispatched", "start_node_completed"]);
44
+ });
45
+ test("active child and caller-supplied wrong daemon fail closed", async () => {
46
+ const u = handlers(USER);
47
+ db.run(`UPDATE nodes SET lifecycle_state='active' WHERE node_id=?1`, CHILD);
48
+ expect((await call(u.start_node, { child_node_id: CHILD, network_id: NET })).error).toBe("node_not_stopped");
49
+ db.run(`UPDATE nodes SET lifecycle_state='stopped' WHERE node_id=?1`, CHILD);
50
+ expect((await call(u.start_node, { child_node_id: CHILD, daemon_node_id: "node_wrong", network_id: NET })).error).toBe("daemon_child_mismatch");
51
+ });
52
+ });
package/src/tools.ts CHANGED
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
7
7
  import { getSSEStats, hasSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
8
8
  import { assertNodeActive } from "./lifecycle-guard.js";
9
+ import { pendingInboxCount } from "./inbox-count.js";
9
10
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
10
11
  import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
11
12
  import {
@@ -80,6 +81,49 @@ function normalizeMetaJson(meta: unknown): string | null {
80
81
  try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
81
82
  }
82
83
 
84
+ // ── #1281 — 子节点生命周期工具的参数名统一 ────────────────────────────
85
+ //
86
+ // stop_node / start_node / delete_node 历史上用 `child_node_id`(RFC-027),
87
+ // restart_node / update_node_config 用 `node_id`。同一个对象(子节点)两个名字,
88
+ // 调用方按一个工具的习惯给另一个传就吃 -32602。两侧都已是**已发布契约**
89
+ // (start_node 随 #1273 合入),且 hub API 在 RFC-030 混版滚动期老客户端还在
90
+ // 发老名——所以不能硬改名,改为**两个名字都接受**:
91
+ // · canonical = `node_id`(= DB 列 nodes.node_id、= create_node 的输出、
92
+ // restart/update 历史用名;「建完节点→再操作它」用同一个名才是傻瓜式)。
93
+ // · `child_node_id` 作为**兼容 alias 保留** + deprecation 注释;不加运行时
94
+ // warning(老客户端是设计内兼容,不是错误,别刷屏)。
95
+ // create_node 的 `daemon_node_id` 指宿主 daemon,是另一层语义,不在本次统一内。
96
+ //
97
+ // server.tool 收的是 ZodRawShape(裸对象),挂不了 .refine(),所以「至少一个
98
+ // 必填」+「两个都传须相等」在 handler 侧用 resolveNodeIdArg 兜——返回结构化
99
+ // ok:false 反而比 schema 层的 -32602 更友好。两个名字在 schema 层都做成
100
+ // optional + 宽松 .min(1).max(200)(不带 regex),使命名与校验强度同时统一:
101
+ // 对 restart/update 不变(本就宽松),对 stop/start/delete 是放宽(原 regex 会
102
+ // 在 schema 层挡掉 alias/杂串,放宽后落到查表返回 node_not_found,对合法调用
103
+ // 方无影响,只是错误形态更一致)。
104
+ const NODE_ID_ALIAS_FIELDS = {
105
+ node_id: z.string().min(1).max(200).optional()
106
+ .describe("Target child node ID (canonical, #1281). The value create_node returns. Must be in caller's network."),
107
+ child_node_id: z.string().min(1).max(200).optional()
108
+ .describe("DEPRECATED alias for node_id (#1281): kept for back-compat with pre-unification clients; prefer node_id."),
109
+ } as const;
110
+
111
+ /** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
112
+ * node_id_conflict(挡「node_id 填 A、child_node_id 填 B」的手滑,比单纯二选一强);
113
+ * 都不传 ⇒ node_id_required。纯函数,模块级导出以便单测直接验解析逻辑。 */
114
+ export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }):
115
+ { ok: true; node_id: string } | { ok: false; error: string; message: string } {
116
+ const n = a.node_id, c = a.child_node_id;
117
+ if (n && c && n !== c) {
118
+ return { ok: false, error: "node_id_conflict", message: "node_id and child_node_id both provided but differ; send only one (they are aliases for the same child node)" };
119
+ }
120
+ const id = n ?? c;
121
+ if (!id) {
122
+ return { ok: false, error: "node_id_required", message: "one of node_id / child_node_id is required (child_node_id is a deprecated alias; prefer node_id)" };
123
+ }
124
+ return { ok: true, node_id: id };
125
+ }
126
+
83
127
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
84
128
  // Default from_session for outbound tools — extracted from the calling
85
129
  // token's binding (ntok_ → node alias, utok_ → username). Without this,
@@ -1581,7 +1625,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1581
1625
  );
1582
1626
 
1583
1627
  // 同 send_task:可达性用订阅者注册表判,不用心跳时间戳猜。
1584
- pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
1628
+ pushEvent(targetAlias, { type: "new_message", inbox_count: pendingInboxCount(targetAlias, effectiveNetId), from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
1585
1629
 
1586
1630
  const offlineReply = deliveryTargetReply(target, { message_id: id });
1587
1631
  if (offlineReply) {
@@ -1893,7 +1937,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1893
1937
  }
1894
1938
 
1895
1939
  const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
1896
- pushEvent(replyTargetAlias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
1940
+ pushEvent(replyTargetAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyTargetAlias, effectiveNetId), from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
1897
1941
  // #461 network observer summary — ids + routing only, no reply text.
1898
1942
  pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
1899
1943
 
@@ -2033,7 +2077,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2033
2077
  });
2034
2078
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
2035
2079
  // SSE push (unconditional — channel is keyed by alias, not network)
2036
- pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
2080
+ pushEvent(task.to_name, { type: "new_task", inbox_count: pendingInboxCount(task.to_name, effectiveNetId ?? task.network_id ?? null), priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
2037
2081
  pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
2038
2082
  return {
2039
2083
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
@@ -2247,7 +2291,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2247
2291
  [newInboxId, task_id, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
2248
2292
  });
2249
2293
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
2250
- pushEvent(reassignedAlias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session, ...(canonical.renamed ? { renamed_from: new_alias } : {}) }, effectiveNetId ?? task.network_id ?? null);
2294
+ pushEvent(reassignedAlias, { type: "new_task", inbox_count: pendingInboxCount(reassignedAlias, effectiveNetId ?? task.network_id ?? null), priority: task.priority, from: from_session, ...(canonical.renamed ? { renamed_from: new_alias } : {}) }, effectiveNetId ?? task.network_id ?? null);
2251
2295
  pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
2252
2296
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: reassignedAlias, ...(canonical.renamed ? { renamed_from: new_alias, renamed_to: reassignedAlias } : {}) }) }] };
2253
2297
  }
@@ -2365,32 +2409,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2365
2409
 
2366
2410
  const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
2367
2411
  const ids: string[] = [];
2412
+ // #1440 ① — the push loop must walk the recipients that actually
2413
+ // got an inbox row, not every candidate target. Walking `targets`
2414
+ // announced a broadcast to nodes the lifecycle guard had just
2415
+ // skipped, i.e. nodes with nothing to fetch.
2416
+ const delivered: Array<{ alias: string; netId: string | null }> = [];
2368
2417
 
2369
2418
  for (const t of targets) {
2370
2419
  // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
2371
2420
  // Broadcast skips non-active recipients silently rather than
2372
2421
  // failing the entire send — broadcast semantics are best-effort
2373
2422
  // per-recipient and a stopped node simply gets nothing.
2374
- const lc = assertNodeActive(t.alias, effectiveNetId ?? t.network_id ?? null);
2423
+ const netId = effectiveNetId ?? t.network_id ?? null;
2424
+ const lc = assertNodeActive(t.alias, netId);
2375
2425
  if (!lc.ok) continue;
2376
2426
  const id = uuidv4();
2377
2427
  db.run(
2378
2428
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
2379
2429
  VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
2380
- [id, t.alias, t.node_id ?? null, message, effectiveNetId ?? t.network_id ?? null]
2430
+ [id, t.alias, t.node_id ?? null, message, netId]
2381
2431
  );
2382
2432
  ids.push(id);
2433
+ delivered.push({ alias: t.alias, netId });
2383
2434
  }
2384
2435
 
2385
- for (const t of targets) {
2386
- pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, effectiveNetId ?? t.network_id ?? null);
2436
+ for (const d of delivered) {
2437
+ pushEvent(d.alias, { type: "broadcast", inbox_count: pendingInboxCount(d.alias, d.netId) }, d.netId);
2387
2438
  }
2388
2439
 
2389
2440
  return {
2390
2441
  content: [
2391
2442
  {
2392
2443
  type: "text" as const,
2393
- text: JSON.stringify({ ok: true, recipients: targets.length, message_ids: ids }),
2444
+ text: JSON.stringify({ ok: true, recipients: ids.length, message_ids: ids }),
2394
2445
  },
2395
2446
  ],
2396
2447
  };
@@ -2483,7 +2534,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2483
2534
  "update_node_config",
2484
2535
  "Set the desired per-node config (model + flags) and push a doorbell to the node. The node pulls + validates + applies (hot or restart per field tier). RFC-024.",
2485
2536
  {
2486
- node_id: z.string().min(1).max(200).describe("Target node ID (not alias). Must be in caller's network."),
2537
+ ...NODE_ID_ALIAS_FIELDS,
2487
2538
  base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
2488
2539
  patch: z.object({
2489
2540
  model: z.string().max(200).optional(),
@@ -2507,7 +2558,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2507
2558
  }).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
2508
2559
  network_id: z.string().max(200).optional(),
2509
2560
  },
2510
- async ({ node_id: nodeId, base_revision: baseRev, patch, network_id: clientNetId }) => {
2561
+ async ({ node_id, child_node_id, base_revision: baseRev, patch, network_id: clientNetId }) => {
2562
+ const idArg = resolveNodeIdArg({ node_id, child_node_id });
2563
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
2564
+ const nodeId = idArg.node_id;
2511
2565
  const effectiveNetId = getNetworkId(clientNetId);
2512
2566
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2513
2567
 
@@ -2816,10 +2870,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2816
2870
  "restart_node",
2817
2871
  "Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
2818
2872
  {
2819
- node_id: z.string().min(1).max(200),
2873
+ ...NODE_ID_ALIAS_FIELDS,
2820
2874
  network_id: z.string().max(200).optional(),
2821
2875
  },
2822
- async ({ node_id: nodeId, network_id: clientNetId }) => {
2876
+ async ({ node_id, child_node_id, network_id: clientNetId }) => {
2877
+ const idArg = resolveNodeIdArg({ node_id, child_node_id });
2878
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
2879
+ const nodeId = idArg.node_id;
2823
2880
  const effectiveNetId = getNetworkId(clientNetId);
2824
2881
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2825
2882
 
@@ -3718,7 +3775,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3718
3775
  "stop_node",
3719
3776
  "Stop the agent-node child process; keep config dir intact. Reversible via restart_node. RFC-027 §2.2. daemon_node_id is auto-resolved from child_node_id when omitted (looked up in node_create_requests).",
3720
3777
  {
3721
- child_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/),
3778
+ ...NODE_ID_ALIAS_FIELDS,
3722
3779
  // PR2 prereq: dashboard rarely knows daemon_node_id directly.
3723
3780
  // When omitted, hub resolves from the original creation record
3724
3781
  // (node_create_requests.daemon_node_id keyed by this child).
@@ -3727,7 +3784,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3727
3784
  grace_seconds: z.number().int().min(5).max(60).optional().default(10),
3728
3785
  network_id: z.string().max(200).optional(),
3729
3786
  },
3730
- async ({ child_node_id, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
3787
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
3788
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
3789
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3790
+ const child_node_id = idArg.node_id;
3731
3791
  const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
3732
3792
  if (!resolved) {
3733
3793
  return { content: [{ type: "text" as const, text: JSON.stringify({
@@ -3748,7 +3808,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3748
3808
  "delete_node",
3749
3809
  "Stop child + revoke ntok + delete hub row + (default) backup config to ~/.anet/deleted/<ts>-<alias>/ for 30d. confirm_alias must equal the node's alias. daemon_node_id is auto-resolved when omitted. RFC-027 §2.2.",
3750
3810
  {
3751
- child_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/),
3811
+ ...NODE_ID_ALIAS_FIELDS,
3752
3812
  daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
3753
3813
  confirm_alias: z.string().min(1).max(200),
3754
3814
  force: z.boolean().optional().default(false),
@@ -3756,7 +3816,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3756
3816
  delete_config: z.boolean().optional().default(true),
3757
3817
  network_id: z.string().max(200).optional(),
3758
3818
  },
3759
- async ({ child_node_id, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
3819
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
3820
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
3821
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3822
+ const child_node_id = idArg.node_id;
3760
3823
  const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
3761
3824
  if (!resolved) {
3762
3825
  return { content: [{ type: "text" as const, text: JSON.stringify({
@@ -3927,6 +3990,141 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3927
3990
  },
3928
3991
  );
3929
3992
 
3993
+ // start_node is deliberately daemon-mediated. A stopped child has no SSE
3994
+ // consumer of its own, so restart_node's self-doorbell cannot revive it.
3995
+ server.tool(
3996
+ "start_node",
3997
+ "Start a stopped child through its host supervisor daemon. daemon_node_id is auto-resolved from the original create request.",
3998
+ {
3999
+ ...NODE_ID_ALIAS_FIELDS,
4000
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
4001
+ network_id: z.string().max(200).optional(),
4002
+ },
4003
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId }) => {
4004
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
4005
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
4006
+ const child_node_id = idArg.node_id;
4007
+ const resolvedDaemonId = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4008
+ if (!resolvedDaemonId) {
4009
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4010
+ ok: false, error: "daemon_not_resolvable",
4011
+ message: "no create request maps this child to a daemon",
4012
+ }) }] };
4013
+ }
4014
+ const scope = resolveReadScope(clientNetId);
4015
+ if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
4016
+ const q: any[] = [child_node_id];
4017
+ const child = db.get<{ node_id: string; alias: string; network_id: string; lifecycle_state: string | null }>(
4018
+ addReadScope(`SELECT node_id, alias, network_id, lifecycle_state FROM nodes WHERE node_id = ?1`, q, scope), ...q,
4019
+ );
4020
+ if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
4021
+ if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
4022
+ if ((child.lifecycle_state ?? "active") !== "stopped") {
4023
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: child.lifecycle_state ?? "active" }) }] };
4024
+ }
4025
+ // The caller may provide daemon_node_id, but it cannot override the
4026
+ // authoritative child->daemon relationship recorded at creation.
4027
+ const authoritativeDaemonId = resolveDaemonForChild(child_node_id);
4028
+ if (!authoritativeDaemonId || authoritativeDaemonId !== resolvedDaemonId) {
4029
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_child_mismatch" }) }] };
4030
+ }
4031
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
4032
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, resolvedDaemonId,
4033
+ );
4034
+ if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
4035
+ if (daemon.network_id !== child.network_id) {
4036
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_cross_tenant" }) }] };
4037
+ }
4038
+ const requestId = generateId("str");
4039
+ const now = Date.now();
4040
+ try {
4041
+ db.transaction(() => {
4042
+ db.run(
4043
+ `INSERT INTO node_start_requests
4044
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias,
4045
+ created_by_token, status, created_at)
4046
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
4047
+ [requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now],
4048
+ );
4049
+ db.run(`UPDATE nodes SET lifecycle_state = 'starting' WHERE node_id = ?1 AND lifecycle_state = 'stopped'`, [child.node_id]);
4050
+ auditCreateNodeStrict({
4051
+ action: "start_node_dispatched", user_id: enforceUserId,
4052
+ network_id: child.network_id, target_id: requestId,
4053
+ detail: { child_node_id: child.node_id, child_alias: child.alias, daemon_node_id: daemon.node_id,
4054
+ lifecycle_state_before: "stopped", lifecycle_state_after: "starting", ts_request: now },
4055
+ });
4056
+ });
4057
+ } catch (e: any) {
4058
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) }) }] };
4059
+ }
4060
+ pushEvent(daemon.alias, { type: "start_node", request_id: requestId }, child.network_id);
4061
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, lifecycle_state: "starting" }) }] };
4062
+ },
4063
+ );
4064
+
4065
+ server.tool(
4066
+ "get_start_request",
4067
+ "Host supervisor pulls an authenticated pending start request.",
4068
+ { request_id: z.string().min(1).max(200) },
4069
+ async ({ request_id }) => {
4070
+ const callerDaemon = resolveCallerDaemonTokenBound();
4071
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4072
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
4073
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id = ?1`, request_id,
4074
+ );
4075
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4076
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4077
+ if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4078
+ if (!['pending', 'delivered'].includes(row.status)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_startable", status: row.status }) }] };
4079
+ db.run(`UPDATE node_start_requests SET status='delivered', delivered_at=?1 WHERE request_id=?2 AND status='pending'`, [Date.now(), request_id]);
4080
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id, child_node_id: row.child_node_id, child_alias: row.child_alias }) }] };
4081
+ },
4082
+ );
4083
+
4084
+ server.tool(
4085
+ "ack_start_request",
4086
+ "Host supervisor reports start completion or failure.",
4087
+ {
4088
+ request_id: z.string().min(1).max(200),
4089
+ status: z.enum(["started", "start_failed"]),
4090
+ child_pid: z.number().int().positive().optional(),
4091
+ error: z.string().max(1000).optional(),
4092
+ },
4093
+ async ({ request_id, status, child_pid, error: ackError }) => {
4094
+ const callerDaemon = resolveCallerDaemonTokenBound();
4095
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4096
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
4097
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id=?1`, request_id,
4098
+ );
4099
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4100
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4101
+ if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4102
+ if (row.status === "started" || row.status === "start_failed") {
4103
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: row.status, idempotent: true }) }] };
4104
+ }
4105
+ if (!['pending', 'delivered'].includes(row.status)) {
4106
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_ackable", status: row.status }) }] };
4107
+ }
4108
+ if (status === "started" && !child_pid) {
4109
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "child_pid_required" }) }] };
4110
+ }
4111
+ const now = Date.now();
4112
+ try {
4113
+ db.transaction(() => {
4114
+ db.run(`UPDATE node_start_requests SET status=?1,error=?2,child_pid=?3,acked_at=?4 WHERE request_id=?5`, [status, ackError || null, child_pid || null, now, request_id]);
4115
+ db.run(`UPDATE nodes SET lifecycle_state=?1 WHERE node_id=?2`, [status === "started" ? "active" : "stopped", row.child_node_id]);
4116
+ if (status === "started") auditCreateNodeStrict({
4117
+ action: "start_node_completed", network_id: row.network_id, target_id: request_id,
4118
+ detail: { child_node_id: row.child_node_id, child_alias: row.child_alias, child_pid, lifecycle_state_after: "active", ts_daemon_ack: now },
4119
+ });
4120
+ });
4121
+ } catch (e: any) {
4122
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
4123
+ }
4124
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
4125
+ },
4126
+ );
4127
+
3930
4128
  // RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
3931
4129
  // {child_node_id, alias, lifecycle_state} tuples for nodes whose
3932
4130
  // active create-request has this daemon as daemon_node_id. Used at