@sleep2agi/commhub-server 0.9.0-preview.155 → 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.155",
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",
@@ -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
  }
@@ -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";
@@ -547,13 +547,37 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
547
547
  const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
548
548
  // 「存在」= 有卡片在用 / 有颜色行 / 是登记过的树节点。最后一种让 0 任务的标签也能改名、合并、删除、上色。
549
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);
550
- 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);
551
553
  if (!known) return null;
552
554
  if (op.op === "color") {
553
555
  if (op.color === null) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, op.tag]);
554
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]);
555
557
  return { affected: 0 };
556
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
+ }
557
581
  let affected = 0;
558
582
  for (const card of cards) {
559
583
  const next = applyTagOp(storedTags(card.tags_json), op);
@@ -567,13 +591,14 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
567
591
  return { affected, reparented };
568
592
  });
569
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);
570
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);
571
596
  return Response.json({ ok: true, op: op.op, affected: result.affected, reparented: result.reparented });
572
597
  }
573
598
 
574
599
  /** 改名 / 合并:目标没有颜色就继承第一个有颜色的来源;来源的颜色行删掉。删除:删颜色行。 */
575
600
  function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) => string | null, now: string): void {
576
- if (op.op === "color") return;
601
+ if (op.op === "color" || op.op === "reparent") return;
577
602
  const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
578
603
  if (op.op !== "delete" && colorOf(op.to) === null) {
579
604
  const inherited = sources.map(colorOf).find(color => color !== null);
@@ -590,7 +615,7 @@ function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) =>
590
615
  * 返回被改挂的节点数(客户端能报「N 个子标签已上移」)。
591
616
  */
592
617
  function maintainTagTree(networkId: string, op: TagOp, now: string): number {
593
- if (op.op === "color") return 0;
618
+ if (op.op === "color" || op.op === "reparent") return 0;
594
619
  const rows = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
595
620
  if (!rows.length) return 0;
596
621
  const parentOf = new Map(rows.map(row => [row.name, row.parent_name ?? null]));