@sleep2agi/commhub-server 0.8.3-preview.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/db.ts +2 -0
- package/src/index.ts +23 -12
- package/src/rename.ts +71 -0
- package/src/tools.ts +77 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.8.3
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/db.ts
CHANGED
|
@@ -129,6 +129,7 @@ for (const col of [
|
|
|
129
129
|
{ name: "requires_response", def: "TEXT DEFAULT 'reply'" },
|
|
130
130
|
{ name: "expires_at", def: "TEXT" },
|
|
131
131
|
{ name: "scope", def: "TEXT DEFAULT 'single'" },
|
|
132
|
+
{ name: "meta_json", def: "TEXT" },
|
|
132
133
|
]) {
|
|
133
134
|
try { db.exec(`ALTER TABLE inbox ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
134
135
|
}
|
|
@@ -489,6 +490,7 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions
|
|
|
489
490
|
// admin to see the answer even if 指挥室's own session has died. The hub forwards
|
|
490
491
|
// the reply up the chain via parent_task_id.
|
|
491
492
|
try { db.exec("ALTER TABLE tasks ADD COLUMN parent_task_id TEXT"); } catch {}
|
|
493
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
|
|
492
494
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)"); } catch {}
|
|
493
495
|
|
|
494
496
|
// Helpers
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { registerTools } from "./tools.js";
|
|
|
5
5
|
import { db, logTaskEvent, logAudit } from "./db.js";
|
|
6
6
|
import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
|
|
7
7
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
8
|
-
import {
|
|
8
|
+
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
9
9
|
|
|
10
10
|
const PORT = Number(process.env.PORT) || 9200;
|
|
11
11
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
@@ -51,6 +51,11 @@ console.info = (...args: any[]) => { pushLog("info", args); _origConsole.info(..
|
|
|
51
51
|
console.warn = (...args: any[]) => { pushLog("warn", args); _origConsole.warn(...args); };
|
|
52
52
|
console.error = (...args: any[]) => { pushLog("error", args); _origConsole.error(...args); };
|
|
53
53
|
|
|
54
|
+
function normalizeMetaJson(meta: unknown): string | null {
|
|
55
|
+
if (!meta || typeof meta !== "object") return null;
|
|
56
|
+
try { return JSON.stringify(meta); } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
54
59
|
// ── Rate limiter (in-memory, per IP) ──
|
|
55
60
|
const rateLimits = new Map<string, { count: number; resetAt: number }>();
|
|
56
61
|
function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
|
|
@@ -343,6 +348,8 @@ const TaskSchema = z.object({
|
|
|
343
348
|
from: z.string().max(200).optional(),
|
|
344
349
|
network_id: z.string().max(200).optional(),
|
|
345
350
|
parent_task_id: z.string().max(200).optional(),
|
|
351
|
+
ttl_seconds: z.number().min(1).max(86400).optional(),
|
|
352
|
+
meta: z.any().optional(),
|
|
346
353
|
});
|
|
347
354
|
|
|
348
355
|
const BroadcastSchema = z.object({
|
|
@@ -918,6 +925,7 @@ Bun.serve({
|
|
|
918
925
|
let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
|
|
919
926
|
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
920
927
|
db.run(staleSql, staleParams);
|
|
928
|
+
cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
|
|
921
929
|
const params: any[] = [];
|
|
922
930
|
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
923
931
|
sql = addNetworkScope(sql, params, restScope);
|
|
@@ -1188,9 +1196,12 @@ Bun.serve({
|
|
|
1188
1196
|
if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
|
|
1189
1197
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
1190
1198
|
}
|
|
1199
|
+
const canonical = resolveCanonicalAlias(taskNetId, body.alias);
|
|
1200
|
+
const targetAlias = canonical.alias;
|
|
1191
1201
|
const id = crypto.randomUUID();
|
|
1192
1202
|
const fromSession = body.from || "api";
|
|
1193
1203
|
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
1204
|
+
const metaJson = normalizeMetaJson((body as any).meta);
|
|
1194
1205
|
// Mirror send_task MCP: write inbox + tasks rows in a single
|
|
1195
1206
|
// transaction so the dispatch is visible to dashboard's Tasks page
|
|
1196
1207
|
// and the parent_task_id lineage chain. Previously this endpoint
|
|
@@ -1198,35 +1209,35 @@ Bun.serve({
|
|
|
1198
1209
|
// dispatched via REST (anet demo, dashboard Dispatch button, etc.).
|
|
1199
1210
|
db.transaction(() => {
|
|
1200
1211
|
db.run(
|
|
1201
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
|
|
1202
|
-
VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)`,
|
|
1203
|
-
[id,
|
|
1212
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
1213
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6, ?7)`,
|
|
1214
|
+
[id, targetAlias, body.priority, body.task, fromSession, taskNetId, metaJson]
|
|
1204
1215
|
);
|
|
1205
1216
|
db.run(
|
|
1206
|
-
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id)
|
|
1207
|
-
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8)`,
|
|
1208
|
-
[id, fromSession,
|
|
1217
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
|
|
1218
|
+
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8, ?9)`,
|
|
1219
|
+
[id, fromSession, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
|
|
1209
1220
|
);
|
|
1210
1221
|
// Touch session row so the dashboard reflects "task in flight"
|
|
1211
1222
|
// immediately, without waiting for the agent's report_status to
|
|
1212
1223
|
// arrive. Updating both `task` and `updated_at` is enough — we
|
|
1213
1224
|
// leave `status` to the agent (idle → working → idle).
|
|
1214
|
-
const touchParams: any[] = [body.task.slice(0, 200),
|
|
1225
|
+
const touchParams: any[] = [body.task.slice(0, 200), targetAlias];
|
|
1215
1226
|
let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
|
|
1216
1227
|
if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
|
|
1217
1228
|
db.run(touchSql, touchParams);
|
|
1218
1229
|
});
|
|
1219
1230
|
// SSE push: 秒达
|
|
1220
|
-
const pendingParams: any[] = [
|
|
1231
|
+
const pendingParams: any[] = [targetAlias];
|
|
1221
1232
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
1222
1233
|
if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
|
|
1223
1234
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
1224
|
-
const sessionParams: any[] = [
|
|
1235
|
+
const sessionParams: any[] = [targetAlias];
|
|
1225
1236
|
let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
|
|
1226
1237
|
if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
|
|
1227
1238
|
const targetSession = db.get<any>(sessionSql, ...sessionParams);
|
|
1228
|
-
if (targetSession) pushEvent(
|
|
1229
|
-
return withCors(req, Response.json({ ok: true, task_id: id, message_id: id }));
|
|
1239
|
+
if (targetSession) pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
|
|
1240
|
+
return withCors(req, Response.json({ ok: true, task_id: id, message_id: id, ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}) }));
|
|
1230
1241
|
}
|
|
1231
1242
|
|
|
1232
1243
|
// ── REST: broadcast ──
|
package/src/rename.ts
CHANGED
|
@@ -23,11 +23,81 @@ export interface RenameResult {
|
|
|
23
23
|
error?: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export interface CanonicalAlias {
|
|
27
|
+
alias: string;
|
|
28
|
+
renamed: boolean;
|
|
29
|
+
renamed_from?: string;
|
|
30
|
+
chain: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
function hasWriteAccess(userId: string, networkId: string): boolean {
|
|
27
34
|
const role = getUserNetworkRole(userId, networkId);
|
|
28
35
|
return !!role && role !== "viewer";
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
// Resolve committed alias renames (old -> new), following short chains such
|
|
39
|
+
// as A -> B -> C. This is intentionally server-side canonicalization: stale
|
|
40
|
+
// clients may keep reporting/sending to an old alias after commit, and the hub
|
|
41
|
+
// must not let that recreate orphan session rows (#146/#172).
|
|
42
|
+
export function resolveCanonicalAlias(networkId: string | null | undefined, alias: string): CanonicalAlias {
|
|
43
|
+
if (!networkId || !alias) return { alias, renamed: false, chain: [alias] };
|
|
44
|
+
|
|
45
|
+
const chain = [alias];
|
|
46
|
+
let current = alias;
|
|
47
|
+
const seen = new Set([alias]);
|
|
48
|
+
for (let i = 0; i < 8; i++) {
|
|
49
|
+
const row = db.get<{ new_alias: string }>(
|
|
50
|
+
"SELECT new_alias FROM rename_txn WHERE network_id = ?1 AND old_alias = ?2 AND status = 'committed' ORDER BY committed_at DESC LIMIT 1",
|
|
51
|
+
networkId, current
|
|
52
|
+
);
|
|
53
|
+
if (!row?.new_alias || seen.has(row.new_alias)) break;
|
|
54
|
+
current = row.new_alias;
|
|
55
|
+
chain.push(current);
|
|
56
|
+
seen.add(current);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
alias: current,
|
|
61
|
+
renamed: current !== alias,
|
|
62
|
+
renamed_from: current !== alias ? alias : undefined,
|
|
63
|
+
chain,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function canonicalAliasExists(networkId: string, alias: string, excludingResumeId?: string | null): boolean {
|
|
68
|
+
const params: any[] = [networkId, alias];
|
|
69
|
+
let sql = "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2";
|
|
70
|
+
if (excludingResumeId) {
|
|
71
|
+
sql += " AND resume_id != ?3";
|
|
72
|
+
params.push(excludingResumeId);
|
|
73
|
+
}
|
|
74
|
+
return !!db.get<any>(sql, ...params);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function cleanupRenamedAliasSession(networkId: string | null | undefined, oldAlias: string, newAlias: string): void {
|
|
78
|
+
if (!networkId || oldAlias === newAlias) return;
|
|
79
|
+
const existsNew = db.get<any>(
|
|
80
|
+
"SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2",
|
|
81
|
+
networkId, newAlias
|
|
82
|
+
);
|
|
83
|
+
if (!existsNew) return;
|
|
84
|
+
db.run("DELETE FROM sessions WHERE network_id = ?1 AND alias = ?2", [networkId, oldAlias]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function cleanupCommittedRenameSessions(networkIds: string[] | null = null): void {
|
|
88
|
+
const params: any[] = [];
|
|
89
|
+
let sql = "SELECT network_id, old_alias, new_alias FROM rename_txn WHERE status = 'committed'";
|
|
90
|
+
if (networkIds) {
|
|
91
|
+
if (networkIds.length === 0) return;
|
|
92
|
+
const placeholders = networkIds.map((_, i) => `?${i + 1}`).join(", ");
|
|
93
|
+
sql += ` AND network_id IN (${placeholders})`;
|
|
94
|
+
params.push(...networkIds);
|
|
95
|
+
}
|
|
96
|
+
for (const row of db.all<{ network_id: string; old_alias: string; new_alias: string }>(sql, ...params)) {
|
|
97
|
+
cleanupRenamedAliasSession(row.network_id, row.old_alias, row.new_alias);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
31
101
|
// PHASE 1 P3 — create a prepared rename_txn row reserving new_alias.
|
|
32
102
|
// CAS guard (RFC §4 risk #3 TOCTOU): new_alias must be free both in the
|
|
33
103
|
// sessions registry AND among in-flight prepared rename_txn rows.
|
|
@@ -80,6 +150,7 @@ export function commitRename(userId: string, txnId: string): RenameResult {
|
|
|
80
150
|
db.run(
|
|
81
151
|
"UPDATE sessions SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
|
|
82
152
|
[txn.new_alias, txn.network_id, txn.old_alias]);
|
|
153
|
+
cleanupRenamedAliasSession(txn.network_id, txn.old_alias, txn.new_alias);
|
|
83
154
|
db.run(
|
|
84
155
|
"UPDATE nodes SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
|
|
85
156
|
[txn.new_alias, txn.network_id, txn.old_alias]);
|
package/src/tools.ts
CHANGED
|
@@ -3,11 +3,22 @@ import { z } from "zod/v4";
|
|
|
3
3
|
import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
|
|
4
4
|
import { pushEvent } from "./push.js";
|
|
5
5
|
import { getUserNetworkRole } from "./auth.js";
|
|
6
|
+
import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
|
|
6
7
|
|
|
7
8
|
function ts(): string {
|
|
8
9
|
return new Date().toTimeString().slice(0, 8);
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
function parseMetaJson(value: unknown): unknown | null {
|
|
13
|
+
if (!value || typeof value !== "string") return null;
|
|
14
|
+
try { return JSON.parse(value); } catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeMetaJson(meta: unknown): string | null {
|
|
18
|
+
if (!meta || typeof meta !== "object") return null;
|
|
19
|
+
try { return JSON.stringify(meta); } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false) {
|
|
12
23
|
// Default from_session for outbound tools — extracted from the calling
|
|
13
24
|
// token's binding (ntok_ → node alias, utok_ → username). Without this,
|
|
@@ -149,7 +160,36 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
149
160
|
if (!canWrite(effectiveNetId)) {
|
|
150
161
|
return writeDeniedReply(effectiveNetId);
|
|
151
162
|
}
|
|
152
|
-
|
|
163
|
+
const canonical = resolveCanonicalAlias(sessionNetId, alias);
|
|
164
|
+
let effectiveAlias = canonical.alias;
|
|
165
|
+
if (canonical.renamed) {
|
|
166
|
+
// A stale process may keep heartbeating with the old alias after a
|
|
167
|
+
// committed rename. If the new alias is already active, ignore the
|
|
168
|
+
// stale report and clean the old row instead of letting it recreate
|
|
169
|
+
// a red/orphan dashboard node (#146/#172). If not active yet, rewrite
|
|
170
|
+
// the incoming report to the canonical alias so startup can converge.
|
|
171
|
+
if (canonicalAliasExists(sessionNetId, effectiveAlias, resume_id)) {
|
|
172
|
+
cleanupRenamedAliasSession(sessionNetId, alias, effectiveAlias);
|
|
173
|
+
const pendingParams: any[] = [effectiveAlias];
|
|
174
|
+
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
175
|
+
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
176
|
+
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
177
|
+
return {
|
|
178
|
+
content: [{
|
|
179
|
+
type: "text" as const,
|
|
180
|
+
text: JSON.stringify({
|
|
181
|
+
ok: true,
|
|
182
|
+
resume_id,
|
|
183
|
+
alias: effectiveAlias,
|
|
184
|
+
renamed_from: alias,
|
|
185
|
+
ignored_stale_alias: true,
|
|
186
|
+
inbox_count: pending?.cnt ?? 0,
|
|
187
|
+
}),
|
|
188
|
+
}],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
153
193
|
const trimmedOutput = output?.slice(0, 4000);
|
|
154
194
|
const hostHostname = host?.hostname || hn || null;
|
|
155
195
|
const hostIp = host?.ip || clientIP || null;
|
|
@@ -190,7 +230,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
190
230
|
|
|
191
231
|
db.transaction(() => {
|
|
192
232
|
// Only delete same-alias sessions within the same network
|
|
193
|
-
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [
|
|
233
|
+
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
|
|
194
234
|
db.run(
|
|
195
235
|
`INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, last_seen_at, updated_at)
|
|
196
236
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, datetime('now'), datetime('now'))
|
|
@@ -219,19 +259,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
219
259
|
process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
|
|
220
260
|
process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
|
|
221
261
|
last_seen_at = datetime('now'), updated_at = datetime('now')`,
|
|
222
|
-
[resume_id,
|
|
262
|
+
[resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
|
|
223
263
|
);
|
|
224
264
|
if (host || proc) {
|
|
225
265
|
db.run(
|
|
226
266
|
`INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, created_at)
|
|
227
267
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'))`,
|
|
228
|
-
[uuidv4(), sessionNetId, resume_id,
|
|
268
|
+
[uuidv4(), sessionNetId, resume_id, effectiveAlias, hostHostname, hostIp, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
|
|
229
269
|
);
|
|
230
270
|
}
|
|
231
271
|
});
|
|
232
|
-
pushEvent(
|
|
272
|
+
pushEvent(effectiveAlias, {
|
|
233
273
|
type: "status_update",
|
|
234
|
-
alias,
|
|
274
|
+
alias: effectiveAlias,
|
|
275
|
+
...(canonical.renamed ? { renamed_from: alias } : {}),
|
|
235
276
|
status,
|
|
236
277
|
progress: progress ?? null,
|
|
237
278
|
host: statusHostTelemetry,
|
|
@@ -241,19 +282,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
241
282
|
// V2: sync tasks table — report_status(working) → tasks.running
|
|
242
283
|
if (status === "working" && task) {
|
|
243
284
|
try {
|
|
244
|
-
const runParams: any[] = [
|
|
285
|
+
const runParams: any[] = [effectiveAlias, task];
|
|
245
286
|
let runSql = `UPDATE tasks SET status = 'running', started_at = datetime('now')
|
|
246
287
|
WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`;
|
|
247
288
|
runSql = addScope(runSql, runParams, effectiveNetId);
|
|
248
289
|
const runResult = db.run(runSql, runParams);
|
|
249
290
|
if (runResult.changes > 0) {
|
|
250
291
|
// Find task_id for logging
|
|
251
|
-
const findParams: any[] = [
|
|
292
|
+
const findParams: any[] = [effectiveAlias, task];
|
|
252
293
|
let findSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running'";
|
|
253
294
|
findSql = addScope(findSql, findParams, effectiveNetId);
|
|
254
295
|
findSql += " ORDER BY started_at DESC LIMIT 1";
|
|
255
296
|
const t = db.get<{ task_id: string }>(findSql, ...findParams);
|
|
256
|
-
if (t) logTaskEvent(t.task_id, null, "running",
|
|
297
|
+
if (t) logTaskEvent(t.task_id, null, "running", effectiveAlias);
|
|
257
298
|
}
|
|
258
299
|
} catch {}
|
|
259
300
|
}
|
|
@@ -277,13 +318,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
277
318
|
hostname = COALESCE(?9, nodes.hostname),
|
|
278
319
|
network_id = COALESCE(?10, nodes.network_id),
|
|
279
320
|
updated_at = datetime('now')`,
|
|
280
|
-
[node_id, nn ||
|
|
321
|
+
[node_id, nn || effectiveAlias, effectiveAlias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null, effectiveNetId ?? null]
|
|
281
322
|
);
|
|
282
323
|
} catch {}
|
|
283
324
|
}
|
|
284
325
|
|
|
285
326
|
// inbox uses alias for routing
|
|
286
|
-
const inboxParams: any[] = [
|
|
327
|
+
const inboxParams: any[] = [effectiveAlias];
|
|
287
328
|
let inboxSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
288
329
|
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
289
330
|
const row = db.get<{ cnt: number }>(inboxSql, ...inboxParams);
|
|
@@ -295,7 +336,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
295
336
|
text: JSON.stringify({
|
|
296
337
|
ok: true,
|
|
297
338
|
resume_id,
|
|
298
|
-
alias,
|
|
339
|
+
alias: effectiveAlias,
|
|
340
|
+
...(canonical.renamed ? { renamed_from: alias } : {}),
|
|
299
341
|
inbox_count: row?.cnt ?? 0,
|
|
300
342
|
}),
|
|
301
343
|
},
|
|
@@ -407,13 +449,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
407
449
|
const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
|
|
408
450
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
409
451
|
const rowsParams: any[] = [alias];
|
|
410
|
-
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id
|
|
452
|
+
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
|
|
411
453
|
FROM inbox WHERE session_name = ?1 AND acked = 0`;
|
|
412
454
|
rowsSql = addReadScope(rowsSql, rowsParams, readScope);
|
|
413
455
|
rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
|
|
414
456
|
LIMIT ?${rowsParams.length + 1}`;
|
|
415
457
|
rowsParams.push(limit);
|
|
416
|
-
const rows = db.all(rowsSql, ...rowsParams)
|
|
458
|
+
const rows = db.all(rowsSql, ...rowsParams).map((row: any) => ({
|
|
459
|
+
...row,
|
|
460
|
+
meta: parseMetaJson(row.meta_json),
|
|
461
|
+
}));
|
|
417
462
|
|
|
418
463
|
return {
|
|
419
464
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
|
|
@@ -553,9 +598,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
553
598
|
ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
|
|
554
599
|
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
555
600
|
parent_task_id: z.string().max(200).optional().describe("Parent task this dispatch is on behalf of. When the child task replies the hub will auto-chain the answer to the parent task's originator, so the user sees the final result even if the intermediate session ends."),
|
|
601
|
+
meta: z.any().optional().describe("Optional structured task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
|
|
556
602
|
},
|
|
557
|
-
async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn }) => { const from_session = defaultFrom(_fromIn);
|
|
603
|
+
async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta }) => { const from_session = defaultFrom(_fromIn);
|
|
558
604
|
const effectiveNetId = getNetworkId(netId);
|
|
605
|
+
const metaJson = normalizeMetaJson(meta);
|
|
559
606
|
// Resolve parent_task_id: explicit > inferred (caller's most recent
|
|
560
607
|
// delivered/started inbox task that's still open). Inference is the
|
|
561
608
|
// safety net for when the LLM forgets to pass parent_task_id.
|
|
@@ -587,7 +634,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
587
634
|
}
|
|
588
635
|
}
|
|
589
636
|
|
|
590
|
-
|
|
637
|
+
const canonical = resolveCanonicalAlias(effectiveNetId, alias);
|
|
638
|
+
const targetAlias = canonical.alias;
|
|
639
|
+
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
591
640
|
const id = uuidv4();
|
|
592
641
|
// 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
|
|
593
642
|
// dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
|
|
@@ -595,23 +644,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
595
644
|
// 报告冲突)。
|
|
596
645
|
db.transaction(() => {
|
|
597
646
|
db.run(
|
|
598
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
|
|
599
|
-
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
|
|
600
|
-
[id,
|
|
647
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id, meta_json)
|
|
648
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7, ?8)`,
|
|
649
|
+
[id, targetAlias, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
|
|
601
650
|
);
|
|
602
651
|
db.run(
|
|
603
|
-
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id)
|
|
604
|
-
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8)`,
|
|
605
|
-
[id, from_session,
|
|
652
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
|
|
653
|
+
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8, ?9)`,
|
|
654
|
+
[id, from_session, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
|
|
606
655
|
);
|
|
607
|
-
const touchParams: any[] = [task.slice(0, 200),
|
|
656
|
+
const touchParams: any[] = [task.slice(0, 200), targetAlias];
|
|
608
657
|
let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
|
|
609
658
|
touchSql = addScope(touchSql, touchParams, effectiveNetId);
|
|
610
659
|
db.run(touchSql, touchParams);
|
|
611
660
|
});
|
|
612
|
-
logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${
|
|
661
|
+
logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
|
|
613
662
|
|
|
614
|
-
const session = scopedSessionStatus(
|
|
663
|
+
const session = scopedSessionStatus(targetAlias, effectiveNetId);
|
|
615
664
|
|
|
616
665
|
// SSE push by alias.
|
|
617
666
|
// The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
|
|
@@ -620,11 +669,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
620
669
|
// network_id=null but the sender supplied an explicit network_id (the
|
|
621
670
|
// exact mismatch hit by Dashboard tasks). Push unconditionally; the
|
|
622
671
|
// subscriber's own auth (ntok_) constrains who can listen.
|
|
623
|
-
const pendingParams: any[] = [
|
|
672
|
+
const pendingParams: any[] = [targetAlias];
|
|
624
673
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
625
674
|
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
626
675
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
627
|
-
pushEvent(
|
|
676
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
628
677
|
|
|
629
678
|
return {
|
|
630
679
|
content: [
|
|
@@ -633,6 +682,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
633
682
|
text: JSON.stringify({
|
|
634
683
|
ok: true,
|
|
635
684
|
message_id: id,
|
|
685
|
+
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
636
686
|
session_status: session?.status ?? "unknown",
|
|
637
687
|
}),
|
|
638
688
|
},
|