@sleep2agi/commhub-server 0.9.0-preview.153 → 0.9.0-preview.155
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/db.ts +7 -0
- package/src/project-viewer-can-http.test.ts +3 -1
- package/src/requirements-errors.ts +7 -0
- package/src/requirements-migrate.ts +3 -0
- package/src/requirements-projects-parent-http.test.ts +123 -0
- package/src/requirements-status-participants-http.test.ts +2 -2
- package/src/requirements.ts +74 -5
- package/src/rest-projections.ts +2 -0
- package/src/scheduled-requirement-wake-http.test.ts +11 -2
- package/src/server.ts +35 -1
- 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 +32 -7
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.155",
|
|
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/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
|
}
|
|
@@ -91,7 +91,9 @@ describe("GET /api/requirements/projects 的 viewer_can.edit", () => {
|
|
|
91
91
|
|
|
92
92
|
test("只加字段:其余键与之前逐字相同", async () => {
|
|
93
93
|
const row = (await projects("alice"))["pvc-P2"];
|
|
94
|
-
|
|
94
|
+
// parentId 是 2026-10-24 加的「子项目」字段(admin:管理项目里看不到层级);
|
|
95
|
+
// 它同样是加法式的:这里钉的是「已知键一个不多一个不少」,新键必须显式加进这个清单。
|
|
96
|
+
expect(Object.keys(row).sort()).toEqual(["archived", "color", "createdAt", "id", "name", "parentId", "sort", "viewer_can"]);
|
|
95
97
|
expect(Object.keys(row.viewer_can)).toEqual(["edit"]);
|
|
96
98
|
});
|
|
97
99
|
|
|
@@ -40,6 +40,13 @@ const ENTRIES: Record<string, Entry> = {
|
|
|
40
40
|
tag_parent_not_found: { field: 'parent_id', message: 'No tag named that in this network.', hint: 'Create the parent first (parent_id: null), or read GET /api/requirements/tags → nodes for the names that exist.' },
|
|
41
41
|
invalid_tag_parent_id: { field: 'parent_id', message: 'parent_id must be a tag name in this network, or null for the root.', hint: 'Tag names are 1–20 characters; read GET /api/requirements/tags → nodes for the names that exist.' },
|
|
42
42
|
tag_parent_cycle: { field: 'parent_id', message: 'A tag cannot be its own parent.', hint: 'Pass a different parent, or null for the root.' },
|
|
43
|
+
// 子项目(admin 2026-10-24):项目也能成层级,错误码与标签那几条**分开**,文案指向 proj_… 而不是标签名。
|
|
44
|
+
invalid_project_parent: { field: 'parent_id', message: 'parent_id must be a project id (proj_…) or null for a top-level project.', hint: 'Call projects_list to get project ids.' },
|
|
45
|
+
project_parent_not_found: { field: 'parent_id', message: 'No project with that id in this network (or it was deleted).', hint: 'Call projects_list to get the ids that exist.' },
|
|
46
|
+
project_parent_archived: { field: 'parent_id', message: 'That parent project is archived, so it cannot take children.', hint: 'Unarchive it first, or pick an active project.' },
|
|
47
|
+
project_parent_cycle: { field: 'parent_id', message: 'That parent would make the project its own ancestor.', hint: 'Pick a project that is not this one or one of its children.' },
|
|
48
|
+
project_parent_too_deep: { field: 'parent_id', message: 'Projects nest at most 3 levels (a top-level project counts as level 1).', hint: 'Attach it to a higher-level project.' },
|
|
49
|
+
project_has_children: { message: 'This project still has sub-projects, so it cannot be deleted.', hint: 'Move or delete the sub-projects first (a parent is never silently orphaned).' },
|
|
43
50
|
tag_depth_exceeded: { field: 'parent_id', message: 'Tags nest at most 4 levels (the root counts as level 1).', hint: 'Attach the tag to a higher-level tag.' },
|
|
44
51
|
invalid_tag: { field: 'name', message: 'Tag names are 1–20 characters after trimming, with no control characters.', hint: 'Shorten the name, or remove line breaks.' },
|
|
45
52
|
checklist_item_not_found: { field: 'item_id', message: 'This task has no checklist item with that id.', hint: 'Item ids (ck_…) are in requirements_get → checklist[].id.' },
|
|
@@ -41,6 +41,9 @@ export function ensureRequirementProjects(database: DbAdapter): void {
|
|
|
41
41
|
);
|
|
42
42
|
CREATE INDEX IF NOT EXISTS idx_requirement_projects_network ON requirement_projects(network_id, sort);
|
|
43
43
|
`);
|
|
44
|
+
// 子项目(admin 2026-10-24:管理项目里没有层级)。加法式列,老库用 ALTER 补;重复执行无副作用。
|
|
45
|
+
// NULL = 顶层项目,所以现有行不需要回填。
|
|
46
|
+
try { database.exec("ALTER TABLE requirement_projects ADD COLUMN parent_id TEXT"); } catch {}
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// 子项目(admin 2026-10-24:管理项目里只能平铺,看不到层级)。
|
|
2
|
+
//
|
|
3
|
+
// 本文件钉住四件事,任何一条错都会让「项目」这个词变得不可信:
|
|
4
|
+
// 1. 建/搬家都能指定 parent_id,GET 列表能读回;
|
|
5
|
+
// 2. 深度上限 3 层(顶层=1),超了拒绝而不是悄悄截断;
|
|
6
|
+
// 3. 环(把自己挂到自己的后代下面 / 挂到自己身上)必须拒绝;
|
|
7
|
+
// 4. 有子项目的项目不能直接删(不偷偷把子项目变孤儿或改写它们的层级)。
|
|
8
|
+
// 另外:父项目必须同网络、未归档。
|
|
9
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
10
|
+
import "./require-explicit-test-db.js";
|
|
11
|
+
import { register } from "./auth.js";
|
|
12
|
+
import { bootServer } from "./server.js";
|
|
13
|
+
|
|
14
|
+
let BASE = "";
|
|
15
|
+
let server: any = null;
|
|
16
|
+
let token = "";
|
|
17
|
+
let NET = "";
|
|
18
|
+
|
|
19
|
+
async function send(method: string, path: string, payload?: unknown) {
|
|
20
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
21
|
+
method,
|
|
22
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
23
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
24
|
+
});
|
|
25
|
+
const text = await res.text();
|
|
26
|
+
let body: any = null;
|
|
27
|
+
try { body = JSON.parse(text); } catch {}
|
|
28
|
+
return { status: res.status, body };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const P = (path: string) => `${path}${path.includes("?") ? "&" : "?"}network_id=${NET}`;
|
|
32
|
+
const mk = (name: string, parent_id?: string | null) =>
|
|
33
|
+
send("POST", P("/api/requirements/projects"), parent_id === undefined ? { name } : { name, parent_id });
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
37
|
+
const a = register(`projparent_${suffix}`, "ProjParentPw123!", undefined, "pp");
|
|
38
|
+
expect(a.ok).toBe(true);
|
|
39
|
+
token = a.token!;
|
|
40
|
+
NET = a.network_id!;
|
|
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("sub-projects (parent_id)", () => {
|
|
48
|
+
test("create a child and read the hierarchy back", async () => {
|
|
49
|
+
const parent = await mk("父项目");
|
|
50
|
+
expect(parent.status).toBe(201);
|
|
51
|
+
const parentId = parent.body.project.id;
|
|
52
|
+
expect(parent.body.project.parentId).toBeNull();
|
|
53
|
+
|
|
54
|
+
const child = await mk("子项目", parentId);
|
|
55
|
+
expect(child.status).toBe(201);
|
|
56
|
+
expect(child.body.project.parentId).toBe(parentId);
|
|
57
|
+
|
|
58
|
+
const list = await send("GET", P("/api/requirements/projects"));
|
|
59
|
+
expect(list.status).toBe(200);
|
|
60
|
+
const rows = list.body.projects as any[];
|
|
61
|
+
expect(rows.find((r) => r.name === "子项目")!.parentId).toBe(parentId);
|
|
62
|
+
expect(rows.find((r) => r.name === "父项目")!.parentId).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a project can be moved under another one, and back to the top", async () => {
|
|
66
|
+
const a = await mk("可搬 A");
|
|
67
|
+
const b = await mk("可搬 B");
|
|
68
|
+
const moved = await send("PATCH", P(`/api/requirements/projects/${b.body.project.id}`), { parent_id: a.body.project.id });
|
|
69
|
+
expect(moved.status).toBe(200);
|
|
70
|
+
expect(moved.body.project.parentId).toBe(a.body.project.id);
|
|
71
|
+
|
|
72
|
+
const back = await send("PATCH", P(`/api/requirements/projects/${b.body.project.id}`), { parent_id: null });
|
|
73
|
+
expect(back.status).toBe(200);
|
|
74
|
+
expect(back.body.project.parentId).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("depth is capped at 3 (top level counts as 1)", async () => {
|
|
78
|
+
const l1 = await mk("深 1");
|
|
79
|
+
const l2 = await mk("深 2", l1.body.project.id);
|
|
80
|
+
const l3 = await mk("深 3", l2.body.project.id);
|
|
81
|
+
expect(l3.status).toBe(201);
|
|
82
|
+
const l4 = await mk("深 4", l3.body.project.id);
|
|
83
|
+
expect(l4.status).toBe(400);
|
|
84
|
+
expect(l4.body.error).toBe("project_parent_too_deep");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a cycle is refused (self, and a descendant)", async () => {
|
|
88
|
+
const a = await mk("环 A");
|
|
89
|
+
const b = await mk("环 B", a.body.project.id);
|
|
90
|
+
const self = await send("PATCH", P(`/api/requirements/projects/${a.body.project.id}`), { parent_id: a.body.project.id });
|
|
91
|
+
expect(self.status).toBe(400);
|
|
92
|
+
expect(self.body.error).toBe("project_parent_cycle");
|
|
93
|
+
|
|
94
|
+
// 把 A 挂到它自己的子项目 B 下面 = 环
|
|
95
|
+
const loop = await send("PATCH", P(`/api/requirements/projects/${a.body.project.id}`), { parent_id: b.body.project.id });
|
|
96
|
+
expect(loop.status).toBe(400);
|
|
97
|
+
expect(loop.body.error).toBe("project_parent_cycle");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a parent that does not exist, is archived, or lives in another network is refused", async () => {
|
|
101
|
+
const missing = await mk("缺父", "proj_does_not_exist");
|
|
102
|
+
expect(missing.status).toBe(404);
|
|
103
|
+
expect(missing.body.error).toBe("project_parent_not_found");
|
|
104
|
+
|
|
105
|
+
const parent = await mk("会归档的父");
|
|
106
|
+
await send("PATCH", P(`/api/requirements/projects/${parent.body.project.id}`), { archived: true });
|
|
107
|
+
const archived = await mk("挂到归档父", parent.body.project.id);
|
|
108
|
+
expect(archived.status).toBe(400);
|
|
109
|
+
expect(archived.body.error).toBe("project_parent_archived");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a project with children cannot be deleted (children are not silently orphaned)", async () => {
|
|
113
|
+
const parent = await mk("有孩子的父");
|
|
114
|
+
const child = await mk("孩子", parent.body.project.id);
|
|
115
|
+
const del = await send("DELETE", P(`/api/requirements/projects/${parent.body.project.id}`));
|
|
116
|
+
expect(del.status).toBe(409);
|
|
117
|
+
expect(del.body.error).toBe("project_has_children");
|
|
118
|
+
|
|
119
|
+
// 先删孩子,父就能删
|
|
120
|
+
expect((await send("DELETE", P(`/api/requirements/projects/${child.body.project.id}`))).status).toBe(200);
|
|
121
|
+
expect((await send("DELETE", P(`/api/requirements/projects/${parent.body.project.id}`))).status).toBe(200);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -213,8 +213,8 @@ describe("tool descriptions", () => {
|
|
|
213
213
|
// #478:节点令牌的 tools/list 里本来就没有 projects_create / projects_update(调了也是 403),那两条的说明从人的列表里读。
|
|
214
214
|
expect(byName.has("projects_create")).toBe(false);
|
|
215
215
|
const human = new Map(((await rpc(U.member.token, "tools/list", {})).result.tools as any[]).map(t => [t.name, t]));
|
|
216
|
-
expect(byName.get("projects_list").description).toMatch(/node
|
|
217
|
-
for (const name of ["projects_create", "projects_update"]) expect(human.get(name).description).toMatch(/node
|
|
216
|
+
expect(byName.get("projects_list").description).toMatch(/node (?:\(Agent\) )?tokens can only read projects/i);
|
|
217
|
+
for (const name of ["projects_create", "projects_update"]) expect(human.get(name).description).toMatch(/node (?:\(Agent\) )?tokens can only read projects/i);
|
|
218
218
|
const denied = await mcp(U.nodeA.token, "projects_create", { name: "示例项目" });
|
|
219
219
|
expect(denied.status).toBe(403);
|
|
220
220
|
expect(denied.error).toBe("user_token_required");
|
package/src/requirements.ts
CHANGED
|
@@ -348,11 +348,13 @@ function storedChecklist(raw: string | null): ChecklistItem[] {
|
|
|
348
348
|
// ── 项目 ──
|
|
349
349
|
// 按网络隔离;名字 1–40 字,同一网络里未归档的项目不重名;颜色 #RRGGBB;sort 越小越靠前。
|
|
350
350
|
// 删除 = 删项目行 + 把引用它的卡片 project_id 置空(卡片本身不动);归档 = 保留引用,只是不能再被选。
|
|
351
|
-
type ProjectRow = { project_id: string; network_id: string; name: string; color: string; sort: number; archived: number; created_at: string };
|
|
351
|
+
type ProjectRow = { project_id: string; network_id: string; name: string; color: string; sort: number; archived: number; created_at: string; parent_id: string | null };
|
|
352
352
|
const PROJECT_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
353
353
|
const PROJECT_PALETTE = ["#2563eb", "#16a34a", "#d97706", "#dc2626", "#7c3aed", "#0891b2", "#db2777", "#4b5563"];
|
|
354
|
-
const PROJECT_SELECT = "project_id, network_id, name, color, sort, archived, created_at";
|
|
355
|
-
|
|
354
|
+
const PROJECT_SELECT = "project_id, network_id, name, color, sort, archived, created_at, parent_id";
|
|
355
|
+
/** 子项目最多 3 层(顶层=1)。再深的话「项目」这个词就没有意义了,也没人能在选择器里点对。 */
|
|
356
|
+
const PROJECT_MAX_DEPTH = 3;
|
|
357
|
+
const projectPublic = (row: ProjectRow) => ({ id: row.project_id, name: row.name, color: row.color, sort: row.sort, archived: !!row.archived, createdAt: row.created_at, parentId: row.parent_id ?? null });
|
|
356
358
|
const projectName = (raw: unknown): string | null => {
|
|
357
359
|
if (typeof raw !== "string") return null;
|
|
358
360
|
const name = raw.replace(/[\r\n\t]+/g, " ").trim();
|
|
@@ -361,6 +363,50 @@ const projectName = (raw: unknown): string | null => {
|
|
|
361
363
|
function projectNameTaken(networkId: string, name: string, except?: string): boolean {
|
|
362
364
|
return !!db.get("SELECT 1 FROM requirement_projects WHERE network_id = ?1 AND name = ?2 AND archived = 0 AND project_id != ?3", networkId, name, except ?? "");
|
|
363
365
|
}
|
|
366
|
+
/** 这个项目往上数有几层(自己算第一层)。用于深度上限与环路判断。 */
|
|
367
|
+
function projectDepth(projectId: string): number {
|
|
368
|
+
let depth = 0;
|
|
369
|
+
let cur: string | null = projectId;
|
|
370
|
+
const seen = new Set<string>();
|
|
371
|
+
while (cur && depth < 64) {
|
|
372
|
+
if (seen.has(cur)) return Number.POSITIVE_INFINITY; // 循环:当成超深,调用方会拒绝
|
|
373
|
+
seen.add(cur);
|
|
374
|
+
depth++;
|
|
375
|
+
const row: { parent_id: string | null } | null = db.get("SELECT parent_id FROM requirement_projects WHERE project_id = ?1", cur);
|
|
376
|
+
cur = row?.parent_id ?? null;
|
|
377
|
+
}
|
|
378
|
+
return depth;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* 校验 parent_id(新项目或搬家时)。返回 { parent } 或错误码。
|
|
383
|
+
* 规则:同一网络、存在、未归档、不是自己或自己的后代、加进来后不超过 PROJECT_MAX_DEPTH。
|
|
384
|
+
*/
|
|
385
|
+
function validateProjectParent(
|
|
386
|
+
networkId: string, parentId: string, selfId: string | null,
|
|
387
|
+
): { ok: true; parent: ProjectRow } | { ok: false; error: string } {
|
|
388
|
+
const parent = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, parentId);
|
|
389
|
+
if (!parent || parent.network_id !== networkId) return { ok: false, error: "project_parent_not_found" };
|
|
390
|
+
if (parent.archived) return { ok: false, error: "project_parent_archived" };
|
|
391
|
+
if (selfId) {
|
|
392
|
+
if (parent.project_id === selfId) return { ok: false, error: "project_parent_cycle" };
|
|
393
|
+
// 把 A 挂到 A 的后代下面 = 环。沿 parent 链往上走,遇到自己就拒绝。
|
|
394
|
+
let cur: string | null = parent.parent_id ?? null;
|
|
395
|
+
const seen = new Set<string>();
|
|
396
|
+
while (cur && !seen.has(cur)) {
|
|
397
|
+
if (cur === selfId) return { ok: false, error: "project_parent_cycle" };
|
|
398
|
+
seen.add(cur);
|
|
399
|
+
cur = db.get<{ parent_id: string | null }>("SELECT parent_id FROM requirement_projects WHERE project_id = ?1", cur)?.parent_id ?? null;
|
|
400
|
+
}
|
|
401
|
+
if (projectDepth(selfId) + projectDepth(parent.project_id) > PROJECT_MAX_DEPTH) {
|
|
402
|
+
return { ok: false, error: "project_parent_too_deep" };
|
|
403
|
+
}
|
|
404
|
+
} else if (projectDepth(parent.project_id) + 1 > PROJECT_MAX_DEPTH) {
|
|
405
|
+
return { ok: false, error: "project_parent_too_deep" };
|
|
406
|
+
}
|
|
407
|
+
return { ok: true, parent };
|
|
408
|
+
}
|
|
409
|
+
|
|
364
410
|
/** 卡片上的 project_id:null 清空;字符串必须是同一网络、未归档的项目。 */
|
|
365
411
|
function projectRef(value: unknown, networkId: string): string | null {
|
|
366
412
|
if (value === null) return null;
|
|
@@ -405,13 +451,24 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
|
|
|
405
451
|
const sort = body.sort === undefined ? count : body.sort;
|
|
406
452
|
if (!Number.isInteger(sort)) return jsonError("invalid_project_sort", 400);
|
|
407
453
|
const id = `proj_${crypto.randomUUID()}`;
|
|
408
|
-
|
|
454
|
+
// 子项目:parent_id 可选;null/缺省 = 顶层。
|
|
455
|
+
let parentId: string | null = null;
|
|
456
|
+
if (body.parent_id !== undefined && body.parent_id !== null && body.parent_id !== "") {
|
|
457
|
+
if (typeof body.parent_id !== "string") return jsonError("invalid_project_parent", 400);
|
|
458
|
+
const check = validateProjectParent(networkId, body.parent_id, null);
|
|
459
|
+
if (!check.ok) return jsonError(check.error, check.error === "project_parent_not_found" ? 404 : 400);
|
|
460
|
+
parentId = check.parent.project_id;
|
|
461
|
+
}
|
|
462
|
+
db.run(`INSERT INTO requirement_projects (${PROJECT_SELECT}) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7)`, [id, networkId, name, color, sort, new Date().toISOString(), parentId]);
|
|
409
463
|
return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, id)!) }, { status: 201 });
|
|
410
464
|
}
|
|
411
465
|
if (!one) return jsonError("not_found", 404);
|
|
412
466
|
const current = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, decodeURIComponent(one[1]));
|
|
413
467
|
if (!current || current.network_id !== networkId) return jsonError("project_not_found", 404);
|
|
414
468
|
if (req.method === "DELETE") {
|
|
469
|
+
// 子项目不能凭空变孤儿:先搬走或删掉它们(明确拒绝,不偷偷改层级)。
|
|
470
|
+
const kids = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_projects WHERE parent_id = ?1", current.project_id)?.n ?? 0;
|
|
471
|
+
if (kids > 0) return jsonError("project_has_children", 409);
|
|
415
472
|
// 先把卡片上的引用置空,再删项目:卡片一张不少。
|
|
416
473
|
// updated_at 跟着动:按 updated_since 增量同步的客户端要看得见「项目被清空」这一改动。
|
|
417
474
|
// 每张被清空项目的卡记一条「项目 X → 无」(与清空同一个事务)。
|
|
@@ -445,8 +502,20 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
|
|
|
445
502
|
if (typeof body.archived !== "boolean") return jsonError("invalid_project_archived", 400);
|
|
446
503
|
archived = body.archived ? 1 : 0;
|
|
447
504
|
}
|
|
505
|
+
let parentId: string | null = current.parent_id ?? null;
|
|
506
|
+
if ("parent_id" in body) {
|
|
507
|
+
if (body.parent_id === null || body.parent_id === "") {
|
|
508
|
+
parentId = null; // 变成顶层
|
|
509
|
+
} else if (typeof body.parent_id !== "string") {
|
|
510
|
+
return jsonError("invalid_project_parent", 400);
|
|
511
|
+
} else {
|
|
512
|
+
const check = validateProjectParent(networkId, body.parent_id, current.project_id);
|
|
513
|
+
if (!check.ok) return jsonError(check.error, check.error === "project_parent_not_found" ? 404 : 400);
|
|
514
|
+
parentId = check.parent.project_id;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
448
517
|
if (!archived && projectNameTaken(networkId, name, current.project_id)) return jsonError("project_name_taken", 409);
|
|
449
|
-
db.run("UPDATE requirement_projects SET name = ?1, color = ?2, sort = ?3, archived = ?4 WHERE project_id = ?5", [name, color, sort, archived, current.project_id]);
|
|
518
|
+
db.run("UPDATE requirement_projects SET name = ?1, color = ?2, sort = ?3, archived = ?4, parent_id = ?6 WHERE project_id = ?5", [name, color, sort, archived, current.project_id, parentId]);
|
|
450
519
|
return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, current.project_id)!) });
|
|
451
520
|
}
|
|
452
521
|
|
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";
|
|
@@ -3224,6 +3225,20 @@ return Bun.serve({
|
|
|
3224
3225
|
// #500 step 2 — full projection only: queue_depth = open tasks on that node (task-queue-ahead.ts), one query for
|
|
3225
3226
|
// all rows. The light / alias-resolver projections keep their exact bytes and do not read tasks.
|
|
3226
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
|
+
})();
|
|
3227
3242
|
// `model` comes straight from the explicit sessions projection; `runtime` is
|
|
3228
3243
|
// derived from the raw `agent` field. Both default to null for old nodes
|
|
3229
3244
|
// that never reported a model — the dashboard falls back to a placeholder.
|
|
@@ -3252,11 +3267,30 @@ return Bun.serve({
|
|
|
3252
3267
|
if (typeof s.external_schedules !== "string") return null;
|
|
3253
3268
|
try { return JSON.parse(s.external_schedules); } catch { return null; }
|
|
3254
3269
|
})();
|
|
3255
|
-
|
|
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;
|
|
3256
3279
|
return {
|
|
3257
3280
|
...publicSession,
|
|
3258
3281
|
external_schedules: externalSchedules,
|
|
3259
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
|
+
})(),
|
|
3260
3294
|
rules_file_capable: s.rules_file_capable === 1,
|
|
3261
3295
|
skills_capable: s.skills_capable === 1,
|
|
3262
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)
|
|
@@ -4081,7 +4104,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4081
4104
|
|
|
4082
4105
|
server.tool(
|
|
4083
4106
|
"tail_node_logs",
|
|
4084
|
-
"
|
|
4107
|
+
"Tail of a node's own agent-node run log (read-only, redacted on the node). No path argument: it reads only its own log directory. User logins only (node owner or network owner/admin). Poll get_rules_file_result; JSON {files, lines, truncated, matched}; handed out once, then purged.",
|
|
4085
4108
|
{
|
|
4086
4109
|
...NODE_ID_ALIAS_FIELDS,
|
|
4087
4110
|
alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting logs_capable)."),
|
|
@@ -6893,7 +6916,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6893
6916
|
|
|
6894
6917
|
server.tool(
|
|
6895
6918
|
"projects_list",
|
|
6896
|
-
"List requirement projects
|
|
6919
|
+
"List requirement projects (id, name, color, sort, archived, parentId). node tokens can only read projects; creating / changing needs a person's token (projects_create / projects_update).",
|
|
6897
6920
|
{ network_id: z.string().max(200).optional() },
|
|
6898
6921
|
async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
|
|
6899
6922
|
);
|
|
@@ -6903,19 +6926,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6903
6926
|
// write access and is refused to 「仅相关任务」(task-scoped) members; events use the task list's visibility.
|
|
6904
6927
|
server.tool(
|
|
6905
6928
|
"projects_create",
|
|
6906
|
-
"Create a requirement project
|
|
6929
|
+
"Create a requirement project (管理项目 → 新建). name 1–40, unique; color #RRGGBB; sort int; parent_id optional (≤3 levels). Returns { project }. node tokens can only read projects (403 user_token_required); task-scoped / read-only members are refused.",
|
|
6907
6930
|
{
|
|
6908
6931
|
network_id: z.string().max(200).optional(),
|
|
6909
6932
|
name: z.string().min(1).max(40),
|
|
6910
6933
|
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
6911
6934
|
sort: z.number().int().optional(),
|
|
6935
|
+
parent_id: z.string().max(200).nullable().optional(),
|
|
6912
6936
|
},
|
|
6913
|
-
async (args) => requirementsCall("POST", "/api/requirements/projects", args.network_id, pick(args, ["name", "color", "sort"])),
|
|
6937
|
+
async (args) => requirementsCall("POST", "/api/requirements/projects", args.network_id, pick(args, ["name", "color", "sort", "parent_id"])),
|
|
6914
6938
|
);
|
|
6915
6939
|
|
|
6916
6940
|
server.tool(
|
|
6917
6941
|
"projects_update",
|
|
6918
|
-
"Rename, recolour, reorder, archive or
|
|
6942
|
+
"Rename, recolour, reorder, archive/unarchive, or move a project under another (parent_id; null = top level, cycles / depth>3 refused). Only the fields you pass change; archived hides it from pickers. Returns { project }. node tokens can only read projects (403); task-scoped / read-only members refused.",
|
|
6919
6943
|
{
|
|
6920
6944
|
network_id: z.string().max(200).optional(),
|
|
6921
6945
|
id: z.string().min(1).max(200).describe("project id (proj_…), from projects_list"),
|
|
@@ -6923,8 +6947,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6923
6947
|
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
6924
6948
|
sort: z.number().int().optional(),
|
|
6925
6949
|
archived: z.boolean().optional(),
|
|
6950
|
+
parent_id: z.string().max(200).nullable().optional(),
|
|
6926
6951
|
},
|
|
6927
|
-
async (args) => requirementsCall("PATCH", `/api/requirements/projects/${encodeURIComponent(args.id)}`, args.network_id, pick(args, ["name", "color", "sort", "archived"])),
|
|
6952
|
+
async (args) => requirementsCall("PATCH", `/api/requirements/projects/${encodeURIComponent(args.id)}`, args.network_id, pick(args, ["name", "color", "sort", "archived", "parent_id"])),
|
|
6928
6953
|
);
|
|
6929
6954
|
|
|
6930
6955
|
// #476 —— 不带 requirement_id 的全网动态默认 50 条(REST 默认 200 不变,App 走 REST);严格参数同 #471。
|