@sleep2agi/commhub-server 0.9.0-preview.70 → 0.9.0-preview.72
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 +5 -3
- package/package.json +1 -1
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +222 -11
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +8 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/auth.ts +98 -7
- package/src/db-adapter-guard.test.ts +63 -0
- package/src/db-adapter-translate.test.ts +123 -0
- package/src/db-adapter.ts +246 -65
- package/src/db.ts +46 -3
- package/src/pg-worker.ts +87 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-mcp.test.ts +6 -0
- package/src/requirements-migrate.ts +61 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +192 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements.ts +139 -23
- package/src/retention.ts +5 -2
- package/src/scheduled-tasks-http.test.ts +93 -29
- package/src/scheduled-tasks.ts +3 -2
- package/src/server.ts +118 -12
- package/src/side-thread-command-transport.test.ts +14 -4
- package/src/side-thread-command-transport.ts +4 -4
- package/src/side-thread.ts +4 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/tools.ts +16 -13
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Agent 分组的性能守卫(RFC-038 §8):visibleAgents() 每个请求多做一次「组授权 → 组成员」展开,
|
|
2
|
+
// 这里量它的额外开销,并用 EXPLAIN QUERY PLAN 钉住展开查询走索引(不是全表扫)。
|
|
3
|
+
//
|
|
4
|
+
// 场景:NET 里 300 个节点;50 个组,每组 6 个节点(合起来覆盖全部 300 个)。
|
|
5
|
+
// baseline —— bob 直接授权这 300 个节点(没有组);
|
|
6
|
+
// groups —— alice 授权这 50 个组(没有直接授权),可见集合与 bob 完全相同。
|
|
7
|
+
// 两人看到的 Agent 一样多,差值就是「按组展开」本身的代价(交替量 15 轮,取每轮差值的中位数)。团队要求 ≈2 ms 以内(本机实测 −0.5~+1.9 ms);
|
|
8
|
+
// 断言放宽到 max(5 ms, 基线的 15%)—— CI 容器比本机慢时两边一起变慢,绝对差也跟着放大。数字打印出来。
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { register } from "./auth.js";
|
|
15
|
+
import { db } from "./db.js";
|
|
16
|
+
import { visibleAgents } from "./agent-access.js";
|
|
17
|
+
|
|
18
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-agent-groups-perf-"));
|
|
19
|
+
let BASE = "";
|
|
20
|
+
let hub: any = null;
|
|
21
|
+
const PW = "GroupPerfPassw0rd!x";
|
|
22
|
+
let NET = "";
|
|
23
|
+
let adminToken = "";
|
|
24
|
+
let adminId = "";
|
|
25
|
+
let alice = { token: "", id: "" };
|
|
26
|
+
let bob = { token: "", id: "" };
|
|
27
|
+
const NODES = 300;
|
|
28
|
+
const GROUPS = 50;
|
|
29
|
+
const PER_GROUP = NODES / GROUPS;
|
|
30
|
+
const BOUND_MS = 5;
|
|
31
|
+
const bound = (base: number) => Math.max(BOUND_MS, base * 0.15);
|
|
32
|
+
// CI 容器里一次 visibleAgents() 约 65 ms(本机约 28 ms):原来 40 次 × 两边再加预热,顶破了 bun 默认的 5 s 单测超时(#2131 CI 实测)。
|
|
33
|
+
// 15 次取中位数足够稳;性能用例另给显式超时。
|
|
34
|
+
const WARMUP = 3;
|
|
35
|
+
const ITER = 15;
|
|
36
|
+
const PERF_TIMEOUT_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
const json = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" });
|
|
39
|
+
async function send(token: string, method: string, path: string, payload?: unknown) {
|
|
40
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: json(token), body: payload === undefined ? undefined : JSON.stringify(payload) });
|
|
41
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
42
|
+
}
|
|
43
|
+
const median = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };
|
|
44
|
+
/**
|
|
45
|
+
* 交替计时:每一轮先量 A 再量 B,取「B − A」的中位数(再附上两边各自的中位数给人看)。
|
|
46
|
+
* 不能 A 连量 N 次再 B 连量 N 次:test638 在同一台机器上并发跑两份 aggregate,负载随时间变,
|
|
47
|
+
* 两段分开量时差值被负载漂移主导(--cpus=1 实测差出 26–33 ms,真实差值 < 1 ms)。
|
|
48
|
+
*/
|
|
49
|
+
function pairedDelta(a: () => void, b: () => void, n: number): { a: number; b: number; delta: number } {
|
|
50
|
+
for (let i = 0; i < WARMUP; i++) { a(); b(); }
|
|
51
|
+
const as: number[] = [], bs: number[] = [], ds: number[] = [];
|
|
52
|
+
for (let i = 0; i < n; i++) {
|
|
53
|
+
let t = performance.now(); a(); const ta = performance.now() - t;
|
|
54
|
+
t = performance.now(); b(); const tb = performance.now() - t;
|
|
55
|
+
as.push(ta); bs.push(tb); ds.push(tb - ta);
|
|
56
|
+
}
|
|
57
|
+
return { a: median(as), b: median(bs), delta: median(ds) };
|
|
58
|
+
}
|
|
59
|
+
async function pairedDeltaAsync(a: () => Promise<void>, b: () => Promise<void>, n: number): Promise<{ a: number; b: number; delta: number }> {
|
|
60
|
+
for (let i = 0; i < WARMUP; i++) { await a(); await b(); }
|
|
61
|
+
const as: number[] = [], bs: number[] = [], ds: number[] = [];
|
|
62
|
+
for (let i = 0; i < n; i++) {
|
|
63
|
+
let t = performance.now(); await a(); const ta = performance.now() - t;
|
|
64
|
+
t = performance.now(); await b(); const tb = performance.now() - t;
|
|
65
|
+
as.push(ta); bs.push(tb); ds.push(tb - ta);
|
|
66
|
+
}
|
|
67
|
+
return { a: median(as), b: median(bs), delta: median(ds) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
72
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
73
|
+
process.env.HOST = "127.0.0.1";
|
|
74
|
+
const admin = register(`perf_admin_${Date.now()}`, PW, undefined, "Admin");
|
|
75
|
+
expect(admin.ok).toBe(true);
|
|
76
|
+
adminToken = admin.token!;
|
|
77
|
+
adminId = admin.user!.user_id;
|
|
78
|
+
NET = admin.network_id!;
|
|
79
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
|
|
80
|
+
db.transaction(() => {
|
|
81
|
+
for (let i = 0; i < NODES; i++) {
|
|
82
|
+
const node = `node_perf_${i}`;
|
|
83
|
+
const alias = `perf-agent-${i}`;
|
|
84
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`, [node, alias, NET, adminId]);
|
|
85
|
+
db.run(`INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`, [`resume_${node}`, alias, node, NET]);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const mod: any = await import("./server.js");
|
|
89
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
90
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
91
|
+
|
|
92
|
+
const mk = async (username: string) => {
|
|
93
|
+
const r = await send(adminToken, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
|
|
94
|
+
expect(r.status).toBe(200);
|
|
95
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
96
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
97
|
+
};
|
|
98
|
+
alice = await mk(`perf_alice_${Date.now()}`);
|
|
99
|
+
bob = await mk(`perf_bob_${Date.now()}`);
|
|
100
|
+
|
|
101
|
+
const groupIds: string[] = [];
|
|
102
|
+
for (let g = 0; g < GROUPS; g++) {
|
|
103
|
+
const nodeIds = Array.from({ length: PER_GROUP }, (_, k) => `node_perf_${g * PER_GROUP + k}`);
|
|
104
|
+
const r = await send(adminToken, "POST", `/api/networks/${NET}/agent-groups`, { name: `perf-group-${g}`, node_ids: nodeIds });
|
|
105
|
+
expect(r.status).toBe(200);
|
|
106
|
+
groupIds.push(r.body.group.group_id);
|
|
107
|
+
}
|
|
108
|
+
expect((await send(adminToken, "PUT", `/api/networks/${NET}/members/${alice.id}/agent-grants`, { group_grants: groupIds })).status).toBe(200);
|
|
109
|
+
expect((await send(adminToken, "PUT", `/api/networks/${NET}/members/${bob.id}/agent-grants`, { grants: Array.from({ length: NODES }, (_, i) => `node_perf_${i}`) })).status).toBe(200);
|
|
110
|
+
}, 60_000);
|
|
111
|
+
|
|
112
|
+
afterAll(() => {
|
|
113
|
+
try { hub?.stop?.(true); } catch {}
|
|
114
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("Agent 分组:性能守卫(50 组 × 300 节点)", () => {
|
|
118
|
+
test("两人可见集合相同(比较才公平)", () => {
|
|
119
|
+
const a = visibleAgents(alice.id, NET);
|
|
120
|
+
const b = visibleAgents(bob.id, NET);
|
|
121
|
+
expect(a.nodeIds.length).toBe(NODES);
|
|
122
|
+
expect([...a.nodeIds].sort()).toEqual([...b.nodeIds].sort());
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test(`visibleAgents():组展开的额外开销 ≤ max(${BOUND_MS} ms, 基线 15%)(目标 ≈2 ms)`, () => {
|
|
126
|
+
const r = pairedDelta(() => { visibleAgents(bob.id, NET); }, () => { visibleAgents(alice.id, NET); }, ITER);
|
|
127
|
+
console.log(`[perf] visibleAgents median: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms paired-delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms`);
|
|
128
|
+
expect(r.delta).toBeLessThanOrEqual(bound(r.a));
|
|
129
|
+
}, PERF_TIMEOUT_MS);
|
|
130
|
+
|
|
131
|
+
// #2133:visibleAgents() 以前每个授权节点查一次 alias(300 条 ≈ 28–43 ms)。这两条钉住它不再回到 N+1:
|
|
132
|
+
// 查询条数(直接授权 1 + 组授权 1 + alias 1)是主判据,不受机器负载影响;绝对上限只兜底 ——
|
|
133
|
+
// 本机实测 ≈2.6–3.7 ms(负载 ~5),15 ms 给 test638 那种 --cpus=1 双份并发留足余量,仍远低于 N+1 的 28 ms。
|
|
134
|
+
const ABS_MS = 15;
|
|
135
|
+
test(`visibleAgents() at 300 grants stays under ${ABS_MS} ms (direct and via groups)`, () => {
|
|
136
|
+
const t = pairedDelta(() => { visibleAgents(bob.id, NET); }, () => { visibleAgents(alice.id, NET); }, 40);
|
|
137
|
+
console.log(`[perf] visibleAgents absolute: direct-300=${t.a.toFixed(2)}ms groups-50x6=${t.b.toFixed(2)}ms bound=${ABS_MS}ms`);
|
|
138
|
+
expect(t.a).toBeLessThan(ABS_MS);
|
|
139
|
+
expect(t.b).toBeLessThan(ABS_MS);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("visibleAgents() makes a fixed number of queries, not one per granted node (#2133)", () => {
|
|
143
|
+
for (const who of [bob, alice]) {
|
|
144
|
+
let queries = 0;
|
|
145
|
+
const all = db.all.bind(db), get = db.get.bind(db);
|
|
146
|
+
(db as any).all = (...a: any[]) => { queries++; return (all as any)(...a); };
|
|
147
|
+
(db as any).get = (...a: any[]) => { queries++; return (get as any)(...a); };
|
|
148
|
+
try { visibleAgents(who.id, NET); } finally { (db as any).all = all; (db as any).get = get; }
|
|
149
|
+
console.log(`[perf] visibleAgents queries (${who === bob ? "direct-300" : "groups-50x6"}): ${queries}`);
|
|
150
|
+
expect(queries).toBeLessThanOrEqual(3);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test(`受限成员 GET /api/status:组授权比等量直接授权多出 ≤ max(${BOUND_MS} ms, 基线 15%)`, async () => {
|
|
155
|
+
const hit = async (token: string) => {
|
|
156
|
+
const r = await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: json(token) });
|
|
157
|
+
const body = await r.json() as any;
|
|
158
|
+
if ((body.sessions ?? []).length !== NODES) throw new Error(`expected ${NODES} sessions, got ${(body.sessions ?? []).length}`);
|
|
159
|
+
};
|
|
160
|
+
const r = await pairedDeltaAsync(() => hit(bob.token), () => hit(alice.token), ITER);
|
|
161
|
+
console.log(`[perf] GET /api/status median: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms paired-delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms`);
|
|
162
|
+
expect(r.delta).toBeLessThanOrEqual(bound(r.a));
|
|
163
|
+
}, PERF_TIMEOUT_MS);
|
|
164
|
+
|
|
165
|
+
test("EXPLAIN QUERY PLAN:组展开走索引,不全表扫描", () => {
|
|
166
|
+
const plan = db.all<{ detail: string }>(
|
|
167
|
+
`EXPLAIN QUERY PLAN
|
|
168
|
+
SELECT m.node_id, g.can_message
|
|
169
|
+
FROM network_member_group_grants g
|
|
170
|
+
JOIN agent_groups ag ON ag.group_id = g.group_id AND ag.network_id = g.network_id
|
|
171
|
+
JOIN agent_group_members m ON m.group_id = g.group_id
|
|
172
|
+
JOIN nodes n ON n.node_id = m.node_id AND n.network_id = g.network_id
|
|
173
|
+
WHERE g.network_id = ?1 AND g.user_id = ?2`,
|
|
174
|
+
NET, alice.id,
|
|
175
|
+
).map((r) => r.detail);
|
|
176
|
+
console.log(`[perf] plan:\n ${plan.join("\n ")}`);
|
|
177
|
+
// 四张表都是 SEARCH(按主键 / 索引定位),没有一张是 SCAN。
|
|
178
|
+
expect(plan.length).toBeGreaterThanOrEqual(4);
|
|
179
|
+
expect(plan.filter((d) => /^SCAN\b/.test(d))).toEqual([]);
|
|
180
|
+
expect(plan.every((d) => /^SEARCH\b/.test(d) && /(PRIMARY KEY|INDEX)/.test(d))).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("EXPLAIN QUERY PLAN:按组找被授权成员(改组成员时断流用)走 group_id 索引", () => {
|
|
184
|
+
const plan = db.all<{ detail: string }>(
|
|
185
|
+
"EXPLAIN QUERY PLAN SELECT user_id FROM network_member_group_grants WHERE group_id = ?1", "x",
|
|
186
|
+
).map((r) => r.detail);
|
|
187
|
+
console.log(`[perf] plan (usersGrantedGroup): ${plan.join(" | ")}`);
|
|
188
|
+
expect(plan.some((d) => /SEARCH .*idx_group_grants_group/.test(d))).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -438,12 +438,15 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
|
|
|
438
438
|
// Check if any sessions/tasks still reference this network
|
|
439
439
|
const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
|
|
440
440
|
if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
|
|
441
|
-
db.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
441
|
+
db.transaction(() => {
|
|
442
|
+
db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
|
|
443
|
+
// PR #519 codex catch: leaving membership rows behind made deleted
|
|
444
|
+
// networks count toward getUserNetworkIds — a stale row could flip a
|
|
445
|
+
// single-network user to "ambiguous" or auto-resolve writes INTO the
|
|
446
|
+
// deleted network. Remove memberships with the network.
|
|
447
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
|
|
448
|
+
deleteNetworkAgentGroups(networkId); // #2144
|
|
449
|
+
});
|
|
447
450
|
return { ok: true };
|
|
448
451
|
}
|
|
449
452
|
|
|
@@ -582,7 +585,8 @@ export function getNetworkMembers(networkId: string) {
|
|
|
582
585
|
return db.all<any>(
|
|
583
586
|
`SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name,
|
|
584
587
|
CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access,
|
|
585
|
-
(SELECT COUNT(*) FROM network_member_agent_grants g WHERE g.network_id = nm.network_id AND g.user_id = nm.user_id) AS agent_grant_count
|
|
588
|
+
(SELECT COUNT(*) FROM network_member_agent_grants g WHERE g.network_id = nm.network_id AND g.user_id = nm.user_id) AS agent_grant_count,
|
|
589
|
+
(SELECT COUNT(*) FROM network_member_group_grants gg WHERE gg.network_id = nm.network_id AND gg.user_id = nm.user_id) AS agent_group_count
|
|
586
590
|
FROM network_members nm JOIN users u ON nm.user_id = u.user_id
|
|
587
591
|
WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
|
|
588
592
|
networkId);
|
|
@@ -793,3 +797,90 @@ export function listUsersWithMemberships() {
|
|
|
793
797
|
}
|
|
794
798
|
return users.map((u) => ({ ...u, networks: byUser.get(u.user_id) ?? [] }));
|
|
795
799
|
}
|
|
800
|
+
|
|
801
|
+
// Hub 管理员删**别人的**网络(清理旧测试账号留下的个人网络)。只删空网络:
|
|
802
|
+
// 下面每张表里有一行就拒(409),并把计数原样回给调用方。表是按 schema 里所有带
|
|
803
|
+
// network_id 的表逐个分类的,不是凭记忆 —— 新加一张带 network_id 的表要在这里归一类。
|
|
804
|
+
// 阻断:真实内容,或会在删后重新长出节点的在途请求。
|
|
805
|
+
// 清理:只描述「这个网络」本身的元数据/历史,网络没了它们就悬空。
|
|
806
|
+
// 保留:audit_log(删除记录本身要留下)。
|
|
807
|
+
const NETWORK_CONTENT_CHECKS: Array<[label: string, sql: string]> = [
|
|
808
|
+
["nodes", "SELECT COUNT(*) AS cnt FROM nodes WHERE network_id = ?1"],
|
|
809
|
+
["sessions", "SELECT COUNT(*) AS cnt FROM sessions WHERE network_id = ?1"],
|
|
810
|
+
["tasks", "SELECT COUNT(*) AS cnt FROM tasks WHERE network_id = ?1"],
|
|
811
|
+
["inbox", "SELECT COUNT(*) AS cnt FROM inbox WHERE network_id = ?1"],
|
|
812
|
+
["user_inbox", "SELECT COUNT(*) AS cnt FROM user_inbox WHERE network_id = ?1"],
|
|
813
|
+
["requirements", "SELECT COUNT(*) AS cnt FROM requirements WHERE network_id = ?1"],
|
|
814
|
+
["requirement_projects", "SELECT COUNT(*) AS cnt FROM requirement_projects WHERE network_id = ?1"],
|
|
815
|
+
["scheduled_tasks", "SELECT COUNT(*) AS cnt FROM scheduled_tasks WHERE network_id = ?1"],
|
|
816
|
+
["providers", "SELECT COUNT(*) AS cnt FROM providers WHERE network_id = ?1"],
|
|
817
|
+
["network_secrets", "SELECT COUNT(*) AS cnt FROM network_secrets WHERE network_id = ?1"],
|
|
818
|
+
["skillhub_skills", "SELECT COUNT(*) AS cnt FROM skillhub_skills WHERE network_id = ?1"],
|
|
819
|
+
["side_chats", "SELECT COUNT(*) AS cnt FROM side_chats WHERE network_id = ?1"],
|
|
820
|
+
// #2144 —— 组是管理员手工建的配置,和 requirements 一样算内容:有组就拒,别替人删掉。
|
|
821
|
+
["agent_groups", "SELECT COUNT(*) AS cnt FROM agent_groups WHERE network_id = ?1"],
|
|
822
|
+
["pending_node_create_requests", "SELECT COUNT(*) AS cnt FROM node_create_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
|
|
823
|
+
["pending_node_start_requests", "SELECT COUNT(*) AS cnt FROM node_start_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
|
|
824
|
+
];
|
|
825
|
+
const NETWORK_CLEANUP_TABLES = [
|
|
826
|
+
"network_members", "network_member_agent_grants", "network_invites", "api_tokens",
|
|
827
|
+
"node_create_requests", "node_start_requests", "node_stop_requests", "node_rules_requests",
|
|
828
|
+
"node_config_updates", "rename_txn", "probe_results", "agent_telemetry", "completions",
|
|
829
|
+
"task_events", "task_terminal_events", "scheduled_task_runs", "external_schedule_edits",
|
|
830
|
+
];
|
|
831
|
+
|
|
832
|
+
function tableExists(name: string): boolean {
|
|
833
|
+
return db.dialect === "postgres"
|
|
834
|
+
? !!db.get("SELECT 1 AS x FROM information_schema.tables WHERE table_name = ?1", name)
|
|
835
|
+
: !!db.get("SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?1", name);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export function countNetworkContents(networkId: string): Record<string, number> {
|
|
839
|
+
const counts: Record<string, number> = {};
|
|
840
|
+
for (const [label, sql] of NETWORK_CONTENT_CHECKS) {
|
|
841
|
+
const table = sql.match(/FROM (\w+)/)![1];
|
|
842
|
+
// 按需建表的模块(requirements / side-thread)在没用过的库上可能还没有这张表 —— 那就是 0 行。
|
|
843
|
+
if (!tableExists(table)) continue;
|
|
844
|
+
const cnt = db.get<{ cnt: number }>(sql, networkId)?.cnt ?? 0;
|
|
845
|
+
if (cnt > 0) counts[label] = cnt;
|
|
846
|
+
}
|
|
847
|
+
return counts;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export type AdminDeleteNetworkResult =
|
|
851
|
+
| { ok: true; owner_id: string; network_name: string; cleaned: Record<string, number> }
|
|
852
|
+
| { ok: false; status: number; error: string; counts?: Record<string, number> };
|
|
853
|
+
|
|
854
|
+
// extraCounts:库外的内容(上传文件的索引在磁盘上),由调用方数好传进来,和库内计数一起判。
|
|
855
|
+
export function adminDeleteEmptyNetwork(networkId: string, extraCounts: Record<string, number> = {}): AdminDeleteNetworkResult {
|
|
856
|
+
if (networkId === "default") return { ok: false, status: 409, error: "the default network cannot be deleted" };
|
|
857
|
+
return db.transaction((): AdminDeleteNetworkResult => {
|
|
858
|
+
const net = db.get<{ owner_id: string; network_name: string }>("SELECT owner_id, network_name FROM networks WHERE network_id = ?1", networkId);
|
|
859
|
+
if (!net) return { ok: false, status: 404, error: "network not found" };
|
|
860
|
+
const counts = { ...countNetworkContents(networkId) };
|
|
861
|
+
for (const [k, v] of Object.entries(extraCounts)) if (v > 0) counts[k] = v;
|
|
862
|
+
if (Object.keys(counts).length > 0) {
|
|
863
|
+
const summary = Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
864
|
+
return { ok: false, status: 409, error: `network is not empty (${summary}); only empty networks can be deleted by a hub admin`, counts };
|
|
865
|
+
}
|
|
866
|
+
const cleaned: Record<string, number> = {};
|
|
867
|
+
for (const table of NETWORK_CLEANUP_TABLES) {
|
|
868
|
+
if (!tableExists(table)) continue;
|
|
869
|
+
const changes = db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [networkId]).changes;
|
|
870
|
+
if (changes > 0) cleaned[table] = changes;
|
|
871
|
+
}
|
|
872
|
+
for (const [table, changes] of Object.entries(deleteNetworkAgentGroups(networkId))) if (changes > 0) cleaned[table] = changes;
|
|
873
|
+
db.run("DELETE FROM networks WHERE network_id = ?1", [networkId]);
|
|
874
|
+
return { ok: true, owner_id: net.owner_id, network_name: net.network_name, cleaned };
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// #2144 —— Agent 分组(#2131)的三张表跟着网络一起删。agent_group_members 没有 network_id 列,
|
|
879
|
+
// 只能按该网络的 group_id 删,所以必须在删 agent_groups 之前删它。owner 删网络与管理员删空网络共用。
|
|
880
|
+
export function deleteNetworkAgentGroups(networkId: string): Record<string, number> {
|
|
881
|
+
return {
|
|
882
|
+
network_member_group_grants: db.run("DELETE FROM network_member_group_grants WHERE network_id = ?1", [networkId]).changes,
|
|
883
|
+
agent_group_members: db.run("DELETE FROM agent_group_members WHERE group_id IN (SELECT group_id FROM agent_groups WHERE network_id = ?1)", [networkId]).changes,
|
|
884
|
+
agent_groups: db.run("DELETE FROM agent_groups WHERE network_id = ?1", [networkId]).changes,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
assertSafeTestDatabaseEnv,
|
|
4
|
+
redactPgUrl,
|
|
4
5
|
resolveDatabaseTarget,
|
|
5
6
|
} from "./db-adapter";
|
|
6
7
|
|
|
@@ -83,3 +84,65 @@ describe("#435 inherited DATABASE_URL guard", () => {
|
|
|
83
84
|
}
|
|
84
85
|
});
|
|
85
86
|
});
|
|
87
|
+
|
|
88
|
+
describe("RFC-039 §9 COMMHUB_TEST_PG_URL (test-only PostgreSQL)", () => {
|
|
89
|
+
const TEST = { ...BASE_ENV, NODE_ENV: "test" };
|
|
90
|
+
const ok = (url: string) => expect(resolveDatabaseTarget({ ...TEST, COMMHUB_TEST_PG_URL: url })).toEqual({ kind: "postgres", url });
|
|
91
|
+
const refused = (url: string, why: RegExp) => {
|
|
92
|
+
let message = "";
|
|
93
|
+
try { resolveDatabaseTarget({ ...TEST, COMMHUB_TEST_PG_URL: url }); } catch (e: any) { message = String(e?.message ?? e); }
|
|
94
|
+
expect(message).toMatch(/REFUSING COMMHUB_TEST_PG_URL/);
|
|
95
|
+
expect(message).toMatch(why);
|
|
96
|
+
// Credentials never reach an error message.
|
|
97
|
+
expect(message).not.toContain("s3cret");
|
|
98
|
+
expect(message).not.toContain("tester");
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
test("loopback host + anet_*_test database is accepted under NODE_ENV=test", () => {
|
|
102
|
+
ok("postgres://tester:s3cret@127.0.0.1:5432/anet_sched_test");
|
|
103
|
+
ok("postgresql://tester:s3cret@localhost/anet_side_thread_test");
|
|
104
|
+
ok("postgres://tester:s3cret@[::1]:6543/anet_x_test?connect_timeout=2&sslmode=disable");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("ignored outside NODE_ENV=test", () => {
|
|
108
|
+
expect(resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV: "production", COMMHUB_DB: "/tmp/i.db", COMMHUB_TEST_PG_URL: "postgres://tester:s3cret@127.0.0.1/anet_x_test" }))
|
|
109
|
+
.toEqual({ kind: "sqlite", path: "/tmp/i.db" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("inherited DATABASE_URL still refuses even when COMMHUB_TEST_PG_URL is set", () => {
|
|
113
|
+
expect(() => resolveDatabaseTarget({
|
|
114
|
+
...TEST,
|
|
115
|
+
DATABASE_URL: "postgres://user:pw@prod.example:5432/commhub",
|
|
116
|
+
COMMHUB_TEST_PG_URL: "postgres://tester:s3cret@127.0.0.1/anet_x_test",
|
|
117
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("refuses a value that is not a URL or not postgres", () => {
|
|
121
|
+
refused("not a url", /not a URL/);
|
|
122
|
+
refused("mysql://tester:s3cret@127.0.0.1/anet_x_test", /scheme/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("refuses every host that is not literally loopback", () => {
|
|
126
|
+
for (const host of ["10.0.0.5", "db.internal", "localhost.evil.example", "127.0.0.2", "127.0.0.1,evil.example", "LOCALHOST", "%2Ftmp"]) {
|
|
127
|
+
refused(`postgres://tester:s3cret@${host}/anet_x_test`, /host must be/);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("refuses database names outside /^anet_[a-z0-9_]*_test$/", () => {
|
|
132
|
+
for (const name of ["commhub", "anet_x", "anet_x_test2", "ANET_X_TEST", "anet-x_test", "anet_x_test/extra", ""]) {
|
|
133
|
+
refused(`postgres://tester:s3cret@127.0.0.1/${name}`, /database name/);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("refuses connection-steering query parameters", () => {
|
|
138
|
+
refused("postgres://tester:s3cret@127.0.0.1/anet_x_test?host=db.internal", /"host" is not allowed/);
|
|
139
|
+
refused("postgres://tester:s3cret@127.0.0.1/anet_x_test?options=-csearch_path%3Dprod", /"options" is not allowed/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("redactPgUrl drops user and password", () => {
|
|
143
|
+
const out = redactPgUrl("postgres://tester:s3cret@127.0.0.1:5432/anet_x_test");
|
|
144
|
+
expect(out).not.toContain("tester");
|
|
145
|
+
expect(out).not.toContain("s3cret");
|
|
146
|
+
expect(out).toContain("127.0.0.1:5432/anet_x_test");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { PG_EXPERIMENTAL_ENV, PgAdapter, SQLiteAdapter, splitSqlStatements, sqliteToPostgres } from "./db-adapter";
|
|
4
|
+
|
|
5
|
+
const UTC_NOW = "to_char((NOW()) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')";
|
|
6
|
+
|
|
7
|
+
describe("RFC-039 S2a sqliteToPostgres", () => {
|
|
8
|
+
test("datetime('now') stays SQLite-format UTC text", () => {
|
|
9
|
+
expect(sqliteToPostgres("SELECT datetime('now')")).toBe(`SELECT ${UTC_NOW}`);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("DEFAULT (datetime('now')) keeps the column TEXT", () => {
|
|
13
|
+
const out = sqliteToPostgres("created_at TEXT NOT NULL DEFAULT (datetime('now'))");
|
|
14
|
+
expect(out).toBe(`created_at TEXT NOT NULL DEFAULT (${UTC_NOW})`);
|
|
15
|
+
expect(out).not.toContain("TIMESTAMP");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("strftime with milliseconds keeps SQLite's %f shape", () => {
|
|
19
|
+
expect(sqliteToPostgres("VALUES (strftime('%Y-%m-%d %H:%M:%f', 'now'))")).toBe(
|
|
20
|
+
"VALUES (to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.MS'))",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("literal offset", () => {
|
|
25
|
+
expect(sqliteToPostgres("x < datetime('now', '+14 days')")).toBe(
|
|
26
|
+
"x < to_char((NOW() + INTERVAL '14 days') AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("parameter offset is cast so PG can infer its type", () => {
|
|
31
|
+
expect(sqliteToPostgres("x < datetime('now', ?2)")).toBe(
|
|
32
|
+
"x < to_char((NOW() + CAST($2 AS TEXT)::INTERVAL) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("INTEGER → BIGINT (epoch-ms columns), identifiers containing integer untouched", () => {
|
|
37
|
+
expect(sqliteToPostgres("created_at INTEGER NOT NULL, integer_ref TEXT")).toBe("created_at BIGINT NOT NULL, integer_ref TEXT");
|
|
38
|
+
expect(sqliteToPostgres("CAST(x AS INTEGER)")).toBe("CAST(x AS BIGINT)");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("BLOB → BYTEA, identifiers containing blob untouched", () => {
|
|
42
|
+
expect(sqliteToPostgres("iv BLOB NOT NULL, blob_ref TEXT")).toBe("iv BYTEA NOT NULL, blob_ref TEXT");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("placeholders and AUTOINCREMENT unchanged in behaviour", () => {
|
|
46
|
+
expect(sqliteToPostgres("SELECT * FROM t WHERE a = ?1 AND b = ?2")).toBe("SELECT * FROM t WHERE a = $1 AND b = $2");
|
|
47
|
+
expect(sqliteToPostgres("a = ? AND b = ?")).toBe("a = $1 AND b = $2");
|
|
48
|
+
expect(sqliteToPostgres("id INTEGER PRIMARY KEY AUTOINCREMENT")).toBe("id BIGSERIAL PRIMARY KEY");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("RFC-039 S2a splitSqlStatements", () => {
|
|
53
|
+
test("a `;` inside a -- comment does not split (db.ts first schema block)", () => {
|
|
54
|
+
expect(splitSqlStatements("CREATE TABLE a (x TEXT);\n-- note; more words\nCREATE TABLE b (y TEXT);")).toEqual([
|
|
55
|
+
"CREATE TABLE a (x TEXT)",
|
|
56
|
+
"CREATE TABLE b (y TEXT)",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("block comments are dropped", () => {
|
|
61
|
+
expect(splitSqlStatements("SELECT 1 /* a; b */ ; SELECT 2")).toEqual(["SELECT 1", "SELECT 2"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("quoted strings and identifiers keep their `;` and doubled quotes", () => {
|
|
65
|
+
expect(splitSqlStatements(`INSERT INTO t VALUES ('a;b', 'it''s;'); SELECT "c;d" FROM t`)).toEqual([
|
|
66
|
+
`INSERT INTO t VALUES ('a;b', 'it''s;')`,
|
|
67
|
+
`SELECT "c;d" FROM t`,
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a plpgsql $$ body stays one statement", () => {
|
|
72
|
+
const fn = "CREATE FUNCTION f() RETURNS trigger AS $$\nBEGIN\n INSERT INTO t VALUES (1);\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql";
|
|
73
|
+
expect(splitSqlStatements(`${fn};\nDROP TRIGGER IF EXISTS x ON t;`)).toEqual([fn, "DROP TRIGGER IF EXISTS x ON t"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("tagged dollar quotes; $1 placeholders are not dollar quotes", () => {
|
|
77
|
+
expect(splitSqlStatements("SELECT $body$a;b$body$; UPDATE t SET a = $1 WHERE b = $2")).toEqual([
|
|
78
|
+
"SELECT $body$a;b$body$",
|
|
79
|
+
"UPDATE t SET a = $1 WHERE b = $2",
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("empty statements are dropped", () => {
|
|
84
|
+
expect(splitSqlStatements(" ; ;\n SELECT 1 ;; ")).toEqual(["SELECT 1"]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("RFC-039 transactional feature gates", () => {
|
|
89
|
+
// A PgAdapter without its constructor: no worker, no connection.
|
|
90
|
+
function pgWith(atomic: boolean) {
|
|
91
|
+
const pg = Object.create(PgAdapter.prototype);
|
|
92
|
+
Object.defineProperty(pg, "atomicTransactions", { value: atomic });
|
|
93
|
+
return pg as PgAdapter;
|
|
94
|
+
}
|
|
95
|
+
function withFlag<T>(value: string | undefined, fn: () => T): T {
|
|
96
|
+
const saved = process.env[PG_EXPERIMENTAL_ENV];
|
|
97
|
+
if (value === undefined) delete process.env[PG_EXPERIMENTAL_ENV];
|
|
98
|
+
else process.env[PG_EXPERIMENTAL_ENV] = value;
|
|
99
|
+
try { return fn(); } finally {
|
|
100
|
+
if (saved === undefined) delete process.env[PG_EXPERIMENTAL_ENV];
|
|
101
|
+
else process.env[PG_EXPERIMENTAL_ENV] = saved;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test("SQLite: open, flag irrelevant", () => {
|
|
106
|
+
const sqlite = new SQLiteAdapter(new Database(":memory:"));
|
|
107
|
+
expect(withFlag(undefined, () => sqlite.transactionalFeaturesRefusal)).toBeNull();
|
|
108
|
+
expect(withFlag("1", () => sqlite.transactionalFeaturesRefusal)).toBeNull();
|
|
109
|
+
sqlite.close();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("PostgreSQL with real transactions: closed by default, message names the variable", () => {
|
|
113
|
+
const refusal = withFlag(undefined, () => pgWith(true).transactionalFeaturesRefusal);
|
|
114
|
+
expect(refusal).toContain(`${PG_EXPERIMENTAL_ENV}=1`);
|
|
115
|
+
expect(refusal).toContain("experimental");
|
|
116
|
+
expect(withFlag("true", () => pgWith(true).transactionalFeaturesRefusal)).not.toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("PostgreSQL: opens only with atomic transactions AND the opt-in", () => {
|
|
120
|
+
expect(withFlag("1", () => pgWith(true).transactionalFeaturesRefusal)).toBeNull();
|
|
121
|
+
expect(withFlag("1", () => pgWith(false).transactionalFeaturesRefusal)).toContain("not atomic");
|
|
122
|
+
});
|
|
123
|
+
});
|