@sleep2agi/commhub-server 0.9.0-preview.152 → 0.9.0-preview.154
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/auth.ts +5 -0
- package/src/db.ts +7 -0
- package/src/network-rules-http.test.ts +43 -1
- package/src/network-rules.ts +14 -4
- package/src/rest-projections.ts +2 -0
- package/src/scheduled-requirement-wake-http.test.ts +11 -2
- package/src/server.ts +42 -4
- package/src/team-rules-status-http.test.ts +90 -0
- package/src/team-rules-status.test.ts +59 -0
- package/src/team-rules-status.ts +72 -0
- package/src/tools.ts +24 -1
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.154",
|
|
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/auth.ts
CHANGED
|
@@ -516,6 +516,9 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
|
|
|
516
516
|
// deleted network. Remove memberships with the network.
|
|
517
517
|
db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
|
|
518
518
|
deleteNetworkAgentGroups(networkId); deleteDepartmentsForNetwork(networkId); for (const t of AGENT_TEAM_TABLES) if (tableExists(t)) db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [networkId]); // #2144, #419, #764 (one line: docs pin auth.ts line numbers)
|
|
519
|
+
// #993: the rules text belongs to the network; leaving it behind would make a
|
|
520
|
+
// later network that reuses the id inherit somebody else's rules.
|
|
521
|
+
if (tableExists("network_rules")) db.run("DELETE FROM network_rules WHERE network_id = ?1", [networkId]);
|
|
519
522
|
});
|
|
520
523
|
return { ok: true };
|
|
521
524
|
}
|
|
@@ -966,6 +969,8 @@ const NETWORK_CLEANUP_TABLES = [
|
|
|
966
969
|
"task_events", "task_terminal_events", "task_late_replies", "scheduled_task_runs", "external_schedule_edits",
|
|
967
970
|
// 标签颜色:网络本身的元数据(标签本体在 requirements 行里,那张表是阻断项)。
|
|
968
971
|
"network_tags",
|
|
972
|
+
// #993/#1542 全团队规则:规则文本是网络元数据,网络删了它不该留成孤儿行。
|
|
973
|
+
"network_rules",
|
|
969
974
|
// 组织架构(board #419):部门是网络本身的元数据。
|
|
970
975
|
"network_departments",
|
|
971
976
|
// Agent 团队(#764):Agent 自己的组织树,同样是网络元数据。
|
package/src/db.ts
CHANGED
|
@@ -137,6 +137,13 @@ for (const col of [
|
|
|
137
137
|
// Node run-log view — the same session answers logs_tail on the rules-file
|
|
138
138
|
// doorbell (node-logs.ts). Sticky, bound-token only, like above.
|
|
139
139
|
{ name: "logs_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
140
|
+
// #2211 — what the node last did with the network's team rules (agent-node .156+
|
|
141
|
+
// reports it on every status). Not sticky: the newest report wins, and a node that
|
|
142
|
+
// later starts reporting `skipped` must be able to clear an older revision.
|
|
143
|
+
{ name: "team_rules_revision", def: "INTEGER" },
|
|
144
|
+
{ name: "team_rules_status", def: "TEXT" },
|
|
145
|
+
{ name: "team_rules_at", def: "TEXT" },
|
|
146
|
+
{ name: "team_rules_reason", def: "TEXT" },
|
|
140
147
|
]) {
|
|
141
148
|
try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
142
149
|
}
|
|
@@ -102,11 +102,53 @@ describe("#993 — network rules storage + CAS write", () => {
|
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
test("oversized content is refused (bytes, not UTF-16 units)", async () => {
|
|
105
|
-
const r = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: "a".repeat(
|
|
105
|
+
const r = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: "a".repeat(256 * 1024 + 1), expected_revision: 1 });
|
|
106
106
|
expect(r.status).toBe(413);
|
|
107
107
|
expect(r.body.error).toBe("rules_too_large");
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test("re-writing identical bytes is idempotent (revision does not move, changed=false)", async () => {
|
|
111
|
+
// The next slice injects a managed block keyed by revision; a no-op save that
|
|
112
|
+
// bumped the number would make every node rewrite its file for nothing.
|
|
113
|
+
const before = (await send(adminToken, "GET", `/api/networks/${netId}/rules`)).body.rules;
|
|
114
|
+
const w = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, {
|
|
115
|
+
content: before.content, expected_revision: before.revision,
|
|
116
|
+
});
|
|
117
|
+
expect(w.status).toBe(200);
|
|
118
|
+
expect(w.body.changed).toBe(false);
|
|
119
|
+
expect(w.body.rules.revision).toBe(before.revision);
|
|
120
|
+
// …and a real edit still moves it.
|
|
121
|
+
const w2 = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, {
|
|
122
|
+
content: before.content + "\n- one more line\n", expected_revision: before.revision,
|
|
123
|
+
});
|
|
124
|
+
expect(w2.status).toBe(200);
|
|
125
|
+
expect(w2.body.changed).toBe(true);
|
|
126
|
+
expect(w2.body.rules.revision).toBe(before.revision + 1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("the 256 KiB cap is the same ruler as write_node_rules_file", async () => {
|
|
130
|
+
// 200 KiB used to be the cap here while node rules allowed 256 KiB — the same text
|
|
131
|
+
// could fit in the Hub and then be refused at the node.
|
|
132
|
+
const atCap = "a".repeat(256 * 1024);
|
|
133
|
+
const rev = (await send(adminToken, "GET", `/api/networks/${netId}/rules`)).body.rules.revision;
|
|
134
|
+
const ok = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: atCap, expected_revision: rev });
|
|
135
|
+
expect(ok.status).toBe(200);
|
|
136
|
+
const rev2 = ok.body.rules.revision;
|
|
137
|
+
const over = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: atCap + "a", expected_revision: rev2 });
|
|
138
|
+
expect(over.status).toBe(413);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("deleting the network deletes its rules row (no orphans)", async () => {
|
|
142
|
+
const own = register(`nr_del_${Date.now()}`, "DelPw123Aa!", undefined, "del");
|
|
143
|
+
expect(own.ok).toBe(true);
|
|
144
|
+
const put = await send(own.token!, "PUT", `/api/networks/${own.network_id}/rules`, { content: "team rules", expected_revision: 0 });
|
|
145
|
+
expect(put.status).toBe(200);
|
|
146
|
+
const del = await send(own.token!, "DELETE", `/api/networks/${own.network_id}`);
|
|
147
|
+
expect(del.status).toBe(200);
|
|
148
|
+
const left = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM network_rules WHERE network_id = ?1", own.network_id!);
|
|
149
|
+
expect(left!.cnt).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
110
152
|
test("a node token cannot read another network's rules", async () => {
|
|
111
153
|
// The node token belongs to a hub admin (that is how it was minted), so the
|
|
112
154
|
// hubAdmin branch alone would have allowed it. A node stays inside its network.
|
package/src/network-rules.ts
CHANGED
|
@@ -16,8 +16,12 @@
|
|
|
16
16
|
// content is a file the node will write to disk.
|
|
17
17
|
import type { DbAdapter } from "./db-adapter.js";
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* 256 KiB — deliberately the same ruler as `write_node_rules_file` (tools.ts), because
|
|
21
|
+
* these two texts are meant to end up next to each other on a node. Two different caps
|
|
22
|
+
* for the same kind of document is how "it fit in the Hub but not on the node" happens.
|
|
23
|
+
*/
|
|
24
|
+
export const NETWORK_RULES_MAX_BYTES = 256 * 1024;
|
|
21
25
|
|
|
22
26
|
export type NetworkRules = {
|
|
23
27
|
network_id: string;
|
|
@@ -76,7 +80,7 @@ export function readNetworkRules(database: DbAdapter, networkId: string): Networ
|
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
export type WriteResult =
|
|
79
|
-
| { ok: true; rules: NetworkRules }
|
|
83
|
+
| { ok: true; rules: NetworkRules; changed: boolean }
|
|
80
84
|
| { ok: false; status: number; error: "invalid_rules_content" | "rules_too_large" | "rules_revision_conflict" };
|
|
81
85
|
|
|
82
86
|
/**
|
|
@@ -102,6 +106,12 @@ export function writeNetworkRules(
|
|
|
102
106
|
const row = current(database, networkId);
|
|
103
107
|
const revision = row ? Number(row.revision) || 0 : 0;
|
|
104
108
|
if (revision !== expectedRevision) return { ok: false, status: 409, error: "rules_revision_conflict" };
|
|
109
|
+
// Idempotent revision: writing the same bytes must not mint a new revision.
|
|
110
|
+
// The next slice injects a managed block keyed by revision, so a no-op save that
|
|
111
|
+
// bumped the number would make every node rewrite its file for nothing.
|
|
112
|
+
if (row && row.content === content) {
|
|
113
|
+
return { ok: true, rules: readNetworkRules(database, networkId), changed: false };
|
|
114
|
+
}
|
|
105
115
|
const next = revision + 1;
|
|
106
116
|
const actorJson = JSON.stringify({ user_id: actor.user_id, username: actor.username });
|
|
107
117
|
if (row) {
|
|
@@ -115,5 +125,5 @@ export function writeNetworkRules(
|
|
|
115
125
|
[networkId, content, next, now, actorJson],
|
|
116
126
|
);
|
|
117
127
|
}
|
|
118
|
-
return { ok: true, rules: readNetworkRules(database, networkId) };
|
|
128
|
+
return { ok: true, rules: readNetworkRules(database, networkId), changed: true };
|
|
119
129
|
}
|
package/src/rest-projections.ts
CHANGED
|
@@ -21,6 +21,8 @@ export const SESSION_REST_COLUMNS = [
|
|
|
21
21
|
"node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
|
|
22
22
|
"external_schedules", "rules_file_capable", "skills_capable", "files_capable",
|
|
23
23
|
"logs_capable",
|
|
24
|
+
// #2211 — the node's last team-rules outcome (see team-rules-status.ts).
|
|
25
|
+
"team_rules_revision", "team_rules_status", "team_rules_at", "team_rules_reason",
|
|
24
26
|
] as const;
|
|
25
27
|
|
|
26
28
|
export const AUDIT_LOG_REST_COLUMNS = [
|
|
@@ -252,12 +252,21 @@ describe("#1279 authoritative scoped requirement-event wake over HTTP/MCP", () =
|
|
|
252
252
|
// schema startup is not the race under qualification.
|
|
253
253
|
for(let i=0;i<2;i++){
|
|
254
254
|
const child=Bun.spawn([process.execPath,"-e",code],{cwd:process.cwd(),stdout:"pipe",stderr:"pipe",env:process.env});
|
|
255
|
+
// #1279: the child's stderr used to be piped and never read, so a worker that
|
|
256
|
+
// exited non-zero below left only "Received: 1" — no reason at all. Drain it in
|
|
257
|
+
// parallel and surface it when the exit code is not 0.
|
|
258
|
+
const errChunks:string[]=[];
|
|
259
|
+
const drain=(async()=>{const r=child.stderr.getReader();for(;;){const part=await r.read();if(part.done)break;errChunks.push(new TextDecoder().decode(part.value));}})();
|
|
255
260
|
const reader=child.stdout.getReader();let start="";
|
|
256
261
|
for(;;){const part=await reader.read();if(part.done)throw new Error('worker exited before ready');start+=new TextDecoder().decode(part.value);if(start.includes('READY'))break;}
|
|
257
|
-
workers.push({child,reader,start});
|
|
262
|
+
workers.push({child,reader,start,stderr:()=>errChunks.join(''),drain});
|
|
258
263
|
}
|
|
259
264
|
await Bun.write(gate,"go");const results=[];
|
|
260
|
-
for(const w of workers){let output=w.start;for(;;){const part=await w.reader.read();if(part.done)break;output+=new TextDecoder().decode(part.value);}
|
|
265
|
+
for(const w of workers){let output=w.start;for(;;){const part=await w.reader.read();if(part.done)break;output+=new TextDecoder().decode(part.value);}
|
|
266
|
+
const code=await w.child.exited;await w.drain;
|
|
267
|
+
// Show the worker's own words on failure instead of a bare exit code.
|
|
268
|
+
expect({exit:code,stderr:w.stderr().trim().slice(0,800)}).toEqual({exit:0,stderr:""});
|
|
269
|
+
results.push(JSON.parse(output.trim().split("\n").at(-1)!).wake);}
|
|
261
270
|
expect(results.sort()).toEqual([false,true]);expect(state(scheduleId).revision).toBe(before.revision+1);expect(poll(scheduleId)).toBe(false);
|
|
262
271
|
});
|
|
263
272
|
|
package/src/server.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { z } from "zod/v4";
|
|
|
25
25
|
import { registerTools } from "./tools.js";
|
|
26
26
|
import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
|
|
27
27
|
import { ensureNetworkRulesOnce, readNetworkRules, writeNetworkRules } from "./network-rules.js";
|
|
28
|
+
import { deriveTeamRulesView } from "./team-rules-status.js";
|
|
28
29
|
import { recentNodeIdentityConflicts } from "./node-identity-conflict.js";
|
|
29
30
|
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
|
|
30
31
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
@@ -1929,9 +1930,13 @@ return Bun.serve({
|
|
|
1929
1930
|
username: resolved.user.username ?? null,
|
|
1930
1931
|
});
|
|
1931
1932
|
if (!result.ok) return withCors(req, Response.json({ ok: false, error: result.error }, { status: result.status }));
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1933
|
+
// An idempotent save (same bytes) changes nothing, so it must not invent an
|
|
1934
|
+
// audit entry that says the rules were updated.
|
|
1935
|
+
if (result.changed) {
|
|
1936
|
+
logAudit(resolved.user.user_id, resolved.user.username ?? null, "network_rules_updated", "network_rules", netId,
|
|
1937
|
+
JSON.stringify({ revision: result.rules.revision, bytes: Buffer.byteLength(result.rules.content, "utf8") }), undefined, netId);
|
|
1938
|
+
}
|
|
1939
|
+
return withCors(req, Response.json({ ok: true, changed: result.changed, rules: result.rules }));
|
|
1935
1940
|
}
|
|
1936
1941
|
return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1937
1942
|
}
|
|
@@ -3220,6 +3225,20 @@ return Bun.serve({
|
|
|
3220
3225
|
// #500 step 2 — full projection only: queue_depth = open tasks on that node (task-queue-ahead.ts), one query for
|
|
3221
3226
|
// all rows. The light / alias-resolver projections keep their exact bytes and do not read tasks.
|
|
3222
3227
|
const depth = isLight ? null : queueDepthByNode(rows.map((r: any) => r.alias));
|
|
3228
|
+
// #2211 — the network's current team-rules revision, one query for all rows (not N+1).
|
|
3229
|
+
// Language models must not be able to infer "current" from a missing row: absent = no rules.
|
|
3230
|
+
// The table is created lazily on first use of the rules API, so a Hub that never
|
|
3231
|
+
// had a rules write may not have it yet — treat "no table" as "no rules".
|
|
3232
|
+
const rulesRevisionByNetwork = isLight ? null : (() => {
|
|
3233
|
+
try {
|
|
3234
|
+
return new Map<string, number>(
|
|
3235
|
+
db.all<{ network_id: string; revision: number }>("SELECT network_id, revision FROM network_rules")
|
|
3236
|
+
.map((r) => [r.network_id, Number(r.revision)]),
|
|
3237
|
+
);
|
|
3238
|
+
} catch {
|
|
3239
|
+
return new Map<string, number>();
|
|
3240
|
+
}
|
|
3241
|
+
})();
|
|
3223
3242
|
// `model` comes straight from the explicit sessions projection; `runtime` is
|
|
3224
3243
|
// derived from the raw `agent` field. Both default to null for old nodes
|
|
3225
3244
|
// that never reported a model — the dashboard falls back to a placeholder.
|
|
@@ -3248,11 +3267,30 @@ return Bun.serve({
|
|
|
3248
3267
|
if (typeof s.external_schedules !== "string") return null;
|
|
3249
3268
|
try { return JSON.parse(s.external_schedules); } catch { return null; }
|
|
3250
3269
|
})();
|
|
3251
|
-
|
|
3270
|
+
// #2211 — the four raw columns feed `team_rules` below; they must not leak into
|
|
3271
|
+
// the session object itself (a `toEqual` on this shape is how the API contract is
|
|
3272
|
+
// pinned in several suites).
|
|
3273
|
+
const {
|
|
3274
|
+
external_schedules: _externalSchedulesJson,
|
|
3275
|
+
team_rules_revision: _trRev, team_rules_status: _trStatus,
|
|
3276
|
+
team_rules_at: _trAt, team_rules_reason: _trReason,
|
|
3277
|
+
...publicSession
|
|
3278
|
+
} = s;
|
|
3252
3279
|
return {
|
|
3253
3280
|
...publicSession,
|
|
3254
3281
|
external_schedules: externalSchedules,
|
|
3255
3282
|
// app#225 follow-up — sqlite INTEGER 0/1 → boolean on the wire.
|
|
3283
|
+
// #2211 — one derived word per node: current / outdated / failed / never.
|
|
3284
|
+
// Omitted entirely for a session that never reported (keeps old clients' bytes stable).
|
|
3285
|
+
...(() => {
|
|
3286
|
+
const view = deriveTeamRulesView(
|
|
3287
|
+
s.team_rules_revision == null && !s.team_rules_status
|
|
3288
|
+
? null
|
|
3289
|
+
: { revision: s.team_rules_revision, status: s.team_rules_status, reason: s.team_rules_reason, at: s.team_rules_at },
|
|
3290
|
+
rulesRevisionByNetwork ? (rulesRevisionByNetwork.get(s.network_id) ?? null) : null,
|
|
3291
|
+
);
|
|
3292
|
+
return view ? { team_rules: view } : {};
|
|
3293
|
+
})(),
|
|
3256
3294
|
rules_file_capable: s.rules_file_capable === 1,
|
|
3257
3295
|
skills_capable: s.skills_capable === 1,
|
|
3258
3296
|
files_capable: s.files_capable === 1,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// #2211 — the wired half: what the node reports must reach `/api/status` as one word.
|
|
2
|
+
//
|
|
3
|
+
// The pure derivation has its own tests (team-rules-status.test.ts). This file checks the
|
|
4
|
+
// three seams that can still be wrong after that module is correct:
|
|
5
|
+
// 1. the session row actually carries the reported columns (db.ts migration + tools.ts persist);
|
|
6
|
+
// 2. the projection compares against the *network's* current revision (not a global one);
|
|
7
|
+
// 3. a session that never reported stays byte-identical (no `team_rules` key at all).
|
|
8
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
9
|
+
import "./require-explicit-test-db.js";
|
|
10
|
+
import { register } from "./auth.js";
|
|
11
|
+
import { db } from "./db.js";
|
|
12
|
+
import { ensureNetworkRules } from "./network-rules.js";
|
|
13
|
+
|
|
14
|
+
let BASE = "";
|
|
15
|
+
let server: any = null;
|
|
16
|
+
let token = "";
|
|
17
|
+
let netId = "";
|
|
18
|
+
|
|
19
|
+
async function getStatus(query = "") {
|
|
20
|
+
const res = await fetch(`${BASE}/api/status${query}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
21
|
+
expect(res.status).toBe(200);
|
|
22
|
+
return await res.json();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function seedSession(resumeId: string, alias: string, cols: Record<string, unknown> = {}) {
|
|
26
|
+
const keys = ["resume_id", "alias", "network_id", ...Object.keys(cols)];
|
|
27
|
+
const values = [resumeId, alias, netId, ...Object.values(cols)];
|
|
28
|
+
db.run(
|
|
29
|
+
`INSERT INTO sessions (${keys.join(",")}) VALUES (${keys.map((_, i) => `?${i + 1}`).join(",")})`,
|
|
30
|
+
values,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
36
|
+
const a = register(`trs_${suffix}`, "TrsPw123Aa!", undefined, "trs");
|
|
37
|
+
expect(a.ok).toBe(true);
|
|
38
|
+
token = a.token!;
|
|
39
|
+
netId = a.network_id!;
|
|
40
|
+
const { bootServer } = await import("./server.js");
|
|
41
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
42
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
43
|
+
}, 30_000);
|
|
44
|
+
|
|
45
|
+
afterAll(() => { try { server?.stop?.(true); } catch {} });
|
|
46
|
+
|
|
47
|
+
describe("#2211 — team rules state in /api/status", () => {
|
|
48
|
+
test("a session that never reported has no team_rules key", async () => {
|
|
49
|
+
seedSession(`never-${Date.now()}`, `never-${Date.now()}`);
|
|
50
|
+
const body = await getStatus();
|
|
51
|
+
const row = body.sessions.find((s: any) => s.alias.startsWith("never-"));
|
|
52
|
+
expect(row).toBeTruthy();
|
|
53
|
+
expect("team_rules" in row).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("reported revision behind the network's → outdated; equal → current", async () => {
|
|
57
|
+
ensureNetworkRules(db); // created lazily by the rules API; this test seeds it directly
|
|
58
|
+
const rev = Number((db.get<{ revision: number }>("SELECT revision FROM network_rules WHERE network_id = ?1", netId)?.revision) ?? 0) + 2;
|
|
59
|
+
db.run(
|
|
60
|
+
"INSERT INTO network_rules (network_id, content, revision, updated_at, updated_by_json) VALUES (?1, 'rules', ?2, datetime('now'), NULL) ON CONFLICT(network_id) DO UPDATE SET revision = ?2",
|
|
61
|
+
[netId, rev],
|
|
62
|
+
);
|
|
63
|
+
seedSession(`behind-${Date.now()}`, `behind-${Date.now()}`, { team_rules_revision: rev - 1, team_rules_status: "unchanged" });
|
|
64
|
+
seedSession(`equal-${Date.now()}`, `equal-${Date.now()}`, { team_rules_revision: rev, team_rules_status: "injected" });
|
|
65
|
+
const body = await getStatus();
|
|
66
|
+
const behind = body.sessions.find((s: any) => s.alias.startsWith("behind-"));
|
|
67
|
+
const equal = body.sessions.find((s: any) => s.alias.startsWith("equal-"));
|
|
68
|
+
expect(behind.team_rules.state).toBe("outdated");
|
|
69
|
+
expect(behind.team_rules.applied_revision).toBe(rev - 1);
|
|
70
|
+
expect(behind.team_rules.current_revision).toBe(rev);
|
|
71
|
+
expect(equal.team_rules.state).toBe("current");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("an error report is failed, with its reason", async () => {
|
|
75
|
+
seedSession(`errored-${Date.now()}`, `errored-${Date.now()}`, {
|
|
76
|
+
team_rules_status: "error", team_rules_reason: "expected exactly one BEGIN",
|
|
77
|
+
});
|
|
78
|
+
const body = await getStatus();
|
|
79
|
+
const row = body.sessions.find((s: any) => s.alias.startsWith("errored-"));
|
|
80
|
+
expect(row.team_rules.state).toBe("failed");
|
|
81
|
+
expect(row.team_rules.reason).toBe("expected exactly one BEGIN");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("the light projection keeps its exact shape (no team_rules, no extra bytes)", async () => {
|
|
85
|
+
const res = await fetch(`${BASE}/api/status?light=1`, { headers: { Authorization: `Bearer ${token}` } });
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
const body = await res.json();
|
|
88
|
+
for (const row of body.sessions) expect("team_rules" in row).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// #2211 — the four states, as decisions rather than as prose.
|
|
2
|
+
//
|
|
3
|
+
// The point of these cases is that "behind" and "failed" are different words:
|
|
4
|
+
// v1 injects at full start, so a node that simply has not restarted is `outdated`.
|
|
5
|
+
// Calling that `failed` would send someone to debug a node that is doing fine.
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { deriveTeamRulesView } from "./team-rules-status";
|
|
8
|
+
|
|
9
|
+
const report = (o: Record<string, unknown>) => o as any;
|
|
10
|
+
|
|
11
|
+
describe("deriveTeamRulesView (#2211)", () => {
|
|
12
|
+
test("a node that never attempted is omitted entirely (not reported as null state)", () => {
|
|
13
|
+
expect(deriveTeamRulesView(null, 3)).toBeNull();
|
|
14
|
+
expect(deriveTeamRulesView(undefined, 3)).toBeNull();
|
|
15
|
+
expect(deriveTeamRulesView(report({}), 3)).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("applied revision equals the network's current revision → current", () => {
|
|
19
|
+
const v = deriveTeamRulesView(report({ revision: 3, status: "injected", at: "2026-10-24T12:00:00Z" }), 3)!;
|
|
20
|
+
expect(v).toMatchObject({ state: "current", applied_revision: 3, current_revision: 3 });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("behind the current revision → outdated, with the reason kept for context", () => {
|
|
24
|
+
const v = deriveTeamRulesView(report({ revision: 2, status: "unchanged" }), 3)!;
|
|
25
|
+
expect(v.state).toBe("outdated");
|
|
26
|
+
expect(v.applied_revision).toBe(2);
|
|
27
|
+
expect(v.current_revision).toBe(3);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("an error is failed even when it also happens to carry the current revision", () => {
|
|
31
|
+
const v = deriveTeamRulesView(report({ revision: 3, status: "error", reason: "expected exactly one BEGIN" }), 3)!;
|
|
32
|
+
expect(v.state).toBe("failed");
|
|
33
|
+
expect(v.reason).toBe("expected exactly one BEGIN");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("skipped with no revision (disabled / hub unreachable) → never, reason preserved", () => {
|
|
37
|
+
const v = deriveTeamRulesView(report({ revision: null, status: "skipped", reason: "hub_timeout" }), 3)!;
|
|
38
|
+
expect(v).toMatchObject({ state: "never", applied_revision: null, reason: "hub_timeout" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the network lost its rules but the node still carries a block → outdated", () => {
|
|
42
|
+
const v = deriveTeamRulesView(report({ revision: 4, status: "injected" }), null)!;
|
|
43
|
+
expect(v.state).toBe("outdated");
|
|
44
|
+
expect(v.current_revision).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a hub rollback (node ahead of the current revision) is not called current", () => {
|
|
48
|
+
const v = deriveTeamRulesView(report({ revision: 5, status: "injected" }), 3)!;
|
|
49
|
+
expect(v.state).toBe("outdated");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("malformed input never throws and never fabricates a revision", () => {
|
|
53
|
+
const v = deriveTeamRulesView(report({ revision: "3" as any, status: 42 as any, reason: 7 as any }), 3);
|
|
54
|
+
expect(v).not.toBeNull();
|
|
55
|
+
expect(v!.state).toBe("never");
|
|
56
|
+
expect(v!.applied_revision).toBeNull();
|
|
57
|
+
expect(v!.reason).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// #2211 — turn "what the node last did" into one word an operator can act on.
|
|
2
|
+
//
|
|
3
|
+
// The node reports `team_rules: { revision, status, reason?, at }` on every status
|
|
4
|
+
// report (agent-node .156). The Hub knows the network's current revision. This module
|
|
5
|
+
// is the pure comparison between the two — no database, no HTTP, so the rules are
|
|
6
|
+
// testable before any wiring exists.
|
|
7
|
+
//
|
|
8
|
+
// The four states are deliberately about *the node being behind*, not about a failed
|
|
9
|
+
// push: v1 injects at full start, so a node that has not restarted since a rule change
|
|
10
|
+
// is `outdated`, not `failed`. Calling that "failed" would blame the wrong side.
|
|
11
|
+
//
|
|
12
|
+
// current — applied revision equals the network's current revision
|
|
13
|
+
// outdated — this node has not picked up the current revision yet (restart needed)
|
|
14
|
+
// failed — the last attempt ended in an error; `reason` says why
|
|
15
|
+
// never — this node has never attempted an injection (or the rules are empty and
|
|
16
|
+
// it has nothing to apply)
|
|
17
|
+
//
|
|
18
|
+
// Unknown input never throws: a malformed report degrades to `never` with the reason
|
|
19
|
+
// preserved, because a status line is not worth a 500.
|
|
20
|
+
|
|
21
|
+
export type TeamRulesState = "current" | "outdated" | "failed" | "never";
|
|
22
|
+
|
|
23
|
+
export type TeamRulesReportIn = {
|
|
24
|
+
revision?: number | null;
|
|
25
|
+
status?: string | null;
|
|
26
|
+
reason?: string | null;
|
|
27
|
+
at?: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TeamRulesView = {
|
|
31
|
+
state: TeamRulesState;
|
|
32
|
+
applied_revision: number | null;
|
|
33
|
+
current_revision: number | null;
|
|
34
|
+
status: string | null;
|
|
35
|
+
reason: string | null;
|
|
36
|
+
at: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param report what the node last reported (null/undefined = it never reported)
|
|
41
|
+
* @param currentRevision the network's current `network_rules.revision` (null when no row)
|
|
42
|
+
*/
|
|
43
|
+
export function deriveTeamRulesView(
|
|
44
|
+
report: TeamRulesReportIn | null | undefined,
|
|
45
|
+
currentRevision: number | null,
|
|
46
|
+
): TeamRulesView | null {
|
|
47
|
+
if (!report || (report.revision == null && !report.status)) return null;
|
|
48
|
+
|
|
49
|
+
const applied = typeof report.revision === "number" && Number.isFinite(report.revision) ? report.revision : null;
|
|
50
|
+
const current = typeof currentRevision === "number" && Number.isFinite(currentRevision) ? currentRevision : null;
|
|
51
|
+
const status = typeof report.status === "string" ? report.status : null;
|
|
52
|
+
const reason = typeof report.reason === "string" && report.reason ? report.reason : null;
|
|
53
|
+
const at = typeof report.at === "string" && report.at ? report.at : null;
|
|
54
|
+
|
|
55
|
+
let state: TeamRulesState;
|
|
56
|
+
if (status === "error") {
|
|
57
|
+
state = "failed";
|
|
58
|
+
} else if (applied == null) {
|
|
59
|
+
// attempted but nothing was applied (disabled / skipped with no revision)
|
|
60
|
+
state = "never";
|
|
61
|
+
} else if (current == null) {
|
|
62
|
+
// the network has no rules row (revision absent) but the node carries a block
|
|
63
|
+
state = "outdated";
|
|
64
|
+
} else if (applied === current) {
|
|
65
|
+
state = "current";
|
|
66
|
+
} else {
|
|
67
|
+
// behind (usual case) or ahead after a Hub rollback — either way it does not match
|
|
68
|
+
state = "outdated";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { state, applied_revision: applied, current_revision: current, status, reason, at };
|
|
72
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1053,6 +1053,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1053
1053
|
// no `nodes` row, and config_snapshot is persisted per node_id. Only a
|
|
1054
1054
|
// node token bound to this same alias can set it; see the UPDATE below.
|
|
1055
1055
|
rules_file_capable: z.literal(true).optional(),
|
|
1056
|
+
// #2211 — what this process last did with the network's team rules.
|
|
1057
|
+
// Every field is `.catch(...)`: a malformed value must not reject the whole
|
|
1058
|
+
// status report (that would mark a healthy node offline — see the #1225 note
|
|
1059
|
+
// above). Unknown extra keys are dropped, not rejected: this object is not strict.
|
|
1060
|
+
team_rules: z.object({
|
|
1061
|
+
revision: z.number().nullable().optional().catch(null),
|
|
1062
|
+
status: z.string().max(40).optional().catch(undefined),
|
|
1063
|
+
reason: z.string().max(500).optional().catch(undefined),
|
|
1064
|
+
at: z.string().max(40).optional().catch(undefined),
|
|
1065
|
+
}).optional(),
|
|
1056
1066
|
// Node skills view — same doorbell, ops skills_list / skill_read.
|
|
1057
1067
|
skills_capable: z.literal(true).optional(),
|
|
1058
1068
|
// Project folder view — same doorbell, ops files_list / file_read.
|
|
@@ -1064,7 +1074,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1064
1074
|
// instead of rejecting the whole report (see node-health-store.ts).
|
|
1065
1075
|
health: nodeHealthSchema,
|
|
1066
1076
|
},
|
|
1067
|
-
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, os_user: osUser, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable, logs_capable: logsCapable, health: nodeHealth, secret_keys: secretKeys, env_transport: envTransport }) => {
|
|
1077
|
+
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, os_user: osUser, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable, logs_capable: logsCapable, team_rules: teamRules, health: nodeHealth, secret_keys: secretKeys, env_transport: envTransport }) => {
|
|
1068
1078
|
const effectiveNetId = getNetworkId(netId);
|
|
1069
1079
|
const sessionNetId = effectiveNetId ?? "default";
|
|
1070
1080
|
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
@@ -1359,6 +1369,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1359
1369
|
if (logsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
1360
1370
|
db.run("UPDATE sessions SET logs_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
1361
1371
|
}
|
|
1372
|
+
// #2211 — newest report wins (not sticky): a later `skipped` must be able to
|
|
1373
|
+
// clear an older revision. Only this alias's own bound node token may write it.
|
|
1374
|
+
if (teamRules && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
1375
|
+
db.run(
|
|
1376
|
+
`UPDATE sessions SET team_rules_revision = ?2, team_rules_status = ?3, team_rules_at = ?4, team_rules_reason = ?5
|
|
1377
|
+
WHERE resume_id = ?1`,
|
|
1378
|
+
[resume_id,
|
|
1379
|
+
typeof teamRules.revision === "number" ? teamRules.revision : null,
|
|
1380
|
+
typeof teamRules.status === "string" ? teamRules.status : null,
|
|
1381
|
+
typeof teamRules.at === "string" ? teamRules.at : null,
|
|
1382
|
+
typeof teamRules.reason === "string" ? teamRules.reason : null],
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1362
1385
|
if (host || proc) {
|
|
1363
1386
|
db.run(
|
|
1364
1387
|
`INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, created_at)
|