@sleep2agi/commhub-server 0.5.0-preview.1 → 0.5.0-preview.10
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 +99 -0
- package/package.json +1 -1
- package/src/auth.ts +134 -0
- package/src/db.ts +119 -0
- package/src/index.ts +138 -2
- package/src/tools.ts +218 -7
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
设置 `COMMHUB_AUTH_TOKEN` 后, 所有端点需要 Bearer token:
|
|
93
|
+
- Header: `Authorization: Bearer <token>`
|
|
94
|
+
- 或 Query: `?token=<token>`
|
|
95
|
+
- `/health` 不需要 auth
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
package/package.json
CHANGED
package/src/auth.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -115,7 +115,126 @@ db.exec(`
|
|
|
115
115
|
CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(created_at);
|
|
116
116
|
`);
|
|
117
117
|
|
|
118
|
+
// nodes table (V2 Sprint 2) — persistent node identity, separate from runtime sessions
|
|
119
|
+
db.exec(`
|
|
120
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
121
|
+
node_id TEXT PRIMARY KEY,
|
|
122
|
+
node_name TEXT NOT NULL,
|
|
123
|
+
alias TEXT,
|
|
124
|
+
runtime TEXT,
|
|
125
|
+
model TEXT,
|
|
126
|
+
config_path TEXT,
|
|
127
|
+
channels TEXT,
|
|
128
|
+
server TEXT,
|
|
129
|
+
hostname TEXT,
|
|
130
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
131
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(node_name);
|
|
135
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_alias ON nodes(alias);
|
|
136
|
+
`);
|
|
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: add network_id to existing tables ──
|
|
205
|
+
for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
|
|
206
|
+
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|
|
207
|
+
}
|
|
208
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
|
|
209
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
|
|
210
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
|
|
211
|
+
|
|
118
212
|
// Helpers
|
|
119
213
|
export function uuidv4(): string {
|
|
120
214
|
return crypto.randomUUID();
|
|
121
215
|
}
|
|
216
|
+
|
|
217
|
+
export function generateId(prefix: string): string {
|
|
218
|
+
return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function hashPassword(password: string): string {
|
|
222
|
+
return new Bun.CryptoHasher("sha256").update(`anet:${password}`).digest("hex");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function hashToken(token: string): string {
|
|
226
|
+
return new Bun.CryptoHasher("sha256").update(token).digest("hex");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function generateToken(): string {
|
|
230
|
+
return `atok_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
|
|
234
|
+
try {
|
|
235
|
+
db.run(
|
|
236
|
+
"INSERT INTO task_events (task_id, from_status, to_status, actor, detail) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
237
|
+
[taskId, fromStatus, toStatus, actor, detail ?? null]
|
|
238
|
+
);
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
|
-
import { db } from "./db.js";
|
|
5
|
+
import { db, logTaskEvent } from "./db.js";
|
|
6
6
|
import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
|
|
7
|
+
import { register, login, resolveToken, getUserNetworks, createNetwork, 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;
|
|
@@ -84,6 +85,11 @@ setInterval(() => {
|
|
|
84
85
|
);
|
|
85
86
|
if (result.changes > 0) {
|
|
86
87
|
console.log(`[patrol] expired ${result.changes} stale task(s)`);
|
|
88
|
+
// Log events for expired tasks
|
|
89
|
+
const expired = db.query<{ task_id: string }, []>(
|
|
90
|
+
"SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
|
|
91
|
+
).all();
|
|
92
|
+
for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
|
|
87
93
|
}
|
|
88
94
|
} catch {}
|
|
89
95
|
}, 5 * 60 * 1000);
|
|
@@ -135,6 +141,91 @@ Bun.serve({
|
|
|
135
141
|
return createSSEStream(sessionName);
|
|
136
142
|
}
|
|
137
143
|
|
|
144
|
+
// ── V3: Auth endpoints (public) ──
|
|
145
|
+
if (url.pathname === "/api/auth/register" && req.method === "POST") {
|
|
146
|
+
try {
|
|
147
|
+
const body = await req.json() as any;
|
|
148
|
+
const result = register(body.username, body.password, body.email, body.display_name);
|
|
149
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
150
|
+
} catch (e: any) {
|
|
151
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (url.pathname === "/api/auth/login" && req.method === "POST") {
|
|
156
|
+
try {
|
|
157
|
+
const body = await req.json() as any;
|
|
158
|
+
const result = login(body.username, body.password);
|
|
159
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
|
|
160
|
+
} catch (e: any) {
|
|
161
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (url.pathname === "/api/auth/me" && req.method === "GET") {
|
|
166
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
167
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
168
|
+
const resolved = resolveToken(token);
|
|
169
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
170
|
+
const networks = getUserNetworks(resolved.user.user_id);
|
|
171
|
+
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── V3: Network management ──
|
|
175
|
+
if (url.pathname === "/api/networks" && req.method === "GET") {
|
|
176
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
177
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
178
|
+
const resolved = resolveToken(token);
|
|
179
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
180
|
+
const networks = getUserNetworks(resolved.user.user_id);
|
|
181
|
+
return withCors(req, Response.json({ ok: true, networks }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (url.pathname === "/api/networks" && req.method === "POST") {
|
|
185
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
186
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
187
|
+
const resolved = resolveToken(token);
|
|
188
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
189
|
+
try {
|
|
190
|
+
const body = await req.json() as any;
|
|
191
|
+
const result = createNetwork(resolved.user.user_id, body.name, body.description);
|
|
192
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
193
|
+
} catch (e: any) {
|
|
194
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── V3: Admin APIs (require auth) ──
|
|
199
|
+
if (url.pathname === "/api/users" && req.method === "GET") {
|
|
200
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
201
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
202
|
+
const resolved = resolveToken(token);
|
|
203
|
+
if (!resolved || resolved.user.role !== "admin") {
|
|
204
|
+
return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
|
|
205
|
+
}
|
|
206
|
+
const users = db.query("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at").all();
|
|
207
|
+
return withCors(req, Response.json({ ok: true, users }));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
|
|
211
|
+
if (netDetailMatch && req.method === "GET") {
|
|
212
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
213
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
214
|
+
const resolved = resolveToken(token);
|
|
215
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
216
|
+
const networkId = netDetailMatch[1];
|
|
217
|
+
const network = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
|
|
218
|
+
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
219
|
+
// Get network stats
|
|
220
|
+
const nodeCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(networkId);
|
|
221
|
+
const sessionCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1").get(networkId);
|
|
222
|
+
const taskStats = db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(networkId);
|
|
223
|
+
return withCors(req, Response.json({
|
|
224
|
+
ok: true, network,
|
|
225
|
+
stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
|
|
138
229
|
// ── REST: health (public, no auth) ──
|
|
139
230
|
if (url.pathname === "/health") {
|
|
140
231
|
const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();
|
|
@@ -281,6 +372,50 @@ Bun.serve({
|
|
|
281
372
|
return withCors(req, Response.json({ ok: true, messages: rows }));
|
|
282
373
|
}
|
|
283
374
|
|
|
375
|
+
// ── REST: stats summary ──
|
|
376
|
+
if (url.pathname === "/api/stats") {
|
|
377
|
+
const taskStats = db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
|
|
378
|
+
const sessionStats = db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
|
|
379
|
+
const totalTasks = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
|
|
380
|
+
const totalNodes = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
|
|
381
|
+
const recentTasks = db.query<any, []>(
|
|
382
|
+
"SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5"
|
|
383
|
+
).all();
|
|
384
|
+
return withCors(req, Response.json({
|
|
385
|
+
ok: true,
|
|
386
|
+
tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
|
|
387
|
+
sessions: { by_status: sessionStats },
|
|
388
|
+
nodes: { total: totalNodes?.cnt || 0 },
|
|
389
|
+
recent_tasks: recentTasks,
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── REST: task events (V2 Sprint 2) ──
|
|
394
|
+
if (url.pathname === "/api/task_events") {
|
|
395
|
+
const taskId = url.searchParams.get("task_id");
|
|
396
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
|
|
397
|
+
let sql = "SELECT * FROM task_events";
|
|
398
|
+
const params: any[] = [];
|
|
399
|
+
if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
|
|
400
|
+
sql += " ORDER BY created_at DESC LIMIT ?";
|
|
401
|
+
params.push(limit);
|
|
402
|
+
const rows = db.query(sql).all(...params);
|
|
403
|
+
return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── REST: nodes table (V2 Sprint 2) ──
|
|
407
|
+
if (url.pathname === "/api/nodes") {
|
|
408
|
+
const nodeId = url.searchParams.get("node_id");
|
|
409
|
+
const alias = url.searchParams.get("alias");
|
|
410
|
+
let sql = "SELECT * FROM nodes WHERE 1=1";
|
|
411
|
+
const params: any[] = [];
|
|
412
|
+
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
413
|
+
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
414
|
+
sql += " ORDER BY updated_at DESC";
|
|
415
|
+
const rows = db.query(sql).all(...params);
|
|
416
|
+
return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
|
|
417
|
+
}
|
|
418
|
+
|
|
284
419
|
// ── REST: tasks table (V2) ──
|
|
285
420
|
if (url.pathname === "/api/tasks") {
|
|
286
421
|
const taskId = url.searchParams.get("task_id");
|
|
@@ -299,7 +434,8 @@ Bun.serve({
|
|
|
299
434
|
params.push(limit);
|
|
300
435
|
|
|
301
436
|
const rows = db.query(sql).all(...params);
|
|
302
|
-
|
|
437
|
+
const stats = db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
|
|
438
|
+
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|
|
303
439
|
}
|
|
304
440
|
|
|
305
441
|
// ── REST: recent completions ──
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
|
-
import { db, uuidv4 } from "./db.js";
|
|
3
|
+
import { db, uuidv4, logTaskEvent } from "./db.js";
|
|
4
4
|
import { pushEvent, pushBroadcast } from "./push.js";
|
|
5
5
|
|
|
6
6
|
function ts(): string {
|
|
@@ -34,8 +34,10 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
34
34
|
session_id: z.string().max(200).optional().describe("Runtime session/thread ID"),
|
|
35
35
|
config_path: z.string().max(1000).optional().describe("Config file path"),
|
|
36
36
|
channels: z.string().max(2000).optional().describe("JSON array of channels"),
|
|
37
|
+
model: z.string().max(200).optional().describe("AI model name"),
|
|
38
|
+
node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
|
|
37
39
|
},
|
|
38
|
-
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 }) => {
|
|
40
|
+
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 }) => {
|
|
39
41
|
console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}`);
|
|
40
42
|
const trimmedOutput = output?.slice(0, 4000);
|
|
41
43
|
|
|
@@ -76,11 +78,41 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
76
78
|
// V2: sync tasks table — report_status(working) → tasks.running
|
|
77
79
|
if (status === "working" && task) {
|
|
78
80
|
try {
|
|
79
|
-
db.run(
|
|
81
|
+
const runResult = db.run(
|
|
80
82
|
`UPDATE tasks SET status = 'running', started_at = datetime('now')
|
|
81
83
|
WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
|
|
82
84
|
[alias, task]
|
|
83
85
|
);
|
|
86
|
+
if (runResult.changes > 0) {
|
|
87
|
+
// Find task_id for logging
|
|
88
|
+
const t = db.query<{ task_id: string }, [string, string]>(
|
|
89
|
+
"SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2 AND status = 'running' ORDER BY started_at DESC LIMIT 1"
|
|
90
|
+
).get(alias, task);
|
|
91
|
+
if (t) logTaskEvent(t.task_id, null, "running", alias);
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// V2: upsert nodes table for persistent node identity
|
|
97
|
+
if (node_id) {
|
|
98
|
+
try {
|
|
99
|
+
// Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
|
|
100
|
+
const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
|
|
101
|
+
db.run(
|
|
102
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, updated_at)
|
|
103
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
|
|
104
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
105
|
+
node_name = COALESCE(?2, nodes.node_name),
|
|
106
|
+
alias = COALESCE(?3, nodes.alias),
|
|
107
|
+
runtime = COALESCE(?4, nodes.runtime),
|
|
108
|
+
model = COALESCE(?5, nodes.model),
|
|
109
|
+
config_path = COALESCE(?6, nodes.config_path),
|
|
110
|
+
channels = COALESCE(?7, nodes.channels),
|
|
111
|
+
server = COALESCE(?8, nodes.server),
|
|
112
|
+
hostname = COALESCE(?9, nodes.hostname),
|
|
113
|
+
updated_at = datetime('now')`,
|
|
114
|
+
[node_id, nn || alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
|
|
115
|
+
);
|
|
84
116
|
} catch {}
|
|
85
117
|
}
|
|
86
118
|
|
|
@@ -155,6 +187,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
155
187
|
}
|
|
156
188
|
|
|
157
189
|
db.run("COMMIT");
|
|
190
|
+
// Log event after commit
|
|
191
|
+
const updatedTaskId = taskUpdate.changes > 0 ? task : (db.query<{ task_id: string }, [string]>(
|
|
192
|
+
"SELECT task_id FROM tasks WHERE to_name = ?1 AND status = 'replied' ORDER BY completed_at DESC LIMIT 1"
|
|
193
|
+
).get(alias)?.task_id);
|
|
194
|
+
if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
|
|
158
195
|
} catch (e) {
|
|
159
196
|
try { db.run("ROLLBACK"); } catch {}
|
|
160
197
|
throw e;
|
|
@@ -209,10 +246,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
209
246
|
}
|
|
210
247
|
// V2: sync tasks table — ack_inbox means delivered→acked
|
|
211
248
|
try {
|
|
212
|
-
db.run(
|
|
249
|
+
const ackResult = db.run(
|
|
213
250
|
`UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
|
|
214
251
|
[message_id]
|
|
215
252
|
);
|
|
253
|
+
if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
|
|
216
254
|
} catch {}
|
|
217
255
|
return {
|
|
218
256
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
|
|
@@ -295,8 +333,9 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
295
333
|
priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
|
|
296
334
|
context: z.string().max(10000).optional(),
|
|
297
335
|
from_session: z.string().max(200).optional().default("hub"),
|
|
336
|
+
ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
|
|
298
337
|
},
|
|
299
|
-
async ({ alias, task, priority, context, from_session }) => {
|
|
338
|
+
async ({ alias, task, priority, context, from_session, ttl_seconds }) => {
|
|
300
339
|
console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
|
|
301
340
|
const id = uuidv4();
|
|
302
341
|
// 事务:inbox + tasks 双写
|
|
@@ -309,10 +348,11 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
309
348
|
);
|
|
310
349
|
db.run(
|
|
311
350
|
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at)
|
|
312
|
-
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now',
|
|
313
|
-
[id, from_session, alias, priority, task]
|
|
351
|
+
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6))`,
|
|
352
|
+
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`]
|
|
314
353
|
);
|
|
315
354
|
db.run("COMMIT");
|
|
355
|
+
logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
|
|
316
356
|
} catch (e) {
|
|
317
357
|
try { db.run("ROLLBACK"); } catch {}
|
|
318
358
|
throw e;
|
|
@@ -391,6 +431,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
391
431
|
async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
|
|
392
432
|
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
|
|
393
433
|
const id = uuidv4();
|
|
434
|
+
let replyLogged = false;
|
|
394
435
|
try {
|
|
395
436
|
db.run("BEGIN IMMEDIATE");
|
|
396
437
|
db.run(
|
|
@@ -408,6 +449,8 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
408
449
|
);
|
|
409
450
|
if (result.changes === 0) {
|
|
410
451
|
console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
|
|
452
|
+
} else {
|
|
453
|
+
replyLogged = true;
|
|
411
454
|
}
|
|
412
455
|
}
|
|
413
456
|
db.run("COMMIT");
|
|
@@ -416,6 +459,9 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
416
459
|
throw e;
|
|
417
460
|
}
|
|
418
461
|
|
|
462
|
+
// Log event after commit (outside transaction)
|
|
463
|
+
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
464
|
+
|
|
419
465
|
const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
|
|
420
466
|
pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
|
|
421
467
|
|
|
@@ -442,6 +488,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
442
488
|
`UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
|
|
443
489
|
[task_id]
|
|
444
490
|
);
|
|
491
|
+
if (result.changes > 0) logTaskEvent(task_id, "delivered", "acked", from_session);
|
|
445
492
|
return {
|
|
446
493
|
content: [{
|
|
447
494
|
type: "text" as const,
|
|
@@ -451,6 +498,170 @@ export function registerTools(server: McpServer, clientIP?: string) {
|
|
|
451
498
|
}
|
|
452
499
|
);
|
|
453
500
|
|
|
501
|
+
// ── V2: retry_task (重新投递失败/过期任务) ──
|
|
502
|
+
server.tool(
|
|
503
|
+
"retry_task",
|
|
504
|
+
"Retry a failed, expired, or cancelled task. Resets status to delivered and re-queues in inbox.",
|
|
505
|
+
{
|
|
506
|
+
task_id: z.string().min(1).max(200).describe("Task ID to retry"),
|
|
507
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
508
|
+
},
|
|
509
|
+
async ({ task_id, from_session }) => {
|
|
510
|
+
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
511
|
+
// Find the original task
|
|
512
|
+
const task = db.query<any, [string]>(
|
|
513
|
+
"SELECT * FROM tasks WHERE task_id = ?1"
|
|
514
|
+
).get(task_id);
|
|
515
|
+
if (!task) {
|
|
516
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
517
|
+
}
|
|
518
|
+
if (!["failed", "expired", "cancelled"].includes(task.status)) {
|
|
519
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
db.run("BEGIN IMMEDIATE");
|
|
523
|
+
// Reset task status
|
|
524
|
+
db.run(
|
|
525
|
+
`UPDATE tasks SET status = 'delivered', result = NULL, completed_at = NULL, started_at = NULL, delivered_at = datetime('now'), expires_at = datetime('now', '+1 hour')
|
|
526
|
+
WHERE task_id = ?1`,
|
|
527
|
+
[task_id]
|
|
528
|
+
);
|
|
529
|
+
// Re-queue in inbox with new ID (original ID may already exist)
|
|
530
|
+
const retryInboxId = uuidv4();
|
|
531
|
+
db.run(
|
|
532
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response)
|
|
533
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')`,
|
|
534
|
+
[retryInboxId, task.to_name, task.priority, task.content, from_session]
|
|
535
|
+
);
|
|
536
|
+
db.run("COMMIT");
|
|
537
|
+
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
538
|
+
} catch (e) {
|
|
539
|
+
try { db.run("ROLLBACK"); } catch {}
|
|
540
|
+
throw e;
|
|
541
|
+
}
|
|
542
|
+
// SSE push
|
|
543
|
+
pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
544
|
+
return {
|
|
545
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
// ── V2: get_task (查询任务状态) ──
|
|
551
|
+
server.tool(
|
|
552
|
+
"get_task",
|
|
553
|
+
"Get task details by task_id. Returns status, result, timestamps.",
|
|
554
|
+
{
|
|
555
|
+
task_id: z.string().min(1).max(200).describe("Task ID to query"),
|
|
556
|
+
},
|
|
557
|
+
async ({ task_id }) => {
|
|
558
|
+
const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
|
|
559
|
+
return {
|
|
560
|
+
content: [{
|
|
561
|
+
type: "text" as const,
|
|
562
|
+
text: JSON.stringify(task ? { ok: true, task } : { ok: false, error: "task not found" }),
|
|
563
|
+
}],
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
// ── V2: list_tasks (查询任务列表) ──
|
|
569
|
+
server.tool(
|
|
570
|
+
"list_tasks",
|
|
571
|
+
"List tasks with filters. Agents can query their own pending/running tasks.",
|
|
572
|
+
{
|
|
573
|
+
alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
|
|
574
|
+
status: z.string().max(50).optional().describe("Filter by status"),
|
|
575
|
+
from_name: z.string().max(200).optional().describe("Filter by sender"),
|
|
576
|
+
limit: z.number().min(1).max(100).optional().default(20),
|
|
577
|
+
},
|
|
578
|
+
async ({ alias, status, from_name, limit }) => {
|
|
579
|
+
let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
|
|
580
|
+
const params: any[] = [];
|
|
581
|
+
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
582
|
+
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
583
|
+
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
584
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
585
|
+
params.push(limit);
|
|
586
|
+
const tasks = db.query(sql).all(...params);
|
|
587
|
+
|
|
588
|
+
// Stats
|
|
589
|
+
const stats = db.query<any, []>(
|
|
590
|
+
"SELECT status, COUNT(*) as count FROM tasks GROUP BY status"
|
|
591
|
+
).all();
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
content: [{
|
|
595
|
+
type: "text" as const,
|
|
596
|
+
text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
|
|
597
|
+
}],
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
// ── V2: cancel_task (取消任务) ──
|
|
603
|
+
server.tool(
|
|
604
|
+
"cancel_task",
|
|
605
|
+
"Cancel a pending task. Works on delivered/acked/running tasks.",
|
|
606
|
+
{
|
|
607
|
+
task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
|
|
608
|
+
reason: z.string().max(1000).optional().describe("Cancellation reason"),
|
|
609
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
610
|
+
},
|
|
611
|
+
async ({ task_id, reason, from_session }) => {
|
|
612
|
+
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
613
|
+
const result = db.run(
|
|
614
|
+
`UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
|
|
615
|
+
WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
|
|
616
|
+
[reason || "cancelled by " + from_session, task_id]
|
|
617
|
+
);
|
|
618
|
+
// Also ack the inbox entry to prevent agent from picking it up
|
|
619
|
+
if (result.changes > 0) {
|
|
620
|
+
db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
|
|
621
|
+
logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
// ── V2: reassign_task (转移任务到另一个 agent) ──
|
|
630
|
+
server.tool(
|
|
631
|
+
"reassign_task",
|
|
632
|
+
"Reassign a task to a different agent. Works on any non-terminal task (delivered/acked/running).",
|
|
633
|
+
{
|
|
634
|
+
task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
|
|
635
|
+
new_alias: z.string().min(1).max(200).describe("Target agent alias"),
|
|
636
|
+
from_session: z.string().max(200).optional().default("hub"),
|
|
637
|
+
},
|
|
638
|
+
async ({ task_id, new_alias, from_session }) => {
|
|
639
|
+
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
640
|
+
const task = db.query<any, [string]>("SELECT * FROM tasks WHERE task_id = ?1").get(task_id);
|
|
641
|
+
if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };
|
|
642
|
+
if (["replied", "failed", "cancelled", "expired"].includes(task.status)) {
|
|
643
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
|
|
644
|
+
}
|
|
645
|
+
const oldAlias = task.to_name;
|
|
646
|
+
try {
|
|
647
|
+
db.run("BEGIN IMMEDIATE");
|
|
648
|
+
// Ack old inbox to prevent original agent from picking it up
|
|
649
|
+
db.run("UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0", [task_id]);
|
|
650
|
+
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]);
|
|
651
|
+
const newInboxId = uuidv4();
|
|
652
|
+
db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply')",
|
|
653
|
+
[newInboxId, new_alias, task.priority, task.content, from_session]);
|
|
654
|
+
db.run("COMMIT");
|
|
655
|
+
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
|
|
656
|
+
} catch (e) {
|
|
657
|
+
try { db.run("ROLLBACK"); } catch {}
|
|
658
|
+
throw e;
|
|
659
|
+
}
|
|
660
|
+
pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session });
|
|
661
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
|
|
662
|
+
}
|
|
663
|
+
);
|
|
664
|
+
|
|
454
665
|
server.tool(
|
|
455
666
|
"broadcast",
|
|
456
667
|
"Send a message to multiple sessions.",
|