@sleep2agi/commhub-server 0.5.0-preview.2 → 0.5.0-preview.21

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.2",
3
+ "version": "0.5.0-preview.21",
4
4
  "description": "CommHub MCP Server — AI Agent communication hub with SSE push, MCP protocol, and REST API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts ADDED
@@ -0,0 +1,164 @@
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 listTokens(userId: string) {
137
+ return db.query<any, [string]>(
138
+ "SELECT token_id, name, scope, network_id, last_used_at, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC"
139
+ ).all(userId);
140
+ }
141
+
142
+ export function createToken(userId: string, name: string, networkId?: string): { ok: boolean; token?: string; token_id?: string; error?: string } {
143
+ const token = generateToken();
144
+ const tokenId = generateId("tok");
145
+ db.run(
146
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
147
+ [tokenId, hashToken(token), userId, networkId || null, name, "full"]
148
+ );
149
+ return { ok: true, token, token_id: tokenId };
150
+ }
151
+
152
+ export function revokeToken(userId: string, tokenId: string): { ok: boolean; error?: string } {
153
+ const result = db.run("DELETE FROM api_tokens WHERE token_id = ?1 AND user_id = ?2", [tokenId, userId]);
154
+ return result.changes > 0 ? { ok: true } : { ok: false, error: "token not found" };
155
+ }
156
+
157
+ export function changePassword(userId: string, oldPassword: string, newPassword: string): { ok: boolean; error?: string } {
158
+ if (!newPassword || newPassword.length < 6) return { ok: false, error: "new password must be at least 6 characters" };
159
+ const user = db.query<any, [string]>("SELECT password_hash FROM users WHERE user_id = ?1").get(userId);
160
+ if (!user) return { ok: false, error: "user not found" };
161
+ if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
162
+ db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
163
+ return { ok: true };
164
+ }
@@ -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
+ }