@sleep2agi/commhub-server 0.9.0-preview.31 → 0.9.0-preview.33

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.
@@ -11,8 +11,10 @@ import {
11
11
  __resetSSEClientsForTest,
12
12
  createNetworkObserverStream,
13
13
  createSSEStream,
14
+ createUserEventStream,
14
15
  pushEvent,
15
16
  pushNetworkObserverEvent,
17
+ pushUserEvent,
16
18
  } from "./push";
17
19
 
18
20
  afterEach(() => {
@@ -174,3 +176,77 @@ describe("#461 network observer stream", () => {
174
176
  await reader2.cancel();
175
177
  });
176
178
  });
179
+
180
+ describe("Desktop user SSE stream", () => {
181
+ test("user stream receives connected frame, then desktop_message", async () => {
182
+ const res = createUserEventStream("net_user_a", "u_user_a");
183
+ const reader = res.body!.getReader();
184
+
185
+ const connected = await readFrame(reader);
186
+ expect(connected.type).toBe("connected");
187
+ expect(connected.user).toBe(true);
188
+ expect(connected.network_id).toBe("net_user_a");
189
+ expect(connected.user_id).toBe("u_user_a");
190
+
191
+ pushUserEvent("net_user_a", "u_user_a", {
192
+ type: "desktop_message",
193
+ message_id: "dm_one",
194
+ from: "agent-a",
195
+ message: "hello",
196
+ });
197
+
198
+ const evt = await readFrame(reader);
199
+ expect(evt.type).toBe("desktop_message");
200
+ expect(evt.message_id).toBe("dm_one");
201
+ expect(evt.from).toBe("agent-a");
202
+ expect(evt.message).toBe("hello");
203
+ expect(evt.network_id).toBe("net_user_a");
204
+ expect(evt.user_id).toBe("u_user_a");
205
+ expect(evt.scope).toBe("user");
206
+ await reader.cancel();
207
+ });
208
+
209
+ test("user stream is isolated from alias and observer key spaces", async () => {
210
+ const userRes = createUserEventStream("net_user_iso", "u_iso");
211
+ const userReader = userRes.body!.getReader();
212
+ await readFrame(userReader);
213
+
214
+ const sessionRes = createSSEStream("u_iso", "net_user_iso");
215
+ const sessionReader = sessionRes.body!.getReader();
216
+ await readFrame(sessionReader);
217
+
218
+ const observerRes = createNetworkObserverStream("net_user_iso");
219
+ const observerReader = observerRes.body!.getReader();
220
+ await readFrame(observerReader);
221
+
222
+ pushEvent("u_iso", { type: "alias_marker", marker: "alias" }, "net_user_iso");
223
+ pushNetworkObserverEvent("net_user_iso", { type: "observer_marker", marker: "observer" });
224
+ pushUserEvent("net_user_iso", "u_iso", { type: "desktop_message", marker: "user" });
225
+
226
+ expect((await readFrame(sessionReader)).marker).toBe("alias");
227
+ expect((await readFrame(observerReader)).marker).toBe("observer");
228
+ expect((await readFrame(userReader)).marker).toBe("user");
229
+
230
+ await userReader.cancel();
231
+ await sessionReader.cancel();
232
+ await observerReader.cancel();
233
+ });
234
+
235
+ test("same user and network fan out to multiple live clients; cancelling one leaves the other live", async () => {
236
+ const r1 = createUserEventStream("net_user_multi", "u_multi");
237
+ const r2 = createUserEventStream("net_user_multi", "u_multi");
238
+ const reader1 = r1.body!.getReader();
239
+ const reader2 = r2.body!.getReader();
240
+ await readFrame(reader1);
241
+ await readFrame(reader2);
242
+
243
+ pushUserEvent("net_user_multi", "u_multi", { type: "desktop_message", message_id: "dm_fanout_1" });
244
+ expect((await readFrame(reader1)).message_id).toBe("dm_fanout_1");
245
+ expect((await readFrame(reader2)).message_id).toBe("dm_fanout_1");
246
+
247
+ await reader1.cancel();
248
+ pushUserEvent("net_user_multi", "u_multi", { type: "desktop_message", message_id: "dm_fanout_2" });
249
+ expect((await readFrame(reader2)).message_id).toBe("dm_fanout_2");
250
+ await reader2.cancel();
251
+ });
252
+ });
package/src/push.ts CHANGED
@@ -109,6 +109,7 @@ function clientKey(sessionName: string, networkId?: string | null): string {
109
109
  // master-token caller could even inject a NUL into network_id via the
110
110
  // legacy ?network_id= path, and master token is root already.
111
111
  const OBSERVER_KEY_PREFIX = "\u0000netobs:";
112
+ const USER_KEY_PREFIX = "\u0000user:";
112
113
 
113
114
  /** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
114
115
  * `printableKey()` (below) and therefore the shape callers see via
@@ -123,6 +124,10 @@ function observerKey(networkId: string): string {
123
124
  return `${OBSERVER_KEY_PREFIX}${networkId}`;
124
125
  }
125
126
 
127
+ function userKey(networkId: string, userId: string): string {
128
+ return `${USER_KEY_PREFIX}${networkId}:${userId}`;
129
+ }
130
+
126
131
  /** Render a client key for logs / stats. Observer keys embed a raw NUL
127
132
  * byte (collision guard above); printing that verbatim makes grep treat
128
133
  * hub logs as binary and breaks log shippers, and getSSEStats feeds
@@ -265,6 +270,15 @@ export function createNetworkObserverStream(networkId: string): Response {
265
270
  );
266
271
  }
267
272
 
273
+ /** Desktop/user client SSE stream. Keyed by authenticated user_id, not
274
+ * alias/session, so Desktop does not need to mint a pseudo node token. */
275
+ export function createUserEventStream(networkId: string, userId: string): Response {
276
+ return createStreamForKey(
277
+ userKey(networkId, userId),
278
+ { type: "connected", user: true, network_id: networkId, user_id: userId },
279
+ );
280
+ }
281
+
268
282
  function createStreamForKey(key: string, initialEvent: Record<string, unknown>): Response {
269
283
  const encoder = new TextEncoder();
270
284
  let client: SSEClient;
@@ -382,6 +396,17 @@ eventBus.on("rename-committed", (event: RenameCommittedEvent) => {
382
396
  });
383
397
 
384
398
  /** 推送事件给指定 session 的所有 SSE 连接 */
399
+ /** 这个 alias 上此刻是否真的有活着的 SSE 订阅者。
400
+ *
401
+ * 这是投递可达性的**实测**,区别于 resolveDeliveryTarget 里那个基于
402
+ * last_seen_at 的**猜测**。一个安静但连着的会话(例如遵守「停发心跳」
403
+ * 规则的 MCP 会话)在时间戳判据下是 offline,在这里是 true。 */
404
+ export function hasSubscribers(sessionName: string, networkId?: string | null): boolean {
405
+ const arr = clients.get(clientKey(sessionName, networkId));
406
+ if (!arr) return false;
407
+ return arr.some((c) => !c.closed);
408
+ }
409
+
385
410
  export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
386
411
  const key = clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined));
387
412
  const arr = clients.get(key);
@@ -436,6 +461,34 @@ export function pushNetworkObserverEvent(
436
461
  if (needPrune) pruneClosed(key);
437
462
  }
438
463
 
464
+ /** Push an event to every active Desktop/web client for one user in one
465
+ * network. No-op for falsy network/user ids; auth must be enforced by
466
+ * the caller before invoking this helper. */
467
+ export function pushUserEvent(
468
+ networkId: string | null | undefined,
469
+ userId: string | null | undefined,
470
+ event: Record<string, unknown>,
471
+ ): void {
472
+ if (!networkId || !userId) return;
473
+ const key = userKey(networkId, userId);
474
+ const arr = clients.get(key);
475
+ if (!arr || arr.length === 0) return;
476
+
477
+ const data = `data: ${JSON.stringify({ ...event, network_id: networkId, user_id: userId, scope: "user" })}\n\n`;
478
+ let needPrune = false;
479
+
480
+ for (const c of arr) {
481
+ if (c.closed) {
482
+ needPrune = true;
483
+ continue;
484
+ }
485
+ const result = tryEnqueueBytes(c, c.encoder.encode(data));
486
+ if (result === "dead") needPrune = true;
487
+ }
488
+
489
+ if (needPrune) pruneClosed(key);
490
+ }
491
+
439
492
  export function __resetSSEClientsForTest(): void {
440
493
  for (const arr of clients.values()) {
441
494
  for (const c of arr) closeClient(c, "reset-for-test");
@@ -0,0 +1,96 @@
1
+ // f015d9d6 根因隔离:admin(dashboard/人类) 发给「无 node_id 的 MCP 会话」的任务,
2
+ // 其回执被 reply_target_mismatch 拒绝。
3
+ //
4
+ // 上报时给出的假设是「校验依赖投递绑定 ⇒ 任务还没真正 deliver 进会话时回执会被拒」。
5
+ // 本文件把两个变量分开量,一次只动一个:
6
+ // 变量 1 = 任务是否已投递(status created vs delivered,有无 SSE 订阅者)
7
+ // 变量 2 = 调用方是否显式传 alias
8
+ // 如果假设成立,变量 1 应当决定结果;如果 alias 推导才是决定因素,变量 2 才会。
9
+ import { beforeEach, afterAll, describe, expect, test } from "bun:test";
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { db } from "./db.js";
12
+ import { registerTools } from "./tools.js";
13
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
14
+
15
+ const NET = "net_admin_reply_f015";
16
+ const USER = "user_admin_reply_f015";
17
+ const ADMIN = "admin-f015"; // 人类/dashboard 发起方,无 node
18
+ const AGENT = "agent-mcp-f015"; // MCP 会话:sessions 行 node_id = NULL
19
+ const AGENT_NODE = "node-agent-f015"; // token 绑定的 node(会话本身仍无 node_id)
20
+
21
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
22
+
23
+ function cleanup() {
24
+ __resetSSEClientsForTest();
25
+ for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
26
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
27
+ }
28
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
29
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
30
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
31
+ }
32
+
33
+ function seed() {
34
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
35
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
36
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
37
+ db.run(
38
+ "INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, config_snapshot, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', '{}', datetime('now'), datetime('now'), 'active')",
39
+ [AGENT_NODE, AGENT, NET],
40
+ );
41
+ // 🔴 关键夹具属性:会话行 node_id 为 NULL —— 这正是 MCP 挂载的 claude-code 会话的形状
42
+ db.run(
43
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now'), datetime('now'))",
44
+ [`cc-n_${AGENT}`, AGENT, NET],
45
+ );
46
+ db.run(
47
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
48
+ [`token-${AGENT}`, `hash-${AGENT}`, USER, NET, `node:${AGENT}`, AGENT_NODE],
49
+ );
50
+ }
51
+
52
+ function toolsFor(alias: string): Record<string, ToolHandler> {
53
+ const server = new McpServer({ name: "admin-reply-f015", version: "0" }) as any;
54
+ const tools: Record<string, ToolHandler> = {};
55
+ const original = server.tool.bind(server);
56
+ server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
57
+ tools[name] = handler; return original(name, description, schema, handler);
58
+ };
59
+ registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
60
+ return tools;
61
+ }
62
+
63
+ function parse(res: { content: Array<{ text: string }> }) { return JSON.parse(res.content[0].text); }
64
+
65
+ /** 造一条 admin(人类) → AGENT 的任务;delivered 控制「是否已投递」这一个变量。 */
66
+ function seedAdminTask(taskId: string, delivered: boolean) {
67
+ db.run(
68
+ `INSERT INTO tasks (task_id, from_name, from_node_id, to_name, to_node_id, content, status, priority, network_id, created_at)
69
+ VALUES (?1, ?2, NULL, ?3, NULL, 'ping', ?4, 'normal', ?5, datetime('now'))`,
70
+ [taskId, ADMIN, AGENT, delivered ? "delivered" : "created", NET],
71
+ );
72
+ }
73
+
74
+ describe("f015d9d6 — admin→无 node_id 的 MCP 会话,回执绑定", () => {
75
+ beforeEach(() => { cleanup(); seed(); });
76
+ afterAll(() => cleanup());
77
+
78
+ test("变量1:任务【未投递】+ 省略 alias(走 #1085 推导)⇒ 回执应当成功", async () => {
79
+ seedAdminTask("t-undelivered", false);
80
+ const r = parse(await toolsFor(AGENT).send_reply({ in_reply_to: "t-undelivered", text: "done", status: "completed" }));
81
+ expect(r).toEqual(expect.objectContaining({ ok: true }));
82
+ });
83
+
84
+ test("变量1 对照:任务【已投递且有 SSE 订阅者】+ 省略 alias ⇒ 同样成功", async () => {
85
+ seedAdminTask("t-delivered", true);
86
+ createSSEStream(AGENT, NET);
87
+ const r = parse(await toolsFor(AGENT).send_reply({ in_reply_to: "t-delivered", text: "done", status: "completed" }));
88
+ expect(r).toEqual(expect.objectContaining({ ok: true }));
89
+ });
90
+
91
+ test("变量2:显式传一个不等于任务发起方的 alias ⇒ reply_target_mismatch", async () => {
92
+ seedAdminTask("t-wrong-alias", true);
93
+ const r = parse(await toolsFor(AGENT).send_reply({ alias: "someone-else", in_reply_to: "t-wrong-alias", text: "done", status: "completed" }));
94
+ expect(r).toEqual(expect.objectContaining({ ok: false, error: "reply_target_mismatch" }));
95
+ });
96
+ });
@@ -0,0 +1,131 @@
1
+ // #1085 — send_reply must not require `alias` when `in_reply_to` is set.
2
+ //
3
+ // Symptom (deployed SDK-runtime nodes): `mcp__commhub__send_reply(task_id,
4
+ // text)` → `-32602 Invalid input: expected string, received undefined at
5
+ // alias`. That is the HUB's Zod schema rejecting a required `alias` the
6
+ // client never sent — the request dies before the handler runs, so the
7
+ // original task is never terminalized and its inbox row stays unacked
8
+ // (pending inflation).
9
+ //
10
+ // Fix (two parts, both in tools.ts):
11
+ // 1. `replyToolSchema.alias` → optional (stops the MCP-layer Zod -32602).
12
+ // 2. `handleReply` derives the target from the in_reply_to task's
13
+ // `from_name` when alias is omitted — the target was always knowable
14
+ // from the task the hub already looks up, so the param was redundant.
15
+ //
16
+ // This suite drives the registered handler directly (MCP Zod layer is
17
+ // exercised separately by the framework), so it pins part (2): omit alias
18
+ // → derive → reply lands on the original sender; omit both → clean error.
19
+ //
20
+ // 改坏报红 gate: revert the derive block (require alias again) and
21
+ // "alias omitted → derives to original sender" flips to a resolve failure.
22
+
23
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
24
+ import { db } from "./db.js";
25
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
+ import { registerTools } from "./tools.js";
27
+
28
+ const NET_ID = "net_1085";
29
+ const USER_ID = "u_1085";
30
+ const NODE_ID = "node_1085";
31
+ const AGENT_ALIAS = "worker-1085";
32
+ const ORIGIN_ALIAS = "boss-1085"; // the task's from_name — the derive target
33
+ const AGENT_TOK_ID = "tok_1085";
34
+
35
+ interface ToolHandler {
36
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
37
+ }
38
+ interface Reply { ok?: boolean; message_id?: string; error?: string; message?: string; [k: string]: unknown; }
39
+
40
+ function cleanup() {
41
+ for (const t of ["tasks", "inbox", "task_events", "sessions", "nodes", "api_tokens", "network_members", "networks"]) {
42
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET_ID]); } catch {}
43
+ }
44
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
45
+ }
46
+ beforeEach(cleanup);
47
+ afterAll(cleanup);
48
+
49
+ function seed(): { taskId: string } {
50
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER_ID, USER_ID]);
51
+ 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]);
52
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER_ID, NET_ID]);
53
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`, [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`]);
54
+ db.run(`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`, [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID]);
55
+ // The reply TARGET session (the task originator). Its alias is what the
56
+ // handler must derive from the task's from_name.
57
+ db.run(`INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, datetime('now'), datetime('now'))`, [`res_${ORIGIN_ALIAS}`, ORIGIN_ALIAS, NET_ID]);
58
+ // A task from ORIGIN_ALIAS → the worker. Replying to it should land on
59
+ // ORIGIN_ALIAS even when the caller omits alias.
60
+ const taskId = `task_1085_${Date.now()}${Math.floor(Math.random() * 1000)}`;
61
+ db.run(`INSERT INTO tasks (task_id, from_name, to_name, status, content, requires_response, created_at, delivered_at, expires_at, network_id, priority) VALUES (?1, ?2, ?3, 'delivered', 'do the thing', 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?4, 'normal')`, [taskId, ORIGIN_ALIAS, AGENT_ALIAS, NET_ID]);
62
+ return { taskId };
63
+ }
64
+
65
+ async function getHandler(): Promise<ToolHandler> {
66
+ const server = new McpServer({ name: "test", version: "0" });
67
+ const tools: Record<string, ToolHandler> = {};
68
+ const originalTool = (server as any).tool.bind(server);
69
+ (server as any).tool = (name: string, ...rest: any[]) => {
70
+ const handler = rest[rest.length - 1];
71
+ if (typeof handler === "function") tools[name] = handler;
72
+ return originalTool(name, ...rest);
73
+ };
74
+ registerTools(server, {
75
+ enforceNetworkId: NET_ID,
76
+ enforceUserId: USER_ID,
77
+ callerAlias: AGENT_ALIAS,
78
+ callerTokenIsNetwork: true,
79
+ callerTokenId: AGENT_TOK_ID,
80
+ } as any);
81
+ const handler = tools.send_reply;
82
+ if (!handler) throw new Error("send_reply handler not registered");
83
+ return handler;
84
+ }
85
+
86
+ async function call(h: ToolHandler, args: any): Promise<Reply> {
87
+ return JSON.parse((await h(args)).content[0].text) as Reply;
88
+ }
89
+
90
+ describe("#1085 send_reply — alias optional, derived from in_reply_to", () => {
91
+ test("alias OMITTED + in_reply_to set → derives target from task from_name, reply lands on originator", async () => {
92
+ const { taskId } = seed();
93
+ const h = await getHandler();
94
+ const r = await call(h, { in_reply_to: taskId, text: "done", status: "replied", from_session: AGENT_ALIAS });
95
+ expect(r.ok).toBe(true);
96
+ // The reply inbox row must be addressed to the derived originator.
97
+ const row = db.get<{ session_name: string; in_reply_to: string }>(
98
+ "SELECT session_name, in_reply_to FROM inbox WHERE id = ?1", [r.message_id],
99
+ );
100
+ expect(row?.session_name).toBe(ORIGIN_ALIAS);
101
+ expect(row?.in_reply_to).toBe(taskId);
102
+ // And the original task is terminalized (no longer pending).
103
+ const task = db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", [taskId]);
104
+ expect(task?.status).toBe("replied");
105
+ });
106
+
107
+ test("alias EXPLICIT still works (unchanged path)", async () => {
108
+ const { taskId } = seed();
109
+ const h = await getHandler();
110
+ const r = await call(h, { alias: ORIGIN_ALIAS, in_reply_to: taskId, text: "done", from_session: AGENT_ALIAS });
111
+ expect(r.ok).toBe(true);
112
+ const row = db.get<{ session_name: string }>("SELECT session_name FROM inbox WHERE id = ?1", [r.message_id]);
113
+ expect(row?.session_name).toBe(ORIGIN_ALIAS);
114
+ });
115
+
116
+ test("alias OMITTED + no in_reply_to → clean error, not a crash", async () => {
117
+ seed();
118
+ const h = await getHandler();
119
+ const r = await call(h, { text: "orphan reply", from_session: AGENT_ALIAS });
120
+ expect(r.ok).toBe(false);
121
+ expect(r.error).toBe("reply_target_required");
122
+ });
123
+
124
+ test("alias OMITTED + in_reply_to points at nonexistent task → resolve error", async () => {
125
+ seed();
126
+ const h = await getHandler();
127
+ const r = await call(h, { in_reply_to: "task_does_not_exist_1085", text: "x", from_session: AGENT_ALIAS });
128
+ expect(r.ok).toBe(false);
129
+ expect(r.error).toBe("reply_target_unresolved");
130
+ });
131
+ });
package/src/server.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { parseDbTimestampMs } from "./db-timestamp.js";
2
3
  import { resolvePort } from "./resolve-port.js";
3
4
  import { normalizeSessionRuntime } from "./runtime-label.js";
4
5
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5
6
  import { z } from "zod/v4";
6
7
  import { registerTools } from "./tools.js";
7
8
  import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
8
- import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
9
+ import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
9
10
  import { assertNodeActive } from "./lifecycle-guard.js";
10
11
  import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
11
12
  import { validateAvatarUrl } from "./avatar-validate.js";
@@ -870,6 +871,32 @@ return Bun.serve({
870
871
  return createNetworkObserverStream(observedNetId);
871
872
  }
872
873
 
874
+ // GET /events/users/me?network_id=... → Desktop/user-client stream.
875
+ // This is intentionally keyed by authenticated user_id, not alias.
876
+ // Desktop must use its utok_; ntok_ is a node credential and cannot
877
+ // subscribe to user channels.
878
+ if (url.pathname === "/events/users/me" && req.method === "GET") {
879
+ const authErr = requireAuth(req);
880
+ if (authErr) return authErr;
881
+ const token = requestToken(req);
882
+ if (!token?.startsWith("utok_")) {
883
+ return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
884
+ }
885
+ const authCtx = resolveRequestAuth(req);
886
+ if (!authCtx) {
887
+ return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
888
+ }
889
+ const networkId = url.searchParams.get("network_id");
890
+ if (!networkId) {
891
+ return withCors(req, Response.json({ ok: false, error: "network_id required" }, { status: 400 }));
892
+ }
893
+ const role = getUserNetworkRole(authCtx.userId, networkId);
894
+ if (!role) {
895
+ return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
896
+ }
897
+ return createUserEventStream(networkId, authCtx.userId);
898
+ }
899
+
873
900
  // ── SSE push: Agent 实时接收任务推送 ──
874
901
  // GET /events/知识哥 → 保持长连接,send_task 时秒推
875
902
  const eventsMatch = url.pathname.match(/^\/events\/(.+)$/);
@@ -2598,13 +2625,27 @@ return Bun.serve({
2598
2625
  if (url.pathname === "/api/messages") {
2599
2626
  const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
2600
2627
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
2628
+ const alias = url.searchParams.get("alias")?.trim() || null;
2601
2629
  const params: any[] = [since];
2602
- let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at, network_id FROM inbox WHERE created_at >= ?1";
2630
+ let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, acked, created_at, network_id FROM inbox WHERE created_at >= ?1";
2631
+ if (alias) {
2632
+ params.push(alias);
2633
+ sql += ` AND session_name = ?${params.length}`;
2634
+ }
2603
2635
  sql = addNetworkScope(sql, params, restScope);
2604
2636
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2605
2637
  params.push(limit);
2606
2638
  const rows = db.all(sql, ...params);
2607
- return withCors(req, Response.json({ ok: true, messages: rows }));
2639
+
2640
+ let pendingCount: number | undefined;
2641
+ if (alias) {
2642
+ const countParams: any[] = [alias];
2643
+ let countSql = "SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
2644
+ countSql = addNetworkScope(countSql, countParams, restScope);
2645
+ pendingCount = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
2646
+ }
2647
+
2648
+ return withCors(req, Response.json({ ok: true, messages: rows, ...(alias ? { pending_count: pendingCount ?? 0 } : {}) }));
2608
2649
  }
2609
2650
 
2610
2651
  // ── REST: stats summary ──
@@ -3008,7 +3049,10 @@ return Bun.serve({
3008
3049
  `, effectiveNetId);
3009
3050
 
3010
3051
  const nowMs = Date.now();
3011
- const ONLINE_MS = 60_000;
3052
+ // 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
3053
+ // 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
3054
+ // (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
3055
+ const ONLINE_MS = 5 * 60_000;
3012
3056
  const daemons = sqlRows
3013
3057
  .map(r => {
3014
3058
  let snapRole: string | null = null;
@@ -3026,7 +3070,7 @@ return Bun.serve({
3026
3070
  let lastSeenAt: string | null = null;
3027
3071
  if (r.session_last_seen) {
3028
3072
  lastSeenAt = r.session_last_seen;
3029
- const t = Date.parse(r.session_last_seen);
3073
+ const t = parseDbTimestampMs(r.session_last_seen);
3030
3074
  if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
3031
3075
  }
3032
3076
  let runtimes: string[] = [];