@sleep2agi/commhub-server 0.9.0-preview.136 → 0.9.0-preview.138
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/package.json +1 -1
- package/src/create-node.ts +22 -11
- package/src/host-supervisor-identity-http.test.ts +136 -0
- package/src/host-supervisor-identity.ts +92 -0
- package/src/list-host-supervisors.test.ts +5 -4
- package/src/requirements-stats-http.test.ts +69 -0
- package/src/requirements-stats.ts +30 -2
- package/src/requirements.ts +2 -2
- package/src/server.ts +4 -24
- package/src/tools.ts +5 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.138",
|
|
4
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",
|
package/src/create-node.ts
CHANGED
|
@@ -237,7 +237,8 @@ export type NodeCallerResolution =
|
|
|
237
237
|
| { ok: true; kind: "unregistered"; alias: string; networkId: string }
|
|
238
238
|
| { ok: false; reason: "not_a_node_token" | "bound_node_missing" | "ambiguous" | "not_owner" };
|
|
239
239
|
|
|
240
|
-
type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
|
|
240
|
+
export type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
|
|
241
|
+
export type NodeCallerToken = { name: string; network_id: string | null; user_id: string | null; bound_node_id: string | null; node_identity_epoch: number };
|
|
241
242
|
|
|
242
243
|
// nodes has no unique (network, alias). An unbound token that hits several
|
|
243
244
|
// rows is not rejected outright: the row owned by the token's issuer wins;
|
|
@@ -246,18 +247,12 @@ type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner
|
|
|
246
247
|
function disambiguateUnboundAlias(
|
|
247
248
|
rows: NodeCallerRow[],
|
|
248
249
|
userId: string | null,
|
|
249
|
-
|
|
250
|
-
networkId: string,
|
|
250
|
+
recentNodeId: string | null,
|
|
251
251
|
): NodeCallerRow | undefined {
|
|
252
252
|
const owned = userId ? rows.filter((row) => row.owner_user_id === userId) : [];
|
|
253
253
|
if (owned.length === 1) return owned[0]; // board679-unbound-dup
|
|
254
254
|
const pool = owned.length > 1 ? owned : rows;
|
|
255
|
-
const
|
|
256
|
-
`SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1`,
|
|
257
|
-
alias,
|
|
258
|
-
networkId,
|
|
259
|
-
);
|
|
260
|
-
const hit = recent?.node_id ? pool.filter((row) => row.node_id === recent.node_id) : [];
|
|
255
|
+
const hit = recentNodeId ? pool.filter((row) => row.node_id === recentNodeId) : [];
|
|
261
256
|
return hit.length === 1 ? hit[0] : undefined;
|
|
262
257
|
}
|
|
263
258
|
|
|
@@ -268,7 +263,7 @@ function disambiguateUnboundAlias(
|
|
|
268
263
|
// do not get a second exception.
|
|
269
264
|
export function resolveNodeCaller(tokenId: string | null | undefined): NodeCallerResolution {
|
|
270
265
|
if (!tokenId) return { ok: false, reason: "not_a_node_token" };
|
|
271
|
-
const tokRow = db.get<
|
|
266
|
+
const tokRow = db.get<NodeCallerToken>(
|
|
272
267
|
`SELECT name, network_id, user_id, bound_node_id, node_identity_epoch FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
|
|
273
268
|
tokenId,
|
|
274
269
|
);
|
|
@@ -280,10 +275,26 @@ export function resolveNodeCaller(tokenId: string | null | undefined): NodeCalle
|
|
|
280
275
|
const rows = tokRow.bound_node_id
|
|
281
276
|
? db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE node_id = ?1 AND network_id = ?2`, tokRow.bound_node_id, tokRow.network_id)
|
|
282
277
|
: db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE alias = ?1 AND network_id = ?2`, tokenAlias, tokRow.network_id);
|
|
278
|
+
const recent = !tokRow.bound_node_id && rows.length > 1
|
|
279
|
+
? db.get<{ node_id: string | null }>(
|
|
280
|
+
`SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1`,
|
|
281
|
+
tokenAlias, tokRow.network_id,
|
|
282
|
+
) : null;
|
|
283
|
+
return resolveNodeCallerFromRows(tokRow, rows, recent?.node_id ?? null);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Shared identity contract for individual callers and scoped, batched list reads. */
|
|
287
|
+
export function resolveNodeCallerFromRows(
|
|
288
|
+
tokRow: NodeCallerToken, rows: NodeCallerRow[], recentNodeId: string | null,
|
|
289
|
+
): NodeCallerResolution {
|
|
290
|
+
if (!tokRow.name?.startsWith("node:") || !tokRow.network_id) {
|
|
291
|
+
return { ok: false, reason: "not_a_node_token" };
|
|
292
|
+
}
|
|
293
|
+
const tokenAlias = tokRow.name.slice(5);
|
|
283
294
|
const nodeRow = rows.length === 1
|
|
284
295
|
? rows[0]
|
|
285
296
|
: (!tokRow.bound_node_id && rows.length > 1
|
|
286
|
-
? disambiguateUnboundAlias(rows, tokRow.user_id,
|
|
297
|
+
? disambiguateUnboundAlias(rows, tokRow.user_id, recentNodeId)
|
|
287
298
|
: undefined);
|
|
288
299
|
// board679-bound-row-missing: a bound token with no row is refused. Do not look up by name.
|
|
289
300
|
if (tokRow.bound_node_id && !nodeRow) return { ok: false, reason: "bound_node_missing" };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { register } from "./auth.js";
|
|
5
|
+
import { registerTools } from "./tools.js";
|
|
6
|
+
import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
|
|
7
|
+
|
|
8
|
+
if (!process.env.COMMHUB_DB && !process.env.COMMHUB_TEST_PG_URL) throw Error("explicit fixture DB required");
|
|
9
|
+
let net: string, foreign: string, user: string, token: string, hub: any, base: string;
|
|
10
|
+
let sequence = 0;
|
|
11
|
+
const next = () => `n_lhs1109_${++sequence}`;
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const auth = register(`lhs1109_${Date.now()}`, "FixturePassword1109!", undefined, "fixture");
|
|
14
|
+
if (!auth.ok || !auth.token || !auth.user || !auth.network_id) throw Error("fixture registration failed");
|
|
15
|
+
user = auth.user.user_id; token = auth.token; net = auth.network_id;
|
|
16
|
+
foreign = `net_foreign_lhs1109_${Date.now()}`;
|
|
17
|
+
db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,'foreign',?2)", [foreign, user]);
|
|
18
|
+
process.env.HOST = "127.0.0.1";
|
|
19
|
+
const { bootServer } = await import("./server.js");
|
|
20
|
+
hub = await bootServer({ port: 0 }); base = `http://127.0.0.1:${hub.port}`;
|
|
21
|
+
}, 30_000);
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
db.run("DELETE FROM api_tokens WHERE name LIKE 'node:lhs1109_%'");
|
|
24
|
+
db.run("DELETE FROM sessions WHERE alias LIKE 'lhs1109_%'");
|
|
25
|
+
db.run("DELETE FROM nodes WHERE alias LIKE 'lhs1109_%'");
|
|
26
|
+
});
|
|
27
|
+
afterAll(() => hub?.stop(true));
|
|
28
|
+
|
|
29
|
+
function node(alias: string, network = net, owner: string | null = user) {
|
|
30
|
+
const id = next();
|
|
31
|
+
db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,config_snapshot)
|
|
32
|
+
VALUES(?1,?2,?2,?3,?4,'{"role":"host_supervisor"}')`, [id, `lhs1109_${alias}`, network, owner]);
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
35
|
+
function credential(id: string, opts: { bound?: string | null; name?: string; issuer?: string; epoch?: number; network?: string; scope?: string; expired?: boolean; revoked?: boolean } = {}) {
|
|
36
|
+
const n = db.get<any>("SELECT alias, network_id FROM nodes WHERE node_id=?1", id)!;
|
|
37
|
+
const tid = `tok_lhs1109_${++sequence}`;
|
|
38
|
+
db.run(`INSERT INTO api_tokens(token_id,token_hash,user_id,network_id,scope,name,bound_node_id,node_identity_epoch,expires_at,revoked_at)
|
|
39
|
+
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`, [tid, `hash_${tid}`, opts.issuer ?? user,
|
|
40
|
+
opts.network ?? n.network_id, opts.scope ?? "network", `node:${opts.name ?? n.alias}`,
|
|
41
|
+
opts.bound === undefined ? id : opts.bound, opts.epoch ?? 2,
|
|
42
|
+
opts.expired ? "2000-01-01 00:00:00" : null, opts.revoked ? "2000-01-01 00:00:00" : null]);
|
|
43
|
+
return tid;
|
|
44
|
+
}
|
|
45
|
+
function heartbeat(id: string, opts: { bound?: string | null; network?: string | null; alias?: string; age?: number } = {}) {
|
|
46
|
+
const n = db.get<any>("SELECT alias,network_id FROM nodes WHERE node_id=?1", id)!;
|
|
47
|
+
db.run(`INSERT INTO sessions(resume_id,alias,network_id,node_id,last_seen_at,status,cpu_cores,mem_total_gb,ip)
|
|
48
|
+
VALUES(?1,?2,?3,?4,?5,'idle',8,16,'10.1.1.1')`, [`s_lhs1109_${++sequence}`, opts.alias ?? n.alias,
|
|
49
|
+
opts.network === undefined ? n.network_id : opts.network, opts.bound === undefined ? id : opts.bound,
|
|
50
|
+
new Date(Date.now() - (opts.age ?? 0)).toISOString()]);
|
|
51
|
+
}
|
|
52
|
+
async function list(entry: "MCP" | "REST") {
|
|
53
|
+
if (entry === "REST") {
|
|
54
|
+
const r = await fetch(`${base}/api/host-supervisors?network_id=${net}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
55
|
+
expect(r.status).toBe(200); return (await r.json() as any).daemons as any[];
|
|
56
|
+
}
|
|
57
|
+
const server = new McpServer({ name: "fixture", version: "0" }) as any;
|
|
58
|
+
let handler: any;
|
|
59
|
+
const original = server.tool.bind(server);
|
|
60
|
+
server.tool = (name: string, desc: string, schema: any, fn: any) => {
|
|
61
|
+
if (name === "list_host_supervisors") handler = fn;
|
|
62
|
+
return original(name, desc, schema, fn);
|
|
63
|
+
};
|
|
64
|
+
registerTools(server, undefined, null, user, null, false, null);
|
|
65
|
+
const result = await handler({ network_id: net });
|
|
66
|
+
const parsed = JSON.parse(result.content[0].text); expect(parsed.ok).toBe(true); return parsed.daemons as any[];
|
|
67
|
+
}
|
|
68
|
+
for (const entry of ["MCP", "REST"] as const) {
|
|
69
|
+
test(`${entry}: bound credentials survive stale token names and rotations dedupe`, async () => {
|
|
70
|
+
const id = node("renamed"); credential(id, { name: "lhs1109_oldname" }); credential(id); heartbeat(id);
|
|
71
|
+
const rows = await list(entry); expect(rows.length).toBe(1);
|
|
72
|
+
expect(rows[0]).toMatchObject({ daemon_node_id: id, online: true });
|
|
73
|
+
expect(JSON.stringify(rows)).not.toContain("hash_");
|
|
74
|
+
});
|
|
75
|
+
test(`${entry}: same-name foreign binding and missing bound node cannot authenticate daemon`, async () => {
|
|
76
|
+
const id = node("victim"), other = node("victim", foreign);
|
|
77
|
+
credential(id, { bound: other }); credential(id, { bound: "n_lhs1109_missing" }); heartbeat(id);
|
|
78
|
+
expect(await list(entry)).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
test(`${entry}: legacy owner and epoch-zero ownerless keep compatibility; squatter and new ownerless rejected`, async () => {
|
|
81
|
+
const good = node("owned"), old = node("old", net, null), squatter = node("squatter"), recent = node("recent", net, null);
|
|
82
|
+
credential(good, { bound: null }); credential(old, { bound: null, epoch: 0 });
|
|
83
|
+
credential(squatter, { bound: null, issuer: "u_foreign_fixture" }); credential(recent, { bound: null, epoch: 1 });
|
|
84
|
+
const ids = (await list(entry)).map(r => r.daemon_node_id).sort(); expect(ids).toEqual([good, old].sort());
|
|
85
|
+
});
|
|
86
|
+
test(`${entry}: same-alias sibling heartbeat cannot make stopped daemon online`, async () => {
|
|
87
|
+
const stopped = node("duplicate"), live = node("duplicate"); credential(stopped); credential(live); heartbeat(live);
|
|
88
|
+
const rows = await list(entry);
|
|
89
|
+
expect(rows.find(r => r.daemon_node_id === stopped)).toMatchObject({ online: false, last_seen_at: null });
|
|
90
|
+
expect(rows.find(r => r.daemon_node_id === live)).toMatchObject({ online: true });
|
|
91
|
+
});
|
|
92
|
+
test(`${entry}: null-ID heartbeat requires unique alias in its network`, async () => {
|
|
93
|
+
const unique = node("unique"), a = node("ambiguous"), b = node("ambiguous");
|
|
94
|
+
for (const id of [unique,a,b]) credential(id);
|
|
95
|
+
heartbeat(unique, { bound: null }); heartbeat(a, { bound: null });
|
|
96
|
+
const rows = await list(entry);
|
|
97
|
+
expect(rows.find(r => r.daemon_node_id === unique).online).toBe(true);
|
|
98
|
+
expect(rows.filter(r => r.daemon_node_id !== unique).map(r => r.online)).toEqual([false,false]);
|
|
99
|
+
});
|
|
100
|
+
test(`${entry}: null-network legacy heartbeat is accepted only for globally unique aliases`, async () => {
|
|
101
|
+
const unique = node("globalunique"), shared = node("globalshared"); node("globalshared", foreign);
|
|
102
|
+
credential(unique); credential(shared);
|
|
103
|
+
heartbeat(unique, { bound: null, network: null }); heartbeat(shared, { bound: null, network: null });
|
|
104
|
+
const rows = await list(entry);
|
|
105
|
+
expect(rows.find(r => r.daemon_node_id === unique).online).toBe(true);
|
|
106
|
+
expect(rows.find(r => r.daemon_node_id === shared).online).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
test(`${entry}: bound heartbeat ignores stale alias but refuses wrong network and stale timestamps`, async () => {
|
|
109
|
+
const renamed = node("newalias"), wrong = node("wrongnet"), stale = node("stale");
|
|
110
|
+
for (const id of [renamed,wrong,stale]) credential(id);
|
|
111
|
+
heartbeat(renamed, { alias: "lhs1109_stalealias" }); heartbeat(wrong, { network: foreign }); heartbeat(stale, { age: 360_000 });
|
|
112
|
+
const rows = await list(entry);
|
|
113
|
+
expect(rows.find(r => r.daemon_node_id === renamed).online).toBe(true);
|
|
114
|
+
expect(rows.filter(r => r.daemon_node_id !== renamed).map(r => r.online)).toEqual([false,false]);
|
|
115
|
+
});
|
|
116
|
+
test(`${entry}: unbound duplicate-owner disambiguation uses authoritative recent session`, async () => {
|
|
117
|
+
const a = node("unboundduplicate"), b = node("unboundduplicate"); credential(a, { bound: null }); heartbeat(b);
|
|
118
|
+
const rows = await list(entry); expect(rows.length).toBe(1); expect(rows[0]).toMatchObject({ daemon_node_id: b, online: true });
|
|
119
|
+
});
|
|
120
|
+
test(`${entry}: deleted, revoked, expired and wrong-scope credentials excluded`, async () => {
|
|
121
|
+
for (const [label,opts] of [["expired",{ expired:true }],["revoked",{ revoked:true }],["scope",{ scope:"full" }],["deleted",{}]] as const) {
|
|
122
|
+
const id = node(label), tid = credential(id, opts); heartbeat(id);
|
|
123
|
+
if (label === "deleted") db.run("DELETE FROM api_tokens WHERE token_id=?1", [tid]);
|
|
124
|
+
}
|
|
125
|
+
expect(await list(entry)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
test("batched list performs three reads for many daemons and multiple scoped networks", () => {
|
|
129
|
+
for (let i=0;i<30;i++) { const id=node(`fleet${i}`, i%2 ? net : foreign); credential(id); heartbeat(id); }
|
|
130
|
+
const original = db.all.bind(db); let reads=0;
|
|
131
|
+
db.all = ((sql: string, ...params: any[]) => { reads++; return original(sql, ...params); }) as any;
|
|
132
|
+
try {
|
|
133
|
+
const rows = hostSupervisorIdentityRows("SELECT * FROM nodes WHERE network_id=?1 OR network_id=?2", [net,foreign]);
|
|
134
|
+
expect(rows.length).toBe(30); expect(reads).toBe(3);
|
|
135
|
+
} finally { db.all = original; }
|
|
136
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { resolveNodeCallerFromRows, type NodeCallerRow, type NodeCallerToken } from "./create-node.js";
|
|
3
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
4
|
+
|
|
5
|
+
type NodeRow = NodeCallerRow & Record<string, any>;
|
|
6
|
+
type SessionRow = {
|
|
7
|
+
node_id: string | null; alias: string; network_id: string | null;
|
|
8
|
+
last_seen_at: string | null; status: string | null;
|
|
9
|
+
cpu_cores: number | null; mem_total_gb: number | null; ip: string | null;
|
|
10
|
+
legacy_alias_count: number;
|
|
11
|
+
};
|
|
12
|
+
const key = (network: string, alias: string) => JSON.stringify([network, alias]);
|
|
13
|
+
const push = <T>(map: Map<string, T[]>, id: string, value: T) => {
|
|
14
|
+
const list = map.get(id) ?? []; list.push(value); map.set(id, list);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The caller supplies a scoped nodes query, never an unchecked network id.
|
|
18
|
+
* Three SQL reads regardless of fleet size; no token secrets are selected.
|
|
19
|
+
* All node roles participate in legacy alias disambiguation.
|
|
20
|
+
*/
|
|
21
|
+
export function hostSupervisorIdentityRows(scopedNodesSql: string, params: any[]): NodeRow[] {
|
|
22
|
+
const nodes = db.all<NodeRow>(scopedNodesSql, ...params);
|
|
23
|
+
if (!nodes.length) return [];
|
|
24
|
+
const tokens = db.all<NodeCallerToken>(`
|
|
25
|
+
SELECT t.name, t.network_id, t.user_id, t.bound_node_id, t.node_identity_epoch
|
|
26
|
+
FROM api_tokens t
|
|
27
|
+
WHERE t.network_id IN (SELECT scoped.network_id FROM (${scopedNodesSql}) scoped)
|
|
28
|
+
AND t.scope='network' AND t.revoked_at IS NULL
|
|
29
|
+
AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
|
|
30
|
+
AND t.name LIKE 'node:%'
|
|
31
|
+
`, ...params);
|
|
32
|
+
const sessions = db.all<SessionRow>(`
|
|
33
|
+
SELECT s.node_id, s.alias, s.network_id, s.last_seen_at, s.status,
|
|
34
|
+
s.cpu_cores, s.mem_total_gb, s.ip,
|
|
35
|
+
CASE WHEN s.network_id IS NULL AND s.node_id IS NULL THEN
|
|
36
|
+
(SELECT COUNT(*) FROM nodes n WHERE n.alias=s.alias)
|
|
37
|
+
ELSE 0 END AS legacy_alias_count
|
|
38
|
+
FROM sessions s
|
|
39
|
+
WHERE s.network_id IN (SELECT scoped.network_id FROM (${scopedNodesSql}) scoped)
|
|
40
|
+
OR (s.network_id IS NULL AND (
|
|
41
|
+
s.alias IN (SELECT scoped.alias FROM (${scopedNodesSql}) scoped)
|
|
42
|
+
OR s.node_id IN (SELECT scoped.node_id FROM (${scopedNodesSql}) scoped)))
|
|
43
|
+
ORDER BY s.updated_at DESC
|
|
44
|
+
`, ...params);
|
|
45
|
+
const byId = new Map(nodes.map(n => [n.node_id, n]));
|
|
46
|
+
const byAlias = new Map<string, NodeRow[]>();
|
|
47
|
+
const byGlobalAlias = new Map<string, NodeRow[]>();
|
|
48
|
+
for (const n of nodes) {
|
|
49
|
+
push(byAlias, key(n.network_id, n.alias), n);
|
|
50
|
+
push(byGlobalAlias, n.alias, n);
|
|
51
|
+
}
|
|
52
|
+
const recent = new Map<string, string>();
|
|
53
|
+
for (const s of sessions) {
|
|
54
|
+
if (!s.network_id || !s.node_id) continue;
|
|
55
|
+
const id = key(s.network_id, s.alias);
|
|
56
|
+
if (!recent.has(id)) recent.set(id, s.node_id);
|
|
57
|
+
}
|
|
58
|
+
const credentialed = new Set<string>();
|
|
59
|
+
for (const t of tokens) {
|
|
60
|
+
if (!t.network_id) continue;
|
|
61
|
+
const bound = t.bound_node_id ? byId.get(t.bound_node_id) : null;
|
|
62
|
+
const rows = t.bound_node_id
|
|
63
|
+
? (bound?.network_id === t.network_id ? [bound] : [])
|
|
64
|
+
: byAlias.get(key(t.network_id, t.name.slice(5))) ?? [];
|
|
65
|
+
const caller = resolveNodeCallerFromRows(t, rows, recent.get(key(t.network_id, t.name.slice(5))) ?? null);
|
|
66
|
+
if (caller.ok && caller.kind === "node") credentialed.add(caller.nodeId);
|
|
67
|
+
}
|
|
68
|
+
const heartbeat = new Map<string, SessionRow>();
|
|
69
|
+
for (const s of sessions) {
|
|
70
|
+
let node: NodeRow | undefined;
|
|
71
|
+
if (s.node_id) {
|
|
72
|
+
node = byId.get(s.node_id);
|
|
73
|
+
if (s.network_id !== null && node?.network_id !== s.network_id) continue;
|
|
74
|
+
} else if (s.network_id) {
|
|
75
|
+
const matches = byAlias.get(key(s.network_id, s.alias)) ?? [];
|
|
76
|
+
if (matches.length === 1) node = matches[0];
|
|
77
|
+
} else if (Number(s.legacy_alias_count) === 1) {
|
|
78
|
+
node = byGlobalAlias.get(s.alias)?.[0];
|
|
79
|
+
}
|
|
80
|
+
if (!node || !s.last_seen_at || !Number.isFinite(parseDbTimestampMs(s.last_seen_at))) continue;
|
|
81
|
+
const previous = heartbeat.get(node.node_id);
|
|
82
|
+
if (!previous || parseDbTimestampMs(s.last_seen_at) > parseDbTimestampMs(previous.last_seen_at!)) {
|
|
83
|
+
heartbeat.set(node.node_id, s);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return nodes.filter(n => credentialed.has(n.node_id)).map(n => {
|
|
87
|
+
const s = heartbeat.get(n.node_id);
|
|
88
|
+
return { ...n, session_last_seen: s?.last_seen_at ?? null, session_status: s?.status ?? null,
|
|
89
|
+
session_cpu_cores: s?.cpu_cores ?? null, session_mem_total_gb: s?.mem_total_gb ?? null,
|
|
90
|
+
session_ip: s?.ip ?? null };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -40,6 +40,7 @@ function cleanup() {
|
|
|
40
40
|
try { db.run("DELETE FROM users WHERE user_id LIKE 'u_lhs_%'"); } catch {}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Each case starts empty; plain INSERT keeps these fixtures portable to PostgreSQL.
|
|
43
44
|
beforeEach(cleanup);
|
|
44
45
|
afterAll(cleanup);
|
|
45
46
|
|
|
@@ -52,7 +53,7 @@ function seedUser(user_id: string) {
|
|
|
52
53
|
}
|
|
53
54
|
function seedNetwork(net_id: string, owner_id = "u_lhs_owner") {
|
|
54
55
|
db.run(
|
|
55
|
-
`INSERT
|
|
56
|
+
`INSERT INTO networks (network_id, network_name, owner_id, created_at)
|
|
56
57
|
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
57
58
|
[net_id, net_id, owner_id],
|
|
58
59
|
);
|
|
@@ -81,7 +82,7 @@ function seedDaemon(opts: {
|
|
|
81
82
|
allowed_secret_keys: opts.secrets ?? [],
|
|
82
83
|
});
|
|
83
84
|
db.run(
|
|
84
|
-
`INSERT
|
|
85
|
+
`INSERT INTO nodes (
|
|
85
86
|
node_id, node_name, alias, network_id,
|
|
86
87
|
runtimes_supported, allowed_secret_keys,
|
|
87
88
|
config_snapshot, hostname,
|
|
@@ -95,14 +96,14 @@ function seedDaemon(opts: {
|
|
|
95
96
|
if (opts.sessionAgeMs !== undefined) {
|
|
96
97
|
const ts = new Date(Date.now() - opts.sessionAgeMs).toISOString().replace("T", " ").replace(/\..+/, "");
|
|
97
98
|
db.run(
|
|
98
|
-
`INSERT
|
|
99
|
+
`INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
|
|
99
100
|
VALUES (?1, ?2, ?3, ?4, 'idle', 8, 16, '10.0.0.5')`,
|
|
100
101
|
[`s_${node_id}`, opts.alias, opts.network_id, ts],
|
|
101
102
|
);
|
|
102
103
|
}
|
|
103
104
|
if (opts.mintToken !== false && !opts.tokenDeleted) {
|
|
104
105
|
db.run(
|
|
105
|
-
`INSERT
|
|
106
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
106
107
|
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, ?6)`,
|
|
107
108
|
[`tok_${node_id}`, `u_seed_${node_id}`, opts.network_id, `node:${opts.alias}`,
|
|
108
109
|
`hash_${node_id}`,
|
|
@@ -286,3 +286,72 @@ describe("GET /api/requirements/stats", () => {
|
|
|
286
286
|
expect(r.body.requirement).toBeUndefined();
|
|
287
287
|
});
|
|
288
288
|
});
|
|
289
|
+
|
|
290
|
+
describe("tag_status current snapshot #1091", () => {
|
|
291
|
+
let project = "";
|
|
292
|
+
test("current unarchived four states, distinct memberships and separate null bucket", async () => {
|
|
293
|
+
const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "tag-status" });
|
|
294
|
+
expect(p.status).toBe(201); project = p.body.project.id;
|
|
295
|
+
const seed = (name: string, extra: Record<string, unknown>) => card(admin.token, name, { project_id: project, ...extra });
|
|
296
|
+
await seed("tag-pool", { tags: ["a", "b", "a"] });
|
|
297
|
+
await seed("tag-done", { tags: ["a", "b"], column: "done" });
|
|
298
|
+
await seed("tag-doing", { tags: ["b"], column: "doing" });
|
|
299
|
+
await seed("tag-abandoned", { column: "abandoned" });
|
|
300
|
+
await seed("tag-literal", { tags: ["无标签"] });
|
|
301
|
+
const invalid = await seed("tag-invalid-legacy", { column: "done" });
|
|
302
|
+
db.run("UPDATE requirements SET tags_json='invalid legacy json' WHERE requirement_id=?1", [invalid.id]);
|
|
303
|
+
await seed("tag-visible", { tags: ["visible"], column: "doing", owner: { kind: "user", id: alice.id } });
|
|
304
|
+
await seed("tag-secret", { tags: ["secret"], column: "done" });
|
|
305
|
+
const archived = await seed("tag-archived", { tags: ["archived-only"], column: "doing" });
|
|
306
|
+
expect((await send(admin.token, "PATCH", `/api/requirements/${archived.id}?network_id=${NET}`, { archived: true })).status).toBe(200);
|
|
307
|
+
const r = await stats(admin.token, `&project_id=${project}`);
|
|
308
|
+
expect(r.status).toBe(200);
|
|
309
|
+
expect(r.body.tag_status).toMatchObject({ basis: "current", complete: true, includes_archived: false, project_filter: project, total: 8 });
|
|
310
|
+
const buckets = new Map(r.body.tag_status.by_tag.map((x: any) => [x.tag, x]));
|
|
311
|
+
expect(buckets.get("a")).toEqual({ tag: "a", pool: 1, doing: 0, done: 1, abandoned: 0, total: 2 });
|
|
312
|
+
expect(buckets.get("b")).toEqual({ tag: "b", pool: 1, doing: 1, done: 1, abandoned: 0, total: 3 });
|
|
313
|
+
expect(buckets.get(null)).toEqual({ tag: null, pool: 0, doing: 0, done: 1, abandoned: 1, total: 2 });
|
|
314
|
+
expect(buckets.get("无标签")).toMatchObject({ pool: 1, total: 1 });
|
|
315
|
+
expect(buckets.has("archived-only")).toBe(false);
|
|
316
|
+
expect(r.body.tag_status.by_tag[0].tag).toBe("b");
|
|
317
|
+
expect(r.body.tag_status.by_tag.reduce((sum: number, x: any) => sum + x.total, 0)).toBe(10);
|
|
318
|
+
});
|
|
319
|
+
test("snapshot ignores time range; completion totals still follow it", async () => {
|
|
320
|
+
const future = new Date(Date.now() + 86_400_000).toISOString();
|
|
321
|
+
const r = await stats(admin.token, `&project_id=${project}&from=${encodeURIComponent(future)}&to=${encodeURIComponent(new Date(Date.now()+2*86_400_000).toISOString())}`);
|
|
322
|
+
expect(r.body.totals.done).toBe(0); expect(r.body.tag_status.total).toBe(8);
|
|
323
|
+
expect(r.body.tag_status.by_tag.find((x: any) => x.tag === "a").done).toBe(1);
|
|
324
|
+
});
|
|
325
|
+
test("same visibility scope hides inaccessible tags and project results", async () => {
|
|
326
|
+
const r = await stats(alice.token, `&project_id=${project}`);
|
|
327
|
+
expect(r.status).toBe(200); expect(r.body.tag_status.total).toBe(1);
|
|
328
|
+
expect(r.body.tag_status.by_tag).toEqual([{ tag: "visible", pool: 0, doing: 1, done: 0, abandoned: 0, total: 1 }]);
|
|
329
|
+
expect(r.text).not.toContain('"secret"'); expect(r.text).not.toContain('"archived-only"');
|
|
330
|
+
const empty = await stats(alice.token, `&project_id=${PROJ}`);
|
|
331
|
+
expect(empty.body.tag_status.total).toBe(0); expect(empty.body.tag_status.by_tag).toEqual([]);
|
|
332
|
+
});
|
|
333
|
+
test("project none, unknown project and invalid project query", async () => {
|
|
334
|
+
const r = await stats(admin.token, "&project_id=none");
|
|
335
|
+
expect(r.body.tag_status.project_filter).toBe("none");
|
|
336
|
+
expect(r.body.tag_status.by_tag.every((x: any) => x.tag === null)).toBe(true);
|
|
337
|
+
expect((await stats(admin.token, "&project_id=proj_missing")).body.tag_status).toMatchObject({ total: 0, by_tag: [] });
|
|
338
|
+
expect((await stats(admin.token, `&project_id=${"x".repeat(201)}`)).body.error).toBe("invalid_project_id");
|
|
339
|
+
const list = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
|
|
340
|
+
expect(list.body.capabilities).toContain("tag_status_snapshot");
|
|
341
|
+
});
|
|
342
|
+
test("all visible rows counted beyond client 500 limit; another network excluded", async () => {
|
|
343
|
+
const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "large-tag-status" });
|
|
344
|
+
expect(p.status).toBe(201); const large = p.body.project.id;
|
|
345
|
+
const other = `net_tag_foreign_${Date.now()}`;
|
|
346
|
+
db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,'foreign',?2)", [other,admin.id]);
|
|
347
|
+
db.transaction(() => {
|
|
348
|
+
for (let i=0;i<502;i++) db.run(`INSERT INTO requirements(requirement_id,network_id,title,column_name,tags_json,project_id,archived,created_at,updated_at)
|
|
349
|
+
VALUES(?1,?2,'bulk','pool','["bulk"]',?3,0,datetime('now'),datetime('now'))`, [`req_tag_bulk_${large}_${i}`,i===501?other:NET,large]);
|
|
350
|
+
});
|
|
351
|
+
const r = await stats(admin.token, `&project_id=${large}&recent=0`);
|
|
352
|
+
expect(r.status).toBe(200); expect(r.body.tag_status.total).toBe(501);
|
|
353
|
+
expect(r.body.tag_status.by_tag).toEqual([{ tag: "bulk", pool: 501, doing: 0, done: 0, abandoned: 0, total: 501 }]);
|
|
354
|
+
const denied = await send(alice.token,"GET",`/api/requirements/stats?network_id=${other}`);
|
|
355
|
+
expect([403,404]).toContain(denied.status);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// 这里只按时间范围 / 时区分桶、按项目 / 完成者计数。放在 JS 里而不是 SQL:按 IANA 时区切「哪一天」在
|
|
3
3
|
// SQLite 和 PostgreSQL 上写法不同,而一个网络的卡片数是几百到几千,整表轻量列读一次就够。
|
|
4
4
|
import { isoInstant } from "./requirements-migrate.js";
|
|
5
|
+
import { storedTags } from "./requirement-tags.js";
|
|
5
6
|
|
|
6
7
|
export const STATS_DEFAULT_DAYS = 30;
|
|
7
8
|
export const STATS_MAX_DAYS = 371; // 53 周:够画一整年的贡献热力图
|
|
@@ -21,9 +22,10 @@ export type StatsRow = {
|
|
|
21
22
|
completed_at: string | null;
|
|
22
23
|
completed_at_approx: number | null;
|
|
23
24
|
completed_by_json: string | null;
|
|
25
|
+
tags_json?: string | null;
|
|
24
26
|
};
|
|
25
27
|
|
|
26
|
-
export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number };
|
|
28
|
+
export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number; projectId?: string | null };
|
|
27
29
|
type Ref = { kind: "user" | "node"; id: string };
|
|
28
30
|
|
|
29
31
|
/** 解析 from / to / tz / days。不合法 → 错误码(400)。to 缺省 = 现在;from 缺省 = 不设下限(全部)。 */
|
|
@@ -48,7 +50,10 @@ export function parseStatsQuery(q: URLSearchParams, nowMs: number): StatsQuery |
|
|
|
48
50
|
const recentRaw = q.get("recent");
|
|
49
51
|
const recent = recentRaw === null || recentRaw === "" ? STATS_DEFAULT_RECENT : Number(recentRaw);
|
|
50
52
|
if (!Number.isInteger(recent) || recent < 0 || recent > STATS_MAX_RECENT) return { error: "invalid_recent" };
|
|
51
|
-
|
|
53
|
+
const project = q.get("project_id");
|
|
54
|
+
if (project && (project.length > 200 || /[\u0000-\u001f\u007f]/u.test(project))) return { error: "invalid_project_id" };
|
|
55
|
+
return { fromMs: from, toMs, tz, days, recent,
|
|
56
|
+
...(project ? { projectId: project === "none" ? null : project } : {}) };
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
function validTimeZone(tz: string): boolean {
|
|
@@ -86,6 +91,7 @@ const parseRef = (json: string | null): Ref | null => {
|
|
|
86
91
|
* 但归到 unattributed,不出现在 by_completer 里 —— 不给他留「那个节点做了几张」的探测口。
|
|
87
92
|
*/
|
|
88
93
|
export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId: string) => boolean) | null) {
|
|
94
|
+
if (q.projectId !== undefined) rows = rows.filter(row => row.project_id === q.projectId);
|
|
89
95
|
const inRange = (ms: number | null) => ms !== null && ms <= q.toMs && (q.fromMs === null || ms >= q.fromMs);
|
|
90
96
|
const days = dayRange(localDate(q.toMs, q.tz), q.days);
|
|
91
97
|
const firstDay = days[0];
|
|
@@ -97,8 +103,21 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
|
|
|
97
103
|
const byProject = new Map<string | null, number>();
|
|
98
104
|
const byCompleter = new Map<string, { ref: Ref; n: number; spark: Map<string, number> }>();
|
|
99
105
|
const recent: { row: StatsRow; ms: number; by: Ref | null }[] = [];
|
|
106
|
+
const byTag = new Map<string | null, { tag: string | null; pool: number; doing: number; done: number; abandoned: number; total: number }>();
|
|
107
|
+
let snapshotTotal = 0;
|
|
100
108
|
|
|
101
109
|
for (const row of rows) {
|
|
110
|
+
if (!row.archived && ["pool", "doing", "done", "abandoned"].includes(row.column_name)) {
|
|
111
|
+
snapshotTotal++;
|
|
112
|
+
const tags: (string | null)[] = storedTags(row.tags_json ?? null);
|
|
113
|
+
if (!tags.length) tags.push(null);
|
|
114
|
+
for (const tag of tags) {
|
|
115
|
+
let bucket = byTag.get(tag);
|
|
116
|
+
if (!bucket) byTag.set(tag, bucket = { tag, pool: 0, doing: 0, done: 0, abandoned: 0, total: 0 });
|
|
117
|
+
bucket[row.column_name as "pool" | "doing" | "done" | "abandoned"]++;
|
|
118
|
+
bucket.total++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
102
121
|
// 当前还开着的卡(不看时间范围;归档的不算「进行中」)。
|
|
103
122
|
if (!row.archived && row.column_name === "doing") doing++;
|
|
104
123
|
if (!row.archived && row.column_name === "pool") pool++;
|
|
@@ -133,6 +152,15 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
|
|
|
133
152
|
|
|
134
153
|
const spark = days.slice(-STATS_SPARK_DAYS);
|
|
135
154
|
return {
|
|
155
|
+
tag_status: {
|
|
156
|
+
basis: "current", complete: true, includes_archived: false,
|
|
157
|
+
project_filter: q.projectId === undefined ? null : q.projectId ?? "none",
|
|
158
|
+
total: snapshotTotal,
|
|
159
|
+
// Membership counts: a multi-tag card counts once in each distinct tag.
|
|
160
|
+
by_tag: [...byTag.values()].sort((a, b) =>
|
|
161
|
+
(b.pool + b.doing) - (a.pool + a.doing) || b.total - a.total ||
|
|
162
|
+
String(a.tag ?? "\uffff").localeCompare(String(b.tag ?? "\uffff"))),
|
|
163
|
+
},
|
|
136
164
|
range: { from: q.fromMs === null ? null : new Date(q.fromMs).toISOString(), to: new Date(q.toMs).toISOString(), tz: q.tz },
|
|
137
165
|
totals: {
|
|
138
166
|
done,
|
package/src/requirements.ts
CHANGED
|
@@ -770,7 +770,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
|
|
|
770
770
|
// list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
|
|
771
771
|
// (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
|
|
772
772
|
// events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
|
|
773
|
-
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
|
|
773
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
|
|
774
774
|
|
|
775
775
|
// ── 子需求 ──
|
|
776
776
|
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
@@ -1584,7 +1584,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1584
1584
|
if ("error" in sq) return jsonError(sq.error, 400);
|
|
1585
1585
|
const statsParams: unknown[] = [networkId];
|
|
1586
1586
|
const statsSql = addTaskVisibilityScope(
|
|
1587
|
-
"SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json FROM requirements WHERE network_id = ?1",
|
|
1587
|
+
"SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json, tags_json FROM requirements WHERE network_id = ?1",
|
|
1588
1588
|
statsParams, taskCaller(ctx),
|
|
1589
1589
|
);
|
|
1590
1590
|
const rows = db.all<StatsRow>(statsSql, ...statsParams);
|
package/src/server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
|
|
1
2
|
import { buildServeErrorResponse } from "./serve-error.js";
|
|
2
3
|
import { sanitizeRuntimeReadiness } from "./runtime-readiness.js";
|
|
3
4
|
import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip";
|
|
@@ -4574,31 +4575,10 @@ return Bun.serve({
|
|
|
4574
4575
|
isPrivileged = true;
|
|
4575
4576
|
}
|
|
4576
4577
|
|
|
4577
|
-
|
|
4578
|
-
// rows (revokeToken DELETEs the row in production — PR2 v1
|
|
4579
|
-
// SHOULD-FIX per 通信龙 audit). EXISTS naturally dedupes daemons
|
|
4580
|
-
// with rotated tokens.
|
|
4581
|
-
const sqlRows = db.all<Record<string, any>>(`
|
|
4582
|
-
SELECT
|
|
4583
|
-
n.node_id, n.alias, n.hostname, n.network_id,
|
|
4578
|
+
const sqlRows = hostSupervisorIdentityRows(`SELECT n.node_id, n.alias, n.hostname, n.network_id, n.owner_user_id,
|
|
4584
4579
|
n.runtimes_supported, n.allowed_secret_keys,
|
|
4585
|
-
n.created_at, n.updated_at,
|
|
4586
|
-
|
|
4587
|
-
s.cpu_cores AS session_cpu_cores,
|
|
4588
|
-
s.mem_total_gb AS session_mem_total_gb,
|
|
4589
|
-
s.ip AS session_ip,
|
|
4590
|
-
n.config_snapshot
|
|
4591
|
-
FROM nodes n
|
|
4592
|
-
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
4593
|
-
WHERE n.network_id = ?1
|
|
4594
|
-
AND EXISTS (
|
|
4595
|
-
SELECT 1 FROM api_tokens t
|
|
4596
|
-
WHERE t.network_id = n.network_id
|
|
4597
|
-
AND t.name = 'node:' || n.alias
|
|
4598
|
-
AND t.revoked_at IS NULL
|
|
4599
|
-
)
|
|
4600
|
-
ORDER BY n.updated_at DESC
|
|
4601
|
-
`, effectiveNetId);
|
|
4580
|
+
n.created_at, n.updated_at, n.config_snapshot
|
|
4581
|
+
FROM nodes n WHERE n.network_id = ?1 ORDER BY n.updated_at DESC`, [effectiveNetId]);
|
|
4602
4582
|
|
|
4603
4583
|
const nowMs = Date.now();
|
|
4604
4584
|
// 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
|
package/src/tools.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
|
|
1
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
3
|
import { markTaskActivity } from "./task-activity.js";
|
|
3
4
|
import { requestProviderSnapshot, requestProviderWrite, requestProviderCollection, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
@@ -4277,35 +4278,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4277
4278
|
// (already gate-locked by enforceNetworkId path of resolveReadScope).
|
|
4278
4279
|
const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
|
|
4279
4280
|
|
|
4280
|
-
//
|
|
4281
|
-
|
|
4282
|
-
// (the standard revokeToken path deletes the row, not flagging
|
|
4283
|
-
// revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
|
|
4284
|
-
// dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
|
|
4285
|
-
let sql = `
|
|
4286
|
-
SELECT
|
|
4287
|
-
n.node_id, n.alias, n.hostname, n.network_id,
|
|
4281
|
+
// Scope first; batch immutable credential + heartbeat identity for both list entry points.
|
|
4282
|
+
let sql = `SELECT n.node_id, n.alias, n.hostname, n.network_id, n.owner_user_id,
|
|
4288
4283
|
n.runtimes_supported, n.allowed_secret_keys,
|
|
4289
|
-
n.created_at, n.updated_at,
|
|
4290
|
-
s.last_seen_at AS session_last_seen,
|
|
4291
|
-
s.status AS session_status,
|
|
4292
|
-
s.cpu_cores AS session_cpu_cores,
|
|
4293
|
-
s.mem_total_gb AS session_mem_total_gb,
|
|
4294
|
-
s.ip AS session_ip,
|
|
4295
|
-
n.config_snapshot
|
|
4296
|
-
FROM nodes n
|
|
4297
|
-
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
4298
|
-
WHERE EXISTS (
|
|
4299
|
-
SELECT 1 FROM api_tokens t
|
|
4300
|
-
WHERE t.network_id = n.network_id
|
|
4301
|
-
AND t.name = 'node:' || n.alias
|
|
4302
|
-
AND t.revoked_at IS NULL
|
|
4303
|
-
)
|
|
4304
|
-
`;
|
|
4284
|
+
n.created_at, n.updated_at, n.config_snapshot FROM nodes n WHERE 1=1`;
|
|
4305
4285
|
const sqlParams: any[] = [];
|
|
4306
4286
|
sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
|
|
4307
4287
|
sql += ` ORDER BY n.updated_at DESC`;
|
|
4308
|
-
const rows =
|
|
4288
|
+
const rows = hostSupervisorIdentityRows(sql, sqlParams);
|
|
4309
4289
|
|
|
4310
4290
|
// Filter to role=host_supervisor (read from config_snapshot;
|
|
4311
4291
|
// schema-promoted columns runtimes_supported/allowed_secret_keys
|