@sleep2agi/commhub-server 0.8.6-preview.0 → 0.8.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 +3 -2
- package/src/auth_login_guard.test.ts +130 -0
- package/src/auth_login_guard.ts +142 -0
- package/src/db.ts +189 -4
- package/src/event_bus.ts +10 -0
- package/src/index.ts +97 -43
- package/src/push.test.ts +47 -0
- package/src/push.ts +28 -0
- package/src/rename.ts +39 -14
- package/src/tools.ts +60 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.8.6
|
|
3
|
+
"version": "0.8.6",
|
|
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",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
49
|
-
"bun-types": "^1.3.13"
|
|
49
|
+
"bun-types": "^1.3.13",
|
|
50
|
+
"hono": "^4.12.25"
|
|
50
51
|
}
|
|
51
52
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
LOGIN_FAILURE_THRESHOLD,
|
|
4
|
+
LOGIN_LOCK_BASE_MS,
|
|
5
|
+
LOGIN_LOCK_MAX_MS,
|
|
6
|
+
LOGIN_IP_MAX_PER_WINDOW,
|
|
7
|
+
LOGIN_IP_WINDOW_MS,
|
|
8
|
+
getLoginClientIp,
|
|
9
|
+
LoginFailureLockout,
|
|
10
|
+
LoginIpRateLimiter,
|
|
11
|
+
normalizeLoginUsername,
|
|
12
|
+
} from "./auth_login_guard";
|
|
13
|
+
|
|
14
|
+
describe("LoginIpRateLimiter", () => {
|
|
15
|
+
test("allows the first N attempts and blocks N+1 within the window", () => {
|
|
16
|
+
const lim = new LoginIpRateLimiter(60_000, 3);
|
|
17
|
+
expect(lim.check("203.0.113.10", 1000).allowed).toBe(true);
|
|
18
|
+
expect(lim.check("203.0.113.10", 1001).allowed).toBe(true);
|
|
19
|
+
expect(lim.check("203.0.113.10", 1002).allowed).toBe(true);
|
|
20
|
+
const blocked = lim.check("203.0.113.10", 1003);
|
|
21
|
+
expect(blocked.allowed).toBe(false);
|
|
22
|
+
expect(blocked.retryAfterMs).toBeGreaterThan(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("default public IP limit is 10/min", () => {
|
|
26
|
+
expect(LOGIN_IP_WINDOW_MS).toBe(60_000);
|
|
27
|
+
expect(LOGIN_IP_MAX_PER_WINDOW).toBe(10);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("evicts expired entries before enforcing the cap", () => {
|
|
31
|
+
const lim = new LoginIpRateLimiter(100, 10, 2);
|
|
32
|
+
expect(lim.check("192.0.2.1", 0).allowed).toBe(true);
|
|
33
|
+
expect(lim.check("192.0.2.2", 0).allowed).toBe(true);
|
|
34
|
+
expect(lim.check("192.0.2.3", 101).allowed).toBe(true);
|
|
35
|
+
|
|
36
|
+
expect(lim.check("192.0.2.1", 102).remaining).toBe(9);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("evicts the oldest active entry when the cap is full", () => {
|
|
40
|
+
const lim = new LoginIpRateLimiter(60_000, 1, 2);
|
|
41
|
+
expect(lim.check("192.0.2.1", 0).allowed).toBe(true);
|
|
42
|
+
expect(lim.check("192.0.2.2", 1).allowed).toBe(true);
|
|
43
|
+
expect(lim.check("192.0.2.3", 2).allowed).toBe(true);
|
|
44
|
+
|
|
45
|
+
expect(lim.check("192.0.2.1", 3).allowed).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("LoginFailureLockout", () => {
|
|
50
|
+
test("locks on the fifth failure and doubles subsequent lock durations", () => {
|
|
51
|
+
const lockout = new LoginFailureLockout(5, 30_000, 900_000);
|
|
52
|
+
const t0 = 1_700_000_000_000;
|
|
53
|
+
for (let i = 0; i < 4; i++) {
|
|
54
|
+
const r = lockout.recordFailure("Alice", t0 + i);
|
|
55
|
+
expect(r.locked).toBe(false);
|
|
56
|
+
expect(r.failures).toBe(i + 1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const first = lockout.recordFailure(" alice ", t0 + 5);
|
|
60
|
+
expect(first.locked).toBe(true);
|
|
61
|
+
expect(first.lockMs).toBe(30_000);
|
|
62
|
+
expect(lockout.check("ALICE", t0 + 6).locked).toBe(true);
|
|
63
|
+
|
|
64
|
+
const second = lockout.recordFailure("alice", t0 + 30_006);
|
|
65
|
+
expect(second.locked).toBe(true);
|
|
66
|
+
expect(second.lockMs).toBe(60_000);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("caps exponential lock duration and success clears failure state", () => {
|
|
70
|
+
const lockout = new LoginFailureLockout(2, 1000, 4000);
|
|
71
|
+
const t0 = 10_000;
|
|
72
|
+
expect(lockout.recordFailure("bob", t0).locked).toBe(false);
|
|
73
|
+
expect(lockout.recordFailure("bob", t0 + 1).lockMs).toBe(1000);
|
|
74
|
+
expect(lockout.recordFailure("bob", t0 + 1001).lockMs).toBe(2000);
|
|
75
|
+
expect(lockout.recordFailure("bob", t0 + 3002).lockMs).toBe(4000);
|
|
76
|
+
expect(lockout.recordFailure("bob", t0 + 7003).lockMs).toBe(4000);
|
|
77
|
+
|
|
78
|
+
lockout.recordSuccess("bob");
|
|
79
|
+
expect(lockout.check("bob", t0 + 7004).locked).toBe(false);
|
|
80
|
+
expect(lockout.recordFailure("bob", t0 + 7005).locked).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("default failure policy is 5 failures, 30s base, 15min cap", () => {
|
|
84
|
+
expect(LOGIN_FAILURE_THRESHOLD).toBe(5);
|
|
85
|
+
expect(LOGIN_LOCK_BASE_MS).toBe(30_000);
|
|
86
|
+
expect(LOGIN_LOCK_MAX_MS).toBe(15 * 60_000);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("evicts expired failure entries before enforcing the cap", () => {
|
|
90
|
+
const lockout = new LoginFailureLockout(2, 100, 1000, 2);
|
|
91
|
+
expect(lockout.recordFailure("alice", 0).locked).toBe(false);
|
|
92
|
+
expect(lockout.recordFailure("bob", 0).locked).toBe(false);
|
|
93
|
+
expect(lockout.recordFailure("carol", 1).locked).toBe(false);
|
|
94
|
+
|
|
95
|
+
expect(lockout.recordFailure("alice", 2).locked).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("evicts the oldest active failure entry when the cap is full", () => {
|
|
99
|
+
const lockout = new LoginFailureLockout(2, 1000, 1000, 2);
|
|
100
|
+
expect(lockout.recordFailure("alice", 0).locked).toBe(false);
|
|
101
|
+
expect(lockout.recordFailure("bob", 1).locked).toBe(false);
|
|
102
|
+
expect(lockout.recordFailure("carol", 2).locked).toBe(false);
|
|
103
|
+
|
|
104
|
+
expect(lockout.recordFailure("alice", 3).locked).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("normalizeLoginUsername", () => {
|
|
109
|
+
test("normalizes for case-insensitive lockout keys", () => {
|
|
110
|
+
expect(normalizeLoginUsername(" Alice ")).toBe("alice");
|
|
111
|
+
expect(normalizeLoginUsername(undefined)).toBe("");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("getLoginClientIp", () => {
|
|
116
|
+
test("uses the last x-forwarded-for hop so spoofed first hops do not bypass login rate limits", () => {
|
|
117
|
+
const req = new Request("http://127.0.0.1/api/auth/login", {
|
|
118
|
+
headers: { "x-forwarded-for": "198.51.100.99, 203.0.113.10" },
|
|
119
|
+
});
|
|
120
|
+
expect(getLoginClientIp(req)).toBe("203.0.113.10");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("falls back to x-real-ip, then unknown", () => {
|
|
124
|
+
const withRealIp = new Request("http://127.0.0.1/api/auth/login", {
|
|
125
|
+
headers: { "x-real-ip": "203.0.113.20" },
|
|
126
|
+
});
|
|
127
|
+
expect(getLoginClientIp(withRealIp)).toBe("203.0.113.20");
|
|
128
|
+
expect(getLoginClientIp(new Request("http://127.0.0.1/api/auth/login"))).toBe("unknown");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// #226 — public login brute-force guard.
|
|
2
|
+
// In-memory by design: zero dependency, process-local defence-in-depth for the
|
|
3
|
+
// public /api/auth/login surface. Bearer token auth paths do not use this file.
|
|
4
|
+
|
|
5
|
+
export const LOGIN_IP_WINDOW_MS = 60_000;
|
|
6
|
+
export const LOGIN_IP_MAX_PER_WINDOW = 10;
|
|
7
|
+
export const LOGIN_FAILURE_THRESHOLD = 5;
|
|
8
|
+
export const LOGIN_LOCK_BASE_MS = 30_000;
|
|
9
|
+
export const LOGIN_LOCK_MAX_MS = 15 * 60_000;
|
|
10
|
+
export const LOGIN_GUARD_MAX_ENTRIES = 50_000;
|
|
11
|
+
|
|
12
|
+
type WindowState = { count: number; resetAt: number };
|
|
13
|
+
|
|
14
|
+
export class LoginIpRateLimiter {
|
|
15
|
+
private state = new Map<string, WindowState>();
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly windowMs: number = LOGIN_IP_WINDOW_MS,
|
|
19
|
+
private readonly maxPerWindow: number = LOGIN_IP_MAX_PER_WINDOW,
|
|
20
|
+
private readonly maxEntries: number = LOGIN_GUARD_MAX_ENTRIES,
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
check(key: string, nowMs: number = Date.now()): { allowed: boolean; remaining: number; resetAt: number; retryAfterMs?: number } {
|
|
24
|
+
if (this.state.size >= this.maxEntries && !this.state.has(key)) {
|
|
25
|
+
pruneExpired(this.state, nowMs, (entry) => nowMs >= entry.resetAt);
|
|
26
|
+
evictOldestUntilBelowCap(this.state, this.maxEntries);
|
|
27
|
+
}
|
|
28
|
+
const entry = this.state.get(key);
|
|
29
|
+
if (!entry || nowMs >= entry.resetAt) {
|
|
30
|
+
const resetAt = nowMs + this.windowMs;
|
|
31
|
+
this.state.set(key, { count: 1, resetAt });
|
|
32
|
+
return { allowed: true, remaining: this.maxPerWindow - 1, resetAt };
|
|
33
|
+
}
|
|
34
|
+
if (entry.count >= this.maxPerWindow) {
|
|
35
|
+
return { allowed: false, remaining: 0, resetAt: entry.resetAt, retryAfterMs: entry.resetAt - nowMs };
|
|
36
|
+
}
|
|
37
|
+
entry.count++;
|
|
38
|
+
return { allowed: true, remaining: this.maxPerWindow - entry.count, resetAt: entry.resetAt };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
reset(): void {
|
|
42
|
+
this.state.clear();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type FailureState = { failures: number; lockedUntil: number };
|
|
47
|
+
|
|
48
|
+
export class LoginFailureLockout {
|
|
49
|
+
private state = new Map<string, FailureState>();
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly threshold: number = LOGIN_FAILURE_THRESHOLD,
|
|
53
|
+
private readonly baseLockMs: number = LOGIN_LOCK_BASE_MS,
|
|
54
|
+
private readonly maxLockMs: number = LOGIN_LOCK_MAX_MS,
|
|
55
|
+
private readonly maxEntries: number = LOGIN_GUARD_MAX_ENTRIES,
|
|
56
|
+
) {}
|
|
57
|
+
|
|
58
|
+
check(username: string, nowMs: number = Date.now()): { locked: boolean; retryAfterMs?: number; lockedUntil?: number } {
|
|
59
|
+
const key = normalizeLoginUsername(username);
|
|
60
|
+
if (!key) return { locked: false };
|
|
61
|
+
const entry = this.state.get(key);
|
|
62
|
+
if (!entry || !entry.lockedUntil || nowMs >= entry.lockedUntil) return { locked: false };
|
|
63
|
+
return { locked: true, retryAfterMs: entry.lockedUntil - nowMs, lockedUntil: entry.lockedUntil };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
recordFailure(username: string, nowMs: number = Date.now()): { locked: boolean; failures: number; lockMs?: number; lockedUntil?: number } {
|
|
67
|
+
const key = normalizeLoginUsername(username);
|
|
68
|
+
if (!key) return { locked: false, failures: 0 };
|
|
69
|
+
if (this.state.size >= this.maxEntries && !this.state.has(key)) {
|
|
70
|
+
pruneExpired(this.state, nowMs, (entry) => !entry.lockedUntil || nowMs >= entry.lockedUntil);
|
|
71
|
+
evictOldestUntilBelowCap(this.state, this.maxEntries);
|
|
72
|
+
}
|
|
73
|
+
const current = this.state.get(key);
|
|
74
|
+
const failures = (current?.failures ?? 0) + 1;
|
|
75
|
+
if (failures < this.threshold) {
|
|
76
|
+
this.state.set(key, { failures, lockedUntil: 0 });
|
|
77
|
+
return { locked: false, failures };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const exponent = failures - this.threshold;
|
|
81
|
+
const lockMs = Math.min(this.baseLockMs * Math.pow(2, exponent), this.maxLockMs);
|
|
82
|
+
const lockedUntil = nowMs + lockMs;
|
|
83
|
+
this.state.set(key, { failures, lockedUntil });
|
|
84
|
+
return { locked: true, failures, lockMs, lockedUntil };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
recordSuccess(username: string): void {
|
|
88
|
+
const key = normalizeLoginUsername(username);
|
|
89
|
+
if (key) this.state.delete(key);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
reset(): void {
|
|
93
|
+
this.state.clear();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function normalizeLoginUsername(username: unknown): string {
|
|
98
|
+
return typeof username === "string" ? username.trim().toLowerCase() : "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getLoginClientIp(req: Request): string {
|
|
102
|
+
const fwd = req.headers.get("x-forwarded-for");
|
|
103
|
+
if (fwd) {
|
|
104
|
+
const last = fwd.split(",").map((s) => s.trim()).filter(Boolean).at(-1);
|
|
105
|
+
if (last) return last;
|
|
106
|
+
}
|
|
107
|
+
return req.headers.get("x-real-ip")?.trim() || "unknown";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pruneExpired<T>(map: Map<string, T>, nowMs: number, expired: (entry: T, nowMs: number) => boolean): void {
|
|
111
|
+
for (const [key, entry] of map) {
|
|
112
|
+
if (expired(entry, nowMs)) map.delete(key);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function evictOldestUntilBelowCap<T>(map: Map<string, T>, maxEntries: number): void {
|
|
117
|
+
while (map.size >= maxEntries) {
|
|
118
|
+
const oldest = map.keys().next().value as string | undefined;
|
|
119
|
+
if (!oldest) return;
|
|
120
|
+
map.delete(oldest);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function envInt(name: string, fallback: number): number {
|
|
125
|
+
const raw = process.env[name];
|
|
126
|
+
if (!raw) return fallback;
|
|
127
|
+
const n = Number(raw);
|
|
128
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const sharedLoginIpRateLimiter = new LoginIpRateLimiter(
|
|
132
|
+
envInt("COMMHUB_LOGIN_IP_WINDOW_MS", LOGIN_IP_WINDOW_MS),
|
|
133
|
+
envInt("COMMHUB_LOGIN_IP_MAX", LOGIN_IP_MAX_PER_WINDOW),
|
|
134
|
+
envInt("COMMHUB_LOGIN_GUARD_MAX_ENTRIES", LOGIN_GUARD_MAX_ENTRIES),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
export const sharedLoginFailureLockout = new LoginFailureLockout(
|
|
138
|
+
envInt("COMMHUB_LOGIN_FAILURE_THRESHOLD", LOGIN_FAILURE_THRESHOLD),
|
|
139
|
+
envInt("COMMHUB_LOGIN_LOCK_BASE_MS", LOGIN_LOCK_BASE_MS),
|
|
140
|
+
envInt("COMMHUB_LOGIN_LOCK_MAX_MS", LOGIN_LOCK_MAX_MS),
|
|
141
|
+
envInt("COMMHUB_LOGIN_GUARD_MAX_ENTRIES", LOGIN_GUARD_MAX_ENTRIES),
|
|
142
|
+
);
|
package/src/db.ts
CHANGED
|
@@ -123,13 +123,14 @@ db.exec(`
|
|
|
123
123
|
ON agent_telemetry(network_id, alias, created_at);
|
|
124
124
|
`);
|
|
125
125
|
|
|
126
|
-
// inbox: add in_reply_to, requires_response, expires_at, scope
|
|
126
|
+
// inbox: add in_reply_to, requires_response, expires_at, scope, node identity
|
|
127
127
|
for (const col of [
|
|
128
128
|
{ name: "in_reply_to", def: "TEXT" },
|
|
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
132
|
{ name: "meta_json", def: "TEXT" },
|
|
133
|
+
{ name: "node_id", def: "TEXT" },
|
|
133
134
|
]) {
|
|
134
135
|
try { db.exec(`ALTER TABLE inbox ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
135
136
|
}
|
|
@@ -138,6 +139,7 @@ for (const col of [
|
|
|
138
139
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_type ON inbox(type)"); } catch {}
|
|
139
140
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_from ON inbox(from_session)"); } catch {}
|
|
140
141
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_reply ON inbox(in_reply_to)"); } catch {}
|
|
142
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_node ON inbox(node_id)"); } catch {}
|
|
141
143
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_node ON sessions(node_id)"); } catch {}
|
|
142
144
|
|
|
143
145
|
// tasks table (V2)
|
|
@@ -384,6 +386,185 @@ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events", "comp
|
|
|
384
386
|
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|
|
385
387
|
}
|
|
386
388
|
|
|
389
|
+
// PR-1 (#146): durable node identity on historical inbox/tasks rows. Some
|
|
390
|
+
// existing databases created `tasks` before from_node_id/to_node_id were in the
|
|
391
|
+
// CREATE TABLE statement, so keep these ALTERs explicit and idempotent.
|
|
392
|
+
for (const col of [
|
|
393
|
+
{ table: "tasks", name: "from_node_id", def: "TEXT" },
|
|
394
|
+
{ table: "tasks", name: "to_node_id", def: "TEXT" },
|
|
395
|
+
{ table: "inbox", name: "node_id", def: "TEXT" },
|
|
396
|
+
]) {
|
|
397
|
+
try { db.exec(`ALTER TABLE ${col.table} ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_from_node ON tasks(from_node_id)"); } catch {}
|
|
401
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_to_node ON tasks(to_node_id)"); } catch {}
|
|
402
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_node ON inbox(node_id)"); } catch {}
|
|
403
|
+
|
|
404
|
+
function backfillMessageNodeIds() {
|
|
405
|
+
let inboxDirect = 0;
|
|
406
|
+
let inboxRenamed = 0;
|
|
407
|
+
let tasksToDirect = 0;
|
|
408
|
+
let tasksToRenamed = 0;
|
|
409
|
+
let tasksFromDirect = 0;
|
|
410
|
+
let tasksFromRenamed = 0;
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
inboxDirect = db.run(`
|
|
414
|
+
UPDATE inbox
|
|
415
|
+
SET node_id = (
|
|
416
|
+
SELECT s.node_id FROM sessions s
|
|
417
|
+
WHERE s.node_id IS NOT NULL
|
|
418
|
+
AND s.alias = inbox.session_name
|
|
419
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(inbox.network_id, 'default')
|
|
420
|
+
ORDER BY s.updated_at DESC
|
|
421
|
+
LIMIT 1
|
|
422
|
+
)
|
|
423
|
+
WHERE node_id IS NULL
|
|
424
|
+
AND EXISTS (
|
|
425
|
+
SELECT 1 FROM sessions s
|
|
426
|
+
WHERE s.node_id IS NOT NULL
|
|
427
|
+
AND s.alias = inbox.session_name
|
|
428
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(inbox.network_id, 'default')
|
|
429
|
+
)
|
|
430
|
+
`).changes;
|
|
431
|
+
} catch {}
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
inboxRenamed = db.run(`
|
|
435
|
+
UPDATE inbox
|
|
436
|
+
SET node_id = (
|
|
437
|
+
SELECT s.node_id FROM rename_txn r
|
|
438
|
+
JOIN sessions s
|
|
439
|
+
ON s.alias = r.new_alias
|
|
440
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
|
|
441
|
+
WHERE s.node_id IS NOT NULL
|
|
442
|
+
AND r.status = 'committed'
|
|
443
|
+
AND r.old_alias = inbox.session_name
|
|
444
|
+
AND COALESCE(r.network_id, 'default') = COALESCE(inbox.network_id, 'default')
|
|
445
|
+
ORDER BY r.committed_at DESC
|
|
446
|
+
LIMIT 1
|
|
447
|
+
)
|
|
448
|
+
WHERE node_id IS NULL
|
|
449
|
+
AND EXISTS (
|
|
450
|
+
SELECT 1 FROM rename_txn r
|
|
451
|
+
JOIN sessions s
|
|
452
|
+
ON s.alias = r.new_alias
|
|
453
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
|
|
454
|
+
WHERE s.node_id IS NOT NULL
|
|
455
|
+
AND r.status = 'committed'
|
|
456
|
+
AND r.old_alias = inbox.session_name
|
|
457
|
+
AND COALESCE(r.network_id, 'default') = COALESCE(inbox.network_id, 'default')
|
|
458
|
+
)
|
|
459
|
+
`).changes;
|
|
460
|
+
} catch {}
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
tasksToDirect = db.run(`
|
|
464
|
+
UPDATE tasks
|
|
465
|
+
SET to_node_id = (
|
|
466
|
+
SELECT s.node_id FROM sessions s
|
|
467
|
+
WHERE s.node_id IS NOT NULL
|
|
468
|
+
AND s.alias = tasks.to_name
|
|
469
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(tasks.network_id, 'default')
|
|
470
|
+
ORDER BY s.updated_at DESC
|
|
471
|
+
LIMIT 1
|
|
472
|
+
)
|
|
473
|
+
WHERE to_node_id IS NULL
|
|
474
|
+
AND EXISTS (
|
|
475
|
+
SELECT 1 FROM sessions s
|
|
476
|
+
WHERE s.node_id IS NOT NULL
|
|
477
|
+
AND s.alias = tasks.to_name
|
|
478
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(tasks.network_id, 'default')
|
|
479
|
+
)
|
|
480
|
+
`).changes;
|
|
481
|
+
} catch {}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
tasksToRenamed = db.run(`
|
|
485
|
+
UPDATE tasks
|
|
486
|
+
SET to_node_id = (
|
|
487
|
+
SELECT s.node_id FROM rename_txn r
|
|
488
|
+
JOIN sessions s
|
|
489
|
+
ON s.alias = r.new_alias
|
|
490
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
|
|
491
|
+
WHERE s.node_id IS NOT NULL
|
|
492
|
+
AND r.status = 'committed'
|
|
493
|
+
AND r.old_alias = tasks.to_name
|
|
494
|
+
AND COALESCE(r.network_id, 'default') = COALESCE(tasks.network_id, 'default')
|
|
495
|
+
ORDER BY r.committed_at DESC
|
|
496
|
+
LIMIT 1
|
|
497
|
+
)
|
|
498
|
+
WHERE to_node_id IS NULL
|
|
499
|
+
AND EXISTS (
|
|
500
|
+
SELECT 1 FROM rename_txn r
|
|
501
|
+
JOIN sessions s
|
|
502
|
+
ON s.alias = r.new_alias
|
|
503
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
|
|
504
|
+
WHERE s.node_id IS NOT NULL
|
|
505
|
+
AND r.status = 'committed'
|
|
506
|
+
AND r.old_alias = tasks.to_name
|
|
507
|
+
AND COALESCE(r.network_id, 'default') = COALESCE(tasks.network_id, 'default')
|
|
508
|
+
)
|
|
509
|
+
`).changes;
|
|
510
|
+
} catch {}
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
tasksFromDirect = db.run(`
|
|
514
|
+
UPDATE tasks
|
|
515
|
+
SET from_node_id = (
|
|
516
|
+
SELECT s.node_id FROM sessions s
|
|
517
|
+
WHERE s.node_id IS NOT NULL
|
|
518
|
+
AND s.alias = tasks.from_name
|
|
519
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(tasks.network_id, 'default')
|
|
520
|
+
ORDER BY s.updated_at DESC
|
|
521
|
+
LIMIT 1
|
|
522
|
+
)
|
|
523
|
+
WHERE from_node_id IS NULL
|
|
524
|
+
AND EXISTS (
|
|
525
|
+
SELECT 1 FROM sessions s
|
|
526
|
+
WHERE s.node_id IS NOT NULL
|
|
527
|
+
AND s.alias = tasks.from_name
|
|
528
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(tasks.network_id, 'default')
|
|
529
|
+
)
|
|
530
|
+
`).changes;
|
|
531
|
+
} catch {}
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
tasksFromRenamed = db.run(`
|
|
535
|
+
UPDATE tasks
|
|
536
|
+
SET from_node_id = (
|
|
537
|
+
SELECT s.node_id FROM rename_txn r
|
|
538
|
+
JOIN sessions s
|
|
539
|
+
ON s.alias = r.new_alias
|
|
540
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
|
|
541
|
+
WHERE s.node_id IS NOT NULL
|
|
542
|
+
AND r.status = 'committed'
|
|
543
|
+
AND r.old_alias = tasks.from_name
|
|
544
|
+
AND COALESCE(r.network_id, 'default') = COALESCE(tasks.network_id, 'default')
|
|
545
|
+
ORDER BY r.committed_at DESC
|
|
546
|
+
LIMIT 1
|
|
547
|
+
)
|
|
548
|
+
WHERE from_node_id IS NULL
|
|
549
|
+
AND EXISTS (
|
|
550
|
+
SELECT 1 FROM rename_txn r
|
|
551
|
+
JOIN sessions s
|
|
552
|
+
ON s.alias = r.new_alias
|
|
553
|
+
AND COALESCE(s.network_id, 'default') = COALESCE(r.network_id, 'default')
|
|
554
|
+
WHERE s.node_id IS NOT NULL
|
|
555
|
+
AND r.status = 'committed'
|
|
556
|
+
AND r.old_alias = tasks.from_name
|
|
557
|
+
AND COALESCE(r.network_id, 'default') = COALESCE(tasks.network_id, 'default')
|
|
558
|
+
)
|
|
559
|
+
`).changes;
|
|
560
|
+
} catch {}
|
|
561
|
+
|
|
562
|
+
const total = inboxDirect + inboxRenamed + tasksToDirect + tasksToRenamed + tasksFromDirect + tasksFromRenamed;
|
|
563
|
+
console.log(`[commhub] node_id backfill: inbox=${inboxDirect + inboxRenamed}, tasks.to=${tasksToDirect + tasksToRenamed}, tasks.from=${tasksFromDirect + tasksFromRenamed}, total=${total}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
backfillMessageNodeIds();
|
|
567
|
+
|
|
387
568
|
// ── P0: sessions alias uniqueness is network-scoped.
|
|
388
569
|
// Older SQLite databases created `alias TEXT UNIQUE`, which prevents the same
|
|
389
570
|
// agent alias from existing in two networks. SQLite cannot drop a UNIQUE
|
|
@@ -588,10 +769,14 @@ export function chainReplyToParent(childTaskId: string, replyText: string, reply
|
|
|
588
769
|
if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
589
770
|
try {
|
|
590
771
|
const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
772
|
+
const notifyNode = db.get<{ node_id: string | null }>(
|
|
773
|
+
"SELECT node_id FROM sessions WHERE alias = ?1 AND COALESCE(network_id, 'default') = COALESCE(?2, 'default') ORDER BY updated_at DESC LIMIT 1",
|
|
774
|
+
[parent.from_name, parent.network_id ?? null]
|
|
775
|
+
);
|
|
591
776
|
db.run(
|
|
592
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
593
|
-
VALUES (?1, ?2, 'reply', 'normal', ?
|
|
594
|
-
[notifyId, parent.from_name, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
|
|
777
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
778
|
+
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
|
|
779
|
+
[notifyId, parent.from_name, notifyNode?.node_id ?? null, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
|
|
595
780
|
);
|
|
596
781
|
} catch {}
|
|
597
782
|
}
|
package/src/event_bus.ts
ADDED
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ 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
8
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
9
9
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
10
|
+
import { getLoginClientIp, sharedLoginFailureLockout, sharedLoginIpRateLimiter } from "./auth_login_guard.js";
|
|
10
11
|
import {
|
|
11
12
|
FILE_ID_REGEX,
|
|
12
13
|
MAX_REQUEST_CONTENT_LENGTH,
|
|
@@ -364,7 +365,7 @@ type RestDeliveryTarget =
|
|
|
364
365
|
|
|
365
366
|
function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
|
|
366
367
|
const params: any[] = [alias];
|
|
367
|
-
let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
|
|
368
|
+
let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
|
|
368
369
|
if (networkId) {
|
|
369
370
|
sql += " AND network_id = ?2";
|
|
370
371
|
params.push(networkId);
|
|
@@ -387,6 +388,13 @@ function resolveRestDeliveryTarget(alias: string, networkId: string | null): Res
|
|
|
387
388
|
return { state: "online", alias, session };
|
|
388
389
|
}
|
|
389
390
|
|
|
391
|
+
function resolveRestNodeIdForAlias(alias: string, networkId: string | null): string | null {
|
|
392
|
+
if (!alias || alias === "hub" || alias === "api") return null;
|
|
393
|
+
const canonical = resolveCanonicalAlias(networkId, alias);
|
|
394
|
+
const target = resolveRestDeliveryTarget(canonical.alias, networkId);
|
|
395
|
+
return target.state === "not_found" ? null : (target.session?.node_id ?? null);
|
|
396
|
+
}
|
|
397
|
+
|
|
390
398
|
// ── REST input schema ───────────────────────────────
|
|
391
399
|
const TaskSchema = z.object({
|
|
392
400
|
alias: z.string().min(1).max(200),
|
|
@@ -605,16 +613,37 @@ Bun.serve({
|
|
|
605
613
|
}
|
|
606
614
|
|
|
607
615
|
if (url.pathname === "/api/auth/login" && req.method === "POST") {
|
|
608
|
-
const clientIP = req
|
|
609
|
-
|
|
616
|
+
const clientIP = getLoginClientIp(req);
|
|
617
|
+
const ipRate = sharedLoginIpRateLimiter.check(clientIP || "unknown");
|
|
618
|
+
if (!ipRate.allowed) {
|
|
610
619
|
logAudit(null, null, "login_rate_limited", "auth", undefined, clientIP);
|
|
611
|
-
return withCors(req, Response.json(
|
|
620
|
+
return withCors(req, Response.json(
|
|
621
|
+
{ ok: false, error: "rate_limited", message: "Too many login attempts. Try again later.", retry_after_ms: ipRate.retryAfterMs },
|
|
622
|
+
{ status: 429, headers: { "Retry-After": String(Math.ceil((ipRate.retryAfterMs ?? 1000) / 1000)) } }
|
|
623
|
+
));
|
|
612
624
|
}
|
|
613
625
|
try {
|
|
614
626
|
const body = await req.json() as any;
|
|
627
|
+
const lock = sharedLoginFailureLockout.check(body.username);
|
|
628
|
+
if (lock.locked) {
|
|
629
|
+
logAudit(null, body.username, "login_locked", "auth", undefined, `retry_after_ms=${lock.retryAfterMs ?? 0}`);
|
|
630
|
+
return withCors(req, Response.json(
|
|
631
|
+
{ ok: false, error: "login_locked", message: "Too many failed login attempts. Try again later.", retry_after_ms: lock.retryAfterMs },
|
|
632
|
+
{ status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
|
|
633
|
+
));
|
|
634
|
+
}
|
|
615
635
|
const result = login(body.username, body.password);
|
|
616
|
-
if (result.ok)
|
|
617
|
-
|
|
636
|
+
if (result.ok) {
|
|
637
|
+
sharedLoginFailureLockout.recordSuccess(body.username);
|
|
638
|
+
logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
|
|
639
|
+
} else {
|
|
640
|
+
const failure = sharedLoginFailureLockout.recordFailure(body.username);
|
|
641
|
+
if (failure.locked) {
|
|
642
|
+
const safeUsername = String(body.username ?? "").replace(/[\r\n\t]/g, " ").slice(0, 80);
|
|
643
|
+
console.warn(`[auth] login locked for username=${safeUsername} failures=${failure.failures} retry_after_ms=${failure.lockMs}`);
|
|
644
|
+
}
|
|
645
|
+
logAudit(null, body.username, "login_failed", "user", undefined, "invalid credentials");
|
|
646
|
+
}
|
|
618
647
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
|
|
619
648
|
} catch (e: any) {
|
|
620
649
|
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
@@ -706,7 +735,7 @@ Bun.serve({
|
|
|
706
735
|
}
|
|
707
736
|
const result = prepareRename(resolved.user.user_id, body.network_id, body.old_alias, body.new_alias);
|
|
708
737
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_prepared", "node", body.old_alias, body.new_alias);
|
|
709
|
-
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
738
|
+
return withCors(req, Response.json(result, { status: result.ok || result.code === "node_local_only" ? 200 : 400 }));
|
|
710
739
|
} catch (e: any) {
|
|
711
740
|
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
712
741
|
}
|
|
@@ -984,37 +1013,60 @@ Bun.serve({
|
|
|
984
1013
|
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
985
1014
|
db.run(staleSql, staleParams);
|
|
986
1015
|
cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
|
|
1016
|
+
// `?light=1` returns a narrow projection (alias / status / agent / task /
|
|
1017
|
+
// server / updated_at + runtime + network_id) — used by the mobile APP
|
|
1018
|
+
// list view, where pulling 30+ fields × 150 agents was making cold open
|
|
1019
|
+
// take 12s+ on flaky cellular (Vincent tg, #220 PWA). Default response
|
|
1020
|
+
// is unchanged so the dashboard / scripts that read full telemetry are
|
|
1021
|
+
// unaffected.
|
|
1022
|
+
const isLight = url.searchParams.get("light") === "1";
|
|
987
1023
|
const params: any[] = [];
|
|
988
|
-
let sql =
|
|
1024
|
+
let sql = isLight
|
|
1025
|
+
? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
|
|
1026
|
+
: "SELECT * FROM sessions WHERE 1=1";
|
|
989
1027
|
sql = addNetworkScope(sql, params, restScope);
|
|
990
1028
|
sql += " ORDER BY updated_at DESC";
|
|
991
1029
|
// `model` comes straight from the sessions row (SELECT *); `runtime` is
|
|
992
1030
|
// derived from the raw `agent` field. Both default to null for old nodes
|
|
993
1031
|
// that never reported a model — the dashboard falls back to a placeholder.
|
|
994
|
-
const sessions = db.all(sql, ...params).map((s: any) =>
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1032
|
+
const sessions = db.all(sql, ...params).map((s: any) => {
|
|
1033
|
+
if (isLight) {
|
|
1034
|
+
return {
|
|
1035
|
+
alias: s.alias,
|
|
1036
|
+
status: s.status,
|
|
1037
|
+
agent: s.agent ?? null,
|
|
1038
|
+
task: s.task ?? null,
|
|
1039
|
+
server: s.server ?? null,
|
|
1040
|
+
updated_at: s.updated_at ?? null,
|
|
1041
|
+
runtime: normalizeRuntime(s.agent),
|
|
1042
|
+
network_id: s.network_id ?? null,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
...s,
|
|
1047
|
+
model: s.model ?? null,
|
|
1048
|
+
runtime: normalizeRuntime(s.agent),
|
|
1049
|
+
host: {
|
|
1050
|
+
hostname: s.hostname ?? null,
|
|
1051
|
+
ip: s.ip ?? null,
|
|
1052
|
+
cpu_load_1min: s.cpu_load_1min ?? null,
|
|
1053
|
+
cpu_cores: s.cpu_cores ?? null,
|
|
1054
|
+
mem_total_gb: s.mem_total_gb ?? null,
|
|
1055
|
+
mem_used_gb: s.mem_used_gb ?? null,
|
|
1056
|
+
mem_avail_gb: s.mem_avail_gb ?? null,
|
|
1057
|
+
disk_total_gb: s.disk_total_gb ?? null,
|
|
1058
|
+
disk_used_gb: s.disk_used_gb ?? null,
|
|
1059
|
+
disk_avail_gb: s.disk_avail_gb ?? null,
|
|
1060
|
+
},
|
|
1061
|
+
process_telemetry: {
|
|
1062
|
+
rss_bytes: s.process_rss_bytes ?? null,
|
|
1063
|
+
rss_mb: s.process_rss_mb ?? null,
|
|
1064
|
+
cpu_pct: s.process_cpu_pct ?? null,
|
|
1065
|
+
uptime_seconds: s.process_uptime_seconds ?? null,
|
|
1066
|
+
in_flight_count: s.process_in_flight_count ?? null,
|
|
1067
|
+
},
|
|
1068
|
+
};
|
|
1069
|
+
});
|
|
1018
1070
|
const summary = sessions.reduce((acc: any, session: any) => {
|
|
1019
1071
|
const raw = String(session.status || "").toLowerCase();
|
|
1020
1072
|
if (raw === "offline") acc.offline++;
|
|
@@ -1486,6 +1538,8 @@ Bun.serve({
|
|
|
1486
1538
|
const id = crypto.randomUUID();
|
|
1487
1539
|
const fromSession = body.from || "api";
|
|
1488
1540
|
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
1541
|
+
const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
|
|
1542
|
+
const targetNodeId = target.session?.node_id ?? null;
|
|
1489
1543
|
// #221 — fold top-level `attachments` into `meta.attachments` so
|
|
1490
1544
|
// the REST and MCP send_task transports produce identical
|
|
1491
1545
|
// tasks.meta_json shape downstream. Top-level wins over any
|
|
@@ -1525,14 +1579,14 @@ Bun.serve({
|
|
|
1525
1579
|
// dispatched via REST (anet demo, dashboard Dispatch button, etc.).
|
|
1526
1580
|
db.transaction(() => {
|
|
1527
1581
|
db.run(
|
|
1528
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
1529
|
-
VALUES (?1, ?2, 'task', ?
|
|
1530
|
-
[id, targetAlias, body.priority, body.task, fromSession, taskNetId, metaJson]
|
|
1582
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
1583
|
+
VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7, ?8)`,
|
|
1584
|
+
[id, targetAlias, targetNodeId, body.priority, body.task, fromSession, taskNetId, metaJson]
|
|
1531
1585
|
);
|
|
1532
1586
|
db.run(
|
|
1533
|
-
`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)
|
|
1534
|
-
VALUES (?1, ?2, ?3, ?4, 'delivered', ?
|
|
1535
|
-
[id, fromSession, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
|
|
1587
|
+
`INSERT INTO tasks (task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
|
|
1588
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
|
|
1589
|
+
[id, fromNodeId, fromSession, targetNodeId, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
|
|
1536
1590
|
);
|
|
1537
1591
|
// Touch session row so the dashboard reflects "task in flight"
|
|
1538
1592
|
// immediately, without waiting for the agent's report_status to
|
|
@@ -1590,19 +1644,19 @@ Bun.serve({
|
|
|
1590
1644
|
if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
|
|
1591
1645
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
1592
1646
|
}
|
|
1593
|
-
let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
1647
|
+
let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
1594
1648
|
const params: any[] = [];
|
|
1595
1649
|
sql = addNetworkScope(sql, params, restScope);
|
|
1596
1650
|
if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
|
|
1597
1651
|
if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
|
|
1598
|
-
const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
|
|
1652
|
+
const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
|
|
1599
1653
|
const ids: string[] = [];
|
|
1600
1654
|
for (const t of targets) {
|
|
1601
1655
|
const id = crypto.randomUUID();
|
|
1602
1656
|
db.run(
|
|
1603
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
1604
|
-
VALUES (?1, ?2, 'broadcast', 'normal', ?
|
|
1605
|
-
[id, t.alias, body.message, t.network_id]
|
|
1657
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
1658
|
+
VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'api', ?5)`,
|
|
1659
|
+
[id, t.alias, t.node_id ?? null, body.message, t.network_id]
|
|
1606
1660
|
);
|
|
1607
1661
|
ids.push(id);
|
|
1608
1662
|
}
|
package/src/push.test.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { eventBus } from "./event_bus";
|
|
3
|
+
import { __resetSSEClientsForTest, createSSEStream, getSSEStats } from "./push";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
__resetSSEClientsForTest();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("rename-committed rekeys existing SSE clients from old alias to new alias (case 8)", async () => {
|
|
10
|
+
const res = createSSEStream("old-agent", "net-rekey-8");
|
|
11
|
+
const reader = res.body!.getReader();
|
|
12
|
+
|
|
13
|
+
expect(getSSEStats().sessions["net-rekey-8:old-agent"]).toBe(1);
|
|
14
|
+
eventBus.emit("rename-committed", {
|
|
15
|
+
networkId: "net-rekey-8",
|
|
16
|
+
old_alias: "old-agent",
|
|
17
|
+
new_alias: "new-agent",
|
|
18
|
+
node_id: "node-8",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const stats = getSSEStats().sessions;
|
|
22
|
+
expect(stats["net-rekey-8:old-agent"]).toBeUndefined();
|
|
23
|
+
expect(stats["net-rekey-8:new-agent"]).toBe(1);
|
|
24
|
+
await reader.cancel();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rename-committed merges old and new SSE client buckets without dropping clients (case 11)", async () => {
|
|
28
|
+
const oldRes = createSSEStream("old-agent", "net-rekey-11");
|
|
29
|
+
const newRes = createSSEStream("new-agent", "net-rekey-11");
|
|
30
|
+
const oldReader = oldRes.body!.getReader();
|
|
31
|
+
const newReader = newRes.body!.getReader();
|
|
32
|
+
|
|
33
|
+
expect(getSSEStats().sessions["net-rekey-11:old-agent"]).toBe(1);
|
|
34
|
+
expect(getSSEStats().sessions["net-rekey-11:new-agent"]).toBe(1);
|
|
35
|
+
eventBus.emit("rename-committed", {
|
|
36
|
+
networkId: "net-rekey-11",
|
|
37
|
+
old_alias: "old-agent",
|
|
38
|
+
new_alias: "new-agent",
|
|
39
|
+
node_id: "node-11",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const stats = getSSEStats().sessions;
|
|
43
|
+
expect(stats["net-rekey-11:old-agent"]).toBeUndefined();
|
|
44
|
+
expect(stats["net-rekey-11:new-agent"]).toBe(2);
|
|
45
|
+
await oldReader.cancel();
|
|
46
|
+
await newReader.cancel();
|
|
47
|
+
});
|
package/src/push.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Agent 连 GET /events/:session → 保持 SSE 长连接
|
|
3
3
|
// send_task 写 inbox 后 → pushEvent() → 秒达
|
|
4
4
|
|
|
5
|
+
import { eventBus, type RenameCommittedEvent } from "./event_bus";
|
|
6
|
+
|
|
5
7
|
type SSEClient = {
|
|
6
8
|
controller: ReadableStreamDefaultController;
|
|
7
9
|
encoder: TextEncoder;
|
|
@@ -72,6 +74,28 @@ function clientKey(sessionName: string, networkId?: string | null): string {
|
|
|
72
74
|
return `${networkId || "global"}:${sessionName}`;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
function rekeyClient(oldSessionName: string, newSessionName: string, networkId?: string | null): number {
|
|
78
|
+
if (!oldSessionName || !newSessionName || oldSessionName === newSessionName) return 0;
|
|
79
|
+
const oldKey = clientKey(oldSessionName, networkId);
|
|
80
|
+
const newKey = clientKey(newSessionName, networkId);
|
|
81
|
+
const oldClients = clients.get(oldKey);
|
|
82
|
+
if (!oldClients || oldClients.length === 0) return 0;
|
|
83
|
+
|
|
84
|
+
const existing = clients.get(newKey) || [];
|
|
85
|
+
clients.set(newKey, existing.concat(oldClients));
|
|
86
|
+
clients.delete(oldKey);
|
|
87
|
+
console.log(`[${ts()}] SSE ↔ rekey ${oldKey} → ${newKey} (${oldClients.length} clients)`);
|
|
88
|
+
return oldClients.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
eventBus.on("rename-committed", (event: RenameCommittedEvent) => {
|
|
92
|
+
try {
|
|
93
|
+
rekeyClient(event.old_alias, event.new_alias, event.networkId);
|
|
94
|
+
} catch (e: any) {
|
|
95
|
+
console.log(`[${ts()}] SSE rekey failed: ${e?.message || e}`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
75
99
|
/** 推送事件给指定 session 的所有 SSE 连接 */
|
|
76
100
|
export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
|
|
77
101
|
const arr = clients.get(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
|
|
@@ -95,6 +119,10 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
|
|
|
95
119
|
if (arr.length === 0) clients.delete(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
export function __resetSSEClientsForTest(): void {
|
|
123
|
+
clients.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
98
126
|
/** 获取当前 SSE 连接统计 */
|
|
99
127
|
export function getSSEStats(): { total: number; sessions: Record<string, number> } {
|
|
100
128
|
let total = 0;
|
package/src/rename.ts
CHANGED
|
@@ -16,11 +16,14 @@ import { randomUUID } from "crypto";
|
|
|
16
16
|
import { db } from "./db";
|
|
17
17
|
import { getUserNetworkRole, getNetworkMembers } from "./auth";
|
|
18
18
|
import { pushEvent } from "./push";
|
|
19
|
+
import { eventBus } from "./event_bus";
|
|
19
20
|
|
|
20
21
|
export interface RenameResult {
|
|
21
22
|
ok: boolean;
|
|
22
23
|
txn_id?: string;
|
|
23
24
|
error?: string;
|
|
25
|
+
code?: string;
|
|
26
|
+
suggested?: string;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export interface CanonicalAlias {
|
|
@@ -111,7 +114,14 @@ export function prepareRename(
|
|
|
111
114
|
// old-alias must exist as a session in this network
|
|
112
115
|
const oldSession = db.get<any>(
|
|
113
116
|
"SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", networkId, oldAlias);
|
|
114
|
-
if (!oldSession)
|
|
117
|
+
if (!oldSession) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
code: "node_local_only",
|
|
121
|
+
error: `node "${oldAlias}" has no server session in this network`,
|
|
122
|
+
suggested: "rename locally",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
115
125
|
|
|
116
126
|
// new-alias must not be taken — in sessions OR reserved by another prepared txn
|
|
117
127
|
const newInSessions = db.get<any>(
|
|
@@ -147,19 +157,32 @@ export function commitRename(userId: string, txnId: string): RenameResult {
|
|
|
147
157
|
"SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", txn.network_id, txn.new_alias);
|
|
148
158
|
if (conflict) return { ok: false, error: `alias "${txn.new_alias}" was taken since prepare` };
|
|
149
159
|
|
|
150
|
-
db.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
160
|
+
const renamedNode = db.transaction(() => {
|
|
161
|
+
db.run(
|
|
162
|
+
"UPDATE sessions SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
|
|
163
|
+
[txn.new_alias, txn.network_id, txn.old_alias]);
|
|
164
|
+
cleanupRenamedAliasSession(txn.network_id, txn.old_alias, txn.new_alias);
|
|
165
|
+
db.run(
|
|
166
|
+
"UPDATE nodes SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
|
|
167
|
+
[txn.new_alias, txn.network_id, txn.old_alias]);
|
|
168
|
+
db.run(
|
|
169
|
+
"UPDATE api_tokens SET name = ?1 WHERE network_id = ?2 AND name = ?3",
|
|
170
|
+
[`node:${txn.new_alias}`, txn.network_id, `node:${txn.old_alias}`]);
|
|
171
|
+
db.run(
|
|
172
|
+
"UPDATE rename_txn SET status = 'committed', committed_at = datetime('now') WHERE txn_id = ?1",
|
|
173
|
+
[txnId]);
|
|
174
|
+
const node = db.get<{ node_id: string | null }>(
|
|
175
|
+
"SELECT node_id FROM sessions WHERE network_id = ?1 AND alias = ?2",
|
|
176
|
+
[txn.network_id, txn.new_alias]
|
|
177
|
+
);
|
|
178
|
+
eventBus.emit("rename-committed", {
|
|
179
|
+
networkId: txn.network_id,
|
|
180
|
+
old_alias: txn.old_alias,
|
|
181
|
+
new_alias: txn.new_alias,
|
|
182
|
+
node_id: node?.node_id ?? null,
|
|
183
|
+
});
|
|
184
|
+
return node;
|
|
185
|
+
});
|
|
163
186
|
|
|
164
187
|
// RFC-010 §4.2.1 C4 — broadcast node.renamed SSE. (#84 实施补漏: the Server
|
|
165
188
|
// surface originally missed C4; N站马's dashboard slice needs this event.)
|
|
@@ -174,9 +197,11 @@ export function commitRename(userId: string, txnId: string): RenameResult {
|
|
|
174
197
|
txn_id: txnId,
|
|
175
198
|
alias: txn.new_alias,
|
|
176
199
|
network_id: txn.network_id,
|
|
200
|
+
node_id: renamedNode?.node_id ?? null,
|
|
177
201
|
data: {
|
|
178
202
|
old_alias: txn.old_alias,
|
|
179
203
|
new_alias: txn.new_alias,
|
|
204
|
+
node_id: renamedNode?.node_id ?? null,
|
|
180
205
|
surfaces_updated: ["config", "tmux", "commhub", "dashboard", "batch_prefix", "session_resume"],
|
|
181
206
|
history_policy: "preserve",
|
|
182
207
|
},
|
package/src/tools.ts
CHANGED
|
@@ -119,11 +119,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
119
119
|
|
|
120
120
|
const scopedSessionStatus = (alias: string, networkId?: string | null) => {
|
|
121
121
|
const params: any[] = [alias];
|
|
122
|
-
let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
|
|
122
|
+
let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
|
|
123
123
|
sql = addScope(sql, params, networkId);
|
|
124
124
|
return db.get<any>(sql, ...params);
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
const resolveNodeIdForAlias = (alias: string, networkId?: string | null): string | null => {
|
|
128
|
+
if (!alias || alias === "hub" || alias === "api") return null;
|
|
129
|
+
const canonical = resolveCanonicalAlias(networkId, alias);
|
|
130
|
+
const session = scopedSessionStatus(canonical.alias, networkId);
|
|
131
|
+
return session?.node_id ?? null;
|
|
132
|
+
};
|
|
133
|
+
|
|
127
134
|
const resolveDeliveryTarget = (alias: string, networkId?: string | null): DeliveryTarget => {
|
|
128
135
|
const session = scopedSessionStatus(alias, networkId);
|
|
129
136
|
if (!session) {
|
|
@@ -746,20 +753,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
746
753
|
|
|
747
754
|
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
748
755
|
const id = uuidv4();
|
|
756
|
+
const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
|
|
757
|
+
const targetNodeId = target.session?.node_id ?? null;
|
|
749
758
|
// 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
|
|
750
759
|
// dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
|
|
751
760
|
// report_status 心跳;status 字段交给 agent,避免与 working/idle
|
|
752
761
|
// 报告冲突)。
|
|
753
762
|
db.transaction(() => {
|
|
754
763
|
db.run(
|
|
755
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id, meta_json)
|
|
756
|
-
VALUES (?1, ?2, 'task', ?
|
|
757
|
-
[id, targetAlias, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
|
|
764
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
|
|
765
|
+
VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
|
|
766
|
+
[id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
|
|
758
767
|
);
|
|
759
768
|
db.run(
|
|
760
|
-
`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)
|
|
761
|
-
VALUES (?1, ?2, ?3, ?4, 'delivered', ?
|
|
762
|
-
[id, from_session, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
|
|
769
|
+
`INSERT INTO tasks (task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
|
|
770
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
|
|
771
|
+
[id, fromNodeId, from_session, targetNodeId, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
|
|
763
772
|
);
|
|
764
773
|
const touchParams: any[] = [task.slice(0, 200), targetAlias];
|
|
765
774
|
let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
|
|
@@ -833,22 +842,31 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
833
842
|
async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
834
843
|
const effectiveNetId = getNetworkId(null);
|
|
835
844
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
836
|
-
const
|
|
845
|
+
const canonical = resolveCanonicalAlias(effectiveNetId, alias);
|
|
846
|
+
const targetAlias = canonical.alias;
|
|
847
|
+
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
837
848
|
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
838
|
-
console.log(`[${ts()}] ${from_session} → send_message → ${
|
|
849
|
+
console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
839
850
|
const id = uuidv4();
|
|
840
851
|
db.run(
|
|
841
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
842
|
-
VALUES (?1, ?2, 'message', 'normal', ?
|
|
843
|
-
[id,
|
|
852
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
853
|
+
VALUES (?1, ?2, ?3, 'message', 'normal', ?4, ?5, ?6)`,
|
|
854
|
+
[id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
|
|
844
855
|
);
|
|
845
856
|
|
|
846
857
|
if (target.state === "online") {
|
|
847
|
-
pushEvent(
|
|
858
|
+
pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
848
859
|
}
|
|
849
860
|
|
|
850
861
|
const offlineReply = deliveryTargetReply(target, { message_id: id });
|
|
851
|
-
if (offlineReply)
|
|
862
|
+
if (offlineReply) {
|
|
863
|
+
const payload = JSON.parse(offlineReply.content[0].text);
|
|
864
|
+
offlineReply.content[0].text = JSON.stringify({
|
|
865
|
+
...payload,
|
|
866
|
+
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
867
|
+
});
|
|
868
|
+
return offlineReply;
|
|
869
|
+
}
|
|
852
870
|
|
|
853
871
|
return {
|
|
854
872
|
content: [
|
|
@@ -857,6 +875,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
857
875
|
text: JSON.stringify({
|
|
858
876
|
ok: true,
|
|
859
877
|
message_id: id,
|
|
878
|
+
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
860
879
|
session_status: target.session?.status ?? "unknown",
|
|
861
880
|
}),
|
|
862
881
|
},
|
|
@@ -917,11 +936,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
917
936
|
};
|
|
918
937
|
}
|
|
919
938
|
}
|
|
939
|
+
const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
|
|
920
940
|
const replyLogged = db.transaction(() => {
|
|
921
941
|
db.run(
|
|
922
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
923
|
-
VALUES (?1, ?2, 'reply', 'normal', ?
|
|
924
|
-
[id, alias, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
|
|
942
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
943
|
+
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
|
|
944
|
+
[id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
|
|
925
945
|
);
|
|
926
946
|
|
|
927
947
|
// 更新 tasks 表
|
|
@@ -1053,9 +1073,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1053
1073
|
// Re-queue in inbox with new ID (original ID may already exist)
|
|
1054
1074
|
const retryInboxId = uuidv4();
|
|
1055
1075
|
db.run(
|
|
1056
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
|
|
1057
|
-
VALUES (?1, ?2, 'task', ?
|
|
1058
|
-
[retryInboxId, task.to_name, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
|
|
1076
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
|
|
1077
|
+
VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)`,
|
|
1078
|
+
[retryInboxId, task.to_name, task.to_node_id ?? resolveNodeIdForAlias(task.to_name, effectiveNetId ?? task.network_id ?? null), task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
|
|
1059
1079
|
);
|
|
1060
1080
|
});
|
|
1061
1081
|
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
@@ -1098,18 +1118,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1098
1118
|
alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
|
|
1099
1119
|
status: z.string().max(50).optional().describe("Filter by status"),
|
|
1100
1120
|
from_name: z.string().max(200).optional().describe("Filter by sender"),
|
|
1121
|
+
from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
|
|
1101
1122
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
1102
1123
|
limit: z.number().min(1).max(100).optional().default(20),
|
|
1103
1124
|
},
|
|
1104
|
-
async ({ alias, status, from_name, network_id: netId, limit }) => {
|
|
1125
|
+
async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
|
|
1105
1126
|
const readScope = resolveReadScope(netId);
|
|
1106
1127
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
1107
|
-
let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
|
|
1128
|
+
let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
|
|
1108
1129
|
const params: any[] = [];
|
|
1109
1130
|
sql = addReadScope(sql, params, readScope);
|
|
1110
1131
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
1111
1132
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
1112
1133
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
1134
|
+
if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
|
|
1113
1135
|
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
1114
1136
|
params.push(limit);
|
|
1115
1137
|
const tasks = db.all(sql, ...params);
|
|
@@ -1184,6 +1206,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1184
1206
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
|
|
1185
1207
|
}
|
|
1186
1208
|
const oldAlias = task.to_name;
|
|
1209
|
+
const canonical = resolveCanonicalAlias(effectiveNetId ?? task.network_id ?? null, new_alias);
|
|
1210
|
+
const reassignedAlias = canonical.alias;
|
|
1211
|
+
const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
|
|
1212
|
+
const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
|
|
1187
1213
|
db.transaction(() => {
|
|
1188
1214
|
// Ack old inbox to prevent original agent from picking it up
|
|
1189
1215
|
const inboxParams: any[] = [task_id];
|
|
@@ -1191,18 +1217,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1191
1217
|
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
1192
1218
|
db.run(inboxSql, inboxParams);
|
|
1193
1219
|
|
|
1194
|
-
const updateParams: any[] = [
|
|
1195
|
-
let updateSql = "UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?
|
|
1220
|
+
const updateParams: any[] = [reassignedAlias, newNodeId, task_id];
|
|
1221
|
+
let updateSql = "UPDATE tasks SET to_name = ?1, to_node_id = ?2, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?3";
|
|
1196
1222
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1197
1223
|
db.run(updateSql, updateParams);
|
|
1198
1224
|
|
|
1199
1225
|
const newInboxId = uuidv4();
|
|
1200
|
-
db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, 'task', ?
|
|
1201
|
-
[newInboxId,
|
|
1226
|
+
db.run("INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)",
|
|
1227
|
+
[newInboxId, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
|
|
1202
1228
|
});
|
|
1203
|
-
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${
|
|
1204
|
-
pushEvent(
|
|
1205
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
|
|
1229
|
+
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
|
|
1230
|
+
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);
|
|
1231
|
+
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 } : {}) }) }] };
|
|
1206
1232
|
}
|
|
1207
1233
|
);
|
|
1208
1234
|
|
|
@@ -1219,21 +1245,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1219
1245
|
const effectiveNetId = getNetworkId(netId);
|
|
1220
1246
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1221
1247
|
console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
|
|
1222
|
-
let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
1248
|
+
let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
1223
1249
|
const params: any[] = [];
|
|
1224
1250
|
sql = addScope(sql, params, effectiveNetId);
|
|
1225
1251
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
1226
1252
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
1227
1253
|
|
|
1228
|
-
const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
|
|
1254
|
+
const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
|
|
1229
1255
|
const ids: string[] = [];
|
|
1230
1256
|
|
|
1231
1257
|
for (const t of targets) {
|
|
1232
1258
|
const id = uuidv4();
|
|
1233
1259
|
db.run(
|
|
1234
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
1235
|
-
VALUES (?1, ?2, 'broadcast', 'normal', ?
|
|
1236
|
-
[id, t.alias, message, effectiveNetId ?? t.network_id ?? null]
|
|
1260
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
1261
|
+
VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
|
|
1262
|
+
[id, t.alias, t.node_id ?? null, message, effectiveNetId ?? t.network_id ?? null]
|
|
1237
1263
|
);
|
|
1238
1264
|
ids.push(id);
|
|
1239
1265
|
}
|