@sleep2agi/commhub-server 0.9.0-preview.66 → 0.9.0-preview.67

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.66",
3
+ "version": "0.9.0-preview.67",
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
@@ -975,7 +975,7 @@ db.exec(`
975
975
  try { db.exec("ALTER TABLE requirements ADD COLUMN client_id TEXT"); } catch {}
976
976
  try { db.exec("ALTER TABLE requirements ADD COLUMN issues_json TEXT"); } catch {}
977
977
  db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_client ON requirements(network_id, client_id) WHERE client_id IS NOT NULL AND client_id != ''");
978
- for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'", "agent_owner_json TEXT", "description TEXT", "checklist_json TEXT", "project_id TEXT"]) {
978
+ for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'", "agent_owner_json TEXT", "description TEXT", "checklist_json TEXT", "project_id TEXT", "external_ref TEXT", "external_url TEXT", "archived INTEGER NOT NULL DEFAULT 0", "created_by_json TEXT", "updated_by_json TEXT", "parent_id TEXT"]) {
979
979
  try { db.exec(`ALTER TABLE requirements ADD COLUMN ${column}`); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
980
980
  }
981
981
 
@@ -1516,6 +1516,10 @@ db.exec(`
1516
1516
  ON scheduled_task_runs(network_id, created_at DESC);
1517
1517
  `);
1518
1518
  try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once'"); } catch {}
1519
+ // Skipped runs (overlap_policy=skip) name the open task that blocked them and
1520
+ // whether the node had ever picked it up: not_received | in_progress.
1521
+ try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_task_id TEXT"); } catch {}
1522
+ try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_state TEXT"); } catch {}
1519
1523
 
1520
1524
  // RFC-036 / B4 — owner-authorized edits of node-host managed schedules.
1521
1525
  // This is intentionally separate from Hub scheduled_tasks: these rows are
@@ -76,7 +76,8 @@ describe("requirements stay on the hub", () => {
76
76
  const humanAsAgent = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: user }) });
77
77
  expect(humanAsAgent.status).toBe(400);
78
78
  expect(humanAsAgent.body.error).toBe('agent_owner_must_be_agent');
79
- const agentAsOwner = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node }) });
79
+ // 新客户端(带了 agent_owner):节点当负责人严格拒绝
80
+ const agentAsOwner = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node, agent_owner: null }) });
80
81
  expect(agentAsOwner.status).toBe(400);
81
82
  expect(agentAsOwner.body.error).toBe('owner_must_be_human');
82
83
  const changed = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: node }) });
@@ -85,9 +86,7 @@ describe("requirements stay on the hub", () => {
85
86
  expect(changed.body.requirement.agent_owner).toEqual(node);
86
87
  expect(changed.body.requirement.participants).toEqual([user, node]);
87
88
  expect(changed.body.requirement.column).toBe('pool');
88
- for (const token of [viewerToken, nodeToken]) {
89
- expect((await api(token, path, { method: 'PATCH', body: JSON.stringify({ owner: null, participants: [] }) })).status).toBe(403);
90
- }
89
+ expect((await api(viewerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: null, participants: [] }) })).status).toBe(403);
91
90
  expect((await api(otherToken, path, { method: 'PATCH', body: JSON.stringify({ owner: null }) })).status).toBe(404);
92
91
  for (const invalid of [{ kind: 'node', id: 'foreign-person-node' }, { kind: 'user', id: 'unknown' }, { kind: 'admin', id: ownerId }]) {
93
92
  expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ participants: [invalid] }) })).status).toBe(400);
@@ -115,7 +114,7 @@ describe("requirements stay on the hub", () => {
115
114
  expect(both.body.requirement.agent_owner).toEqual(agent);
116
115
  const plain = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '都不填' }) });
117
116
  expect(plain.body.requirement.agent_owner).toBeNull();
118
- const wrongOwner = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '节点当负责人', owner: agent }) });
117
+ const wrongOwner = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '节点当负责人', owner: agent, agent_owner: null }) });
119
118
  expect(wrongOwner.status).toBe(400);
120
119
  expect(wrongOwner.body.error).toBe('owner_must_be_human');
121
120
  const wrongAgent = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '人当负责 Agent', agent_owner: human }) });
@@ -164,7 +163,10 @@ describe("requirements stay on the hub", () => {
164
163
  expect((await api(ownerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: "yes" }) })).status).toBe(400);
165
164
  expect((await api(ownerToken, `${path}/checklist/a`, { method: "POST", body: JSON.stringify({ done: true }) })).status).toBe(404);
166
165
  expect((await api(viewerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(403);
167
- expect((await api(nodeToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: false }) })).body.error).toBe("user_token_required");
166
+ // Agent(节点令牌)能勾自己网络里的子任务(PR B 放开)
167
+ const agentTick = await api(nodeToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
168
+ expect(agentTick.status).toBe(200);
169
+ expect(agentTick.body.requirement.updated_by.kind).toBe("node");
168
170
  expect((await api(otherToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(404);
169
171
 
170
172
  // 整个替换:排序 / 增删;只改描述时清单不动;只改清单是有效 PATCH
@@ -193,7 +195,7 @@ describe("requirements stay on the hub", () => {
193
195
  expect(row.checklist.length).toBe(100);
194
196
  expect(row.description.length).toBe(20_000);
195
197
  expect((await api(viewerToken, path, { method: "PATCH", body: JSON.stringify({ description: "越权" }) })).status).toBe(403);
196
- expect((await api(nodeToken, path, { method: "PATCH", body: JSON.stringify({ description: "Agent 暂不能写" }) })).body.error).toBe("user_token_required");
198
+ expect((await api(nodeToken, path, { method: "PATCH", body: JSON.stringify({ description: "Agent 写的描述" }) })).body.requirement.description).toBe("Agent 写的描述");
197
199
  });
198
200
 
199
201
  test("projects: CRUD per network, project_id on cards, delete nulls references, archive keeps them", async () => {
@@ -217,7 +219,9 @@ describe("requirements stay on the hub", () => {
217
219
  // 权限:viewer 能读不能写;节点令牌一律拒绝;别的网络看不见、改不了
218
220
  expect((await api(viewerToken, `/api/requirements/projects${q}`)).body.projects.length).toBe(2);
219
221
  expect((await api(viewerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "越权" }) })).status).toBe(403);
220
- expect((await api(nodeToken, `/api/requirements/projects${q}`)).body.error).toBe("user_token_required");
222
+ // Agent 能读项目(挂卡片要用),不能建 / 改 / 删项目
223
+ expect((await api(nodeToken, `/api/requirements/projects${q}`)).body.projects.length).toBe(2);
224
+ expect((await api(nodeToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "Agent 建的" }) })).body.error).toBe("user_token_required");
221
225
  expect((await api(otherToken, "/api/requirements/projects")).body.projects).toEqual([]);
222
226
  expect((await api(otherToken, `/api/requirements/projects/${tmai.body.project.id}`, { method: "PATCH", body: JSON.stringify({ name: "抢" }) })).status).toBe(404);
223
227
  const foreign = await api(otherToken, "/api/requirements/projects", { method: "POST", body: JSON.stringify({ name: "别人的项目" }) });
@@ -290,7 +294,90 @@ describe("requirements stay on the hub", () => {
290
294
  expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
291
295
  const listed = await api(ownerToken, "/api/requirements");
292
296
  expect(listed.body.capabilities).toContain("due_datetime");
293
- expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime"]);
297
+ expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements"]);
298
+ });
299
+
300
+ test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
301
+ const mk = async (name: string, extra: Record<string, unknown> = {}) => (await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name, network_id: ownerNetwork, ...extra }) })).body.requirement;
302
+ const root = await mk("父需求");
303
+ const a = await mk("子 A", { parent_id: root.id });
304
+ const b = await mk("子 B", { parent_id: root.id, column: "done" });
305
+ expect(a.parent_id).toBe(root.id);
306
+ const parent = (await api(ownerToken, `/api/requirements/${root.id}`)).body.requirement;
307
+ expect(parent.children).toEqual({ total: 2, done: 1 });
308
+ expect(parent.parent_id).toBeNull();
309
+ // 过滤
310
+ const kids = await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}&parent_id=${root.id}`);
311
+ expect(kids.body.requirements.map((r: any) => r.id).sort()).toEqual([a.id, b.id].sort());
312
+ const top = await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}&top_level=1`);
313
+ expect(top.body.requirements.some((r: any) => r.id === root.id)).toBe(true);
314
+ expect(top.body.requirements.some((r: any) => r.id === a.id)).toBe(false);
315
+ // 成环:把父卡挂到自己的子卡下 / 挂到自己
316
+ expect((await api(ownerToken, `/api/requirements/${root.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: a.id }) })).body.error).toBe("parent_cycle");
317
+ expect((await api(ownerToken, `/api/requirements/${a.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: a.id }) })).body.error).toBe("parent_cycle");
318
+ // 深度:第 5 层可以,第 6 层不行;把一棵 2 层的子树挂到第 4 层下面也不行
319
+ let chain = root;
320
+ const levels = [root];
321
+ for (let i = 2; i <= 5; i++) { chain = await mk(`第 ${i} 层`, { parent_id: chain.id }); levels.push(chain); }
322
+ expect(chain.parent_id).toBe(levels[3].id);
323
+ expect((await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "第 6 层", network_id: ownerNetwork, parent_id: chain.id }) })).body.error).toBe("parent_too_deep");
324
+ const subtree = await mk("子树根");
325
+ await mk("子树叶", { parent_id: subtree.id });
326
+ expect((await api(ownerToken, `/api/requirements/${subtree.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: levels[3].id }) })).body.error).toBe("parent_too_deep");
327
+ expect((await api(ownerToken, `/api/requirements/${subtree.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: levels[2].id }) })).status).toBe(200);
328
+ // 别的网络的卡当父卡:拒绝;坏 id:拒绝
329
+ const foreign = (await api(otherToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "别的网络的" }) })).body.requirement;
330
+ expect((await api(ownerToken, `/api/requirements/${a.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: foreign.id }) })).body.error).toBe("parent_not_found");
331
+ expect((await api(otherToken, `/api/requirements/${foreign.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: root.id }) })).body.error).toBe("parent_not_found");
332
+ expect((await api(ownerToken, `/api/requirements/${a.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: "bad id!" }) })).body.error).toBe("invalid_parent_id");
333
+ // 解挂
334
+ expect((await api(ownerToken, `/api/requirements/${b.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: null }) })).body.requirement.parent_id).toBeNull();
335
+ // 删父卡:子卡保留、变顶层
336
+ const before = (await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}`)).body.requirements.length;
337
+ expect((await api(ownerToken, `/api/requirements/${root.id}`, { method: "DELETE" })).status).toBe(200);
338
+ const after = (await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}`)).body.requirements;
339
+ expect(after.length).toBe(before - 1);
340
+ expect(after.find((r: any) => r.id === a.id).parent_id).toBeNull();
341
+ expect(after.find((r: any) => r.id === levels[1].id).parent_id).toBeNull();
342
+ expect(after.find((r: any) => r.id === levels[2].id).parent_id).toBe(levels[1].id);
343
+ });
344
+
345
+ test("old-app compat: owner {kind:'node'} without agent_owner becomes agent_owner (200 + flag); new clients stay strict", async () => {
346
+ const { db } = await import('./db.js');
347
+ const ownerId = db.get<{ owner_id: string }>('SELECT owner_id FROM networks WHERE network_id=?1', ownerNetwork)!.owner_id;
348
+ db.run('INSERT OR IGNORE INTO nodes(node_id,node_name,alias,network_id) VALUES (?1,?2,?2,?3)', ['compat-node', 'compat-node', ownerNetwork]);
349
+ const node = { kind: 'node', id: 'compat-node' };
350
+ const human = { kind: 'user', id: ownerId };
351
+ const card = (await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '旧 App 的卡', owner: human }) })).body.requirement;
352
+ const path = `/api/requirements/${card.id}`;
353
+ // 0.2.142 的详情保存:{name?, owner:{node}} —— 不带 agent_owner
354
+ const old = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node }) });
355
+ expect(old.status).toBe(200);
356
+ expect(old.body.owner_coerced_to_agent_owner).toBe(true);
357
+ // 回显给旧客户端的 owner 是它设的那个节点(它按这个核对「保存生效」);agent_owner 也在
358
+ expect(old.body.requirement.owner).toEqual(node);
359
+ expect(old.body.requirement.agent_owner).toEqual(node);
360
+ // 实际存储:负责人清空,负责 Agent = 节点;GET 看到的是真实值
361
+ const stored = (await api(ownerToken, path)).body.requirement;
362
+ expect(stored.owner).toBeNull();
363
+ expect(stored.agent_owner).toEqual(node);
364
+ const row = db.get<{ owner_json: string | null; agent_owner_json: string | null }>('SELECT owner_json, agent_owner_json FROM requirements WHERE requirement_id=?1', card.id)!;
365
+ expect(row.owner_json).toBeNull();
366
+ expect(JSON.parse(row.agent_owner_json!)).toEqual(node);
367
+ // 旧 App 新建时选了节点当负责人:同样改写
368
+ const created = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '旧 App 新建', owner: node, assignee: '' }) });
369
+ expect(created.status).toBe(201);
370
+ expect(created.body.owner_coerced_to_agent_owner).toBe(true);
371
+ expect((await api(ownerToken, `/api/requirements/${created.body.requirement.id}`)).body.requirement.agent_owner).toEqual(node);
372
+ // 旧 App 设人类负责人:照常,不带标志
373
+ const normal = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: human }) });
374
+ expect(normal.body.owner_coerced_to_agent_owner).toBeUndefined();
375
+ expect(normal.body.requirement.owner).toEqual(human);
376
+ expect(normal.body.requirement.agent_owner).toEqual(node);
377
+ // 新客户端带 agent_owner:严格
378
+ expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node, agent_owner: node }) })).body.error).toBe('owner_must_be_human');
379
+ // 节点不在网络里:仍然 400(不因为兼容放宽成员校验)
380
+ expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: { kind: 'node', id: 'foreign-person-node' } }) })).status).toBe(400);
294
381
  });
295
382
 
296
383
  test("owner creates a card in the pool", async () => {
@@ -340,9 +427,110 @@ describe("requirements stay on the hub", () => {
340
427
  expect(move.status).toBe(403);
341
428
  });
342
429
 
343
- test("a node token cannot use the board", async () => {
344
- const denied = await api(nodeToken, "/api/requirements");
345
- expect(denied.status).toBe(403);
430
+ test("agent API: a node token reads / creates / patches in its own network only; cannot delete; who did what is recorded", async () => {
431
+ const { db } = await import("./db.js");
432
+ const { createNetworkTokenForNode, register } = await import("./auth.js");
433
+ const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id=?1", ownerNetwork)!.owner_id;
434
+ const bound = createNetworkTokenForNode(ownerId, ownerNetwork, "sync-bot", "node_sync_bot");
435
+ expect(bound.ok).toBe(true);
436
+ const bot = bound.token!;
437
+ // 读
438
+ const listed = await api(bot, "/api/requirements");
439
+ expect(listed.status).toBe(200);
440
+ expect(listed.body.requirements.length).toBeGreaterThan(0);
441
+ // 建:记下是哪个节点建的
442
+ const made = await api(bot, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "Agent 建的任务", column: "doing", checklist: [{ id: "x", text: "第一步" }] }) });
443
+ expect(made.status).toBe(201);
444
+ expect(made.body.requirement.created_by).toEqual({ kind: "node", id: "node_sync_bot" });
445
+ expect(made.body.requirement.updated_by).toEqual({ kind: "node", id: "node_sync_bot" });
446
+ const path = `/api/requirements/${made.body.requirement.id}`;
447
+ // 按 id 读、改、勾、归档
448
+ expect((await api(bot, path)).body.requirement.name).toBe("Agent 建的任务");
449
+ expect((await api(bot, path, { method: "PATCH", body: JSON.stringify({ column: "done" }) })).body.requirement.column).toBe("done");
450
+ expect((await api(bot, `${path}/checklist/x`, { method: "PATCH", body: JSON.stringify({ done: true }) })).body.requirement.checklist[0].done).toBe(true);
451
+ // 人改了之后 updated_by 变成人
452
+ const human = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ priority: "high" }) });
453
+ expect(human.body.requirement.updated_by).toEqual({ kind: "user", id: ownerId });
454
+ expect(human.body.requirement.created_by).toEqual({ kind: "node", id: "node_sync_bot" });
455
+ // 不能删,只能归档;归档的默认不在列表里
456
+ const del = await api(bot, path, { method: "DELETE" });
457
+ expect(del.status).toBe(403);
458
+ expect(del.body.error).toBe("user_token_required");
459
+ expect((await api(bot, path, { method: "PATCH", body: JSON.stringify({ archived: true }) })).body.requirement.archived).toBe(true);
460
+ expect((await api(ownerToken, "/api/requirements")).body.requirements.some((r: any) => r.id === made.body.requirement.id)).toBe(false);
461
+ expect((await api(ownerToken, "/api/requirements?include_archived=1")).body.requirements.some((r: any) => r.id === made.body.requirement.id)).toBe(true);
462
+ expect((await api(bot, path, { method: "PATCH", body: JSON.stringify({ archived: "yes" }) })).status).toBe(400);
463
+ // 别的网络的节点令牌:看不到、改不了、也不能往别的网络里建
464
+ const outsider = register(`req_outsider_${Date.now()}`, "RequirementsOutsider123!", undefined, "seed");
465
+ const foreignTok = createNetworkTokenForNode(outsider.user!.user_id, outsider.network_id!, "foreign-bot", "node_foreign_bot");
466
+ expect(foreignTok.ok).toBe(true);
467
+ const foreign = foreignTok.token!;
468
+ expect((await api(foreign, `/api/requirements?include_archived=1`)).body.requirements.some((r: any) => r.id === made.body.requirement.id)).toBe(false);
469
+ expect((await api(foreign, path)).status).toBe(404);
470
+ expect((await api(foreign, path, { method: "PATCH", body: JSON.stringify({ name: "越界" }) })).status).toBe(404);
471
+ expect((await api(foreign, `${path}/checklist/x`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(404);
472
+ const planted = await api(foreign, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "塞进别的网络", network_id: ownerNetwork }) });
473
+ expect(planted.status).toBe(201);
474
+ // 写进的是它自己的网络(令牌绑定的网络优先于 body 里的 network_id)。owner 在测试库里是管理员,所以按网络查。
475
+ expect((await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}`)).body.requirements.some((r: any) => r.id === planted.body.requirement.id)).toBe(false);
476
+ expect((await api(foreign, `/api/requirements/${planted.body.requirement.id}`)).status).toBe(200);
477
+ // 人照常能删
478
+ const humanDel = await api(ownerToken, path, { method: "DELETE" });
479
+ expect(humanDel.status).toBe(200);
480
+ expect((await api(ownerToken, path)).status).toBe(404);
481
+ expect((await api(viewerToken, `/api/requirements/${planted.body.requirement.id}`, { method: "DELETE" })).status).toBe(404);
482
+ });
483
+
484
+ test("external_ref: unique per network, 409 with the existing id, upsert is idempotent, list filters", async () => {
485
+ const { db } = await import("./db.js");
486
+ const { createNetworkTokenForNode } = await import("./auth.js");
487
+ const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id=?1", ownerNetwork)!.owner_id;
488
+ const bot = createNetworkTokenForNode(ownerId, ownerNetwork, "gh-sync", "node_gh_sync").token!;
489
+ const ref = "github:acme/widgets#123";
490
+ const first = await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: ref, external_url: "https://github.com/acme/widgets/issues/123", name: "Issue 标题", description: "issue 正文" }) });
491
+ expect(first.status).toBe(201);
492
+ expect(first.body.created).toBe(true);
493
+ expect(first.body.requirement.external_ref).toBe(ref);
494
+ expect(first.body.requirement.external_url).toBe("https://github.com/acme/widgets/issues/123");
495
+ // 同一个 issue 再同步:改,不重复建;省略的字段(状态)保留
496
+ await api(ownerToken, `/api/requirements/${first.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ column: "doing" }) });
497
+ const again = await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: ref, name: "Issue 改过的标题" }) });
498
+ expect(again.status).toBe(200);
499
+ expect(again.body.created).toBe(false);
500
+ expect(again.body.requirement.id).toBe(first.body.requirement.id);
501
+ expect(again.body.requirement.name).toBe("Issue 改过的标题");
502
+ expect(again.body.requirement.column).toBe("doing");
503
+ const same = await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: ref }) });
504
+ expect(same.body.created).toBe(false);
505
+ expect((await api(ownerToken, `/api/requirements?external_ref=${encodeURIComponent(ref)}`)).body.requirements.length).toBe(1);
506
+ // 普通 POST 撞同一个 external_ref:409 + 已有 id
507
+ const dup = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "重复", external_ref: ref }) });
508
+ expect(dup.status).toBe(409);
509
+ expect(dup.body.error).toBe("external_ref_exists");
510
+ expect(dup.body.existing_id).toBe(first.body.requirement.id);
511
+ // PATCH 改成别人已有的 external_ref 也 409
512
+ const other = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "另一个", external_ref: "github:acme/widgets#124" }) });
513
+ expect((await api(ownerToken, `/api/requirements/${other.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ external_ref: ref }) })).status).toBe(409);
514
+ // 另一个网络可以有同样的 external_ref(唯一只在网络内)
515
+ const elsewhere = await api(otherToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "别的网络同一个 issue", external_ref: ref }) });
516
+ expect(elsewhere.status).toBe(201);
517
+ // 坏值
518
+ for (const bad of [{ external_ref: "has space" }, { external_ref: "" }, { external_url: "javascript:alert(1)" }]) {
519
+ expect((await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "坏", ...bad }) })).status).toBe(400);
520
+ }
521
+ expect((await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "没有 ref" }) })).body.error).toBe("external_ref_required");
522
+ // 列表筛选
523
+ const byStatus = await api(ownerToken, "/api/requirements?status=doing");
524
+ expect(byStatus.body.requirements.every((r: any) => r.column === "doing")).toBe(true);
525
+ expect((await api(ownerToken, "/api/requirements?status=nope")).status).toBe(400);
526
+ const since = new Date(Date.now() - 60_000).toISOString();
527
+ expect((await api(ownerToken, `/api/requirements?updated_since=${encodeURIComponent(since)}`)).body.requirements.some((r: any) => r.id === first.body.requirement.id)).toBe(true);
528
+ expect((await api(ownerToken, `/api/requirements?updated_since=${encodeURIComponent(new Date(Date.now() + 60_000).toISOString())}`)).body.requirements.length).toBe(0);
529
+ expect((await api(ownerToken, "/api/requirements?updated_since=nope")).status).toBe(400);
530
+ const human = { kind: "user", id: ownerId };
531
+ await api(ownerToken, `/api/requirements/${other.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ owner: human }) });
532
+ expect((await api(ownerToken, `/api/requirements?owner=user:${ownerId}`)).body.requirements.map((r: any) => r.id)).toContain(other.body.requirement.id);
533
+ expect((await api(ownerToken, "/api/requirements?owner=bad")).status).toBe(400);
346
534
  });
347
535
 
348
536
  test("the same client_id does not create a second card", async () => {
@@ -0,0 +1,132 @@
1
+ // 需求池 MCP 工具:走真 MCP in-process transport。Agent(节点令牌)在自己网络里读 / 建 / 改 / 勾 / upsert、
2
+ // 别的网络的节点碰不到、删除不存在于工具里(只有人能在 REST 删)。同步场景:同一个 GitHub issue 同步两次不重复。
3
+ // 跑法:cd server && COMMHUB_DB=/tmp/req-mcp.db bun test src/requirements-mcp.test.ts
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-mcp-"));
10
+ process.env.COMMHUB_DB = join(dir, "hub.db");
11
+
12
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
13
+ const { InMemoryTransport } = await import("@modelcontextprotocol/sdk/inMemory.js");
14
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
15
+ const { db } = await import("./db.js");
16
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
17
+ const { registerTools } = await import("./tools.js");
18
+
19
+ type Identity = { net: string; user: string; alias: string; isNetworkToken: boolean; tokenId: string };
20
+ let owner: Identity, bot: Identity, foreignBot: Identity;
21
+ let ownerNet = "";
22
+
23
+ async function connect(id: Identity) {
24
+ const server = new McpServer({ name: "req-mcp-test", version: "1" });
25
+ registerTools(server, undefined, id.isNetworkToken ? id.net : null, id.user, id.alias, id.isNetworkToken, id.tokenId);
26
+ const client = new Client({ name: "req-mcp-client", version: "1" });
27
+ const [ct, st] = InMemoryTransport.createLinkedPair();
28
+ await server.connect(st);
29
+ await client.connect(ct);
30
+ const call = async (name: string, args: Record<string, unknown>) => {
31
+ const r: any = await client.callTool({ name, arguments: args });
32
+ const first = r.content?.[0];
33
+ if (r.isError) return { mcpError: true, text: first?.text };
34
+ return JSON.parse(first.text);
35
+ };
36
+ return { server, call, close: async () => { await client.close(); await server.close(); } };
37
+ }
38
+
39
+ beforeAll(() => {
40
+ const o = register(`reqmcp_owner_${Date.now()}`, "ReqMcpOwner123!", undefined, "seed");
41
+ const x = register(`reqmcp_other_${Date.now()}`, "ReqMcpOther123!", undefined, "seed");
42
+ ownerNet = o.network_id!;
43
+ const t1 = createNetworkTokenForNode(o.user!.user_id, ownerNet, "gh-sync", "node_gh_sync");
44
+ const t2 = createNetworkTokenForNode(x.user!.user_id, x.network_id!, "foreign", "node_foreign");
45
+ owner = { net: ownerNet, user: o.user!.user_id, alias: o.user!.username, isNetworkToken: false, tokenId: "tok_user" };
46
+ bot = { net: ownerNet, user: o.user!.user_id, alias: "gh-sync", isNetworkToken: true, tokenId: t1.token_id! };
47
+ foreignBot = { net: x.network_id!, user: x.user!.user_id, alias: "foreign", isNetworkToken: true, tokenId: t2.token_id! };
48
+ });
49
+ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
50
+
51
+ describe("requirements MCP tools", () => {
52
+ test("the seven tools are registered with validated schemas; there is no delete tool", async () => {
53
+ const s = await connect(owner);
54
+ try {
55
+ const reg: Record<string, any> = (s.server as any)._registeredTools;
56
+ for (const n of ["requirements_list", "requirements_get", "requirements_create", "requirements_update", "requirements_checklist_toggle", "requirements_upsert_by_external_ref", "projects_list"]) expect(reg[n], n).toBeDefined();
57
+ expect(Object.keys(reg).some(n => /^requirements_delete/.test(n))).toBe(false);
58
+ // 校验:坏的 status / 负责人种类 / 子任务 id 在协议边界被拒
59
+ expect((await s.call("requirements_list", { status: "nope" })).mcpError).toBe(true);
60
+ expect((await s.call("requirements_create", { name: "x", owner: { kind: "robot", id: "a" } })).mcpError).toBe(true);
61
+ expect((await s.call("requirements_checklist_toggle", { id: "r", item_id: "has space", done: true })).mcpError).toBe(true);
62
+ expect((await s.call("requirements_create", { name: "" })).mcpError).toBe(true);
63
+ } finally { await s.close(); }
64
+ });
65
+
66
+ test("an agent syncs a GitHub issue idempotently, edits it, ticks a checklist item and archives it", async () => {
67
+ const s = await connect(bot);
68
+ try {
69
+ const ref = "github:acme/widgets#42";
70
+ const first = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, external_url: "https://github.com/acme/widgets/issues/42", name: "修复登录", description: "issue 正文", checklist: [{ id: "c1", text: "复现" }, { id: "c2", text: "修" }] });
71
+ expect(first.ok).toBe(true);
72
+ expect(first.created).toBe(true);
73
+ expect(first.requirement.created_by).toEqual({ kind: "node", id: "node_gh_sync" });
74
+ const again = await s.call("requirements_upsert_by_external_ref", { external_ref: ref, name: "修复登录(改了标题)" });
75
+ expect(again.created).toBe(false);
76
+ expect(again.requirement.id).toBe(first.requirement.id);
77
+ const listed = await s.call("requirements_list", { external_ref: ref });
78
+ expect(listed.requirements.length).toBe(1);
79
+ expect(listed.requirements[0].name).toBe("修复登录(改了标题)");
80
+ const dup = await s.call("requirements_create", { name: "重复", external_ref: ref });
81
+ expect(dup.error).toBe("external_ref_exists");
82
+ expect(dup.existing_id).toBe(first.requirement.id);
83
+ expect(dup.status).toBe(409);
84
+ const id = first.requirement.id;
85
+ expect((await s.call("requirements_update", { id, column: "doing", agent_owner: { kind: "node", id: "node_gh_sync" } })).requirement.column).toBe("doing");
86
+ const ticked = await s.call("requirements_checklist_toggle", { id, item_id: "c1", done: true });
87
+ expect(ticked.requirement.checklist.map((i: any) => i.done)).toEqual([true, false]);
88
+ expect((await s.call("requirements_get", { id })).requirement.agent_owner).toEqual({ kind: "node", id: "node_gh_sync" });
89
+ expect((await s.call("requirements_list", { status: "doing", agent_owner: "node:node_gh_sync" })).requirements.map((r: any) => r.id)).toContain(id);
90
+ expect((await s.call("requirements_update", { id, owner: { kind: "node", id: "node_gh_sync" }, agent_owner: null })).error).toBe("owner_must_be_human");
91
+ expect((await s.call("requirements_update", { id, archived: true })).requirement.archived).toBe(true);
92
+ expect((await s.call("requirements_list", {})).requirements.some((r: any) => r.id === id)).toBe(false);
93
+ expect((await s.call("requirements_list", { include_archived: true })).requirements.some((r: any) => r.id === id)).toBe(true);
94
+ expect(Array.isArray((await s.call("projects_list", {})).projects)).toBe(true);
95
+ // 子需求:建子卡、父卡上的进度、按父卡 / 顶层过滤
96
+ const child = await s.call("requirements_create", { name: "子需求", parent_id: id });
97
+ expect(child.requirement.parent_id).toBe(id);
98
+ expect((await s.call("requirements_get", { id })).requirement.children.total).toBe(1);
99
+ expect((await s.call("requirements_list", { parent_id: id })).requirements.map((r: any) => r.id)).toEqual([child.requirement.id]);
100
+ expect((await s.call("requirements_list", { top_level: true, include_archived: true })).requirements.some((r: any) => r.id === child.requirement.id)).toBe(false);
101
+ expect((await s.call("requirements_update", { id, parent_id: child.requirement.id })).error).toBe("parent_cycle");
102
+ } finally { await s.close(); }
103
+ });
104
+
105
+ test("a node token from another network cannot see or touch these tasks, nor write into this network", async () => {
106
+ const mine = await connect(bot);
107
+ const theirs = await connect(foreignBot);
108
+ try {
109
+ const made = await mine.call("requirements_create", { name: "只属于本网络" });
110
+ const id = made.requirement.id;
111
+ expect((await theirs.call("requirements_get", { id })).error).toBe("requirement_not_found");
112
+ expect((await theirs.call("requirements_update", { id, name: "越界" })).error).toBe("requirement_not_found");
113
+ expect((await theirs.call("requirements_checklist_toggle", { id, item_id: "x", done: true })).error).toBe("requirement_not_found");
114
+ expect((await theirs.call("requirements_list", { network_id: ownerNet })).requirements.some((r: any) => r.id === id)).toBe(false);
115
+ const planted = await theirs.call("requirements_create", { name: "塞进去", network_id: ownerNet });
116
+ expect(planted.ok).toBe(true);
117
+ const row = db.get<{ network_id: string }>("SELECT network_id FROM requirements WHERE requirement_id = ?1", planted.requirement.id)!;
118
+ expect(row.network_id).toBe(foreignBot.net);
119
+ expect((await mine.call("requirements_get", { id })).requirement.name).toBe("只属于本网络");
120
+ } finally { await mine.close(); await theirs.close(); }
121
+ });
122
+
123
+ test("a user token keeps working through the same tools", async () => {
124
+ const s = await connect(owner);
125
+ try {
126
+ const made = await s.call("requirements_create", { name: "人建的", network_id: ownerNet, owner: { kind: "user", id: owner.user } });
127
+ expect(made.ok).toBe(true);
128
+ expect(made.requirement.created_by).toEqual({ kind: "user", id: owner.user });
129
+ expect((await s.call("requirements_list", { network_id: ownerNet, owner: `user:${owner.user}` })).requirements.map((r: any) => r.id)).toContain(made.requirement.id);
130
+ } finally { await s.close(); }
131
+ });
132
+ });
@@ -42,3 +42,10 @@ export function ensureRequirementProjects(database: DbAdapter): void {
42
42
  CREATE INDEX IF NOT EXISTS idx_requirement_projects_network ON requirement_projects(network_id, sort);
43
43
  `);
44
44
  }
45
+
46
+ /** external_ref 在同一网络里唯一(只管有值的行)。IF NOT EXISTS:重复执行无副作用。 */
47
+ export function ensureRequirementIndexes(database: DbAdapter): void {
48
+ database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_external_ref ON requirements(network_id, external_ref) WHERE external_ref IS NOT NULL");
49
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_network_updated ON requirements(network_id, updated_at)");
50
+ database.exec("CREATE INDEX IF NOT EXISTS idx_requirements_parent ON requirements(parent_id) WHERE parent_id IS NOT NULL");
51
+ }
@@ -88,7 +88,7 @@ test("an upgraded hub moves node owners to agent_owner and then enforces human o
88
88
  expect(both.status).toBe(200);
89
89
  expect(both.body.requirement.owner).toEqual(human);
90
90
  expect(both.body.requirement.agent_owner).toEqual(agent);
91
- expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ owner: agent }) })).body.error).toBe("owner_must_be_human");
91
+ expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ owner: agent, agent_owner: agent }) })).body.error).toBe("owner_must_be_human");
92
92
  expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ agent_owner: human }) })).body.error).toBe("agent_owner_must_be_agent");
93
93
 
94
94
  // 预计完成:旧卡的全天值(升级前写的)原样读回;改成带时刻的,存 UTC
@@ -64,6 +64,15 @@ test("startup migration moves node owners to agent_owner, keeps every row, and i
64
64
  expect(extra.every(r => r.description === null && r.checklist_json === null)).toBe(true);
65
65
  // 项目表:建了、是空的(不预置),重复执行不报错;卡片的 project_id 列只加不改
66
66
  expect(cols.includes("project_id")).toBe(true);
67
+ // PR B:外部引用 / 归档 / 谁做的 —— 只加不改;旧行 archived = 0、其余 NULL;external_ref 的部分唯一索引建了
68
+ for (const c of ["external_ref", "external_url", "archived", "created_by_json", "updated_by_json", "parent_id"]) expect(cols.includes(c)).toBe(true);
69
+ expect((db.all("SELECT parent_id FROM requirements WHERE parent_id IS NOT NULL") as any[]).length).toBe(0);
70
+ expect((db.all("PRAGMA index_list(requirements)") as { name: string }[]).some(i => i.name === "idx_requirements_parent")).toBe(true);
71
+ expect((db.all("SELECT archived, external_ref FROM requirements") as any[]).every(r => r.archived === 0 && r.external_ref === null)).toBe(true);
72
+ const idx = (db.all("PRAGMA index_list(requirements)") as { name: string; unique: number }[]).find(i => i.name === "idx_requirements_external_ref");
73
+ expect(idx?.unique).toBe(1);
74
+ const { ensureRequirementIndexes } = await import("./requirements-migrate.js");
75
+ ensureRequirementIndexes(db);
67
76
  expect((db.all("SELECT project_id FROM requirements WHERE project_id IS NOT NULL") as any[]).length).toBe(0);
68
77
  expect((db.get("SELECT COUNT(*) AS n FROM requirement_projects") as any).n).toBe(0);
69
78
  const { ensureRequirementProjects } = await import("./requirements-migrate.js");