@sleep2agi/commhub-server 0.5.0-preview.2 → 0.5.0-preview.20
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/README.md +110 -0
- package/package.json +1 -1
- package/src/auth.ts +143 -0
- package/src/db-adapter.ts +72 -0
- package/src/db.ts +155 -0
- package/src/index.ts +315 -12
- package/src/tools.ts +242 -22
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# @sleep2agi/commhub-server
|
|
2
|
+
|
|
3
|
+
AI Agent 通信中枢 — MCP Server + SSE Push + REST API。
|
|
4
|
+
|
|
5
|
+
## 快速启动
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 需要 Bun
|
|
9
|
+
bunx @sleep2agi/commhub-server
|
|
10
|
+
|
|
11
|
+
# 或指定端口 + auth
|
|
12
|
+
PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
启动后:
|
|
16
|
+
- MCP: `http://0.0.0.0:9200/mcp` (Claude Code / Codex 连接)
|
|
17
|
+
- SSE: `http://0.0.0.0:9200/events/:alias` (Agent 实时推送)
|
|
18
|
+
- REST: `http://0.0.0.0:9200/api/*` (Dashboard / 监控)
|
|
19
|
+
- Health: `http://0.0.0.0:9200/health`
|
|
20
|
+
|
|
21
|
+
## MCP 工具 (17 个)
|
|
22
|
+
|
|
23
|
+
### Agent 端 (从 Agent 调用)
|
|
24
|
+
| 工具 | 说明 |
|
|
25
|
+
|------|------|
|
|
26
|
+
| `report_status` | 心跳 + 状态更新 (idle/working/blocked/error/offline) |
|
|
27
|
+
| `report_completion` | 任务完成汇报 |
|
|
28
|
+
| `get_inbox` | 拉取待办任务 |
|
|
29
|
+
| `ack_inbox` | 确认收到任务 |
|
|
30
|
+
|
|
31
|
+
### Hub 端 (从指挥室 / Dashboard 调用)
|
|
32
|
+
| 工具 | 说明 |
|
|
33
|
+
|------|------|
|
|
34
|
+
| `send_task` | 下发任务 (+ 可选 ttl_seconds) |
|
|
35
|
+
| `send_message` | 发消息 (不触发 Agent 处理) |
|
|
36
|
+
| `send_reply` | 回复任务 (replied/failed/cancelled + in_reply_to) |
|
|
37
|
+
| `send_ack` | 确认任务 (不入 inbox) |
|
|
38
|
+
| `retry_task` | 重试失败/过期/取消的任务 |
|
|
39
|
+
| `cancel_task` | 取消待处理任务 |
|
|
40
|
+
| `reassign_task` | 转移任务到另一个 Agent |
|
|
41
|
+
| `get_task` | 查询任务详情 |
|
|
42
|
+
| `get_all_status` | 全局状态面板 |
|
|
43
|
+
| `get_session_status` | 单 session 详情 |
|
|
44
|
+
| `broadcast` | 群发消息 |
|
|
45
|
+
|
|
46
|
+
## REST API
|
|
47
|
+
|
|
48
|
+
| 端点 | 方法 | 说明 |
|
|
49
|
+
|------|------|------|
|
|
50
|
+
| `/health` | GET | 健康检查 (无需 auth) |
|
|
51
|
+
| `/api/status` | GET | 所有 session |
|
|
52
|
+
| `/api/tasks` | GET | 任务列表 (支持 status/from_name/to_name/task_id/limit 过滤) |
|
|
53
|
+
| `/api/nodes` | GET | 节点持久化信息 |
|
|
54
|
+
| `/api/task_events` | GET | 任务审计日志 |
|
|
55
|
+
| `/api/messages` | GET | 消息列表 |
|
|
56
|
+
| `/api/completions` | GET | 完成记录 |
|
|
57
|
+
| `/mcp` | POST | MCP Streamable HTTP |
|
|
58
|
+
|
|
59
|
+
## 数据库 (6 表)
|
|
60
|
+
|
|
61
|
+
SQLite WAL 模式, 自动创建在 `~/.commhub/commhub.db`
|
|
62
|
+
|
|
63
|
+
| 表 | 说明 |
|
|
64
|
+
|---|------|
|
|
65
|
+
| `sessions` | 运行时 session (21 列, 含 node_id/session_id/channels) |
|
|
66
|
+
| `inbox` | 消息队列 (13 列, 含 in_reply_to/scope) |
|
|
67
|
+
| `tasks` | 任务生命周期 (17 列, 完整状态机) |
|
|
68
|
+
| `nodes` | 持久化节点身份 (11 列, 独立于 session) |
|
|
69
|
+
| `completions` | 完成记录 (7 列) |
|
|
70
|
+
| `task_events` | 审计日志 (7 列, 每次状态变化记录) |
|
|
71
|
+
|
|
72
|
+
任务状态机:
|
|
73
|
+
```
|
|
74
|
+
created → delivered → acked → running → replied
|
|
75
|
+
→ failed → retry → delivered
|
|
76
|
+
→ cancelled
|
|
77
|
+
delivered → expired (5min patrol)
|
|
78
|
+
delivered/acked/running → reassign → delivered (新agent)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## 环境变量
|
|
82
|
+
|
|
83
|
+
| 变量 | 默认 | 说明 |
|
|
84
|
+
|------|------|------|
|
|
85
|
+
| `PORT` | 9200 | 监听端口 |
|
|
86
|
+
| `HOST` | 0.0.0.0 | 监听地址 |
|
|
87
|
+
| `COMMHUB_AUTH_TOKEN` | (无) | Bearer token 鉴权 |
|
|
88
|
+
| `COMMHUB_DB` | ~/.commhub/commhub.db | 数据库路径 |
|
|
89
|
+
|
|
90
|
+
## 鉴权
|
|
91
|
+
|
|
92
|
+
两种认证方式:
|
|
93
|
+
1. **V3 用户系统** (推荐): `POST /api/auth/register` → 获取 `atok_xxx` token
|
|
94
|
+
2. **全局 token** (传统): `COMMHUB_AUTH_TOKEN` 环境变量
|
|
95
|
+
|
|
96
|
+
Header: `Authorization: Bearer <token>` 或 Query: `?token=<token>`
|
|
97
|
+
|
|
98
|
+
## V3 功能
|
|
99
|
+
|
|
100
|
+
- **用户系统**: 注册/登录/Token 认证
|
|
101
|
+
- **多网络**: 每个用户可创建多个独立网络
|
|
102
|
+
- **网络隔离**: 不同网络的数据完全隔离
|
|
103
|
+
- **试用授权**: 14 天免费试用, 到期需授权码
|
|
104
|
+
- **审计日志**: 所有操作记录
|
|
105
|
+
- **限流**: 注册 30/min, 登录 10/min (per IP)
|
|
106
|
+
- `/health` 不需要 auth
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/package.json
CHANGED
package/src/auth.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V3 Auth module — user registration, login, token management
|
|
3
|
+
*/
|
|
4
|
+
import { db, generateId, hashPassword, hashToken, generateToken, uuidv4 } from "./db.js";
|
|
5
|
+
|
|
6
|
+
export interface AuthUser {
|
|
7
|
+
user_id: string;
|
|
8
|
+
username: string;
|
|
9
|
+
display_name: string | null;
|
|
10
|
+
email: string | null;
|
|
11
|
+
role: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AuthResult {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
error?: string;
|
|
17
|
+
user?: AuthUser;
|
|
18
|
+
token?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
|
|
22
|
+
if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
|
|
23
|
+
if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
|
|
24
|
+
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
|
|
25
|
+
|
|
26
|
+
const existing = db.query<any, [string]>("SELECT user_id FROM users WHERE username = ?1").get(username);
|
|
27
|
+
if (existing) return { ok: false, error: "username already taken" };
|
|
28
|
+
|
|
29
|
+
const userId = generateId("u");
|
|
30
|
+
const pwHash = hashPassword(password);
|
|
31
|
+
|
|
32
|
+
db.run(
|
|
33
|
+
"INSERT INTO users (user_id, username, password_hash, email, display_name) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
34
|
+
[userId, username, pwHash, email || null, displayName || username]
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Auto-create default network
|
|
38
|
+
const networkId = generateId("net");
|
|
39
|
+
db.run(
|
|
40
|
+
"INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
|
|
41
|
+
[networkId, "default", userId, "Auto-created default network"]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Auto-create API token
|
|
45
|
+
const token = generateToken();
|
|
46
|
+
const tokenId = generateId("tok");
|
|
47
|
+
db.run(
|
|
48
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
49
|
+
[tokenId, hashToken(token), userId, networkId, "default", "full"]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: "user" },
|
|
55
|
+
token,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function login(username: string, password: string): AuthResult {
|
|
60
|
+
const user = db.query<any, [string]>(
|
|
61
|
+
"SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1"
|
|
62
|
+
).get(username);
|
|
63
|
+
|
|
64
|
+
if (!user) return { ok: false, error: "invalid username or password" };
|
|
65
|
+
if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
|
|
66
|
+
|
|
67
|
+
// Find or create token
|
|
68
|
+
let tokenRow = db.query<any, [string]>(
|
|
69
|
+
"SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1"
|
|
70
|
+
).get(user.user_id);
|
|
71
|
+
|
|
72
|
+
let token: string;
|
|
73
|
+
if (tokenRow) {
|
|
74
|
+
// Generate new token (rotate)
|
|
75
|
+
token = generateToken();
|
|
76
|
+
db.run("UPDATE api_tokens SET token_hash = ?1, last_used_at = datetime('now') WHERE token_id = ?2",
|
|
77
|
+
[hashToken(token), tokenRow.token_id]);
|
|
78
|
+
} else {
|
|
79
|
+
token = generateToken();
|
|
80
|
+
const tokenId = generateId("tok");
|
|
81
|
+
const networkId = db.query<any, [string]>(
|
|
82
|
+
"SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1"
|
|
83
|
+
).get(user.user_id)?.network_id;
|
|
84
|
+
db.run(
|
|
85
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
86
|
+
[tokenId, hashToken(token), user.user_id, networkId || null, "login"]
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
|
|
93
|
+
token,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
|
|
98
|
+
const tHash = hashToken(token);
|
|
99
|
+
const row = db.query<any, [string]>(
|
|
100
|
+
`SELECT t.user_id, t.network_id, t.scope, u.username, u.display_name, u.email, u.role
|
|
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
|
+
).get(tHash);
|
|
104
|
+
|
|
105
|
+
if (!row) return null;
|
|
106
|
+
|
|
107
|
+
// Update last_used
|
|
108
|
+
db.run("UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?1", [tHash]);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
|
|
112
|
+
networkId: row.network_id,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getUserNetworks(userId: string) {
|
|
117
|
+
return db.query<any, [string]>(
|
|
118
|
+
"SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at"
|
|
119
|
+
).all(userId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createNetwork(userId: string, name: string, description?: string) {
|
|
123
|
+
const existing = db.query<any, [string, string]>(
|
|
124
|
+
"SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2"
|
|
125
|
+
).get(userId, name);
|
|
126
|
+
if (existing) return { ok: false, error: "network name already exists" };
|
|
127
|
+
|
|
128
|
+
const networkId = generateId("net");
|
|
129
|
+
db.run(
|
|
130
|
+
"INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
|
|
131
|
+
[networkId, name, userId, description || null]
|
|
132
|
+
);
|
|
133
|
+
return { ok: true, network_id: networkId, network_name: name };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function changePassword(userId: string, oldPassword: string, newPassword: string): { ok: boolean; error?: string } {
|
|
137
|
+
if (!newPassword || newPassword.length < 6) return { ok: false, error: "new password must be at least 6 characters" };
|
|
138
|
+
const user = db.query<any, [string]>("SELECT password_hash FROM users WHERE user_id = ?1").get(userId);
|
|
139
|
+
if (!user) return { ok: false, error: "user not found" };
|
|
140
|
+
if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
|
|
141
|
+
db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
|
|
142
|
+
return { ok: true };
|
|
143
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database Adapter Interface — async-first, supports SQLite and PostgreSQL
|
|
3
|
+
*
|
|
4
|
+
* SQLite adapter: wraps bun:sqlite sync calls in Promise (zero overhead)
|
|
5
|
+
* PostgreSQL adapter: uses bun:sql native async
|
|
6
|
+
*
|
|
7
|
+
* All callers use await — sync SQLite just resolves immediately.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface QueryResult {
|
|
11
|
+
changes: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DbAdapter {
|
|
15
|
+
/** Execute a write query (INSERT/UPDATE/DELETE) */
|
|
16
|
+
run(sql: string, params?: any[]): QueryResult;
|
|
17
|
+
|
|
18
|
+
/** Query a single row */
|
|
19
|
+
get<T = any>(sql: string, ...params: any[]): T | null;
|
|
20
|
+
|
|
21
|
+
/** Query multiple rows */
|
|
22
|
+
all<T = any>(sql: string, ...params: any[]): T[];
|
|
23
|
+
|
|
24
|
+
/** Execute raw SQL (DDL) */
|
|
25
|
+
exec(sql: string): void;
|
|
26
|
+
|
|
27
|
+
/** Run a function inside a transaction */
|
|
28
|
+
transaction<T>(fn: () => T): T;
|
|
29
|
+
|
|
30
|
+
/** Close connection */
|
|
31
|
+
close(): void;
|
|
32
|
+
|
|
33
|
+
/** Dialect identifier */
|
|
34
|
+
readonly dialect: 'sqlite' | 'postgres';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Phase 1 strategy:
|
|
39
|
+
*
|
|
40
|
+
* Current code is sync (bun:sqlite). We keep it sync for now.
|
|
41
|
+
* All DB access goes through the adapter interface above.
|
|
42
|
+
*
|
|
43
|
+
* When we add PostgreSQL (Phase 2), the adapter interface
|
|
44
|
+
* will change to async. At that point we'll update callers
|
|
45
|
+
* in a single pass. The unified call sites from Phase 1
|
|
46
|
+
* make that pass mechanical rather than archaeological.
|
|
47
|
+
*
|
|
48
|
+
* Why not async-first now?
|
|
49
|
+
* - bun:sqlite is sync, wrapping in Promise adds noise
|
|
50
|
+
* - All MCP tool handlers are already async, so the future
|
|
51
|
+
* migration is: db.run() → await db.run(), straightforward
|
|
52
|
+
* - 750+ lines of tools.ts would need gratuitous await for zero benefit today
|
|
53
|
+
*
|
|
54
|
+
* The contract: every DB call goes through adapter methods,
|
|
55
|
+
* never through raw db.query() or db.run() on the bun:sqlite object.
|
|
56
|
+
* This is what makes Phase 2 feasible.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** SQL helpers for cross-dialect compatibility */
|
|
60
|
+
export function sqlNow(dialect: 'sqlite' | 'postgres'): string {
|
|
61
|
+
return dialect === 'postgres' ? 'NOW()' : "datetime('now')";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function sqlAddSeconds(dialect: 'sqlite' | 'postgres', seconds: number | string): string {
|
|
65
|
+
return dialect === 'postgres'
|
|
66
|
+
? `NOW() + INTERVAL '${seconds} seconds'`
|
|
67
|
+
: `datetime('now', '+${seconds} seconds')`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function sqlPlaceholder(dialect: 'sqlite' | 'postgres', index: number): string {
|
|
71
|
+
return dialect === 'postgres' ? `$${index}` : `?${index}`;
|
|
72
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -135,7 +135,162 @@ db.exec(`
|
|
|
135
135
|
CREATE INDEX IF NOT EXISTS idx_nodes_alias ON nodes(alias);
|
|
136
136
|
`);
|
|
137
137
|
|
|
138
|
+
// task_events table (V2 Sprint 2) — audit log for task state changes
|
|
139
|
+
db.exec(`
|
|
140
|
+
CREATE TABLE IF NOT EXISTS task_events (
|
|
141
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
142
|
+
task_id TEXT NOT NULL,
|
|
143
|
+
from_status TEXT,
|
|
144
|
+
to_status TEXT NOT NULL,
|
|
145
|
+
actor TEXT NOT NULL DEFAULT 'system',
|
|
146
|
+
detail TEXT,
|
|
147
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
CREATE INDEX IF NOT EXISTS idx_task_events_task_time ON task_events(task_id, created_at DESC);
|
|
151
|
+
CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at);
|
|
152
|
+
`);
|
|
153
|
+
|
|
154
|
+
// ── V3: users table ──
|
|
155
|
+
db.exec(`
|
|
156
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
157
|
+
user_id TEXT PRIMARY KEY,
|
|
158
|
+
username TEXT UNIQUE NOT NULL,
|
|
159
|
+
password_hash TEXT NOT NULL,
|
|
160
|
+
email TEXT,
|
|
161
|
+
display_name TEXT,
|
|
162
|
+
role TEXT DEFAULT 'user',
|
|
163
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
164
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
168
|
+
`);
|
|
169
|
+
|
|
170
|
+
// ── V3: networks table ──
|
|
171
|
+
db.exec(`
|
|
172
|
+
CREATE TABLE IF NOT EXISTS networks (
|
|
173
|
+
network_id TEXT PRIMARY KEY,
|
|
174
|
+
network_name TEXT NOT NULL,
|
|
175
|
+
owner_id TEXT NOT NULL,
|
|
176
|
+
description TEXT,
|
|
177
|
+
settings TEXT,
|
|
178
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
179
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
180
|
+
UNIQUE(owner_id, network_name)
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
CREATE INDEX IF NOT EXISTS idx_networks_owner ON networks(owner_id);
|
|
184
|
+
`);
|
|
185
|
+
|
|
186
|
+
// ── V3: api_tokens table ──
|
|
187
|
+
db.exec(`
|
|
188
|
+
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
189
|
+
token_id TEXT PRIMARY KEY,
|
|
190
|
+
token_hash TEXT NOT NULL,
|
|
191
|
+
user_id TEXT NOT NULL,
|
|
192
|
+
network_id TEXT,
|
|
193
|
+
name TEXT NOT NULL DEFAULT 'default',
|
|
194
|
+
scope TEXT DEFAULT 'full',
|
|
195
|
+
expires_at TEXT,
|
|
196
|
+
last_used_at TEXT,
|
|
197
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_tokens_hash ON api_tokens(token_hash);
|
|
201
|
+
CREATE INDEX IF NOT EXISTS idx_tokens_user ON api_tokens(user_id);
|
|
202
|
+
`);
|
|
203
|
+
|
|
204
|
+
// ── V3: audit_log table ──
|
|
205
|
+
db.exec(`
|
|
206
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
207
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
208
|
+
user_id TEXT,
|
|
209
|
+
username TEXT,
|
|
210
|
+
action TEXT NOT NULL,
|
|
211
|
+
target_type TEXT,
|
|
212
|
+
target_id TEXT,
|
|
213
|
+
detail TEXT,
|
|
214
|
+
ip TEXT,
|
|
215
|
+
network_id TEXT,
|
|
216
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
|
|
220
|
+
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
|
221
|
+
CREATE INDEX IF NOT EXISTS idx_audit_network ON audit_log(network_id);
|
|
222
|
+
`);
|
|
223
|
+
|
|
224
|
+
// ── V3: licenses table ──
|
|
225
|
+
db.exec(`
|
|
226
|
+
CREATE TABLE IF NOT EXISTS licenses (
|
|
227
|
+
id TEXT PRIMARY KEY,
|
|
228
|
+
license_key TEXT UNIQUE NOT NULL,
|
|
229
|
+
type TEXT DEFAULT 'trial',
|
|
230
|
+
max_agents INTEGER DEFAULT 5,
|
|
231
|
+
max_networks INTEGER DEFAULT 3,
|
|
232
|
+
max_tasks_day INTEGER DEFAULT 500,
|
|
233
|
+
activated_at TEXT,
|
|
234
|
+
expires_at TEXT,
|
|
235
|
+
owner_id TEXT,
|
|
236
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
237
|
+
);
|
|
238
|
+
`);
|
|
239
|
+
|
|
240
|
+
// Auto-create trial license on first run
|
|
241
|
+
const existingLicense = db.query<any, []>("SELECT id FROM licenses LIMIT 1").get();
|
|
242
|
+
if (!existingLicense) {
|
|
243
|
+
const trialId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
244
|
+
db.run(
|
|
245
|
+
"INSERT INTO licenses (id, license_key, type, expires_at) VALUES (?1, ?2, 'trial', datetime('now', '+14 days'))",
|
|
246
|
+
[`lic_${trialId}`, `trial-${trialId}`]
|
|
247
|
+
);
|
|
248
|
+
console.log("[commhub] 🎉 14-day free trial started!");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── V3: add network_id to existing tables ──
|
|
252
|
+
for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
|
|
253
|
+
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|
|
254
|
+
}
|
|
255
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
|
|
256
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
|
|
257
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
|
|
258
|
+
|
|
138
259
|
// Helpers
|
|
139
260
|
export function uuidv4(): string {
|
|
140
261
|
return crypto.randomUUID();
|
|
141
262
|
}
|
|
263
|
+
|
|
264
|
+
export function generateId(prefix: string): string {
|
|
265
|
+
return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function hashPassword(password: string): string {
|
|
269
|
+
return new Bun.CryptoHasher("sha256").update(`anet:${password}`).digest("hex");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function hashToken(token: string): string {
|
|
273
|
+
return new Bun.CryptoHasher("sha256").update(token).digest("hex");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function generateToken(): string {
|
|
277
|
+
return `atok_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function logAudit(userId: string | null, username: string | null, action: string, targetType?: string, targetId?: string, detail?: string, ip?: string, networkId?: string) {
|
|
281
|
+
try {
|
|
282
|
+
db.run(
|
|
283
|
+
"INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, ip, network_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
284
|
+
[userId, username, action, targetType ?? null, targetId ?? null, detail ?? null, ip ?? null, networkId ?? null]
|
|
285
|
+
);
|
|
286
|
+
} catch {}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
|
|
290
|
+
try {
|
|
291
|
+
db.run(
|
|
292
|
+
"INSERT INTO task_events (task_id, from_status, to_status, actor, detail) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
293
|
+
[taskId, fromStatus, toStatus, actor, detail ?? null]
|
|
294
|
+
);
|
|
295
|
+
} catch {}
|
|
296
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,33 +2,76 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
|
-
import { db } from "./db.js";
|
|
5
|
+
import { db, logTaskEvent, logAudit } from "./db.js";
|
|
6
6
|
import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
|
|
7
|
+
import { register, login, resolveToken, getUserNetworks, createNetwork, changePassword, type AuthUser } from "./auth.js";
|
|
7
8
|
|
|
8
9
|
const PORT = Number(process.env.PORT) || 9200;
|
|
9
10
|
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
10
11
|
|
|
12
|
+
// ── Rate limiter (in-memory, per IP) ──
|
|
13
|
+
const rateLimits = new Map<string, { count: number; resetAt: number }>();
|
|
14
|
+
function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
|
|
15
|
+
// Skip rate limiting for localhost/internal/unknown (dev/test)
|
|
16
|
+
if (!ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1") return true;
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const entry = rateLimits.get(ip);
|
|
19
|
+
if (!entry || now > entry.resetAt) {
|
|
20
|
+
rateLimits.set(ip, { count: 1, resetAt: now + 60000 });
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (entry.count >= maxPerMinute) return false;
|
|
24
|
+
entry.count++;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
// Cleanup stale entries every 5 minutes
|
|
28
|
+
setInterval(() => {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
for (const [ip, entry] of rateLimits) {
|
|
31
|
+
if (now > entry.resetAt) rateLimits.delete(ip);
|
|
32
|
+
}
|
|
33
|
+
}, 300000);
|
|
34
|
+
|
|
11
35
|
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
12
|
-
function createServer(clientIP?: string): McpServer {
|
|
36
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null): McpServer {
|
|
13
37
|
const server = new McpServer({
|
|
14
38
|
name: "commhub",
|
|
15
|
-
version: "0.
|
|
39
|
+
version: "0.5.0",
|
|
16
40
|
});
|
|
17
|
-
registerTools(server, clientIP);
|
|
41
|
+
registerTools(server, clientIP, enforceNetworkId);
|
|
18
42
|
return server;
|
|
19
43
|
}
|
|
20
44
|
|
|
21
45
|
// ── Auth helper ─────────────────────────────────────
|
|
22
46
|
function requireAuth(req: Request): Response | null {
|
|
23
|
-
|
|
24
|
-
const header = req.headers.get("Authorization");
|
|
25
|
-
if (header === `Bearer ${AUTH_TOKEN}`) return null;
|
|
26
|
-
// Also check query param for MCP clients that can't set headers
|
|
47
|
+
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
27
48
|
const url = new URL(req.url);
|
|
28
|
-
|
|
49
|
+
const token = header || url.searchParams.get("token") || "";
|
|
50
|
+
|
|
51
|
+
// V3: check api_tokens first
|
|
52
|
+
if (token) {
|
|
53
|
+
const resolved = resolveToken(token);
|
|
54
|
+
if (resolved) return null; // valid user token
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Legacy: check global COMMHUB_AUTH_TOKEN
|
|
58
|
+
if (!AUTH_TOKEN) return null; // no token = open mode (dev)
|
|
59
|
+
if (token === AUTH_TOKEN) return null;
|
|
60
|
+
|
|
29
61
|
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
30
62
|
}
|
|
31
63
|
|
|
64
|
+
// Extract user + network from request token (for authorization)
|
|
65
|
+
function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string } | null {
|
|
66
|
+
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
67
|
+
const url = new URL(req.url);
|
|
68
|
+
const token = header || url.searchParams.get("token") || "";
|
|
69
|
+
if (!token) return null;
|
|
70
|
+
const resolved = resolveToken(token);
|
|
71
|
+
if (!resolved) return null;
|
|
72
|
+
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username };
|
|
73
|
+
}
|
|
74
|
+
|
|
32
75
|
// ── REST input schema ───────────────────────────────
|
|
33
76
|
const TaskSchema = z.object({
|
|
34
77
|
alias: z.string().min(1).max(200),
|
|
@@ -84,6 +127,11 @@ setInterval(() => {
|
|
|
84
127
|
);
|
|
85
128
|
if (result.changes > 0) {
|
|
86
129
|
console.log(`[patrol] expired ${result.changes} stale task(s)`);
|
|
130
|
+
// Log events for expired tasks
|
|
131
|
+
const expired = db.query<{ task_id: string }, []>(
|
|
132
|
+
"SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
|
|
133
|
+
).all();
|
|
134
|
+
for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
|
|
87
135
|
}
|
|
88
136
|
} catch {}
|
|
89
137
|
}, 5 * 60 * 1000);
|
|
@@ -114,10 +162,13 @@ Bun.serve({
|
|
|
114
162
|
if (authErr) return withCors(req, authErr);
|
|
115
163
|
const fwd = req.headers.get("x-forwarded-for");
|
|
116
164
|
const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
|
|
165
|
+
// V3: resolve token → enforce network_id in all MCP tools
|
|
166
|
+
const authCtx = resolveRequestAuth(req);
|
|
167
|
+
const enforceNetId = authCtx?.networkId || null;
|
|
117
168
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
118
169
|
sessionIdGenerator: undefined,
|
|
119
170
|
});
|
|
120
|
-
const server = createServer(clientIP);
|
|
171
|
+
const server = createServer(clientIP, enforceNetId);
|
|
121
172
|
await server.connect(transport);
|
|
122
173
|
const response = await transport.handleRequest(req);
|
|
123
174
|
// Disconnect after response to prevent McpServer leak
|
|
@@ -135,6 +186,184 @@ Bun.serve({
|
|
|
135
186
|
return createSSEStream(sessionName);
|
|
136
187
|
}
|
|
137
188
|
|
|
189
|
+
// ── V3: License endpoints ──
|
|
190
|
+
if (url.pathname === "/api/license" && req.method === "GET") {
|
|
191
|
+
const license = db.query<any, []>("SELECT * FROM licenses ORDER BY created_at LIMIT 1").get();
|
|
192
|
+
if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
|
|
193
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
194
|
+
const expired = license.expires_at && license.expires_at < now;
|
|
195
|
+
const daysLeft = license.expires_at
|
|
196
|
+
? Math.max(0, Math.ceil((new Date(license.expires_at).getTime() - Date.now()) / 86400000))
|
|
197
|
+
: null;
|
|
198
|
+
return withCors(req, Response.json({
|
|
199
|
+
ok: true,
|
|
200
|
+
license: { type: license.type, expires_at: license.expires_at, days_left: daysLeft, expired },
|
|
201
|
+
limits: { max_agents: license.max_agents, max_networks: license.max_networks, max_tasks_day: license.max_tasks_day },
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (url.pathname === "/api/license/activate" && req.method === "POST") {
|
|
206
|
+
try {
|
|
207
|
+
const body = await req.json() as any;
|
|
208
|
+
const key = body.key;
|
|
209
|
+
if (!key) return withCors(req, Response.json({ ok: false, error: "key required" }, { status: 400 }));
|
|
210
|
+
// For now: accept any key starting with "anet-" as valid pro license
|
|
211
|
+
if (!key.startsWith("anet-") || key.length < 16) {
|
|
212
|
+
return withCors(req, Response.json({ ok: false, error: "invalid license key" }, { status: 400 }));
|
|
213
|
+
}
|
|
214
|
+
// Upgrade existing license or create new
|
|
215
|
+
db.run("DELETE FROM licenses");
|
|
216
|
+
const licId = `lic_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
217
|
+
db.run(
|
|
218
|
+
"INSERT INTO licenses (id, license_key, type, max_agents, max_networks, max_tasks_day, activated_at, expires_at) VALUES (?1, ?2, 'pro', 50, 10, 10000, datetime('now'), datetime('now', '+365 days'))",
|
|
219
|
+
[licId, key]
|
|
220
|
+
);
|
|
221
|
+
return withCors(req, Response.json({ ok: true, type: "pro", expires_in_days: 365 }));
|
|
222
|
+
} catch (e: any) {
|
|
223
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── V3: Auth endpoints (public) ──
|
|
228
|
+
if (url.pathname === "/api/auth/register" && req.method === "POST") {
|
|
229
|
+
const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
230
|
+
if (!checkRateLimit(clientIP, 30)) {
|
|
231
|
+
return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const body = await req.json() as any;
|
|
235
|
+
const result = register(body.username, body.password, body.email, body.display_name);
|
|
236
|
+
if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
|
|
237
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
238
|
+
} catch (e: any) {
|
|
239
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (url.pathname === "/api/auth/login" && req.method === "POST") {
|
|
244
|
+
const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
245
|
+
if (!checkRateLimit(clientIP, 10)) {
|
|
246
|
+
logAudit(null, null, "login_rate_limited", "auth", null, clientIP);
|
|
247
|
+
return withCors(req, Response.json({ ok: false, error: "too many attempts, try again later" }, { status: 429 }));
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const body = await req.json() as any;
|
|
251
|
+
const result = login(body.username, body.password);
|
|
252
|
+
if (result.ok) logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
|
|
253
|
+
else logAudit(null, body.username, "login_failed", "user", null, "invalid credentials");
|
|
254
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
|
|
255
|
+
} catch (e: any) {
|
|
256
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (url.pathname === "/api/auth/me" && req.method === "GET") {
|
|
261
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
262
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
263
|
+
const resolved = resolveToken(token);
|
|
264
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
265
|
+
const networks = getUserNetworks(resolved.user.user_id);
|
|
266
|
+
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (url.pathname === "/api/auth/me" && req.method === "PUT") {
|
|
270
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
271
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
272
|
+
const resolved = resolveToken(token);
|
|
273
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
274
|
+
try {
|
|
275
|
+
const body = await req.json() as any;
|
|
276
|
+
const updates: string[] = [];
|
|
277
|
+
const params: any[] = [];
|
|
278
|
+
if (body.display_name) { updates.push(`display_name = ?${params.length + 1}`); params.push(body.display_name); }
|
|
279
|
+
if (body.email) { updates.push(`email = ?${params.length + 1}`); params.push(body.email); }
|
|
280
|
+
if (updates.length > 0) {
|
|
281
|
+
updates.push(`updated_at = datetime('now')`);
|
|
282
|
+
params.push(resolved.user.user_id);
|
|
283
|
+
db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
|
|
284
|
+
}
|
|
285
|
+
// Re-fetch
|
|
286
|
+
const user = db.query<any, [string]>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1").get(resolved.user.user_id);
|
|
287
|
+
return withCors(req, Response.json({ ok: true, user }));
|
|
288
|
+
} catch (e: any) {
|
|
289
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (url.pathname === "/api/auth/password" && req.method === "POST") {
|
|
294
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
295
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
296
|
+
const resolved = resolveToken(token);
|
|
297
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
298
|
+
try {
|
|
299
|
+
const body = await req.json() as any;
|
|
300
|
+
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password);
|
|
301
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
|
|
302
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
303
|
+
} catch (e: any) {
|
|
304
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── V3: Network management ──
|
|
309
|
+
if (url.pathname === "/api/networks" && req.method === "GET") {
|
|
310
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
311
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
312
|
+
const resolved = resolveToken(token);
|
|
313
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
314
|
+
const networks = getUserNetworks(resolved.user.user_id);
|
|
315
|
+
return withCors(req, Response.json({ ok: true, networks }));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (url.pathname === "/api/networks" && req.method === "POST") {
|
|
319
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
320
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
321
|
+
const resolved = resolveToken(token);
|
|
322
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
323
|
+
try {
|
|
324
|
+
const body = await req.json() as any;
|
|
325
|
+
const result = createNetwork(resolved.user.user_id, body.name, body.description);
|
|
326
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
327
|
+
} catch (e: any) {
|
|
328
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── V3: Admin APIs (require auth) ──
|
|
333
|
+
if (url.pathname === "/api/users" && req.method === "GET") {
|
|
334
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
335
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
336
|
+
const resolved = resolveToken(token);
|
|
337
|
+
if (!resolved || resolved.user.role !== "admin") {
|
|
338
|
+
return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
|
|
339
|
+
}
|
|
340
|
+
const users = db.query("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at").all();
|
|
341
|
+
return withCors(req, Response.json({ ok: true, users }));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
|
|
345
|
+
if (netDetailMatch && req.method === "GET") {
|
|
346
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
347
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
348
|
+
const resolved = resolveToken(token);
|
|
349
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
350
|
+
const networkId = netDetailMatch[1];
|
|
351
|
+
const network = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
|
|
352
|
+
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
353
|
+
// Ownership check: only owner or admin can view
|
|
354
|
+
if (network.owner_id !== resolved.user.user_id && resolved.user.role !== "admin") {
|
|
355
|
+
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
356
|
+
}
|
|
357
|
+
// Get network stats
|
|
358
|
+
const nodeCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(networkId);
|
|
359
|
+
const sessionCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1").get(networkId);
|
|
360
|
+
const taskStats = db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(networkId);
|
|
361
|
+
return withCors(req, Response.json({
|
|
362
|
+
ok: true, network,
|
|
363
|
+
stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
|
|
138
367
|
// ── REST: health (public, no auth) ──
|
|
139
368
|
if (url.pathname === "/health") {
|
|
140
369
|
const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();
|
|
@@ -159,7 +388,11 @@ Bun.serve({
|
|
|
159
388
|
if (url.pathname === "/api/status") {
|
|
160
389
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
161
390
|
db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
|
|
162
|
-
const
|
|
391
|
+
const netFilter = url.searchParams.get("network_id");
|
|
392
|
+
const sql = netFilter
|
|
393
|
+
? "SELECT * FROM sessions WHERE network_id = ?1 ORDER BY updated_at DESC"
|
|
394
|
+
: "SELECT * FROM sessions ORDER BY updated_at DESC";
|
|
395
|
+
const sessions = netFilter ? db.query(sql).all(netFilter) : db.query(sql).all();
|
|
163
396
|
return withCors(req, Response.json({ ok: true, sessions }));
|
|
164
397
|
}
|
|
165
398
|
|
|
@@ -281,12 +514,77 @@ Bun.serve({
|
|
|
281
514
|
return withCors(req, Response.json({ ok: true, messages: rows }));
|
|
282
515
|
}
|
|
283
516
|
|
|
517
|
+
// ── REST: stats summary ──
|
|
518
|
+
if (url.pathname === "/api/stats") {
|
|
519
|
+
const n = url.searchParams.get("network_id");
|
|
520
|
+
// Parameterized queries to prevent SQL injection
|
|
521
|
+
const taskStats = n
|
|
522
|
+
? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(n)
|
|
523
|
+
: db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
|
|
524
|
+
const sessionStats = n
|
|
525
|
+
? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM sessions WHERE network_id = ?1 GROUP BY status").all(n)
|
|
526
|
+
: db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
|
|
527
|
+
const totalTasks = n
|
|
528
|
+
? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM tasks WHERE network_id = ?1").get(n)
|
|
529
|
+
: db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
|
|
530
|
+
const totalNodes = n
|
|
531
|
+
? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(n)
|
|
532
|
+
: db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
|
|
533
|
+
const recentTasks = n
|
|
534
|
+
? db.query<any, [string]>("SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE network_id = ?1 ORDER BY created_at DESC LIMIT 5").all(n)
|
|
535
|
+
: db.query<any, []>("SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5").all();
|
|
536
|
+
return withCors(req, Response.json({
|
|
537
|
+
ok: true,
|
|
538
|
+
network_id: n || null,
|
|
539
|
+
tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
|
|
540
|
+
sessions: { by_status: sessionStats },
|
|
541
|
+
nodes: { total: totalNodes?.cnt || 0 },
|
|
542
|
+
recent_tasks: recentTasks,
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── REST: audit log (V3) ──
|
|
547
|
+
if (url.pathname === "/api/audit-log") {
|
|
548
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
549
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
550
|
+
const resolved = resolveToken(token);
|
|
551
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
552
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
553
|
+
const action = url.searchParams.get("action");
|
|
554
|
+
const userId = url.searchParams.get("user_id");
|
|
555
|
+
let sql = "SELECT * FROM audit_log WHERE 1=1";
|
|
556
|
+
const params: any[] = [];
|
|
557
|
+
// Non-admin can only see own logs
|
|
558
|
+
if (resolved.user.role !== "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(resolved.user.user_id); }
|
|
559
|
+
if (action) { sql += ` AND action = ?${params.length + 1}`; params.push(action); }
|
|
560
|
+
if (userId && resolved.user.role === "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(userId); }
|
|
561
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
562
|
+
params.push(limit);
|
|
563
|
+
const logs = db.query(sql).all(...params);
|
|
564
|
+
return withCors(req, Response.json({ ok: true, logs, count: logs.length }));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── REST: task events (V2 Sprint 2) ──
|
|
568
|
+
if (url.pathname === "/api/task_events") {
|
|
569
|
+
const taskId = url.searchParams.get("task_id");
|
|
570
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
|
|
571
|
+
let sql = "SELECT * FROM task_events";
|
|
572
|
+
const params: any[] = [];
|
|
573
|
+
if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
|
|
574
|
+
sql += " ORDER BY created_at DESC LIMIT ?";
|
|
575
|
+
params.push(limit);
|
|
576
|
+
const rows = db.query(sql).all(...params);
|
|
577
|
+
return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
|
|
578
|
+
}
|
|
579
|
+
|
|
284
580
|
// ── REST: nodes table (V2 Sprint 2) ──
|
|
285
581
|
if (url.pathname === "/api/nodes") {
|
|
286
582
|
const nodeId = url.searchParams.get("node_id");
|
|
287
583
|
const alias = url.searchParams.get("alias");
|
|
584
|
+
const netFilter = url.searchParams.get("network_id");
|
|
288
585
|
let sql = "SELECT * FROM nodes WHERE 1=1";
|
|
289
586
|
const params: any[] = [];
|
|
587
|
+
if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
|
|
290
588
|
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
291
589
|
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
292
590
|
sql += " ORDER BY updated_at DESC";
|
|
@@ -300,10 +598,12 @@ Bun.serve({
|
|
|
300
598
|
const status = url.searchParams.get("status");
|
|
301
599
|
const toName = url.searchParams.get("to_name");
|
|
302
600
|
const fromName = url.searchParams.get("from_name");
|
|
601
|
+
const netFilter = url.searchParams.get("network_id");
|
|
303
602
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
304
603
|
|
|
305
604
|
let sql = "SELECT * FROM tasks WHERE 1=1";
|
|
306
605
|
const params: any[] = [];
|
|
606
|
+
if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
|
|
307
607
|
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
308
608
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
309
609
|
if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
|
|
@@ -312,7 +612,10 @@ Bun.serve({
|
|
|
312
612
|
params.push(limit);
|
|
313
613
|
|
|
314
614
|
const rows = db.query(sql).all(...params);
|
|
315
|
-
|
|
615
|
+
const stats = netFilter
|
|
616
|
+
? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(netFilter)
|
|
617
|
+
: db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
|
|
618
|
+
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|
|
316
619
|
}
|
|
317
620
|
|
|
318
621
|
// ── REST: recent completions ──
|
package/src/tools.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
|
-
import { db, uuidv4 } from "./db.js";
|
|
3
|
+
import { db, uuidv4, logTaskEvent } from "./db.js";
|
|
4
4
|
import { pushEvent, pushBroadcast } from "./push.js";
|
|
5
5
|
|
|
6
6
|
function ts(): string {
|
|
7
7
|
return new Date().toTimeString().slice(0, 8);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export function registerTools(server: McpServer, clientIP?: string) {
|
|
10
|
+
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null) {
|
|
11
|
+
// If enforceNetworkId is set, override any client-supplied network_id
|
|
12
|
+
const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
|
|
11
13
|
// ═══════════════════════════════════════════
|
|
12
14
|
// Child Agent Tools (4)
|
|
13
15
|
// ═══════════════════════════════════════════
|
|
@@ -35,17 +37,25 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
35
37
|
config_path: z.string().max(1000).optional().describe("Config file path"),
|
|
36
38
|
channels: z.string().max(2000).optional().describe("JSON array of channels"),
|
|
37
39
|
model: z.string().max(200).optional().describe("AI model name"),
|
|
40
|
+
node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
|
|
41
|
+
network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
|
|
38
42
|
},
|
|
39
|
-
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl }) => {
|
|
40
|
-
|
|
43
|
+
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId }) => {
|
|
44
|
+
const effectiveNetId = getNetworkId(netId);
|
|
45
|
+
console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
|
|
41
46
|
const trimmedOutput = output?.slice(0, 4000);
|
|
42
47
|
|
|
43
48
|
try {
|
|
44
49
|
db.run("BEGIN IMMEDIATE");
|
|
45
|
-
|
|
50
|
+
// Only delete same-alias sessions within the same network (prevent cross-network alias conflict)
|
|
51
|
+
if (effectiveNetId) {
|
|
52
|
+
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, effectiveNetId]);
|
|
53
|
+
} else {
|
|
54
|
+
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
|
|
55
|
+
}
|
|
46
56
|
db.run(
|
|
47
|
-
`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, last_seen_at, updated_at)
|
|
48
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now'), datetime('now'))
|
|
57
|
+
`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
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, datetime('now'), datetime('now'))
|
|
49
59
|
ON CONFLICT(resume_id) DO UPDATE SET
|
|
50
60
|
alias = COALESCE(?2, sessions.alias),
|
|
51
61
|
tmux_name = COALESCE(?3, sessions.tmux_name),
|
|
@@ -64,9 +74,10 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
64
74
|
session_id = COALESCE(?16, sessions.session_id),
|
|
65
75
|
config_path = COALESCE(?17, sessions.config_path),
|
|
66
76
|
channels = COALESCE(?18, sessions.channels),
|
|
77
|
+
network_id = COALESCE(?19, sessions.network_id),
|
|
67
78
|
last_seen_at = datetime('now'),
|
|
68
79
|
updated_at = datetime('now')`,
|
|
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]
|
|
80
|
+
[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]
|
|
70
81
|
);
|
|
71
82
|
db.run("COMMIT");
|
|
72
83
|
} catch (e) {
|
|
@@ -77,11 +88,18 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
77
88
|
// V2: sync tasks table — report_status(working) → tasks.running
|
|
78
89
|
if (status === "working" && task) {
|
|
79
90
|
try {
|
|
80
|
-
db.run(
|
|
91
|
+
const runResult = db.run(
|
|
81
92
|
`UPDATE tasks SET status = 'running', started_at = datetime('now')
|
|
82
93
|
WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
|
|
83
94
|
[alias, task]
|
|
84
95
|
);
|
|
96
|
+
if (runResult.changes > 0) {
|
|
97
|
+
// Find task_id for logging
|
|
98
|
+
const t = db.query<{ task_id: string }, [string, string]>(
|
|
99
|
+
"SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1"
|
|
100
|
+
).get(alias, task);
|
|
101
|
+
if (t) logTaskEvent(t.task_id, null, "running", alias);
|
|
102
|
+
}
|
|
85
103
|
} catch {}
|
|
86
104
|
}
|
|
87
105
|
|
|
@@ -103,7 +121,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
103
121
|
server = COALESCE(?8, nodes.server),
|
|
104
122
|
hostname = COALESCE(?9, nodes.hostname),
|
|
105
123
|
updated_at = datetime('now')`,
|
|
106
|
-
[node_id, alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
|
|
124
|
+
[node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
|
|
107
125
|
);
|
|
108
126
|
} catch {}
|
|
109
127
|
}
|
|
@@ -179,6 +197,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
179
197
|
}
|
|
180
198
|
|
|
181
199
|
db.run("COMMIT");
|
|
200
|
+
// Log event after commit
|
|
201
|
+
const updatedTaskId = taskUpdate.changes > 0 ? task : (db.query<{ task_id: string }, [string]>(
|
|
202
|
+
"SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1"
|
|
203
|
+
).get(alias)?.task_id);
|
|
204
|
+
if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
|
|
182
205
|
} catch (e) {
|
|
183
206
|
try { db.run("ROLLBACK"); } catch {}
|
|
184
207
|
throw e;
|
|
@@ -233,10 +256,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
233
256
|
}
|
|
234
257
|
// V2: sync tasks table — ack_inbox means delivered→acked
|
|
235
258
|
try {
|
|
236
|
-
db.run(
|
|
259
|
+
const ackResult = db.run(
|
|
237
260
|
`UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
|
|
238
261
|
[message_id]
|
|
239
262
|
);
|
|
263
|
+
if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
|
|
240
264
|
} catch {}
|
|
241
265
|
return {
|
|
242
266
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
|
|
@@ -254,9 +278,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
254
278
|
{
|
|
255
279
|
filter_status: z.string().max(50).optional(),
|
|
256
280
|
filter_server: z.string().max(200).optional(),
|
|
281
|
+
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
257
282
|
},
|
|
258
|
-
async ({ filter_status, filter_server }) => {
|
|
259
|
-
|
|
283
|
+
async ({ filter_status, filter_server, network_id: netId }) => {
|
|
284
|
+
const effectiveNetId = getNetworkId(netId);
|
|
285
|
+
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${effectiveNetId ? " net=" + effectiveNetId.slice(0, 12) : ""}`);
|
|
260
286
|
|
|
261
287
|
const sessions = db.transaction(() => {
|
|
262
288
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
@@ -264,6 +290,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
264
290
|
|
|
265
291
|
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
266
292
|
const params: any[] = [];
|
|
293
|
+
if (effectiveNetId) { sql += " AND network_id = ?"; params.push(effectiveNetId); }
|
|
267
294
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
268
295
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
269
296
|
sql += " ORDER BY updated_at DESC";
|
|
@@ -319,24 +346,41 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
319
346
|
priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
|
|
320
347
|
context: z.string().max(10000).optional(),
|
|
321
348
|
from_session: z.string().max(200).optional().default("hub"),
|
|
349
|
+
ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
|
|
350
|
+
network_id: z.string().max(200).optional().describe("Network scope"),
|
|
322
351
|
},
|
|
323
|
-
async ({ alias, task, priority, context, from_session }) => {
|
|
352
|
+
async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
|
|
353
|
+
const effectiveNetId = getNetworkId(netId);
|
|
354
|
+
|
|
355
|
+
// License check
|
|
356
|
+
const license = db.query<any, []>("SELECT type, expires_at FROM licenses ORDER BY created_at LIMIT 1").get();
|
|
357
|
+
if (license?.expires_at) {
|
|
358
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
359
|
+
if (license.expires_at < now) {
|
|
360
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
361
|
+
ok: false, error: "license_expired",
|
|
362
|
+
message: "Trial expired. Activate a license: anet activate <key>",
|
|
363
|
+
}) }] };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
324
367
|
console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
|
|
325
368
|
const id = uuidv4();
|
|
326
369
|
// 事务:inbox + tasks 双写
|
|
327
370
|
try {
|
|
328
371
|
db.run("BEGIN IMMEDIATE");
|
|
329
372
|
db.run(
|
|
330
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response)
|
|
331
|
-
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply')`,
|
|
332
|
-
[id, alias, priority, task, context ?? null, from_session]
|
|
373
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
|
|
374
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7)`,
|
|
375
|
+
[id, alias, priority, task, context ?? null, from_session, effectiveNetId]
|
|
333
376
|
);
|
|
334
377
|
db.run(
|
|
335
|
-
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at)
|
|
336
|
-
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now',
|
|
337
|
-
[id, from_session, alias, priority, task]
|
|
378
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
|
|
379
|
+
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
|
|
380
|
+
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId]
|
|
338
381
|
);
|
|
339
382
|
db.run("COMMIT");
|
|
383
|
+
logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
|
|
340
384
|
} catch (e) {
|
|
341
385
|
try { db.run("ROLLBACK"); } catch {}
|
|
342
386
|
throw e;
|
|
@@ -415,6 +459,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
415
459
|
async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
|
|
416
460
|
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
|
|
417
461
|
const id = uuidv4();
|
|
462
|
+
let replyLogged = false;
|
|
418
463
|
try {
|
|
419
464
|
db.run("BEGIN IMMEDIATE");
|
|
420
465
|
db.run(
|
|
@@ -432,6 +477,8 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
432
477
|
);
|
|
433
478
|
if (result.changes === 0) {
|
|
434
479
|
console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
|
|
480
|
+
} else {
|
|
481
|
+
replyLogged = true;
|
|
435
482
|
}
|
|
436
483
|
}
|
|
437
484
|
db.run("COMMIT");
|
|
@@ -440,6 +487,9 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
440
487
|
throw e;
|
|
441
488
|
}
|
|
442
489
|
|
|
490
|
+
// Log event after commit (outside transaction)
|
|
491
|
+
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
492
|
+
|
|
443
493
|
const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
|
|
444
494
|
pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
|
|
445
495
|
|
|
@@ -466,6 +516,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
466
516
|
`UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
|
|
467
517
|
[task_id]
|
|
468
518
|
);
|
|
519
|
+
if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
|
|
469
520
|
return {
|
|
470
521
|
content: [{
|
|
471
522
|
type: "text" as const,
|
|
@@ -475,6 +526,173 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
475
526
|
}
|
|
476
527
|
);
|
|
477
528
|
|
|
529
|
+
// ── V2: retry_task (重新投递失败/过期任务) ──
|
|
530
|
+
server.tool(
|
|
531
|
+
"retry_task",
|
|
532
|
+
"Retry a failed, expired, or cancelled task. Resets status to delivered and re-queues in inbox.",
|
|
533
|
+
{
|
|
534
|
+
task_id: z.string().min(1).max(200).describe("Task ID to retry"),
|
|
535
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
536
|
+
},
|
|
537
|
+
async ({ task_id, from_session }) => {
|
|
538
|
+
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
539
|
+
// Find the original task
|
|
540
|
+
const task = db.query<any, [string]>(
|
|
541
|
+
"SELECT * FROM tasks WHERE task_id = ?1"
|
|
542
|
+
).get(task_id);
|
|
543
|
+
if (!task) {
|
|
544
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
545
|
+
}
|
|
546
|
+
if (!["failed", "expired", "cancelled"].includes(task.status)) {
|
|
547
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
db.run("BEGIN IMMEDIATE");
|
|
551
|
+
// Reset task status
|
|
552
|
+
db.run(
|
|
553
|
+
`UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
|
|
554
|
+
WHERE task_id = ?1`,
|
|
555
|
+
[task_id]
|
|
556
|
+
);
|
|
557
|
+
// Re-queue in inbox with new ID (original ID may already exist)
|
|
558
|
+
const retryInboxId = uuidv4();
|
|
559
|
+
db.run(
|
|
560
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response)
|
|
561
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')`,
|
|
562
|
+
[retryInboxId, task.to_name, task.priority, task.content, from_session]
|
|
563
|
+
);
|
|
564
|
+
db.run("COMMIT");
|
|
565
|
+
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
566
|
+
} catch (e) {
|
|
567
|
+
try { db.run("ROLLBACK"); } catch {}
|
|
568
|
+
throw e;
|
|
569
|
+
}
|
|
570
|
+
// SSE push
|
|
571
|
+
pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
572
|
+
return {
|
|
573
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
// ── V2: get_task (查询任务状态) ──
|
|
579
|
+
server.tool(
|
|
580
|
+
"get_task",
|
|
581
|
+
"Get task details by task_id. Returns status, result, timestamps.",
|
|
582
|
+
{
|
|
583
|
+
task_id: z.string().min(1).max(200).describe("Task ID to query"),
|
|
584
|
+
},
|
|
585
|
+
async ({ task_id }) => {
|
|
586
|
+
const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
|
|
587
|
+
return {
|
|
588
|
+
content: [{
|
|
589
|
+
type: "text" as const,
|
|
590
|
+
text: JSON.stringify(task ? { ok: true, task } : { ok: false, error: "task not found" }),
|
|
591
|
+
}],
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
// ── V2: list_tasks (查询任务列表) ──
|
|
597
|
+
server.tool(
|
|
598
|
+
"list_tasks",
|
|
599
|
+
"List tasks with filters. Agents can query their own pending/running tasks.",
|
|
600
|
+
{
|
|
601
|
+
alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
|
|
602
|
+
status: z.string().max(50).optional().describe("Filter by status"),
|
|
603
|
+
from_name: z.string().max(200).optional().describe("Filter by sender"),
|
|
604
|
+
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
605
|
+
limit: z.number().min(1).max(100).optional().default(20),
|
|
606
|
+
},
|
|
607
|
+
async ({ alias, status, from_name, network_id: netId, limit }) => {
|
|
608
|
+
const effectiveNetId = getNetworkId(netId);
|
|
609
|
+
let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
|
|
610
|
+
const params: any[] = [];
|
|
611
|
+
if (effectiveNetId) { sql += ` AND network_id = ?${params.length + 1}`; params.push(effectiveNetId); }
|
|
612
|
+
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
613
|
+
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
614
|
+
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
615
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
616
|
+
params.push(limit);
|
|
617
|
+
const tasks = db.query(sql).all(...params);
|
|
618
|
+
|
|
619
|
+
// Stats
|
|
620
|
+
const stats = db.query<any, []>(
|
|
621
|
+
"SELECT status, COUNT(*) as count FROM tasks GROUP BY status"
|
|
622
|
+
).all();
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
content: [{
|
|
626
|
+
type: "text" as const,
|
|
627
|
+
text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
|
|
628
|
+
}],
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
// ── V2: cancel_task (取消任务) ──
|
|
634
|
+
server.tool(
|
|
635
|
+
"cancel_task",
|
|
636
|
+
"Cancel a pending task. Works on delivered/acked/running tasks.",
|
|
637
|
+
{
|
|
638
|
+
task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
|
|
639
|
+
reason: z.string().max(1000).optional().describe("Cancellation reason"),
|
|
640
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
641
|
+
},
|
|
642
|
+
async ({ task_id, reason, from_session }) => {
|
|
643
|
+
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
644
|
+
const result = db.run(
|
|
645
|
+
`UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
|
|
646
|
+
WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
|
|
647
|
+
[reason || "cancelled by " + from_session, task_id]
|
|
648
|
+
);
|
|
649
|
+
// Also ack the inbox entry to prevent agent from picking it up
|
|
650
|
+
if (result.changes > 0) {
|
|
651
|
+
db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
|
|
652
|
+
logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
// ── V2: reassign_task (转移任务到另一个 agent) ──
|
|
661
|
+
server.tool(
|
|
662
|
+
"reassign_task",
|
|
663
|
+
"Reassign a task to a different agent. Works on any non-terminal task (delivered/acked/running).",
|
|
664
|
+
{
|
|
665
|
+
task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
|
|
666
|
+
new_alias: z.string().min(1).max(200).describe("Target agent alias"),
|
|
667
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
668
|
+
},
|
|
669
|
+
async ({ task_id, new_alias, from_session }) => {
|
|
670
|
+
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
671
|
+
const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
|
|
672
|
+
if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
673
|
+
if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
|
|
674
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
|
|
675
|
+
}
|
|
676
|
+
const oldAlias = task.to_name;
|
|
677
|
+
try {
|
|
678
|
+
db.run("BEGIN IMMEDIATE");
|
|
679
|
+
// Ack old inbox to prevent original agent from picking it up
|
|
680
|
+
db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
|
|
681
|
+
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
|
+
const newInboxId = uuidv4();
|
|
683
|
+
db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
|
|
684
|
+
[newInboxId, new_alias, task.priority, task.content, from_session]);
|
|
685
|
+
db.run("COMMIT");
|
|
686
|
+
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
|
|
687
|
+
} catch (e) {
|
|
688
|
+
try { db.run("ROLLBACK"); } catch {}
|
|
689
|
+
throw e;
|
|
690
|
+
}
|
|
691
|
+
pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
692
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
|
|
693
|
+
}
|
|
694
|
+
);
|
|
695
|
+
|
|
478
696
|
server.tool(
|
|
479
697
|
"broadcast",
|
|
480
698
|
"Send a message to multiple sessions.",
|
|
@@ -482,11 +700,13 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
482
700
|
message: z.string().min(1).max(10000),
|
|
483
701
|
filter_server: z.string().max(200).optional(),
|
|
484
702
|
filter_status: z.string().max(50).optional(),
|
|
703
|
+
network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
|
|
485
704
|
},
|
|
486
|
-
async ({ message, filter_server, filter_status }) => {
|
|
487
|
-
console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${
|
|
705
|
+
async ({ message, filter_server, filter_status, network_id: netId }) => {
|
|
706
|
+
console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${netId ? " [net=" + netId.slice(0, 12) + "]" : ""}`);
|
|
488
707
|
let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
|
|
489
708
|
const params: any[] = [];
|
|
709
|
+
if (netId) { sql += " AND network_id = ?"; params.push(netId); }
|
|
490
710
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
491
711
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
492
712
|
|