@sleep2agi/commhub-server 0.5.0-preview.24 → 0.5.0-preview.25
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 +7 -6
- package/src/auth.ts +27 -27
- package/src/db-adapter.ts +209 -32
- package/src/db.ts +3 -11
- package/src/index.ts +36 -37
- package/src/tools.ts +80 -130
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.5.0-preview.
|
|
4
|
-
"description": "CommHub
|
|
3
|
+
"version": "0.5.0-preview.25",
|
|
4
|
+
"description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"bin": {
|
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
"agent",
|
|
22
22
|
"ai",
|
|
23
23
|
"sse",
|
|
24
|
-
"
|
|
24
|
+
"server",
|
|
25
25
|
"orchestration",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
26
|
+
"multi-network",
|
|
27
|
+
"auth",
|
|
28
|
+
"license"
|
|
28
29
|
],
|
|
29
30
|
"author": "sleep2agi",
|
|
30
31
|
"license": "MIT",
|
|
@@ -39,4 +40,4 @@
|
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
41
42
|
}
|
|
42
|
-
}
|
|
43
|
+
}
|
package/src/auth.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
23
23
|
if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
|
|
24
24
|
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
|
|
25
25
|
|
|
26
|
-
const existing = db.
|
|
26
|
+
const existing = db.get<any>("SELECT user_id FROM users WHERE username = ?1", username);
|
|
27
27
|
if (existing) return { ok: false, error: "username already taken" };
|
|
28
28
|
|
|
29
29
|
const userId = generateId("u");
|
|
@@ -57,17 +57,17 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function login(username: string, password: string): AuthResult {
|
|
60
|
-
const user = db.
|
|
61
|
-
"SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1"
|
|
62
|
-
|
|
60
|
+
const user = db.get<any>(
|
|
61
|
+
"SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1",
|
|
62
|
+
username);
|
|
63
63
|
|
|
64
64
|
if (!user) return { ok: false, error: "invalid username or password" };
|
|
65
65
|
if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
|
|
66
66
|
|
|
67
67
|
// Find or create token
|
|
68
|
-
let tokenRow = db.
|
|
69
|
-
"SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1"
|
|
70
|
-
|
|
68
|
+
let tokenRow = db.get<any>(
|
|
69
|
+
"SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1",
|
|
70
|
+
user.user_id);
|
|
71
71
|
|
|
72
72
|
let token: string;
|
|
73
73
|
if (tokenRow) {
|
|
@@ -78,9 +78,9 @@ export function login(username: string, password: string): AuthResult {
|
|
|
78
78
|
} else {
|
|
79
79
|
token = generateToken();
|
|
80
80
|
const tokenId = generateId("tok");
|
|
81
|
-
const networkId = db.
|
|
82
|
-
"SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1"
|
|
83
|
-
|
|
81
|
+
const networkId = db.get<any>(
|
|
82
|
+
"SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1",
|
|
83
|
+
user.user_id)?.network_id;
|
|
84
84
|
db.run(
|
|
85
85
|
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
86
86
|
[tokenId, hashToken(token), user.user_id, networkId || null, "login"]
|
|
@@ -96,11 +96,11 @@ export function login(username: string, password: string): AuthResult {
|
|
|
96
96
|
|
|
97
97
|
export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
|
|
98
98
|
const tHash = hashToken(token);
|
|
99
|
-
const row = db.
|
|
99
|
+
const row = db.get<any>(
|
|
100
100
|
`SELECT t.user_id, t.network_id, t.scope, u.username, u.display_name, u.email, u.role
|
|
101
101
|
FROM api_tokens t JOIN users u ON t.user_id = u.user_id
|
|
102
|
-
WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
|
|
103
|
-
|
|
102
|
+
WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`,
|
|
103
|
+
tHash);
|
|
104
104
|
|
|
105
105
|
if (!row) return null;
|
|
106
106
|
|
|
@@ -114,15 +114,15 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
export function getUserNetworks(userId: string) {
|
|
117
|
-
return db.
|
|
118
|
-
"SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at"
|
|
119
|
-
|
|
117
|
+
return db.all<any>(
|
|
118
|
+
"SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at",
|
|
119
|
+
userId);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
export function createNetwork(userId: string, name: string, description?: string) {
|
|
123
|
-
const existing = db.
|
|
124
|
-
"SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2"
|
|
125
|
-
|
|
123
|
+
const existing = db.get<any>(
|
|
124
|
+
"SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2",
|
|
125
|
+
userId, name);
|
|
126
126
|
if (existing) return { ok: false, error: "network name already exists" };
|
|
127
127
|
|
|
128
128
|
const networkId = generateId("net");
|
|
@@ -134,27 +134,27 @@ export function createNetwork(userId: string, name: string, description?: string
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
export function listTokens(userId: string) {
|
|
137
|
-
return db.
|
|
138
|
-
"SELECT token_id, name, scope, network_id, last_used_at, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC"
|
|
139
|
-
|
|
137
|
+
return db.all<any>(
|
|
138
|
+
"SELECT token_id, name, scope, network_id, last_used_at, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC",
|
|
139
|
+
userId);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
|
|
143
|
-
const net = db.
|
|
143
|
+
const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
|
|
144
144
|
if (!net) return { ok: false, error: "network not found" };
|
|
145
145
|
if (net.owner_id !== userId) return { ok: false, error: "not your network" };
|
|
146
|
-
const dup = db.
|
|
146
|
+
const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
|
|
147
147
|
if (dup) return { ok: false, error: "name already taken" };
|
|
148
148
|
db.run("UPDATE networks SET network_name = ?1, updated_at = datetime('now') WHERE network_id = ?2", [newName, networkId]);
|
|
149
149
|
return { ok: true };
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
|
|
153
|
-
const net = db.
|
|
153
|
+
const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
|
|
154
154
|
if (!net) return { ok: false, error: "network not found" };
|
|
155
155
|
if (net.owner_id !== userId) return { ok: false, error: "not your network" };
|
|
156
156
|
// Check if any sessions/tasks still reference this network
|
|
157
|
-
const sessions = db.
|
|
157
|
+
const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
|
|
158
158
|
if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
|
|
159
159
|
db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
|
|
160
160
|
return { ok: true };
|
|
@@ -177,7 +177,7 @@ export function revokeToken(userId: string, tokenId: string): { ok: boolean; err
|
|
|
177
177
|
|
|
178
178
|
export function changePassword(userId: string, oldPassword: string, newPassword: string): { ok: boolean; error?: string } {
|
|
179
179
|
if (!newPassword || newPassword.length < 6) return { ok: false, error: "new password must be at least 6 characters" };
|
|
180
|
-
const user = db.
|
|
180
|
+
const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
|
|
181
181
|
if (!user) return { ok: false, error: "user not found" };
|
|
182
182
|
if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
|
|
183
183
|
db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
|
package/src/db-adapter.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Database Adapter
|
|
2
|
+
* Database Adapter — supports SQLite and PostgreSQL
|
|
3
3
|
*
|
|
4
|
-
* SQLite adapter: wraps bun:sqlite sync
|
|
5
|
-
* PostgreSQL adapter:
|
|
4
|
+
* SQLite adapter: wraps bun:sqlite (sync)
|
|
5
|
+
* PostgreSQL adapter: wraps pg Pool (async, bridged to sync interface via blocking)
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Key design: callers write SQLite-style SQL. PgAdapter auto-translates:
|
|
8
|
+
* - ?1, ?2 → $1, $2
|
|
9
|
+
* - datetime('now') → NOW()
|
|
10
|
+
* - datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
|
|
11
|
+
* - INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
|
|
12
|
+
* - ON CONFLICT(col) DO UPDATE SET → ON CONFLICT(col) DO UPDATE SET (same syntax)
|
|
8
13
|
*/
|
|
9
14
|
|
|
15
|
+
import { Database } from "bun:sqlite";
|
|
16
|
+
|
|
10
17
|
export interface QueryResult {
|
|
11
18
|
changes: number;
|
|
12
19
|
}
|
|
@@ -31,42 +38,212 @@ export interface DbAdapter {
|
|
|
31
38
|
close(): void;
|
|
32
39
|
|
|
33
40
|
/** Dialect identifier */
|
|
34
|
-
readonly dialect:
|
|
41
|
+
readonly dialect: "sqlite" | "postgres";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ════════════════════════════════════════════
|
|
45
|
+
// SQLite Adapter (bun:sqlite, sync)
|
|
46
|
+
// ════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
export class SQLiteAdapter implements DbAdapter {
|
|
49
|
+
readonly dialect = "sqlite" as const;
|
|
50
|
+
constructor(private readonly rawDb: Database) {}
|
|
51
|
+
|
|
52
|
+
run(sql: string, params?: any[]): QueryResult {
|
|
53
|
+
return this.rawDb.run(sql, params as any);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get<T = any>(sql: string, ...params: any[]): T | null {
|
|
57
|
+
return this.rawDb.query<T, any[]>(sql).get(...params) ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
all<T = any>(sql: string, ...params: any[]): T[] {
|
|
61
|
+
return this.rawDb.query<T, any[]>(sql).all(...params);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
exec(sql: string): void {
|
|
65
|
+
this.rawDb.exec(sql);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
transaction<T>(fn: () => T): T {
|
|
69
|
+
return this.rawDb.transaction(fn)();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
close(): void {
|
|
73
|
+
this.rawDb.close();
|
|
74
|
+
}
|
|
35
75
|
}
|
|
36
76
|
|
|
77
|
+
// ════════════════════════════════════════════
|
|
78
|
+
// PostgreSQL Adapter (pg Pool, sync bridge)
|
|
79
|
+
// ════════════════════════════════════════════
|
|
80
|
+
|
|
37
81
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
82
|
+
* Translate SQLite-style SQL to PostgreSQL.
|
|
83
|
+
* Called on every query — must be fast (simple regex, no parsing).
|
|
84
|
+
*/
|
|
85
|
+
export function sqliteToPostgres(sql: string): string {
|
|
86
|
+
let s = sql;
|
|
87
|
+
// ── datetime translations (before ?N→$N to handle datetime('now', ?N)) ──
|
|
88
|
+
// datetime('now', ?N) → NOW() + $N::INTERVAL (param contains "+3600 seconds")
|
|
89
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*,\s*\?(\d+)\s*\)/gi, (_, n) => {
|
|
90
|
+
return `NOW() + $${n}::INTERVAL`;
|
|
91
|
+
});
|
|
92
|
+
// datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
|
|
93
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*,\s*'([^']+)'\s*\)/gi, (_, offset) => {
|
|
94
|
+
return `NOW() + INTERVAL '${offset.replace(/^\+/, "")}'`;
|
|
95
|
+
});
|
|
96
|
+
// datetime('now') → NOW()
|
|
97
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, "NOW()");
|
|
98
|
+
// TEXT NOT NULL DEFAULT (datetime('now')) → TIMESTAMP NOT NULL DEFAULT NOW()
|
|
99
|
+
s = s.replace(/TEXT\s+NOT\s+NULL\s+DEFAULT\s+\(NOW\(\)\)/gi, "TIMESTAMP NOT NULL DEFAULT NOW()");
|
|
100
|
+
// ── Parameter placeholders ──
|
|
101
|
+
// ?1, ?2 → $1, $2 (positional params)
|
|
102
|
+
s = s.replace(/\?(\d+)/g, (_, n) => `$${n}`);
|
|
103
|
+
// Unindexed ? → $N (sequential)
|
|
104
|
+
let idx = 0;
|
|
105
|
+
s = s.replace(/\?(?!\d)/g, () => `$${++idx}`);
|
|
106
|
+
// ── DDL translations ──
|
|
107
|
+
// INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
|
|
108
|
+
s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "SERIAL PRIMARY KEY");
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* PostgreSQL adapter using pg Pool.
|
|
47
114
|
*
|
|
48
|
-
*
|
|
49
|
-
* -
|
|
50
|
-
*
|
|
51
|
-
* migration is: db.run() → await db.run(), straightforward
|
|
52
|
-
* - 750+ lines of tools.ts would need gratuitous await for zero benefit today
|
|
115
|
+
* Design: uses synchronous blocking via Bun's subprocess or
|
|
116
|
+
* deasync-style approach. Since Bun MCP handlers are async,
|
|
117
|
+
* we store a pool and use blocking queries via pg's synchronous mode.
|
|
53
118
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
119
|
+
* NOTE: This adapter uses `pg` npm package in synchronous mode.
|
|
120
|
+
* For production, the interface should be async. This sync bridge
|
|
121
|
+
* works for the current codebase where MCP handlers are async
|
|
122
|
+
* but DB calls are sync within them.
|
|
57
123
|
*/
|
|
124
|
+
export class PgAdapter implements DbAdapter {
|
|
125
|
+
readonly dialect = "postgres" as const;
|
|
126
|
+
private pool: any; // pg.Pool
|
|
127
|
+
private client: any; // dedicated sync client
|
|
58
128
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
129
|
+
constructor(connectionString: string) {
|
|
130
|
+
// Dynamic import of pg — only loaded when DATABASE_URL is set
|
|
131
|
+
try {
|
|
132
|
+
const pg = require("pg");
|
|
133
|
+
this.pool = new pg.Pool({ connectionString, max: 10 });
|
|
134
|
+
// Get a dedicated client for sync operations
|
|
135
|
+
// We use execSync pattern for blocking
|
|
136
|
+
} catch (e) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"PostgreSQL support requires 'pg' package. Install with: bun add pg\n" +
|
|
139
|
+
` Original error: ${(e as Error).message}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Execute a blocking query against PG.
|
|
146
|
+
* Uses Bun's ability to block on promises in sync context.
|
|
147
|
+
*/
|
|
148
|
+
private querySync(sql: string, params?: any[]): any {
|
|
149
|
+
const pgSql = sqliteToPostgres(sql);
|
|
150
|
+
// Bun supports top-level await and can block — use a shared promise pattern
|
|
151
|
+
// For sync bridge, we use a worker or subprocess
|
|
152
|
+
// Simplest approach: use pg's synchronous query via dedicated connection
|
|
153
|
+
const result = this._blockingQuery(pgSql, params);
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private _blockingQuery(sql: string, params?: any[]): any {
|
|
158
|
+
// Use Bun.spawnSync to run a node script that executes the query
|
|
159
|
+
// This is a pragmatic sync bridge until we migrate to async interface
|
|
160
|
+
const script = `
|
|
161
|
+
const { Pool } = require('pg');
|
|
162
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
163
|
+
pool.query(${JSON.stringify(sql)}, ${JSON.stringify(params || [])})
|
|
164
|
+
.then(r => { process.stdout.write(JSON.stringify({ rows: r.rows, rowCount: r.rowCount })); pool.end(); })
|
|
165
|
+
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message })); pool.end(); process.exit(1); });
|
|
166
|
+
`;
|
|
167
|
+
const proc = Bun.spawnSync(["node", "-e", script], {
|
|
168
|
+
env: { ...process.env },
|
|
169
|
+
stdout: "pipe",
|
|
170
|
+
stderr: "pipe",
|
|
171
|
+
});
|
|
172
|
+
const out = proc.stdout.toString().trim();
|
|
173
|
+
if (proc.exitCode !== 0) {
|
|
174
|
+
const errOut = proc.stderr.toString().trim();
|
|
175
|
+
throw new Error(`PG query failed: ${errOut || out}`);
|
|
176
|
+
}
|
|
177
|
+
return JSON.parse(out);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
run(sql: string, params?: any[]): QueryResult {
|
|
181
|
+
if (sql.trim().toUpperCase().startsWith("PRAGMA")) {
|
|
182
|
+
return { changes: 0 }; // Skip SQLite PRAGMAs
|
|
183
|
+
}
|
|
184
|
+
const result = this.querySync(sql, params);
|
|
185
|
+
return { changes: result.rowCount ?? 0 };
|
|
186
|
+
}
|
|
63
187
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
188
|
+
get<T = any>(sql: string, ...params: any[]): T | null {
|
|
189
|
+
const result = this.querySync(sql, params.length > 0 ? params : undefined);
|
|
190
|
+
return (result.rows?.[0] as T) ?? null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
all<T = any>(sql: string, ...params: any[]): T[] {
|
|
194
|
+
const result = this.querySync(sql, params.length > 0 ? params : undefined);
|
|
195
|
+
return (result.rows as T[]) ?? [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
exec(sql: string): void {
|
|
199
|
+
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return; // Skip
|
|
200
|
+
// Split multiple statements and execute each
|
|
201
|
+
const statements = sql.split(";").map(s => s.trim()).filter(s => s.length > 0);
|
|
202
|
+
for (const stmt of statements) {
|
|
203
|
+
this.querySync(stmt);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
transaction<T>(fn: () => T): T {
|
|
208
|
+
this.querySync("BEGIN");
|
|
209
|
+
try {
|
|
210
|
+
const result = fn();
|
|
211
|
+
this.querySync("COMMIT");
|
|
212
|
+
return result;
|
|
213
|
+
} catch (e) {
|
|
214
|
+
try { this.querySync("ROLLBACK"); } catch {}
|
|
215
|
+
throw e;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
close(): void {
|
|
220
|
+
try { this.pool?.end(); } catch {}
|
|
221
|
+
}
|
|
68
222
|
}
|
|
69
223
|
|
|
70
|
-
|
|
71
|
-
|
|
224
|
+
// ════════════════════════════════════════════
|
|
225
|
+
// Factory
|
|
226
|
+
// ════════════════════════════════════════════
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Create the appropriate adapter based on environment.
|
|
230
|
+
* - DATABASE_URL starts with "postgres://" → PgAdapter
|
|
231
|
+
* - Otherwise → SQLiteAdapter with COMMHUB_DB or default path
|
|
232
|
+
*/
|
|
233
|
+
export function createAdapter(): DbAdapter {
|
|
234
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
235
|
+
if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
|
|
236
|
+
console.log("[commhub] database: PostgreSQL");
|
|
237
|
+
return new PgAdapter(dbUrl);
|
|
238
|
+
}
|
|
239
|
+
// Default: SQLite
|
|
240
|
+
const { mkdirSync } = require("fs");
|
|
241
|
+
const { dirname } = require("path");
|
|
242
|
+
const dbPath = process.env.COMMHUB_DB || `${process.env.HOME}/.commhub/commhub.db`;
|
|
243
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
244
|
+
console.log(`[commhub] database: ${dbPath}`);
|
|
245
|
+
const rawDb = new Database(dbPath);
|
|
246
|
+
rawDb.exec("PRAGMA journal_mode=WAL");
|
|
247
|
+
rawDb.exec("PRAGMA busy_timeout=5000");
|
|
248
|
+
return new SQLiteAdapter(rawDb);
|
|
72
249
|
}
|
package/src/db.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdirSync } from "fs";
|
|
3
|
-
import { dirname } from "path";
|
|
1
|
+
import { createAdapter, type DbAdapter } from "./db-adapter";
|
|
4
2
|
|
|
5
|
-
const
|
|
6
|
-
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
7
|
-
|
|
8
|
-
console.log(`[commhub] database: ${DB_PATH}`);
|
|
9
|
-
export const db = new Database(DB_PATH);
|
|
10
|
-
db.exec("PRAGMA journal_mode=WAL");
|
|
11
|
-
db.exec("PRAGMA busy_timeout=5000");
|
|
3
|
+
export const db: DbAdapter = createAdapter();
|
|
12
4
|
|
|
13
5
|
// Schema
|
|
14
6
|
db.exec(`
|
|
@@ -238,7 +230,7 @@ db.exec(`
|
|
|
238
230
|
`);
|
|
239
231
|
|
|
240
232
|
// Auto-create trial license on first run
|
|
241
|
-
const existingLicense = db.
|
|
233
|
+
const existingLicense = db.get<any>("SELECT id FROM licenses LIMIT 1");
|
|
242
234
|
if (!existingLicense) {
|
|
243
235
|
const trialId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
244
236
|
db.run(
|
package/src/index.ts
CHANGED
|
@@ -128,9 +128,8 @@ setInterval(() => {
|
|
|
128
128
|
if (result.changes > 0) {
|
|
129
129
|
console.log(`[patrol] expired ${result.changes} stale task(s)`);
|
|
130
130
|
// Log events for expired tasks
|
|
131
|
-
const expired = db.
|
|
132
|
-
"SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
|
|
133
|
-
).all();
|
|
131
|
+
const expired = db.all<{ task_id: string }>(
|
|
132
|
+
"SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')");
|
|
134
133
|
for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
|
|
135
134
|
}
|
|
136
135
|
} catch {}
|
|
@@ -188,7 +187,7 @@ Bun.serve({
|
|
|
188
187
|
|
|
189
188
|
// ── V3: License endpoints ──
|
|
190
189
|
if (url.pathname === "/api/license" && req.method === "GET") {
|
|
191
|
-
const license = db.
|
|
190
|
+
const license = db.get<any>("SELECT * FROM licenses ORDER BY created_at LIMIT 1");
|
|
192
191
|
if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
|
|
193
192
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
194
193
|
const expired = license.expires_at && license.expires_at < now;
|
|
@@ -283,7 +282,7 @@ Bun.serve({
|
|
|
283
282
|
db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
|
|
284
283
|
}
|
|
285
284
|
// Re-fetch
|
|
286
|
-
const user = db.
|
|
285
|
+
const user = db.get<any>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1", resolved.user.user_id);
|
|
287
286
|
return withCors(req, Response.json({ ok: true, user }));
|
|
288
287
|
} catch (e: any) {
|
|
289
288
|
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
@@ -373,7 +372,7 @@ Bun.serve({
|
|
|
373
372
|
if (!resolved || resolved.user.role !== "admin") {
|
|
374
373
|
return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
|
|
375
374
|
}
|
|
376
|
-
const users = db.
|
|
375
|
+
const users = db.all("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at");
|
|
377
376
|
return withCors(req, Response.json({ ok: true, users }));
|
|
378
377
|
}
|
|
379
378
|
|
|
@@ -384,16 +383,16 @@ Bun.serve({
|
|
|
384
383
|
const resolved = resolveToken(token);
|
|
385
384
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
386
385
|
const networkId = netDetailMatch[1];
|
|
387
|
-
const network = db.
|
|
386
|
+
const network = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
|
|
388
387
|
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
389
388
|
// Ownership check: only owner or admin can view
|
|
390
389
|
if (network.owner_id !== resolved.user.user_id && resolved.user.role !== "admin") {
|
|
391
390
|
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
392
391
|
}
|
|
393
392
|
// Get network stats
|
|
394
|
-
const nodeCount = db.
|
|
395
|
-
const sessionCount = db.
|
|
396
|
-
const taskStats = db.
|
|
393
|
+
const nodeCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1", networkId);
|
|
394
|
+
const sessionCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
|
|
395
|
+
const taskStats = db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", networkId);
|
|
397
396
|
return withCors(req, Response.json({
|
|
398
397
|
ok: true, network,
|
|
399
398
|
stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
|
|
@@ -430,9 +429,9 @@ Bun.serve({
|
|
|
430
429
|
|
|
431
430
|
// ── REST: health (public, no auth) ──
|
|
432
431
|
if (url.pathname === "/health") {
|
|
433
|
-
const count = db.
|
|
432
|
+
const count = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions");
|
|
434
433
|
const sse = getSSEStats();
|
|
435
|
-
const license = db.
|
|
434
|
+
const license = db.get<any>("SELECT type, expires_at FROM licenses LIMIT 1");
|
|
436
435
|
return withCors(req, Response.json({
|
|
437
436
|
ok: true,
|
|
438
437
|
version: "1.0.0-preview",
|
|
@@ -461,7 +460,7 @@ Bun.serve({
|
|
|
461
460
|
const sql = netFilter
|
|
462
461
|
? "SELECT * FROM sessions WHERE network_id = ?1 ORDER BY updated_at DESC"
|
|
463
462
|
: "SELECT * FROM sessions ORDER BY updated_at DESC";
|
|
464
|
-
const sessions = netFilter ? db.
|
|
463
|
+
const sessions = netFilter ? db.all(sql, netFilter) : db.all(sql);
|
|
465
464
|
return withCors(req, Response.json({ ok: true, sessions }));
|
|
466
465
|
}
|
|
467
466
|
|
|
@@ -486,9 +485,9 @@ Bun.serve({
|
|
|
486
485
|
[id, body.alias, body.priority, body.task, fromSession]
|
|
487
486
|
);
|
|
488
487
|
// SSE push: 秒达
|
|
489
|
-
const pending = db.
|
|
490
|
-
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
|
|
491
|
-
|
|
488
|
+
const pending = db.get<{ cnt: number }>(
|
|
489
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
|
|
490
|
+
body.alias);
|
|
492
491
|
pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession });
|
|
493
492
|
return withCors(req, Response.json({ ok: true, message_id: id }));
|
|
494
493
|
}
|
|
@@ -510,7 +509,7 @@ Bun.serve({
|
|
|
510
509
|
const params: any[] = [];
|
|
511
510
|
if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
|
|
512
511
|
if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
|
|
513
|
-
const targets = db.
|
|
512
|
+
const targets = db.all<{ alias: string }>(sql, ...params);
|
|
514
513
|
const ids: string[] = [];
|
|
515
514
|
for (const t of targets) {
|
|
516
515
|
const id = crypto.randomUUID();
|
|
@@ -577,9 +576,9 @@ Bun.serve({
|
|
|
577
576
|
if (url.pathname === "/api/messages") {
|
|
578
577
|
const limit = Number(url.searchParams.get("limit")) || 100;
|
|
579
578
|
const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
|
|
580
|
-
const rows = db.
|
|
581
|
-
"SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at FROM inbox WHERE created_at >= ?1 ORDER BY created_at DESC LIMIT ?2"
|
|
582
|
-
|
|
579
|
+
const rows = db.all(
|
|
580
|
+
"SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at FROM inbox WHERE created_at >= ?1 ORDER BY created_at DESC LIMIT ?2",
|
|
581
|
+
since, limit);
|
|
583
582
|
return withCors(req, Response.json({ ok: true, messages: rows }));
|
|
584
583
|
}
|
|
585
584
|
|
|
@@ -588,20 +587,20 @@ Bun.serve({
|
|
|
588
587
|
const n = url.searchParams.get("network_id");
|
|
589
588
|
// Parameterized queries to prevent SQL injection
|
|
590
589
|
const taskStats = n
|
|
591
|
-
? db.
|
|
592
|
-
: db.
|
|
590
|
+
? db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", n)
|
|
591
|
+
: db.all<any>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status");
|
|
593
592
|
const sessionStats = n
|
|
594
|
-
? db.
|
|
595
|
-
: db.
|
|
593
|
+
? db.all<any>("SELECT status, COUNT(*) as count FROM sessions WHERE network_id = ?1 GROUP BY status", n)
|
|
594
|
+
: db.all<any>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status");
|
|
596
595
|
const totalTasks = n
|
|
597
|
-
? db.
|
|
598
|
-
: db.
|
|
596
|
+
? db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM tasks WHERE network_id = ?1", n)
|
|
597
|
+
: db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM tasks");
|
|
599
598
|
const totalNodes = n
|
|
600
|
-
? db.
|
|
601
|
-
: db.
|
|
599
|
+
? db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1", n)
|
|
600
|
+
: db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes");
|
|
602
601
|
const recentTasks = n
|
|
603
|
-
? db.
|
|
604
|
-
: db.
|
|
602
|
+
? db.all<any>("SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE network_id = ?1 ORDER BY created_at DESC LIMIT 5", n)
|
|
603
|
+
: db.all<any>("SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5");
|
|
605
604
|
return withCors(req, Response.json({
|
|
606
605
|
ok: true,
|
|
607
606
|
network_id: n || null,
|
|
@@ -629,7 +628,7 @@ Bun.serve({
|
|
|
629
628
|
if (userId && resolved.user.role === "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(userId); }
|
|
630
629
|
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
631
630
|
params.push(limit);
|
|
632
|
-
const logs = db.
|
|
631
|
+
const logs = db.all(sql, ...params);
|
|
633
632
|
return withCors(req, Response.json({ ok: true, logs, count: logs.length }));
|
|
634
633
|
}
|
|
635
634
|
|
|
@@ -642,7 +641,7 @@ Bun.serve({
|
|
|
642
641
|
if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
|
|
643
642
|
sql += " ORDER BY created_at DESC LIMIT ?";
|
|
644
643
|
params.push(limit);
|
|
645
|
-
const rows = db.
|
|
644
|
+
const rows = db.all(sql, ...params);
|
|
646
645
|
return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
|
|
647
646
|
}
|
|
648
647
|
|
|
@@ -657,7 +656,7 @@ Bun.serve({
|
|
|
657
656
|
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
658
657
|
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
659
658
|
sql += " ORDER BY updated_at DESC";
|
|
660
|
-
const rows = db.
|
|
659
|
+
const rows = db.all(sql, ...params);
|
|
661
660
|
return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
|
|
662
661
|
}
|
|
663
662
|
|
|
@@ -680,17 +679,17 @@ Bun.serve({
|
|
|
680
679
|
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
681
680
|
params.push(limit);
|
|
682
681
|
|
|
683
|
-
const rows = db.
|
|
682
|
+
const rows = db.all(sql, ...params);
|
|
684
683
|
const stats = netFilter
|
|
685
|
-
? db.
|
|
686
|
-
: db.
|
|
684
|
+
? db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", netFilter)
|
|
685
|
+
: db.all<any>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status");
|
|
687
686
|
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|
|
688
687
|
}
|
|
689
688
|
|
|
690
689
|
// ── REST: recent completions ──
|
|
691
690
|
if (url.pathname === "/api/completions") {
|
|
692
691
|
const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
|
|
693
|
-
const rows = db.
|
|
692
|
+
const rows = db.all("SELECT * FROM completions WHERE completed_at >= ?1 ORDER BY completed_at DESC LIMIT 100", since);
|
|
694
693
|
return withCors(req, Response.json({ ok: true, completions: rows }));
|
|
695
694
|
}
|
|
696
695
|
|
package/src/tools.ts
CHANGED
|
@@ -45,9 +45,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
45
45
|
console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
|
|
46
46
|
const trimmedOutput = output?.slice(0, 4000);
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// Only delete same-alias sessions within the same network (prevent cross-network alias conflict)
|
|
48
|
+
db.transaction(() => {
|
|
49
|
+
// Only delete same-alias sessions within the same network
|
|
51
50
|
if (effectiveNetId) {
|
|
52
51
|
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, effectiveNetId]);
|
|
53
52
|
} else {
|
|
@@ -57,33 +56,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
57
56
|
`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, last_seen_at, updated_at)
|
|
58
57
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'), datetime('now'))
|
|
59
58
|
ON CONFLICT(resume_id) DO UPDATE SET
|
|
60
|
-
alias = COALESCE(?2, sessions.alias),
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
output = COALESCE(?12, sessions.output),
|
|
71
|
-
progress = COALESCE(?13, sessions.progress),
|
|
72
|
-
score = COALESCE(?14, sessions.score),
|
|
73
|
-
node_id = COALESCE(?15, sessions.node_id),
|
|
74
|
-
session_id = COALESCE(?16, sessions.session_id),
|
|
75
|
-
config_path = COALESCE(?17, sessions.config_path),
|
|
76
|
-
channels = COALESCE(?18, sessions.channels),
|
|
77
|
-
network_id = COALESCE(?19, sessions.network_id),
|
|
78
|
-
last_seen_at = datetime('now'),
|
|
79
|
-
updated_at = datetime('now')`,
|
|
59
|
+
alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
|
|
60
|
+
server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
|
|
61
|
+
hostname = COALESCE(?6, sessions.hostname), agent = COALESCE(?7, sessions.agent),
|
|
62
|
+
project_dir = COALESCE(?8, sessions.project_dir), version = COALESCE(?9, sessions.version),
|
|
63
|
+
status = ?10, task = COALESCE(?11, sessions.task),
|
|
64
|
+
output = COALESCE(?12, sessions.output), progress = COALESCE(?13, sessions.progress),
|
|
65
|
+
score = COALESCE(?14, sessions.score), node_id = COALESCE(?15, sessions.node_id),
|
|
66
|
+
session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
|
|
67
|
+
channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
|
|
68
|
+
last_seen_at = datetime('now'), updated_at = datetime('now')`,
|
|
80
69
|
[resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, 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, netId ?? null]
|
|
81
70
|
);
|
|
82
|
-
|
|
83
|
-
} catch (e) {
|
|
84
|
-
try { db.run("ROLLBACK"); } catch {}
|
|
85
|
-
throw e;
|
|
86
|
-
}
|
|
71
|
+
});
|
|
87
72
|
|
|
88
73
|
// V2: sync tasks table — report_status(working) → tasks.running
|
|
89
74
|
if (status === "working" && task) {
|
|
@@ -95,9 +80,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
95
80
|
);
|
|
96
81
|
if (runResult.changes > 0) {
|
|
97
82
|
// Find task_id for logging
|
|
98
|
-
const t = db.
|
|
99
|
-
"SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1"
|
|
100
|
-
|
|
83
|
+
const t = db.get<{ task_id: string }>(
|
|
84
|
+
"SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1",
|
|
85
|
+
alias, task);
|
|
101
86
|
if (t) logTaskEvent(t.task_id, null, "running", alias);
|
|
102
87
|
}
|
|
103
88
|
} catch {}
|
|
@@ -127,9 +112,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
127
112
|
}
|
|
128
113
|
|
|
129
114
|
// inbox uses alias for routing
|
|
130
|
-
const row = db.
|
|
131
|
-
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
|
|
132
|
-
|
|
115
|
+
const row = db.get<{ cnt: number }>(
|
|
116
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
|
|
117
|
+
alias);
|
|
133
118
|
|
|
134
119
|
return {
|
|
135
120
|
content: [
|
|
@@ -161,51 +146,40 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
161
146
|
async ({ alias, task, result, artifacts, score, duration_minutes }) => {
|
|
162
147
|
console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}`);
|
|
163
148
|
const id = uuidv4();
|
|
164
|
-
|
|
165
|
-
db.run("BEGIN IMMEDIATE");
|
|
149
|
+
const taskUpdateChanges = db.transaction(() => {
|
|
166
150
|
db.run(
|
|
167
151
|
`INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
|
|
168
152
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
|
|
169
153
|
[id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
|
|
170
154
|
);
|
|
171
|
-
|
|
172
155
|
db.run(
|
|
173
156
|
`UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
|
|
174
157
|
WHERE alias = ?1`,
|
|
175
158
|
[alias]
|
|
176
159
|
);
|
|
177
|
-
|
|
178
160
|
// V2: sync tasks table — try by task_id first, then by content
|
|
179
|
-
const
|
|
161
|
+
const tu = db.run(
|
|
180
162
|
`UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
|
|
181
163
|
WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`,
|
|
182
164
|
[result.slice(0, 4000), task]
|
|
183
165
|
);
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
const match = db.query<{ task_id: string }, [string, string]>(
|
|
166
|
+
if (tu.changes === 0) {
|
|
167
|
+
const match = db.get<{ task_id: string }>(
|
|
187
168
|
`SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
|
|
188
|
-
AND status IN ('delivered', 'acked', 'running') ORDER BY created_at DESC LIMIT 1
|
|
189
|
-
|
|
169
|
+
AND status IN ('delivered', 'acked', 'running') ORDER BY created_at DESC LIMIT 1`,
|
|
170
|
+
alias, task);
|
|
190
171
|
if (match) {
|
|
191
|
-
db.run(
|
|
192
|
-
|
|
193
|
-
WHERE task_id = ?2`,
|
|
194
|
-
[result.slice(0, 4000), match.task_id]
|
|
195
|
-
);
|
|
172
|
+
db.run(`UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now') WHERE task_id = ?2`,
|
|
173
|
+
[result.slice(0, 4000), match.task_id]);
|
|
196
174
|
}
|
|
197
175
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
} catch (e) {
|
|
206
|
-
try { db.run("ROLLBACK"); } catch {}
|
|
207
|
-
throw e;
|
|
208
|
-
}
|
|
176
|
+
return tu.changes;
|
|
177
|
+
});
|
|
178
|
+
// Log event after transaction
|
|
179
|
+
const updatedTaskId = taskUpdateChanges > 0 ? task : (db.get<{ task_id: string }>(
|
|
180
|
+
"SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1",
|
|
181
|
+
alias)?.task_id);
|
|
182
|
+
if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
|
|
209
183
|
|
|
210
184
|
return {
|
|
211
185
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, completion_id: id }) }],
|
|
@@ -221,16 +195,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
221
195
|
limit: z.number().min(1).max(100).optional().default(10),
|
|
222
196
|
},
|
|
223
197
|
async ({ alias, limit }) => {
|
|
224
|
-
const rows0 = db.
|
|
225
|
-
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
|
|
226
|
-
|
|
198
|
+
const rows0 = db.get<{ cnt: number }>(
|
|
199
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
|
|
200
|
+
alias);
|
|
227
201
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
228
|
-
const rows = db.
|
|
202
|
+
const rows = db.all(
|
|
229
203
|
`SELECT id, type, priority, content, context, from_session, created_at
|
|
230
204
|
FROM inbox WHERE session_name = ?1 AND acked = 0
|
|
231
205
|
ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
|
|
232
|
-
LIMIT ?2
|
|
233
|
-
|
|
206
|
+
LIMIT ?2`,
|
|
207
|
+
alias, limit);
|
|
234
208
|
|
|
235
209
|
return {
|
|
236
210
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
|
|
@@ -294,12 +268,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
294
268
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
295
269
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
296
270
|
sql += " ORDER BY updated_at DESC";
|
|
297
|
-
return db.
|
|
298
|
-
})
|
|
271
|
+
return db.all(sql, ...params);
|
|
272
|
+
});
|
|
299
273
|
|
|
300
|
-
const summary = db.
|
|
301
|
-
"SELECT status, COUNT(*) as count FROM sessions GROUP BY status"
|
|
302
|
-
).all();
|
|
274
|
+
const summary = db.all(
|
|
275
|
+
"SELECT status, COUNT(*) as count FROM sessions GROUP BY status");
|
|
303
276
|
|
|
304
277
|
return {
|
|
305
278
|
content: [
|
|
@@ -318,13 +291,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
318
291
|
{ alias: z.string().min(1).max(200).describe("Session alias") },
|
|
319
292
|
async ({ alias }) => {
|
|
320
293
|
console.log(`[${ts()}] hub → get_session_status: ${alias}`);
|
|
321
|
-
const session = db.
|
|
322
|
-
const pending = db.
|
|
323
|
-
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
|
|
324
|
-
|
|
325
|
-
const recent = db.
|
|
326
|
-
"SELECT * FROM completions WHERE session_name = ?1 ORDER BY completed_at DESC LIMIT 5"
|
|
327
|
-
|
|
294
|
+
const session = db.get("SELECT * FROM sessions WHERE alias = ?1", alias);
|
|
295
|
+
const pending = db.get<{ cnt: number }>(
|
|
296
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
|
|
297
|
+
alias);
|
|
298
|
+
const recent = db.all(
|
|
299
|
+
"SELECT * FROM completions WHERE session_name = ?1 ORDER BY completed_at DESC LIMIT 5",
|
|
300
|
+
alias);
|
|
328
301
|
|
|
329
302
|
return {
|
|
330
303
|
content: [
|
|
@@ -353,7 +326,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
353
326
|
const effectiveNetId = getNetworkId(netId);
|
|
354
327
|
|
|
355
328
|
// License check
|
|
356
|
-
const license = db.
|
|
329
|
+
const license = db.get<any>("SELECT type, expires_at FROM licenses ORDER BY created_at LIMIT 1");
|
|
357
330
|
if (license?.expires_at) {
|
|
358
331
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
359
332
|
if (license.expires_at < now) {
|
|
@@ -367,8 +340,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
367
340
|
console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
|
|
368
341
|
const id = uuidv4();
|
|
369
342
|
// 事务:inbox + tasks 双写
|
|
370
|
-
|
|
371
|
-
db.run("BEGIN IMMEDIATE");
|
|
343
|
+
db.transaction(() => {
|
|
372
344
|
db.run(
|
|
373
345
|
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
|
|
374
346
|
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
|
|
@@ -379,19 +351,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
379
351
|
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
|
|
380
352
|
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
|
|
381
353
|
);
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
} catch (e) {
|
|
385
|
-
try { db.run("ROLLBACK"); } catch {}
|
|
386
|
-
throw e;
|
|
387
|
-
}
|
|
354
|
+
});
|
|
355
|
+
logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
|
|
388
356
|
|
|
389
|
-
const session = db.
|
|
357
|
+
const session = db.get<any>("SELECT status FROM sessions WHERE alias = ?1", alias);
|
|
390
358
|
|
|
391
359
|
// SSE push by alias
|
|
392
|
-
const pending = db.
|
|
393
|
-
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
|
|
394
|
-
|
|
360
|
+
const pending = db.get<{ cnt: number }>(
|
|
361
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0",
|
|
362
|
+
alias);
|
|
395
363
|
pushEvent(alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session });
|
|
396
364
|
|
|
397
365
|
return {
|
|
@@ -426,7 +394,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
426
394
|
[id, alias, message, from_session]
|
|
427
395
|
);
|
|
428
396
|
|
|
429
|
-
const session = db.
|
|
397
|
+
const session = db.get<any>("SELECT status FROM sessions WHERE alias = ?1", alias);
|
|
430
398
|
|
|
431
399
|
pushEvent(alias, { type: "new_message", message, from: from_session, message_id: id });
|
|
432
400
|
|
|
@@ -459,9 +427,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
459
427
|
async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
|
|
460
428
|
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
|
|
461
429
|
const id = uuidv4();
|
|
462
|
-
|
|
463
|
-
try {
|
|
464
|
-
db.run("BEGIN IMMEDIATE");
|
|
430
|
+
const replyLogged = db.transaction(() => {
|
|
465
431
|
db.run(
|
|
466
432
|
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response)
|
|
467
433
|
VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none')`,
|
|
@@ -477,20 +443,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
477
443
|
);
|
|
478
444
|
if (result.changes === 0) {
|
|
479
445
|
console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
|
|
480
|
-
|
|
481
|
-
replyLogged = true;
|
|
446
|
+
return false;
|
|
482
447
|
}
|
|
448
|
+
return true;
|
|
483
449
|
}
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
try { db.run("ROLLBACK"); } catch {}
|
|
487
|
-
throw e;
|
|
488
|
-
}
|
|
450
|
+
return false;
|
|
451
|
+
});
|
|
489
452
|
|
|
490
453
|
// Log event after commit (outside transaction)
|
|
491
454
|
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
492
455
|
|
|
493
|
-
const session = db.
|
|
456
|
+
const session = db.get<any>("SELECT status FROM sessions WHERE alias = ?1", alias);
|
|
494
457
|
pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
|
|
495
458
|
|
|
496
459
|
return {
|
|
@@ -537,17 +500,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
537
500
|
async ({ task_id, from_session }) => {
|
|
538
501
|
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
539
502
|
// Find the original task
|
|
540
|
-
const task = db.
|
|
541
|
-
"SELECT * FROM tasks WHERE task_id = ?1"
|
|
542
|
-
).get(task_id);
|
|
503
|
+
const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", task_id);
|
|
543
504
|
if (!task) {
|
|
544
505
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
545
506
|
}
|
|
546
507
|
if (!["failed", "expired", "cancelled"].includes(task.status)) {
|
|
547
508
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
|
|
548
509
|
}
|
|
549
|
-
|
|
550
|
-
db.run("BEGIN IMMEDIATE");
|
|
510
|
+
db.transaction(() => {
|
|
551
511
|
// Reset task status
|
|
552
512
|
db.run(
|
|
553
513
|
`UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
|
|
@@ -561,12 +521,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
561
521
|
VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')`,
|
|
562
522
|
[retryInboxId, task.to_name, task.priority, task.content, from_session]
|
|
563
523
|
);
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
} catch (e) {
|
|
567
|
-
try { db.run("ROLLBACK"); } catch {}
|
|
568
|
-
throw e;
|
|
569
|
-
}
|
|
524
|
+
});
|
|
525
|
+
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
570
526
|
// SSE push
|
|
571
527
|
pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
572
528
|
return {
|
|
@@ -583,7 +539,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
583
539
|
task_id: z.string().min(1).max(200).describe("Task ID to query"),
|
|
584
540
|
},
|
|
585
541
|
async ({ task_id }) => {
|
|
586
|
-
const task = db.
|
|
542
|
+
const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", task_id);
|
|
587
543
|
return {
|
|
588
544
|
content: [{
|
|
589
545
|
type: "text" as const,
|
|
@@ -614,12 +570,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
614
570
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
615
571
|
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
616
572
|
params.push(limit);
|
|
617
|
-
const tasks = db.
|
|
573
|
+
const tasks = db.all(sql, ...params);
|
|
618
574
|
|
|
619
575
|
// Stats
|
|
620
|
-
const stats = db.
|
|
621
|
-
"SELECT status, COUNT(*) as count FROM tasks GROUP BY status"
|
|
622
|
-
).all();
|
|
576
|
+
const stats = db.all(
|
|
577
|
+
"SELECT status, COUNT(*) as count FROM tasks GROUP BY status");
|
|
623
578
|
|
|
624
579
|
return {
|
|
625
580
|
content: [{
|
|
@@ -668,26 +623,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
668
623
|
},
|
|
669
624
|
async ({ task_id, new_alias, from_session }) => {
|
|
670
625
|
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
671
|
-
const task = db.
|
|
626
|
+
const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", task_id);
|
|
672
627
|
if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
673
628
|
if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
|
|
674
629
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
|
|
675
630
|
}
|
|
676
631
|
const oldAlias = task.to_name;
|
|
677
|
-
|
|
678
|
-
db.run("BEGIN IMMEDIATE");
|
|
632
|
+
db.transaction(() => {
|
|
679
633
|
// Ack old inbox to prevent original agent from picking it up
|
|
680
634
|
db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
|
|
681
635
|
db.run("UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?2", [new_alias, task_id]);
|
|
682
636
|
const newInboxId = uuidv4();
|
|
683
637
|
db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
|
|
684
638
|
[newInboxId, new_alias, task.priority, task.content, from_session]);
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
} catch (e) {
|
|
688
|
-
try { db.run("ROLLBACK"); } catch {}
|
|
689
|
-
throw e;
|
|
690
|
-
}
|
|
639
|
+
});
|
|
640
|
+
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
|
|
691
641
|
pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
692
642
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
|
|
693
643
|
}
|
|
@@ -710,7 +660,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
710
660
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
711
661
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
712
662
|
|
|
713
|
-
const targets = db.
|
|
663
|
+
const targets = db.all<{ alias: string }>(sql, ...params);
|
|
714
664
|
const ids: string[] = [];
|
|
715
665
|
|
|
716
666
|
for (const t of targets) {
|
|
@@ -759,7 +709,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
759
709
|
sql += ` ORDER BY completed_at DESC LIMIT ?${paramIdx}`;
|
|
760
710
|
params.push(limit);
|
|
761
711
|
|
|
762
|
-
const rows = db.
|
|
712
|
+
const rows = db.all(sql, ...params);
|
|
763
713
|
return {
|
|
764
714
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, completions: rows }) }],
|
|
765
715
|
};
|