@sleep2agi/commhub-server 0.5.0-preview.4 → 0.5.0-preview.6

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.5.0-preview.4",
3
+ "version": "0.5.0-preview.6",
4
4
  "description": "CommHub MCP Server — AI Agent communication hub with SSE push, MCP protocol, and REST API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/db.ts CHANGED
@@ -135,7 +135,32 @@ db.exec(`
135
135
  CREATE INDEX IF NOT EXISTS idx_nodes_alias ON nodes(alias);
136
136
  `);
137
137
 
138
+ // task_events table (V2 Sprint 2) — audit log for task state changes
139
+ db.exec(`
140
+ CREATE TABLE IF NOT EXISTS task_events (
141
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
142
+ task_id TEXT NOT NULL,
143
+ from_status TEXT,
144
+ to_status TEXT NOT NULL,
145
+ actor TEXT NOT NULL DEFAULT 'system',
146
+ detail TEXT,
147
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
148
+ );
149
+
150
+ CREATE INDEX IF NOT EXISTS idx_task_events_task_time ON task_events(task_id, created_at DESC);
151
+ CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at);
152
+ `);
153
+
138
154
  // Helpers
139
155
  export function uuidv4(): string {
140
156
  return crypto.randomUUID();
141
157
  }
158
+
159
+ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
160
+ try {
161
+ db.run(
162
+ "INSERT INTO task_events (task_id, from_status, to_status, actor, detail) VALUES (?1, ?2, ?3, ?4, ?5)",
163
+ [taskId, fromStatus, toStatus, actor, detail ?? null]
164
+ );
165
+ } catch {}
166
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
3
  import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
- import { db } from "./db.js";
5
+ import { db, logTaskEvent } from "./db.js";
6
6
  import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
7
7
 
8
8
  const PORT = Number(process.env.PORT) || 9200;
@@ -84,6 +84,11 @@ setInterval(() => {
84
84
  );
85
85
  if (result.changes > 0) {
86
86
  console.log(`[patrol] expired ${result.changes} stale task(s)`);
87
+ // Log events for expired tasks
88
+ const expired = db.query<{ task_id: string }, []>(
89
+ "SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
90
+ ).all();
91
+ for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
87
92
  }
88
93
  } catch {}
89
94
  }, 5 * 60 * 1000);
@@ -281,6 +286,19 @@ Bun.serve({
281
286
  return withCors(req, Response.json({ ok: true, messages: rows }));
282
287
  }
283
288
 
289
+ // ── REST: task events (V2 Sprint 2) ──
290
+ if (url.pathname === "/api/task_events") {
291
+ const taskId = url.searchParams.get("task_id");
292
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
293
+ let sql = "SELECT * FROM task_events";
294
+ const params: any[] = [];
295
+ if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
296
+ sql += " ORDER BY created_at DESC LIMIT ?";
297
+ params.push(limit);
298
+ const rows = db.query(sql).all(...params);
299
+ return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
300
+ }
301
+
284
302
  // ── REST: nodes table (V2 Sprint 2) ──
285
303
  if (url.pathname === "/api/nodes") {
286
304
  const nodeId = url.searchParams.get("node_id");
package/src/tools.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
- import { db, uuidv4 } from "./db.js";
3
+ import { db, uuidv4, logTaskEvent } from "./db.js";
4
4
  import { pushEvent, pushBroadcast } from "./push.js";
5
5
 
6
6
  function ts(): string {
@@ -78,11 +78,18 @@ export function registerTools(server: McpServer, clientIP?: string) {
78
78
  // V2: sync tasks table — report_status(working) → tasks.running
79
79
  if (status === "working" && task) {
80
80
  try {
81
- db.run(
81
+ const runResult = db.run(
82
82
  `UPDATE tasks SET status = 'running', started_at = datetime('now')
83
83
  WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
84
84
  [alias, task]
85
85
  );
86
+ if (runResult.changes > 0) {
87
+ // Find task_id for logging
88
+ const t = db.query<{ task_id: string }, [string, string]>(
89
+ "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1"
90
+ ).get(alias, task);
91
+ if (t) logTaskEvent(t.task_id, null, "running", alias);
92
+ }
86
93
  } catch {}
87
94
  }
88
95
 
@@ -180,6 +187,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
180
187
  }
181
188
 
182
189
  db.run("COMMIT");
190
+ // Log event after commit
191
+ const updatedTaskId = taskUpdate.changes > 0 ? task : (db.query<{ task_id: string }, [string]>(
192
+ "SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1"
193
+ ).get(alias)?.task_id);
194
+ if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
183
195
  } catch (e) {
184
196
  try { db.run("ROLLBACK"); } catch {}
185
197
  throw e;
@@ -234,10 +246,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
234
246
  }
235
247
  // V2: sync tasks table — ack_inbox means delivered→acked
236
248
  try {
237
- db.run(
249
+ const ackResult = db.run(
238
250
  `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
239
251
  [message_id]
240
252
  );
253
+ if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
241
254
  } catch {}
242
255
  return {
243
256
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
@@ -339,6 +352,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
339
352
  [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`]
340
353
  );
341
354
  db.run("COMMIT");
355
+ logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
342
356
  } catch (e) {
343
357
  try { db.run("ROLLBACK"); } catch {}
344
358
  throw e;
@@ -417,6 +431,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
417
431
  async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
418
432
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
419
433
  const id = uuidv4();
434
+ let replyLogged = false;
420
435
  try {
421
436
  db.run("BEGIN IMMEDIATE");
422
437
  db.run(
@@ -434,6 +449,8 @@ export function registerTools(server: McpServer, clientIP?: string) {
434
449
  );
435
450
  if (result.changes === 0) {
436
451
  console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
452
+ } else {
453
+ replyLogged = true;
437
454
  }
438
455
  }
439
456
  db.run("COMMIT");
@@ -442,6 +459,9 @@ export function registerTools(server: McpServer, clientIP?: string) {
442
459
  throw e;
443
460
  }
444
461
 
462
+ // Log event after commit (outside transaction)
463
+ if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
464
+
445
465
  const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
446
466
  pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
447
467
 
@@ -468,6 +488,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
468
488
  `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
469
489
  [task_id]
470
490
  );
491
+ if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
471
492
  return {
472
493
  content: [{
473
494
  type: "text" as const,
@@ -513,6 +534,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
513
534
  [retryInboxId, task.to_name, task.priority, task.content, from_session]
514
535
  );
515
536
  db.run("COMMIT");
537
+ logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
516
538
  } catch (e) {
517
539
  try { db.run("ROLLBACK"); } catch {}
518
540
  throw e;
@@ -543,6 +565,40 @@ export function registerTools(server: McpServer, clientIP?: string) {
543
565
  }
544
566
  );
545
567
 
568
+ // ── V2: reassign_task (转移任务到另一个 agent) ──
569
+ server.tool(
570
+ "reassign_task",
571
+ "Reassign a task to a different agent. Works on any non-terminal task (delivered/acked/running).",
572
+ {
573
+ task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
574
+ new_alias: z.string().min(1).max(200).describe("Target agent alias"),
575
+ from_session: z.string().max(200).optional().default("hub"),
576
+ },
577
+ async ({ task_id, new_alias, from_session }) => {
578
+ console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
579
+ const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
580
+ if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
581
+ if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
582
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
583
+ }
584
+ const oldAlias = task.to_name;
585
+ try {
586
+ db.run("BEGIN IMMEDIATE");
587
+ db.run("UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?2", [new_alias, task_id]);
588
+ const newInboxId = uuidv4();
589
+ db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
590
+ [newInboxId, new_alias, task.priority, task.content, from_session]);
591
+ db.run("COMMIT");
592
+ logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
593
+ } catch (e) {
594
+ try { db.run("ROLLBACK"); } catch {}
595
+ throw e;
596
+ }
597
+ pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
598
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
599
+ }
600
+ );
601
+
546
602
  server.tool(
547
603
  "broadcast",
548
604
  "Send a message to multiple sessions.",