@sleep2agi/commhub-server 0.9.0-preview.29 → 0.9.0-preview.30
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 +115 -0
- package/package.json +2 -2
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/db.ts +65 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/rest-explicit-columns-http.test.ts +81 -19
- package/src/rest-identity.test.ts +10 -1
- package/src/rest-identity.ts +11 -4
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-tasks-http.test.ts +20 -1
- package/src/scheduled-tasks.ts +13 -2
- package/src/server.ts +135 -16
- package/src/side-thread-command-transport.test.ts +140 -0
- package/src/side-thread-command-transport.ts +309 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread.test.ts +1038 -0
- package/src/side-thread.ts +2241 -0
- package/src/task-consumption.test.ts +69 -0
- package/src/task-diagnostic.test.ts +33 -0
- package/src/task-diagnostic.ts +32 -3
- package/src/task-idempotency-mcp.test.ts +5 -0
- package/src/task-lifecycle-watcher.test.ts +45 -5
- package/src/tools.ts +115 -9
package/README.md
CHANGED
|
@@ -11,6 +11,23 @@ The supported path is to install the `anet` CLI (`@sleep2agi/agent-network`) and
|
|
|
11
11
|
|
|
12
12
|
## Quick start (verified)
|
|
13
13
|
|
|
14
|
+
> ⚠️ **Do not run `bunx @sleep2agi/commhub-server` without a version**
|
|
15
|
+
> Without a version this resolves to the npm `latest` dist-tag, and **`latest` is currently `0.8.8` (published 2026-06-24)**.
|
|
16
|
+
>
|
|
17
|
+
> On `0.8.8` an anonymous `GET /health` **returns the `{networkId}:{alias}` of every live SSE connection** — no token required.
|
|
18
|
+
> The redaction fix is [`7bacb729`](https://github.com/sleep2agi/agent-network/commit/7bacb729) (`security(#473)`, **2026-07-29**),
|
|
19
|
+
> **35 days after** `0.8.8` shipped, so `0.8.8` does not contain it.
|
|
20
|
+
>
|
|
21
|
+
> **Pin a version, or use the preview channel:**
|
|
22
|
+
>
|
|
23
|
+
> ```bash
|
|
24
|
+
> bunx --bun @sleep2agi/commhub-server@preview # contains the redaction fix
|
|
25
|
+
> ```
|
|
26
|
+
>
|
|
27
|
+
> Or use the supported path, `anet hub start`, which pulls the version in `PINNED_SERVER_VERSION` rather than `latest`.
|
|
28
|
+
>
|
|
29
|
+
> Self-check: `curl -sS http://<host>:9200/health | jq 'has("sse_sessions")'` — `true` means you are on an affected build.
|
|
30
|
+
|
|
14
31
|
```bash
|
|
15
32
|
# Recommended — through the anet CLI
|
|
16
33
|
npm install -g @sleep2agi/agent-network
|
|
@@ -74,6 +91,8 @@ authoritative. Pinning versions here goes stale on every release and nobody come
|
|
|
74
91
|
| `list_tasks` | Task list, filterable by `network_id` |
|
|
75
92
|
| `get_completions` | Completion history |
|
|
76
93
|
|
|
94
|
+
> The table above lists the 17 **collaboration-core** tools. Node lifecycle / provider ops tools ship on the same MCP surface — the authoritative full list is [docs-site/docs/api/mcp-tools.md](../docs-site/docs/api/mcp-tools.md). Don't read the count above as "17 tools total".
|
|
95
|
+
|
|
77
96
|
## REST API
|
|
78
97
|
|
|
79
98
|
The server exposes ~33 endpoints across health, auth, networks, and observability surfaces. The endpoints in use today by the verified flow are:
|
|
@@ -149,6 +168,102 @@ DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-serv
|
|
|
149
168
|
| `COMMHUB_DB` | `~/.commhub/commhub.db` | SQLite path |
|
|
150
169
|
| `DATABASE_URL` | (none) | switches to PostgreSQL when set (unverified) |
|
|
151
170
|
|
|
171
|
+
## Running the unit tests
|
|
172
|
+
|
|
173
|
+
### 先说该走哪条路:Docker
|
|
174
|
+
|
|
175
|
+
仓库规约是测试在 Docker 里跑(`CLAUDE.md`「所有测试在 Docker 里跑:不碰本地
|
|
176
|
+
环境,不改生产」)。`server/src` 的测试在 `tests/` 下已经有 **24 个**按主题分片的
|
|
177
|
+
套件覆盖(实测:遍历 main 上 204 个套件目录,run.sh 里跑 `server/src/*.test.ts`
|
|
178
|
+
的有 24 个),每个都在容器内自带 `COMMHUB_DB`。跑其中任意一个都是这个形状:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
# 从仓根跑
|
|
182
|
+
docker build --build-arg SOURCE_COMMIT="$(git rev-parse HEAD)" \
|
|
183
|
+
-t anet-test624 -f tests/test624-task-cursor-pagination/Dockerfile .
|
|
184
|
+
docker run --rm anet-test624
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
(实跑过:退出码 0,`6 pass / 0 fail`,`RESULT: PASS`。换成 `tests/` 下别的
|
|
188
|
+
`test*-*/Dockerfile` 同理,但 **build-arg 的名字要照抄那个 Dockerfile** ——
|
|
189
|
+
main 上 66 处写的是 `SOURCE_COMMIT`,另有若干写成 `TEST<编号>_SOURCE_COMMIT`
|
|
190
|
+
(如 `TEST686_SOURCE_COMMIT`、`TEST765_SOURCE_COMMIT`)。传错名字不会报错,
|
|
191
|
+
只会让套件收到 `unknown` 然后按它自己的规则红或绿 —— 这不是你想要的那种绿。)
|
|
192
|
+
|
|
193
|
+
> ⚠️ **目前没有一个套件跑「`server/src` 下的全部」单测。** 上面那些是按主题
|
|
194
|
+
> 分片的;聚合门在 #798 里,尚未合入 main。所以「所有分片套件都绿」**不等于**
|
|
195
|
+
> 69 个单测文件都跑过 —— 在 #798 合入之前,不要把前者当成后者。
|
|
196
|
+
|
|
197
|
+
### 直接在宿主上跑时的两条契约
|
|
198
|
+
|
|
199
|
+
下面这两条是上面那些套件在容器内**已经替你满足**的东西。你只有在本地迭代
|
|
200
|
+
单个文件时才需要自己处理它们 —— 而那种跑法不构成任何门禁证据。
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
1. **`COMMHUB_DB` must be set.** It is not optional — `~/.commhub/commhub.db` is a
|
|
204
|
+
*live* database on any machine that has ever run a hub. This one is enforced:
|
|
205
|
+
with `NODE_ENV=test` (which `bun test` sets) and no `COMMHUB_DB`, the adapter
|
|
206
|
+
refuses with `REFUSING to open the default SQLite database`.
|
|
207
|
+
|
|
208
|
+
2. **Each test *file* needs its own database.** This is *not* enforced — a single
|
|
209
|
+
shared `COMMHUB_DB` passes the guard above and then fails a handful of tests
|
|
210
|
+
for reasons that look like product bugs but are not. `scripts/qa.sh` encodes
|
|
211
|
+
the real contract:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
# 从仓根跑(见下),每个文件一个全新的库
|
|
215
|
+
f=server/src/auth-validate.test.ts
|
|
216
|
+
db=/tmp/anet-$(basename "$f" .test.ts).db
|
|
217
|
+
rm -f "$db" # 复用上一次的库同样会破坏隔离
|
|
218
|
+
COMMHUB_DB="$db" bun test "$f"
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
整套跑法:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
rc=0
|
|
225
|
+
# 用 find 而不是 `server/src/*.test.ts` —— glob 不进子目录,会漏掉
|
|
226
|
+
# server/src/shared/ 下的 3 个(实测:深度 1 是 66 个,递归是 69 个)。
|
|
227
|
+
# scripts/qa.sh 与 test798 用的就是 find,这里保持一致。
|
|
228
|
+
while IFS= read -r f; do
|
|
229
|
+
# 用完整路径派生库名,不用 basename:加入子目录之后,
|
|
230
|
+
# 两个不同目录下的同名文件会派生出同一个库(原来只扫单层时不可能)。
|
|
231
|
+
db=/tmp/anet-$(printf '%s' "${f%.test.ts}" | tr '/.' '--').db
|
|
232
|
+
rm -f "$db" "$db-wal" "$db-shm"
|
|
233
|
+
COMMHUB_DB="$db" bun test "$f" || { echo "FAILED: $f"; rc=1; }
|
|
234
|
+
done < <(find server/src -type f -name '*.test.ts' | sort)
|
|
235
|
+
[ "$rc" -eq 0 ]
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
⚠️ 最后那行 `[ "$rc" -eq 0 ]` 不是装饰,是这段的**门**。写成
|
|
239
|
+
`bun test "$f" || echo "FAILED: $f"` 会让循环打印一行然后继续,
|
|
240
|
+
整段的退出码恒为 0 —— **测试全红,脚本报绿**。用 `[ ... ]` 而不是
|
|
241
|
+
`exit "$rc"`,是为了这段直接粘进终端时不会把你的 shell 关掉;
|
|
242
|
+
存成脚本跑时,它是最后一条命令,脚本退出码就等于它。
|
|
243
|
+
|
|
244
|
+
⚠️ 别把 `$name` 之类的占位符原样抄进命令行 —— 未定义的变量会展开成空串,
|
|
245
|
+
于是每个文件都写进**同一个** `/tmp/…-.db`,正好是这一节要避免的事。
|
|
246
|
+
|
|
247
|
+
Symptom if you ignore it: `bun test src/` under one database turns
|
|
248
|
+
`admin-networks-http` and `scheduled-tasks-http` red. Both are **global-count
|
|
249
|
+
assertions that are correct under the contract**:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
// admin-networks-http.test.ts — the admin sees EXACTLY this file's two networks
|
|
253
|
+
expect(new Set(rows.map((row) => row.network_id)))
|
|
254
|
+
.toEqual(new Set([adminNetworkId, memberNetworkId]));
|
|
255
|
+
|
|
256
|
+
// scheduled-tasks-http.test.ts — EXACTLY one due occurrence was processed
|
|
257
|
+
expect(runDueScheduledTasks().processed).toBe(1);
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Share a database and other files' networks and due schedules join the count.
|
|
261
|
+
Nothing is broken; the run violated the contract.
|
|
262
|
+
|
|
263
|
+
`scripts/qa.sh` 的 L0 目前只点名跑 5 个 server 测试(白名单),不是全量;
|
|
264
|
+
把 `server/src` 全域逐文件跑起来的那道 CI 门还在评审中(`#798`),
|
|
265
|
+
未进 `main` —— 所以本节描述的是**契约**,不是「已经有东西在替你执行它」。
|
|
266
|
+
|
|
152
267
|
## Auth modes
|
|
153
268
|
|
|
154
269
|
1. **V3 user system (default)** — `POST /api/auth/register` and `/api/auth/login` issue `utok_…` tokens; nodes get `ntok_…`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
4
|
-
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17
|
|
3
|
+
"version": "0.9.0-preview.30",
|
|
4
|
+
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"bin": {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { parseAliasFilter } from "./alias-filter";
|
|
3
|
+
|
|
4
|
+
test("no filter given means no filtering, not an empty match", () => {
|
|
5
|
+
for (const raw of [undefined, null, "", " ", ",", " , , "]) {
|
|
6
|
+
const f = parseAliasFilter(raw as any);
|
|
7
|
+
expect(f.aliases).toEqual([]);
|
|
8
|
+
expect(f.sql).toBe("");
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("a single alias produces one placeholder", () => {
|
|
13
|
+
const f = parseAliasFilter("TM门户马");
|
|
14
|
+
expect(f.aliases).toEqual(["TM门户马"]);
|
|
15
|
+
expect(f.sql).toBe(" AND alias IN (?)");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("several aliases keep their order and count", () => {
|
|
19
|
+
const f = parseAliasFilter("A站内容,A站内容牛,hub");
|
|
20
|
+
expect(f.aliases).toEqual(["A站内容", "A站内容牛", "hub"]);
|
|
21
|
+
expect(f.sql).toBe(" AND alias IN (?,?,?)");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("surrounding whitespace is trimmed", () => {
|
|
25
|
+
expect(parseAliasFilter(" a , b ").aliases).toEqual(["a", "b"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// The point of the module. A trailing comma must not become `alias = ''`,
|
|
29
|
+
// which matches nothing and reads exactly like "those nodes do not exist".
|
|
30
|
+
test("blank entries are dropped, never turned into a match-nothing term", () => {
|
|
31
|
+
const f = parseAliasFilter("a,,b,");
|
|
32
|
+
expect(f.aliases).toEqual(["a", "b"]);
|
|
33
|
+
expect(f.sql).toBe(" AND alias IN (?,?)");
|
|
34
|
+
expect(f.aliases).not.toContain("");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a filter of only commas is the same as no filter — it must not silently match zero rows", () => {
|
|
38
|
+
const f = parseAliasFilter(",,,");
|
|
39
|
+
expect(f.sql).toBe("");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("placeholder count always equals alias count, so params can never misalign", () => {
|
|
43
|
+
for (const raw of ["a", "a,b", "a,,b", " a , b , c ", ",x,"]) {
|
|
44
|
+
const f = parseAliasFilter(raw);
|
|
45
|
+
expect((f.sql.match(/\?/g) ?? []).length).toBe(f.aliases.length);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("aliases are passed through verbatim — no globbing, no case folding", () => {
|
|
50
|
+
const f = parseAliasFilter("A站内容,a站内容");
|
|
51
|
+
expect(f.aliases).toEqual(["A站内容", "a站内容"]);
|
|
52
|
+
expect(f.sql).not.toContain("LIKE");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Wiring: the tool must actually use this module, and must say what its
|
|
56
|
+
// `summary` counted — a caller who asked about three aliases and gets back
|
|
57
|
+
// "idle: 96" can easily read the 96 as being about their three.
|
|
58
|
+
import { readFileSync } from "fs";
|
|
59
|
+
import { join } from "path";
|
|
60
|
+
|
|
61
|
+
test("get_all_status uses parseAliasFilter and declares what summary counted", () => {
|
|
62
|
+
const source = readFileSync(join(import.meta.dir, "tools.ts"), "utf8");
|
|
63
|
+
const a = source.indexOf('"get_all_status"');
|
|
64
|
+
expect(a).toBeGreaterThan(-1);
|
|
65
|
+
const body = source.slice(a, source.indexOf('server.tool(', a + 10));
|
|
66
|
+
expect(body).toContain("filter_alias");
|
|
67
|
+
expect(body).toContain("parseAliasFilter(filter_alias)");
|
|
68
|
+
expect(body).toContain("summary_scope");
|
|
69
|
+
expect(body).toContain("sessions_returned");
|
|
70
|
+
// The alias list must go through parameters, never be interpolated.
|
|
71
|
+
expect(body).not.toMatch(/alias IN \(\$\{/);
|
|
72
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Parse the `filter_alias` argument of `get_all_status` into an exact-match
|
|
2
|
+
// IN list.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists at all: on a 222-session hub the unfiltered response is about
|
|
5
|
+
// 259 KB — past what an MCP client accepts in one result. A caller who wanted
|
|
6
|
+
// the status of three specific nodes could not get it from the tool, and had to
|
|
7
|
+
// go around it to the REST API. The filter is the fix; this module is the part
|
|
8
|
+
// of it worth pinning, because the interesting behaviour is what happens to the
|
|
9
|
+
// inputs that are not a clean alias.
|
|
10
|
+
//
|
|
11
|
+
// Blank entries are DROPPED rather than matched. A trailing comma would
|
|
12
|
+
// otherwise produce `alias = ''`, which matches no row — and "no rows" reads
|
|
13
|
+
// exactly like "those nodes do not exist". The failure and the true answer
|
|
14
|
+
// would be indistinguishable to the caller, which is the thing to avoid.
|
|
15
|
+
|
|
16
|
+
export interface AliasFilter {
|
|
17
|
+
/** Exact aliases to match. Empty means "no alias filtering". */
|
|
18
|
+
aliases: string[];
|
|
19
|
+
/** SQL fragment to append, or "" when there is nothing to filter on. */
|
|
20
|
+
sql: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseAliasFilter(raw: string | undefined | null): AliasFilter {
|
|
24
|
+
const aliases = (raw ?? "")
|
|
25
|
+
.split(",")
|
|
26
|
+
.map(a => a.trim())
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
return {
|
|
29
|
+
aliases,
|
|
30
|
+
sql: aliases.length > 0 ? ` AND alias IN (${aliases.map(() => "?").join(",")})` : "",
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -1101,6 +1101,71 @@ try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
|
|
|
1101
1101
|
try { db.exec("ALTER TABLE tasks ADD COLUMN runtime_submitted_at TEXT"); } catch {}
|
|
1102
1102
|
try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
|
|
1103
1103
|
|
|
1104
|
+
// #1181 durable outbound terminal cursor. A task's created_at cannot be used
|
|
1105
|
+
// as a recovery watermark because an old task may become terminal after newer
|
|
1106
|
+
// tasks. Record terminal transitions in their own monotonically increasing
|
|
1107
|
+
// journal instead. The journal is append-only for a logical task; retry keeps
|
|
1108
|
+
// the existing task identity and therefore the same idempotency domain.
|
|
1109
|
+
db.exec(`
|
|
1110
|
+
CREATE TABLE IF NOT EXISTS task_terminal_events (
|
|
1111
|
+
terminal_seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1112
|
+
task_id TEXT NOT NULL UNIQUE,
|
|
1113
|
+
network_id TEXT,
|
|
1114
|
+
from_node_id TEXT,
|
|
1115
|
+
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1116
|
+
);
|
|
1117
|
+
CREATE INDEX IF NOT EXISTS idx_task_terminal_events_sender_seq
|
|
1118
|
+
ON task_terminal_events(network_id, from_node_id, terminal_seq);
|
|
1119
|
+
`);
|
|
1120
|
+
if (db.dialect === "sqlite") {
|
|
1121
|
+
db.exec(`
|
|
1122
|
+
CREATE TRIGGER IF NOT EXISTS tasks_terminal_event_insert
|
|
1123
|
+
AFTER INSERT ON tasks
|
|
1124
|
+
WHEN NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1125
|
+
BEGIN
|
|
1126
|
+
INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1127
|
+
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, datetime('now')));
|
|
1128
|
+
END;
|
|
1129
|
+
CREATE TRIGGER IF NOT EXISTS tasks_terminal_event_update
|
|
1130
|
+
AFTER UPDATE OF status ON tasks
|
|
1131
|
+
WHEN NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1132
|
+
AND OLD.status NOT IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1133
|
+
BEGIN
|
|
1134
|
+
INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1135
|
+
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, datetime('now')));
|
|
1136
|
+
END;
|
|
1137
|
+
`);
|
|
1138
|
+
db.run(`INSERT OR IGNORE INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1139
|
+
SELECT task_id, network_id, from_node_id, COALESCE(completed_at, created_at)
|
|
1140
|
+
FROM tasks
|
|
1141
|
+
WHERE status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1142
|
+
ORDER BY COALESCE(completed_at, created_at), task_id`);
|
|
1143
|
+
} else {
|
|
1144
|
+
db.exec(`
|
|
1145
|
+
CREATE OR REPLACE FUNCTION record_task_terminal_event() RETURNS trigger AS $$
|
|
1146
|
+
BEGIN
|
|
1147
|
+
IF NEW.status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1148
|
+
AND (TG_OP = 'INSERT' OR OLD.status NOT IN ('replied', 'failed', 'cancelled', 'expired')) THEN
|
|
1149
|
+
INSERT INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1150
|
+
VALUES (NEW.task_id, NEW.network_id, NEW.from_node_id, COALESCE(NEW.completed_at, NOW()))
|
|
1151
|
+
ON CONFLICT (task_id) DO NOTHING;
|
|
1152
|
+
END IF;
|
|
1153
|
+
RETURN NEW;
|
|
1154
|
+
END;
|
|
1155
|
+
$$ LANGUAGE plpgsql;
|
|
1156
|
+
DROP TRIGGER IF EXISTS tasks_terminal_event_insert ON tasks;
|
|
1157
|
+
DROP TRIGGER IF EXISTS tasks_terminal_event_update ON tasks;
|
|
1158
|
+
CREATE TRIGGER tasks_terminal_event_insert AFTER INSERT ON tasks
|
|
1159
|
+
FOR EACH ROW EXECUTE FUNCTION record_task_terminal_event();
|
|
1160
|
+
CREATE TRIGGER tasks_terminal_event_update AFTER UPDATE OF status ON tasks
|
|
1161
|
+
FOR EACH ROW EXECUTE FUNCTION record_task_terminal_event();
|
|
1162
|
+
INSERT INTO task_terminal_events (task_id, network_id, from_node_id, completed_at)
|
|
1163
|
+
SELECT task_id, network_id, from_node_id, COALESCE(completed_at, created_at)
|
|
1164
|
+
FROM tasks WHERE status IN ('replied', 'failed', 'cancelled', 'expired')
|
|
1165
|
+
ON CONFLICT (task_id) DO NOTHING;
|
|
1166
|
+
`);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1104
1169
|
// ── Hub scheduled tasks ──────────────────────────────────────────────
|
|
1105
1170
|
// Scheduling is a Hub concern: clients manage these rows, while agents only
|
|
1106
1171
|
// receive the ordinary inbox/tasks rows produced for each occurrence. Keep
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { DEFAULT_PORT, resolvePort } from "./resolve-port";
|
|
3
|
+
|
|
4
|
+
// The whole reason this module exists.
|
|
5
|
+
test("PORT=0 means an ephemeral port, not the production default", () => {
|
|
6
|
+
expect(resolvePort("0")).toBe(0);
|
|
7
|
+
expect(resolvePort("0")).not.toBe(DEFAULT_PORT);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("unset or empty falls back to the default", () => {
|
|
11
|
+
expect(resolvePort(undefined)).toBe(DEFAULT_PORT);
|
|
12
|
+
expect(resolvePort("")).toBe(DEFAULT_PORT);
|
|
13
|
+
expect(resolvePort(" ")).toBe(DEFAULT_PORT);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("surrounding whitespace is tolerated — it is trimmed, not treated as malformed", () => {
|
|
17
|
+
expect(resolvePort(" 9201 ")).toBe(9201);
|
|
18
|
+
expect(resolvePort("\t0\n")).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("an explicit port is used verbatim", () => {
|
|
22
|
+
expect(resolvePort("9201")).toBe(9201);
|
|
23
|
+
expect(resolvePort("1")).toBe(1);
|
|
24
|
+
expect(resolvePort("65535")).toBe(65535);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Defaulting on a malformed value means a typo silently starts the server on
|
|
28
|
+
// the production port — on this fleet, on top of the running Hub.
|
|
29
|
+
test("a malformed value is rejected, never quietly defaulted", () => {
|
|
30
|
+
for (const bad of ["abc", "80a", "-1", "65536", "1.5", "0x10", "NaN", "Infinity", "9 200", "+80"]) {
|
|
31
|
+
expect(() => resolvePort(bad)).toThrow();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("the rejection names the value and the accepted range", () => {
|
|
36
|
+
try {
|
|
37
|
+
resolvePort("abc");
|
|
38
|
+
throw new Error("should have thrown");
|
|
39
|
+
} catch (e: any) {
|
|
40
|
+
expect(e.message).toContain("abc");
|
|
41
|
+
expect(e.message).toContain("0 and 65535");
|
|
42
|
+
expect(e.message).toContain("ephemeral");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a caller can override the fallback without touching the default", () => {
|
|
47
|
+
expect(resolvePort(undefined, 3000)).toBe(3000);
|
|
48
|
+
expect(resolvePort("0", 3000)).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("server.ts resolves PORT through this module, not through `|| DEFAULT`", () => {
|
|
52
|
+
const src = require("fs").readFileSync(require("path").join(import.meta.dir, "server.ts"), "utf8");
|
|
53
|
+
const code = src.split("\n").filter((l: string) => !l.trim().startsWith("//")).join("\n");
|
|
54
|
+
expect(code).toContain("resolvePort(process.env.PORT)");
|
|
55
|
+
// `Number(env) || default` is the exact shape that swallowed the 0.
|
|
56
|
+
expect(code).not.toMatch(/Number\(process\.env\.PORT\)\s*\|\|/);
|
|
57
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Resolve the listen port from the environment.
|
|
2
|
+
//
|
|
3
|
+
// `Number(process.env.PORT) || 9200` swallows a legitimate `0`. `Number("0")`
|
|
4
|
+
// is `0`, which is falsy, so `PORT=0` — the conventional way to ask the OS for
|
|
5
|
+
// an ephemeral port — silently became 9200, the production Hub port.
|
|
6
|
+
//
|
|
7
|
+
// Three consequences, and the middle one is the worst:
|
|
8
|
+
//
|
|
9
|
+
// 1. On a host where 9200 is already taken (a running Hub), a test that sets
|
|
10
|
+
// PORT=0 dies with EADDRINUSE and reads as a product bug.
|
|
11
|
+
// task-lifecycle-watcher.test.ts fails on main today for exactly this.
|
|
12
|
+
// 2. On a host where 9200 is FREE, the same test passes — by binding 9200.
|
|
13
|
+
// It is green because it grabbed the production port, not because PORT=0
|
|
14
|
+
// worked. Green for the wrong reason is worse than red.
|
|
15
|
+
// 3. Anyone asking for an ephemeral port gets the production port instead.
|
|
16
|
+
//
|
|
17
|
+
// The file already knew: `bootServer` uses `opts.port ?? PORT` with a comment
|
|
18
|
+
// saying `||` "would swallow a legitimate 0". The rule was one level up from
|
|
19
|
+
// where it was needed.
|
|
20
|
+
//
|
|
21
|
+
// A malformed value is rejected rather than defaulted. Falling back to 9200 on
|
|
22
|
+
// `PORT=abc` means a typo silently starts the server somewhere the operator did
|
|
23
|
+
// not ask for — and on this fleet that somewhere is production.
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_PORT = 9200;
|
|
26
|
+
|
|
27
|
+
export function resolvePort(raw: string | undefined, fallback = DEFAULT_PORT): number {
|
|
28
|
+
// Unset or empty means "not specified". An empty string is what a shell
|
|
29
|
+
// exports for an unset variable it still passes along, so treating it as 0
|
|
30
|
+
// would make `PORT= anet hub start` bind an ephemeral port by accident.
|
|
31
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
32
|
+
|
|
33
|
+
// Decimal digits only, after trimming. `Number()` alone accepts "0x10" (16)
|
|
34
|
+
// and " 9200 ", so a value that does not look like a port would still resolve
|
|
35
|
+
// to one — quietly, and to a different number than the operator typed.
|
|
36
|
+
const text = raw.trim();
|
|
37
|
+
const n = /^\d+$/.test(text) ? Number(text) : NaN;
|
|
38
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`PORT must be an integer between 0 and 65535 (0 asks the OS for an ephemeral port); got ${JSON.stringify(raw)}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
@@ -2,15 +2,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
AUDIT_LOG_REST_COLUMNS,
|
|
7
|
-
COMPLETION_REST_COLUMNS,
|
|
8
|
-
NETWORK_REST_COLUMNS,
|
|
9
|
-
SESSION_REST_COLUMNS,
|
|
10
|
-
TASK_EVENT_REST_COLUMNS,
|
|
11
|
-
TASK_REST_COLUMNS,
|
|
12
|
-
SCHEDULED_TASK_STORAGE_COLUMNS,
|
|
13
|
-
} from "./rest-projections.js";
|
|
14
5
|
|
|
15
6
|
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-rest-columns-"));
|
|
16
7
|
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
@@ -25,6 +16,63 @@ const INTERNAL_SENTINEL = "future_internal_only";
|
|
|
25
16
|
const sortedKeys = (value: Record<string, unknown>): string[] => Object.keys(value).sort();
|
|
26
17
|
const sorted = (values: readonly string[]): string[] => [...values].sort();
|
|
27
18
|
|
|
19
|
+
// Independent wire-contract golden. Do not derive these expectations from
|
|
20
|
+
// rest-projections.ts: doing so makes a deleted projection column disappear
|
|
21
|
+
// from both the response and the expected value, producing a false green.
|
|
22
|
+
const GOLDEN_RESPONSE_KEYS = {
|
|
23
|
+
networkList: [
|
|
24
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
25
|
+
"created_at", "updated_at", "visibility", "max_members", "member_role", "name",
|
|
26
|
+
],
|
|
27
|
+
// 🔴 #1097:GET /api/auth/me 走的是 auth.ts 的 getUserAllNetworks(),
|
|
28
|
+
// 与 GET /api/networks 的 admin 分支**不是同一条查询**。
|
|
29
|
+
// server.ts:985 直接返回原始行,没有 :1166 那个 withNetworkNameAlias,
|
|
30
|
+
// 所以比 networkList 少一个 "name"。这里逐字写死,不从 rest-projections.ts 推导
|
|
31
|
+
// —— 理由见本文件顶部:从投影推导会让被删掉的列同时从响应和期望里消失,产生假绿。
|
|
32
|
+
authMeNetwork: [
|
|
33
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
34
|
+
"created_at", "updated_at", "visibility", "max_members", "member_role",
|
|
35
|
+
],
|
|
36
|
+
networkDetail: [
|
|
37
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
38
|
+
"created_at", "updated_at", "visibility", "max_members",
|
|
39
|
+
],
|
|
40
|
+
statusSession: [
|
|
41
|
+
"resume_id", "alias", "tmux_name", "server", "ip", "hostname", "agent",
|
|
42
|
+
"project_dir", "version", "status", "task", "output", "progress", "score",
|
|
43
|
+
"cpu_load_1min", "cpu_cores", "mem_total_gb", "mem_used_gb", "mem_avail_gb",
|
|
44
|
+
"disk_total_gb", "disk_used_gb", "disk_avail_gb", "process_rss_bytes",
|
|
45
|
+
"process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
|
|
46
|
+
"process_in_flight_count", "network_id", "registered_at", "updated_at",
|
|
47
|
+
"node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
|
|
48
|
+
"external_schedules", "runtime", "host", "process_telemetry",
|
|
49
|
+
],
|
|
50
|
+
task: [
|
|
51
|
+
"task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
|
|
52
|
+
"status", "content", "result", "in_reply_to", "requires_response", "scope",
|
|
53
|
+
"created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at",
|
|
54
|
+
"completed_at", "expires_at", "network_id", "parent_task_id", "meta_json",
|
|
55
|
+
],
|
|
56
|
+
audit: [
|
|
57
|
+
"id", "user_id", "username", "action", "target_type", "target_id", "detail",
|
|
58
|
+
"ip", "network_id", "created_at",
|
|
59
|
+
],
|
|
60
|
+
taskEvent: [
|
|
61
|
+
"id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
|
|
62
|
+
"created_at", "network_id",
|
|
63
|
+
],
|
|
64
|
+
completion: [
|
|
65
|
+
"id", "session_name", "task", "result", "artifacts", "score",
|
|
66
|
+
"duration_minutes", "network_id", "completed_at",
|
|
67
|
+
],
|
|
68
|
+
scheduledTask: [
|
|
69
|
+
"schedule_id", "network_id", "name", "target_node_id", "target_alias",
|
|
70
|
+
"task_content", "priority", "schedule_type", "timezone", "overlap_policy",
|
|
71
|
+
"misfire_policy", "status", "next_run_at", "last_run_at", "revision",
|
|
72
|
+
"created_at", "updated_at", "schedule",
|
|
73
|
+
],
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
28
76
|
async function api(path: string): Promise<any> {
|
|
29
77
|
const response = await fetch(`${base}${path}`, {
|
|
30
78
|
headers: { Authorization: `Bearer ${token}` },
|
|
@@ -97,51 +145,65 @@ describe("#311 REST response projections are stable across future ALTER TABLE",
|
|
|
97
145
|
test("network list and detail preserve their existing public keys", async () => {
|
|
98
146
|
const list = await api("/api/networks");
|
|
99
147
|
const row = list.networks.find((item: any) => item.network_id === networkId);
|
|
100
|
-
expect(sortedKeys(row)).toEqual(sorted(
|
|
148
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkList));
|
|
101
149
|
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
102
150
|
|
|
103
151
|
const detail = await api(`/api/networks/${networkId}`);
|
|
104
|
-
expect(sortedKeys(detail.network)).toEqual(sorted(
|
|
152
|
+
expect(sortedKeys(detail.network)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkDetail));
|
|
105
153
|
expect(detail.network[INTERNAL_SENTINEL]).toBeUndefined();
|
|
106
154
|
});
|
|
107
155
|
|
|
156
|
+
// 🔴 #1097:这条补的是 test647 抓到的一个**真覆盖洞**。
|
|
157
|
+
// tests/test647-rest-explicit-columns 的 L3b 变异把 auth.ts 的
|
|
158
|
+
// `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role`
|
|
159
|
+
// 改成 `SELECT n.*, …`,而**整套测试仍然全绿** —— 因为 getUserAllNetworks()
|
|
160
|
+
// 只在两处可达:server.ts:985(GET /api/auth/me) 与 server.ts:1165
|
|
161
|
+
// (GET /api/networks 的三元 **else** 分支),而上面那条测试打的是 /api/networks
|
|
162
|
+
// 且它的用户走 **if** 分支(:1156-1162 的内联查询)⇒ 两条可达路径一条都没覆盖。
|
|
163
|
+
test("auth/me networks come from an explicit column list, not SELECT *", async () => {
|
|
164
|
+
const me = await api("/api/auth/me");
|
|
165
|
+
const row = me.networks.find((item: any) => item.network_id === networkId);
|
|
166
|
+
expect(row).toBeDefined();
|
|
167
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.authMeNetwork));
|
|
168
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
108
171
|
test("full status preserves telemetry compatibility without storage drift", async () => {
|
|
109
172
|
const body = await api(`/api/status?network_id=${networkId}`);
|
|
110
173
|
const row = body.sessions.find((item: any) => item.alias === "rest-shape-node");
|
|
111
|
-
expect(sortedKeys(row)).toEqual(sorted(
|
|
174
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.statusSession));
|
|
112
175
|
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
113
176
|
expect(row.external_schedules).toEqual({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] });
|
|
114
177
|
});
|
|
115
178
|
|
|
116
179
|
test("task list and task detail expose the same explicit contract", async () => {
|
|
117
180
|
const list = await api(`/api/tasks?network_id=${networkId}&task_id=task-rest-shape&skip_stats=1`);
|
|
118
|
-
expect(sortedKeys(list.tasks[0])).toEqual(sorted(
|
|
181
|
+
expect(sortedKeys(list.tasks[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
|
|
119
182
|
expect(list.tasks[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
120
183
|
|
|
121
184
|
const detail = await api(`/api/tasks/task-rest-shape?network_id=${networkId}`);
|
|
122
|
-
expect(sortedKeys(detail.task)).toEqual(sorted(
|
|
185
|
+
expect(sortedKeys(detail.task)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
|
|
123
186
|
expect(detail.task[INTERNAL_SENTINEL]).toBeUndefined();
|
|
124
187
|
});
|
|
125
188
|
|
|
126
189
|
test("audit, task-event, and completion rows are explicit", async () => {
|
|
127
190
|
const audit = await api("/api/audit-log?action=shape_action");
|
|
128
|
-
expect(sortedKeys(audit.logs[0])).toEqual(sorted(
|
|
191
|
+
expect(sortedKeys(audit.logs[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.audit));
|
|
129
192
|
expect(audit.logs[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
130
193
|
|
|
131
194
|
const events = await api(`/api/task_events?network_id=${networkId}&task_id=task-rest-shape`);
|
|
132
|
-
expect(sortedKeys(events.events[0])).toEqual(sorted(
|
|
195
|
+
expect(sortedKeys(events.events[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.taskEvent));
|
|
133
196
|
expect(events.events[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
134
197
|
|
|
135
198
|
const completions = await api(`/api/completions?network_id=${networkId}&since=2000-01-01T00:00:00.000Z`);
|
|
136
|
-
expect(sortedKeys(completions.completions[0])).toEqual(sorted(
|
|
199
|
+
expect(sortedKeys(completions.completions[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.completion));
|
|
137
200
|
expect(completions.completions[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
138
201
|
});
|
|
139
202
|
|
|
140
203
|
test("scheduled-task decoding keeps storage-only identity and JSON private", async () => {
|
|
141
204
|
const body = await api(`/api/scheduled-tasks?network_id=${networkId}`);
|
|
142
205
|
const row = body.schedules.find((item: any) => item.schedule_id === "schedule-rest-shape");
|
|
143
|
-
|
|
144
|
-
expect(sortedKeys(row)).toEqual(sorted([...publicColumns, "schedule"]));
|
|
206
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.scheduledTask));
|
|
145
207
|
expect(row.created_by).toBeUndefined();
|
|
146
208
|
expect(row.schedule_json).toBeUndefined();
|
|
147
209
|
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
@@ -60,7 +60,16 @@ describe("REST from_session identity binding", () => {
|
|
|
60
60
|
})).toEqual({ ok: true, fromSession: "admin" });
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
test("user token with no from
|
|
63
|
+
test("user token with no from defaults to the authenticated username", () => {
|
|
64
|
+
expect(resolveRestFromSession({
|
|
65
|
+
token: UTOK,
|
|
66
|
+
tokenName: null,
|
|
67
|
+
authenticatedUsername: "admin",
|
|
68
|
+
requestedFrom: undefined,
|
|
69
|
+
})).toEqual({ ok: true, fromSession: "admin" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("legacy user auth without a username still falls back to 'api'", () => {
|
|
64
73
|
expect(resolveRestFromSession({
|
|
65
74
|
token: UTOK,
|
|
66
75
|
tokenName: null,
|
package/src/rest-identity.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface RestIdentityInput {
|
|
|
21
21
|
token: string;
|
|
22
22
|
/** `api_tokens.name`; for node tokens it is `node:<alias>`. */
|
|
23
23
|
tokenName: string | null | undefined;
|
|
24
|
+
/** Username resolved from the authenticated token. */
|
|
25
|
+
authenticatedUsername?: string | null;
|
|
24
26
|
/** Caller-supplied `body.from`. */
|
|
25
27
|
requestedFrom: unknown;
|
|
26
28
|
}
|
|
@@ -69,11 +71,16 @@ export function resolveRestFromSession(input: RestIdentityInput): RestIdentityRe
|
|
|
69
71
|
return { ok: true, fromSession: "api" };
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
74
|
+
// A user-token client normally does not need to repeat its identity in every
|
|
75
|
+
// request. Prefer an explicit, backwards-compatible claim when present;
|
|
76
|
+
// otherwise attribute the task to the authenticated username. Falling back
|
|
77
|
+
// to the transport label `api` is reserved for legacy auth contexts where
|
|
78
|
+
// the server genuinely has no user identity.
|
|
75
79
|
if (!alias) {
|
|
76
|
-
|
|
80
|
+
const authenticatedUsername = typeof input.authenticatedUsername === "string"
|
|
81
|
+
? input.authenticatedUsername.trim()
|
|
82
|
+
: "";
|
|
83
|
+
return { ok: true, fromSession: requested || authenticatedUsername || "api" };
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
// Node token claiming somebody else — refuse rather than silently rewrite,
|