@sleep2agi/commhub-server 0.9.0-preview.154 → 0.9.0-preview.156

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.154",
3
+ "version": "0.9.0-preview.156",
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",
@@ -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
- expect(Object.keys(row).sort()).toEqual(["archived", "color", "createdAt", "id", "name", "sort", "viewer_can"]);
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
 
@@ -264,3 +264,120 @@ describe("改名 / 合并 / 删除 / 颜色", () => {
264
264
  expect(cardTags("net2")).toEqual(["UI", "交互"]);
265
265
  });
266
266
  });
267
+
268
+ // ── 改父级(reparent) ──
269
+ // 客户端(0.297+)的「管理标签」里要能改父级;Hub 侧此前只缺这一个 op。
270
+ // 这里只验树:reparent 不给标签改名、不动任何卡片的 tags 数组。
271
+ describe("改父级 reparent", () => {
272
+ const createTag = (token: string, name: string, parent: string | null = null) =>
273
+ send(token, "POST", `/api/requirements/tags/create?network_id=${NET}`, { name, parent_id: parent });
274
+ const nodesOf = async (token: string) =>
275
+ (await send(token, "GET", `/api/requirements/tags?network_id=${NET}`)).body.nodes as Array<{ name: string; parent_id: string | null; depth: number; registered: boolean }>;
276
+ const nodeOf = async (token: string, name: string) => (await nodesOf(token)).find(node => node.name === name)!;
277
+
278
+ test("搬到另一个标签下面,再搬回顶层:父与深度都跟着变,卡片一张都不动", async () => {
279
+ expect((await createTag(admin.token, "RP根")).status).toBe(201);
280
+ expect((await createTag(admin.token, "RP枝")).status).toBe(201);
281
+ expect((await createTag(admin.token, "RP叶", "RP枝")).status).toBe(201);
282
+ const before = db.get("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET).n;
283
+
284
+ const moved = await op(admin.token, { op: "reparent", tag: "RP叶", parent: "RP根" });
285
+ expect(moved.status).toBe(200);
286
+ expect(moved.body).toMatchObject({ ok: true, op: "reparent", affected: 0, reparented: 1 });
287
+ expect(await nodeOf(admin.token, "RP叶")).toMatchObject({ parent_id: "RP根", depth: 2 });
288
+ // 原来是 RP枝 的子孙,搬走之后 RP枝 下面没有它了
289
+ expect((await nodesOf(admin.token)).filter(node => node.parent_id === "RP枝")).toEqual([]);
290
+
291
+ const back = await op(admin.token, { op: "reparent", tag: "RP叶", parent: null });
292
+ expect(back.status).toBe(200);
293
+ expect(await nodeOf(admin.token, "RP叶")).toMatchObject({ parent_id: null, depth: 1 });
294
+ // 只动树:卡片数与卡的标签都没变
295
+ expect(db.get("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET).n).toBe(before);
296
+ });
297
+
298
+ test("父是自己的子孙 = 环,拒绝(400 tag_parent_cycle),树一个字都不改", async () => {
299
+ expect((await createTag(admin.token, "RP环甲")).status).toBe(201);
300
+ expect((await createTag(admin.token, "RP环乙", "RP环甲")).status).toBe(201);
301
+ const r = await op(admin.token, { op: "reparent", tag: "RP环甲", parent: "RP环乙" });
302
+ expect(r.status).toBe(400);
303
+ expect(r.body.error).toBe("tag_parent_cycle");
304
+ expect(await nodeOf(admin.token, "RP环甲")).toMatchObject({ parent_id: null });
305
+ });
306
+
307
+ test("自己当自己的父:解析阶段就拒(400 tag_parent_cycle)", async () => {
308
+ const r = await op(admin.token, { op: "reparent", tag: "RP根", parent: "RP根" });
309
+ expect(r.status).toBe(400);
310
+ expect(r.body.error).toBe("tag_parent_cycle");
311
+ });
312
+
313
+ test("连子树一起搬:新父深度 + 子树高度 > 4 就拒(400 tag_depth_exceeded)", async () => {
314
+ expect((await createTag(admin.token, "RPD1")).status).toBe(201);
315
+ expect((await createTag(admin.token, "RPD2", "RPD1")).status).toBe(201);
316
+ expect((await createTag(admin.token, "RPD3", "RPD2")).status).toBe(201); // 3 层:1>2>3
317
+ expect((await createTag(admin.token, "RPX")).status).toBe(201);
318
+ expect((await createTag(admin.token, "RPY", "RPX")).status).toBe(201); // 2 层子树
319
+ // 把 2 层的 RPX(含 RPY)挂到第 3 层下面 → 最深 5 层 > 4
320
+ const r = await op(admin.token, { op: "reparent", tag: "RPX", parent: "RPD3" });
321
+ expect(r.status).toBe(400);
322
+ expect(r.body.error).toBe("tag_depth_exceeded");
323
+ expect(await nodeOf(admin.token, "RPX")).toMatchObject({ parent_id: null });
324
+ // 只搬叶子(高度 1)就正好 4 层,允许
325
+ const ok = await op(admin.token, { op: "reparent", tag: "RPY", parent: "RPD3" });
326
+ expect(ok.status).toBe(200);
327
+ expect(await nodeOf(admin.token, "RPY")).toMatchObject({ parent_id: "RPD3", depth: 4 });
328
+ });
329
+
330
+ test("父不存在 = 404,不静默升到根", async () => {
331
+ const r = await op(admin.token, { op: "reparent", tag: "RP根", parent: "不存在的父" });
332
+ expect(r.status).toBe(404);
333
+ expect(r.body.error).toBe("tag_parent_not_found");
334
+ expect(await nodeOf(admin.token, "RP根")).toMatchObject({ parent_id: null });
335
+ });
336
+
337
+ test("缺 parent 键 = 400(不能用「没传」把标签静默升到根);parent 为空串 = 顶层", async () => {
338
+ const missing = await op(admin.token, { op: "reparent", tag: "RP根" });
339
+ expect(missing.status).toBe(400);
340
+ expect(missing.body.error).toBe("invalid_tag_parent_id");
341
+ expect(await nodeOf(admin.token, "RP根")).toMatchObject({ parent_id: null });
342
+
343
+ expect((await op(admin.token, { op: "reparent", tag: "RP根", parent: "RPD1" })).status).toBe(200);
344
+ const root = await op(admin.token, { op: "reparent", tag: "RP根", parent: "" });
345
+ expect(root.status).toBe(200);
346
+ expect(await nodeOf(admin.token, "RP根")).toMatchObject({ parent_id: null });
347
+ });
348
+
349
+ test("只在卡片上出现过的老标签也能改父级:先登记成节点,再挂到父下面", async () => {
350
+ const inUse = (await nodesOf(admin.token)).find(node => node.registered === false);
351
+ if (!inUse) return; // 该网络里没有"未登记但在用"的标签时,这条不适用
352
+ const r = await op(admin.token, { op: "reparent", tag: inUse.name, parent: "RPD1" });
353
+ expect(r.status).toBe(200);
354
+ const after = await nodeOf(admin.token, inUse.name);
355
+ expect(after).toMatchObject({ parent_id: "RPD1", depth: 2, registered: true });
356
+ // 卡片上的标签字符串不变(改父级不是改名)
357
+ const stillUsed = db.all<{ tags_json: string }>("SELECT tags_json FROM requirements WHERE network_id = ?1 AND tags_json LIKE ?2", NET, `%${inUse.name}%`);
358
+ expect(stillUsed.length).toBeGreaterThan(0);
359
+ });
360
+
361
+ test("reparent 走同一把标签管理锁:节点令牌与 viewer 都拒(403),树不变", async () => {
362
+ const byNode = await op(nodeToken, { op: "reparent", tag: "RP根", parent: "RPD1" });
363
+ expect(byNode.status).toBe(403);
364
+ const byViewer = await op(vic.token, { op: "reparent", tag: "RP根", parent: "RPD1" });
365
+ expect(byViewer.status).toBe(403);
366
+ expect(await nodeOf(admin.token, "RP根")).toMatchObject({ parent_id: null });
367
+ });
368
+ });
369
+
370
+ describe("改父级 reparent —— 被拒绝时不留副作用", () => {
371
+ const nodes = async () => (await send(admin.token, "GET", `/api/requirements/tags?network_id=${NET}`)).body.nodes as Array<{ name: string; parent_id: string | null; registered: boolean }>;
372
+ test("只在卡片上用过的标签,reparent 到不存在的父:404 且**不会**被登记进树", async () => {
373
+ const before = (await nodes()).filter(node => !node.registered).map(node => node.name);
374
+ if (!before.length) return; // 该网络里没有"未登记但在用"的标签时,这条不适用
375
+ const name = before[0];
376
+ const r = await op(admin.token, { op: "reparent", tag: name, parent: "根本没有这个父" });
377
+ expect(r.status).toBe(404);
378
+ expect(r.body.error).toBe("tag_parent_not_found");
379
+ // 关键:事务返回错误码是**提交**语义,若先 INSERT 登记再报错,这里就会看到 registered=true
380
+ const after = (await nodes()).find(node => node.name === name);
381
+ expect(after?.registered ?? false).toBe(false);
382
+ });
383
+ });
@@ -95,3 +95,33 @@ export function buildTagNodes(registered: TagNodeRow[], inUse: string[]): TagNod
95
95
  }
96
96
  return nodes.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
97
97
  }
98
+
99
+ /**
100
+ * name 这棵子树有几层(叶子 = 1)。改父级时用它算「搬过去之后最深到几层」:
101
+ * 只检查新父的深度是不够的 —— 一个 3 层的子树挂到第 3 层下,最深会到 5 层。
102
+ * 历史脏数据里有环时用 visited 兜住,不要为脏数据死循环。
103
+ */
104
+ export function tagSubtreeHeight(name: string, parentOf: Map<string, string | null>): number {
105
+ const childrenOf = new Map<string, string[]>();
106
+ for (const [child, parent] of parentOf) {
107
+ if (!parent) continue;
108
+ const list = childrenOf.get(parent);
109
+ if (list) list.push(child); else childrenOf.set(parent, [child]);
110
+ }
111
+ let height = 1;
112
+ let frontier = [name];
113
+ const seen = new Set<string>([name]);
114
+ for (;;) {
115
+ const next: string[] = [];
116
+ for (const node of frontier) {
117
+ for (const child of childrenOf.get(node) ?? []) {
118
+ if (seen.has(child)) continue;
119
+ seen.add(child);
120
+ next.push(child);
121
+ }
122
+ }
123
+ if (!next.length) return height;
124
+ height++;
125
+ frontier = next;
126
+ }
127
+ }
@@ -23,7 +23,9 @@ export type TagOp =
23
23
  | { op: "rename"; from: string; to: string }
24
24
  | { op: "merge"; from: string[]; to: string }
25
25
  | { op: "delete"; tag: string }
26
- | { op: "color"; tag: string; color: string | null };
26
+ | { op: "color"; tag: string; color: string | null }
27
+ // 改父级(标签树自己的层级,与卡片无关):parent=null = 升为顶层。
28
+ | { op: "reparent"; tag: string; parent: string | null };
27
29
 
28
30
  /** 单个标签名,规则同 normalizeTags。不合法 = null。 */
29
31
  export function tagName(value: unknown): string | null {
@@ -66,6 +68,17 @@ export function parseTagOp(body: Record<string, unknown>): TagOp | { error: stri
66
68
  if (body.color !== null && (typeof body.color !== "string" || !TAG_COLOR.test(body.color))) return { error: "invalid_tag_color" };
67
69
  return { op: "color", tag, color: body.color === null ? null : (body.color as string).toLowerCase() };
68
70
  }
71
+ case "reparent": {
72
+ const tag = tagName(body.tag);
73
+ if (!tag) return { error: "invalid_tag" };
74
+ // 与 create 同一套读法:缺省 key 算错(不能靠"没传"把标签静默升到根),显式 null = 根。
75
+ if (!("parent" in body)) return { error: "invalid_tag_parent_id" };
76
+ if (body.parent === null || body.parent === "") return { op: "reparent", tag, parent: null };
77
+ const parent = tagName(body.parent);
78
+ if (!parent) return { error: "invalid_tag_parent_id" };
79
+ if (parent === tag) return { error: "tag_parent_cycle" };
80
+ return { op: "reparent", tag, parent };
81
+ }
69
82
  default:
70
83
  return { error: "invalid_tag_op" };
71
84
  }
@@ -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 \(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);
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");
@@ -8,7 +8,7 @@ import { db, logAudit } from "./db.js";
8
8
  import { resolveNodeCaller } from "./create-node.js";
9
9
  import { markGzipReusable } from "./http-gzip.js";
10
10
  import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
11
- import { buildTagNodes, MAX_TAG_DEPTH, parseCreateTag, TAG_TREE_CAPABILITY, tagDepth, type TagNodeRow } from "./requirement-tag-tree.js";
11
+ import { buildTagNodes, isDescendant, MAX_TAG_DEPTH, parseCreateTag, TAG_TREE_CAPABILITY, tagDepth, tagSubtreeHeight, type TagNodeRow } from "./requirement-tag-tree.js";
12
12
  import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
13
13
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
14
14
  import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
@@ -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
- const projectPublic = (row: ProjectRow) => ({ id: row.project_id, name: row.name, color: row.color, sort: row.sort, archived: !!row.archived, createdAt: row.created_at });
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
- db.run(`INSERT INTO requirement_projects (${PROJECT_SELECT}) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6)`, [id, networkId, name, color, sort, new Date().toISOString()]);
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
 
@@ -478,13 +547,37 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
478
547
  const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
479
548
  // 「存在」= 有卡片在用 / 有颜色行 / 是登记过的树节点。最后一种让 0 任务的标签也能改名、合并、删除、上色。
480
549
  const nodeExists = (name: string) => !!db.get<{ x: number }>("SELECT 1 AS x FROM network_tag_nodes WHERE network_id = ?1 AND name = ?2", networkId, name);
481
- const known = cards.some(card => storedTags(card.tags_json).some(tag => sources.includes(tag))) || sources.some(tag => colorOf(tag) !== null || nodeExists(tag));
550
+ // 「存在」的三种形态(卡片在用 / 有颜色行 / 登记过的树节点),reparent 判父存在时用的是同一个口径。
551
+ const tagExists = (name: string) => cards.some(card => storedTags(card.tags_json).includes(name)) || colorOf(name) !== null || nodeExists(name);
552
+ const known = sources.some(tagExists);
482
553
  if (!known) return null;
483
554
  if (op.op === "color") {
484
555
  if (op.color === null) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, op.tag]);
485
556
  else db.run("INSERT INTO network_tags (network_id, name, color, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (network_id, name) DO UPDATE SET color = excluded.color, updated_at = excluded.updated_at", [networkId, op.tag, op.color, now]);
486
557
  return { affected: 0 };
487
558
  }
559
+ // 改父级:只动标签树,不动任何卡片(标签仍然是卡片 tags_json 里的字符串)。
560
+ // 三条规则都明确拒绝而不是静默纠正:父必须存在、不许成环、搬完最深不超过 MAX_TAG_DEPTH。
561
+ if (op.op === "reparent") {
562
+ const nodes = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
563
+ const parentOf = new Map(nodes.map(row => [row.name, row.parent_name ?? null]));
564
+ // 只在卡片上出现过的老标签没有行可改,要登记成顶层节点(这不是把它降级,它本来就没有父)。
565
+ // ⚠ 先**在虚拟树上**判环与深度,判过了再写:事务返回 {error} 是提交而不是回滚,
566
+ // 如果先 INSERT 登记再返回错误,调用方看到的是"被拒绝了",库里却多了一行 —— 树被静默改过。
567
+ const virtual = new Map(parentOf);
568
+ if (!virtual.has(op.tag)) virtual.set(op.tag, null);
569
+ if (op.parent) {
570
+ if (!tagExists(op.parent)) return { error: "tag_parent_not_found" };
571
+ if (!virtual.has(op.parent)) virtual.set(op.parent, null);
572
+ // 环:父是自己(parse 已挡)或自己的子孙(这里挡)。深度:新父的深度 + 自己这棵子树的高度。
573
+ if (isDescendant(op.parent, op.tag, virtual)) return { error: "tag_parent_cycle" };
574
+ if (tagDepth(op.parent, virtual) + tagSubtreeHeight(op.tag, virtual) > MAX_TAG_DEPTH) return { error: "tag_depth_exceeded" };
575
+ }
576
+ if (!parentOf.has(op.tag)) db.run("INSERT INTO network_tag_nodes (network_id, name, parent_name, created_at, updated_at, created_by_json) VALUES (?1, ?2, NULL, ?3, ?3, ?4)", [networkId, op.tag, now, actor]);
577
+ if (op.parent && !parentOf.has(op.parent)) db.run("INSERT INTO network_tag_nodes (network_id, name, parent_name, created_at, updated_at, created_by_json) VALUES (?1, ?2, NULL, ?3, ?3, ?4)", [networkId, op.parent, now, actor]);
578
+ db.run("UPDATE network_tag_nodes SET parent_name = ?1, updated_at = ?2 WHERE network_id = ?3 AND name = ?4", [op.parent, now, networkId, op.tag]);
579
+ return { affected: 0, reparented: 1 };
580
+ }
488
581
  let affected = 0;
489
582
  for (const card of cards) {
490
583
  const next = applyTagOp(storedTags(card.tags_json), op);
@@ -498,13 +591,14 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
498
591
  return { affected, reparented };
499
592
  });
500
593
  if (!result) return jsonError("tag_not_found", 404);
594
+ if ("error" in result) return jsonError(result.error, result.error === "tag_parent_not_found" ? 404 : 400);
501
595
  if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, `requirement_tag_${op.op}`, "requirement_tag", op.op === "rename" || op.op === "merge" ? op.to : op.tag, JSON.stringify({ ...op, affected: result.affected }).slice(0, 1000), undefined, networkId);
502
596
  return Response.json({ ok: true, op: op.op, affected: result.affected, reparented: result.reparented });
503
597
  }
504
598
 
505
599
  /** 改名 / 合并:目标没有颜色就继承第一个有颜色的来源;来源的颜色行删掉。删除:删颜色行。 */
506
600
  function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) => string | null, now: string): void {
507
- if (op.op === "color") return;
601
+ if (op.op === "color" || op.op === "reparent") return;
508
602
  const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
509
603
  if (op.op !== "delete" && colorOf(op.to) === null) {
510
604
  const inherited = sources.map(colorOf).find(color => color !== null);
@@ -521,7 +615,7 @@ function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) =>
521
615
  * 返回被改挂的节点数(客户端能报「N 个子标签已上移」)。
522
616
  */
523
617
  function maintainTagTree(networkId: string, op: TagOp, now: string): number {
524
- if (op.op === "color") return 0;
618
+ if (op.op === "color" || op.op === "reparent") return 0;
525
619
  const rows = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
526
620
  if (!rows.length) return 0;
527
621
  const parentOf = new Map(rows.map(row => [row.name, row.parent_name ?? null]));
package/src/tools.ts CHANGED
@@ -4104,7 +4104,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4104
4104
 
4105
4105
  server.tool(
4106
4106
  "tail_node_logs",
4107
- "Ask a node for the tail of its own agent-node run log (read-only, redacted on the node before it leaves). No path argument: the node reads only its own log directory. User logins only; the node's owner or a network owner/admin. Poll get_rules_file_result — content is JSON {files, lines:[{ts, level, text, key}], truncated, matched, now_ts}; it is handed out once and then purged.",
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.",
4108
4108
  {
4109
4109
  ...NODE_ID_ALIAS_FIELDS,
4110
4110
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting logs_capable)."),
@@ -6916,7 +6916,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6916
6916
 
6917
6917
  server.tool(
6918
6918
  "projects_list",
6919
- "List requirement projects in your network (id, name, color, sort, archived). Node (Agent) tokens can only read projects; creating / changing them (projects_create / projects_update) needs a person's token.",
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).",
6920
6920
  { network_id: z.string().max(200).optional() },
6921
6921
  async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
6922
6922
  );
@@ -6926,19 +6926,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6926
6926
  // write access and is refused to 「仅相关任务」(task-scoped) members; events use the task list's visibility.
6927
6927
  server.tool(
6928
6928
  "projects_create",
6929
- "Create a requirement project in your network (same as 管理项目 → 新建 in the app). name 1–40 characters, unique in the network; color #RRGGBB (default: next palette colour); sort integer. Returns { project }. Needs a person's token: node (Agent) tokens can only read projects (403 user_token_required). Also refused (403) for task-scoped members and read-only roles.",
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.",
6930
6930
  {
6931
6931
  network_id: z.string().max(200).optional(),
6932
6932
  name: z.string().min(1).max(40),
6933
6933
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
6934
6934
  sort: z.number().int().optional(),
6935
+ parent_id: z.string().max(200).nullable().optional(),
6935
6936
  },
6936
- 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"])),
6937
6938
  );
6938
6939
 
6939
6940
  server.tool(
6940
6941
  "projects_update",
6941
- "Rename, recolour, reorder, archive or unarchive a requirement project (same as 管理项目 in the app). Only the fields you pass change. archived=true hides it from pickers (its tasks keep project_id); archived=false brings it back. Returns { project }. Needs a person's token: node (Agent) tokens can only read projects (403 user_token_required). Also refused (403) for task-scoped members and read-only roles.",
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.",
6942
6943
  {
6943
6944
  network_id: z.string().max(200).optional(),
6944
6945
  id: z.string().min(1).max(200).describe("project id (proj_…), from projects_list"),
@@ -6946,8 +6947,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6946
6947
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
6947
6948
  sort: z.number().int().optional(),
6948
6949
  archived: z.boolean().optional(),
6950
+ parent_id: z.string().max(200).nullable().optional(),
6949
6951
  },
6950
- 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"])),
6951
6953
  );
6952
6954
 
6953
6955
  // #476 —— 不带 requirement_id 的全网动态默认 50 条(REST 默认 200 不变,App 走 REST);严格参数同 #471。