@sleep2agi/commhub-server 0.9.0-preview.87 → 0.9.0-preview.89

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.
@@ -0,0 +1,145 @@
1
+ // #470(MCP 任务生命周期测试报告问题 1)—— Agent 经 MCP 把负责人设成节点:以前走旧 App 的兼容改写,
2
+ // 人类负责人被静默清空、负责 Agent 被换掉,响应还显示 owner=节点。现在:
3
+ // - MCP 上 owner 传节点(建 / 改 / upsert,节点令牌和用户令牌都一样)→ 400 owner_must_be_human + hint,什么都不写;
4
+ // - MCP 写入的响应就是落库的那一行(和紧接着的 requirements_get 一字不差);
5
+ // - REST 上旧 App ≤ 0.2.142 的兼容路径不变(requirements-http.test.ts「old-app compat」那条钉着)。
6
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "fs";
8
+ import { join } from "path";
9
+ import { tmpdir } from "os";
10
+ import { register, createNetworkTokenForNode } from "./auth.js";
11
+ import { db } from "./db.js";
12
+
13
+ const DIR = mkdtempSync(join(tmpdir(), "anet-owner-strict-mcp-"));
14
+ let BASE = "";
15
+ let hub: any = null;
16
+ const PW = "OwnerStrictMcpPassw0rd!x";
17
+ let NET = "", ownerToken = "", ownerId = "", nodeToken = "";
18
+ const human = () => ({ kind: "user", id: ownerId });
19
+ const nodeA = { kind: "node", id: "node_strict_a" };
20
+ const nodeB = { kind: "node", id: "node_strict_b" };
21
+
22
+ async function rest(token: string, method: string, path: string, body?: unknown) {
23
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
24
+ const text = await res.text();
25
+ let json: any = null;
26
+ try { json = JSON.parse(text); } catch {}
27
+ return { status: res.status, body: json };
28
+ }
29
+ async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
30
+ const res = await fetch(`${BASE}/mcp`, {
31
+ method: "POST",
32
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
33
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
34
+ });
35
+ const raw = await res.text();
36
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
37
+ const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
38
+ return JSON.parse(out.result.content[0].text);
39
+ }
40
+ const stored = (id: string) => db.get<{ owner_json: string | null; agent_owner_json: string | null }>("SELECT owner_json, agent_owner_json FROM requirements WHERE requirement_id = ?1", id)!;
41
+
42
+ beforeAll(async () => {
43
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
44
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
45
+ process.env.HOST = "127.0.0.1";
46
+ const a = register(`strict_owner_${Date.now()}`, PW, undefined, "Owner");
47
+ ownerToken = a.token!; ownerId = a.user!.user_id; NET = a.network_id!;
48
+ nodeToken = createNetworkTokenForNode(ownerId, NET, "strict-a", "node_strict_a").token!;
49
+ createNetworkTokenForNode(ownerId, NET, "strict-b", "node_strict_b");
50
+ const mod: any = await import("./server.js");
51
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
52
+ BASE = `http://127.0.0.1:${hub.port}`;
53
+ }, 30_000);
54
+
55
+ afterAll(() => {
56
+ try { hub?.stop?.(true); } catch {}
57
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
58
+ });
59
+
60
+ describe("MCP: owner must be a person", () => {
61
+ test("update owner=node → 400 owner_must_be_human + hint; the human owner and the agent owner are untouched", async () => {
62
+ const card = (await mcp(nodeToken, "requirements_create", { name: "负责人保护", owner: human(), agent_owner: nodeB })).requirement;
63
+ expect(card.owner).toEqual(human());
64
+ const r = await mcp(nodeToken, "requirements_update", { id: card.id, owner: nodeA });
65
+ expect(r.ok).toBe(false);
66
+ expect(r.error).toBe("owner_must_be_human");
67
+ expect(r.status).toBe(400);
68
+ expect(r.hint).toContain("agent_owner");
69
+ expect(r.owner_coerced_to_agent_owner).toBeUndefined();
70
+ const row = stored(card.id);
71
+ expect(JSON.parse(row.owner_json!)).toEqual(human());
72
+ expect(JSON.parse(row.agent_owner_json!)).toEqual(nodeB);
73
+ const got = (await mcp(nodeToken, "requirements_get", { id: card.id })).requirement;
74
+ expect(got.owner).toEqual(human());
75
+ expect(got.agent_owner).toEqual(nodeB);
76
+ });
77
+
78
+ test("…the same for a user token over MCP, for create (nothing is written) and for upsert", async () => {
79
+ const r = await mcp(ownerToken, "requirements_update", { network_id: NET, id: (await mcp(ownerToken, "requirements_create", { network_id: NET, name: "用户令牌", owner: human() })).requirement.id, owner: nodeA });
80
+ expect(r.error).toBe("owner_must_be_human");
81
+ const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET)!.n;
82
+ const c = await mcp(nodeToken, "requirements_create", { name: "建时就传节点", owner: nodeA });
83
+ expect(c.error).toBe("owner_must_be_human");
84
+ expect(c.hint).toContain("agent_owner");
85
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET)!.n).toBe(before);
86
+ const u = await mcp(nodeToken, "requirements_upsert_by_external_ref", { external_ref: "github:example/repo#470", name: "upsert", owner: nodeA });
87
+ expect(u.error).toBe("owner_must_be_human");
88
+ expect(db.get("SELECT 1 FROM requirements WHERE external_ref = 'github:example/repo#470'")).toBeNull();
89
+ });
90
+
91
+ test("the right way still works: agent_owner = node, owner = person, owner = null", async () => {
92
+ const card = (await mcp(nodeToken, "requirements_create", { name: "正确的写法", owner: human() })).requirement;
93
+ const r = await mcp(nodeToken, "requirements_update", { id: card.id, agent_owner: nodeA });
94
+ expect(r.ok).toBe(true);
95
+ expect(r.requirement.owner).toEqual(human());
96
+ expect(r.requirement.agent_owner).toEqual(nodeA);
97
+ const cleared = await mcp(nodeToken, "requirements_update", { id: card.id, owner: null });
98
+ expect(cleared.requirement.owner).toBeNull();
99
+ // agent_owner given a person is refused the other way round, with its own hint.
100
+ const wrong = await mcp(nodeToken, "requirements_update", { id: card.id, agent_owner: human() });
101
+ expect(wrong.error).toBe("agent_owner_must_be_agent");
102
+ expect(wrong.hint).toContain("owner");
103
+ });
104
+
105
+ test("every MCP write's response equals a fresh requirements_get", async () => {
106
+ const card = (await mcp(nodeToken, "requirements_create", { name: "响应即落库", owner: human(), checklist: [{ text: "一" }] })).requirement;
107
+ expect((await mcp(nodeToken, "requirements_get", { id: card.id })).requirement).toEqual(card);
108
+ const patches: Record<string, unknown>[] = [
109
+ { column: "doing" }, { agent_owner: nodeA }, { participants: [nodeB, human()] }, { owner: null }, { owner: human(), priority: "high" },
110
+ { agent_owner: null }, { due: "2026-10-10" }, { archived: true }, { archived: false },
111
+ ];
112
+ for (const p of patches) {
113
+ const r = await mcp(nodeToken, "requirements_update", { id: card.id, ...p });
114
+ expect(r.ok).toBe(true);
115
+ expect(r.owner_coerced_to_agent_owner).toBeUndefined();
116
+ expect(r.requirement).toEqual((await mcp(nodeToken, "requirements_get", { id: card.id })).requirement);
117
+ }
118
+ });
119
+ });
120
+
121
+ describe("REST", () => {
122
+ test("new clients' responses equal a fresh GET (the old-app compat echo is the only exception, pinned in requirements-http.test.ts)", async () => {
123
+ const card = (await rest(ownerToken, "POST", "/api/requirements", { network_id: NET, name: "REST 响应", owner: human() })).body.requirement;
124
+ for (const p of [{ agent_owner: nodeA }, { owner: human(), agent_owner: nodeB }, { participants: [nodeA] }]) {
125
+ const r = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, p);
126
+ expect(r.status).toBe(200);
127
+ expect(r.body.requirement).toEqual((await rest(ownerToken, "GET", `/api/requirements/${card.id}`)).body.requirement);
128
+ }
129
+ // A new client (sends agent_owner) passing a node as owner: still strict, now with the hint.
130
+ const strict = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, { owner: nodeA, agent_owner: nodeB });
131
+ expect(strict.status).toBe(400);
132
+ expect(strict.body.error).toBe("owner_must_be_human");
133
+ expect(strict.body.hint).toContain("agent_owner");
134
+ });
135
+
136
+ test("old-app compat on REST is unchanged: owner=node without agent_owner is still coerced (200 + flag)", async () => {
137
+ const card = (await rest(ownerToken, "POST", "/api/requirements", { network_id: NET, name: "旧 App", owner: human() })).body.requirement;
138
+ const old = await rest(ownerToken, "PATCH", `/api/requirements/${card.id}`, { owner: nodeA });
139
+ expect(old.status).toBe(200);
140
+ expect(old.body.owner_coerced_to_agent_owner).toBe(true);
141
+ const row = stored(card.id);
142
+ expect(row.owner_json).toBeNull();
143
+ expect(JSON.parse(row.agent_owner_json!)).toEqual(nodeA);
144
+ });
145
+ });
@@ -94,7 +94,7 @@ export function decodeCursor(raw: string): Cursor | null {
94
94
  }
95
95
  }
96
96
 
97
- export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string };
97
+ export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string; /** #471 —— 只要带这个标签的卡(精确、区分大小写);null = 不按标签筛。 */ tag: string | null };
98
98
 
99
99
  /**
100
100
  * 任务 ID(同 App 的 task-short-id.ts matchesTaskId):「#42」「42」精确对短号 seq;完整 id,或 8 位以上的
@@ -134,5 +134,12 @@ export function parseListQuery(params: URLSearchParams): ListQuery | { error: st
134
134
  const q = params.get("q") ?? "";
135
135
  if (q.length > MAX_QUERY_LENGTH) return { error: "invalid_q" };
136
136
  const terms = searchTerms(q);
137
- return { limit, cursor, terms, idQuery: terms.length ? taskIdQuery(q) : "" };
137
+ // #471 —— tag=:和卡片标签同一套规范(去首尾空白、1–20 个字符、不含控制字符);不带 = 旧行为。
138
+ const rawTag = params.get("tag");
139
+ let tag: string | null = null;
140
+ if (rawTag !== null) {
141
+ tag = rawTag.trim();
142
+ if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return { error: "invalid_tag" };
143
+ }
144
+ return { limit, cursor, terms, idQuery: terms.length ? taskIdQuery(q) : "", tag };
138
145
  }
@@ -1,5 +1,6 @@
1
1
  // 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
+ import { errorBody, errorFrom, RequirementFieldError, type ErrorContext } from "./requirements-errors.js";
3
4
  import { createHash } from "node:crypto";
4
5
  import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
5
6
  import { db, logAudit } from "./db.js";
@@ -37,6 +38,12 @@ export type RequirementsRequestContext = {
37
38
  isAdmin: boolean;
38
39
  isNodeToken: boolean;
39
40
  scope: RestNetworkScope;
41
+ /**
42
+ * #470 —— 负责人(owner)只收人,传节点一律 400 owner_must_be_human,不走旧 App 的「节点负责人 → 负责 Agent」兼容。
43
+ * MCP 工具(Agent 与人经 MCP 的调用)总是严格:那条兼容只为 App ≤ 0.2.142 的 REST 保存存在,Agent 走它会
44
+ * 静默清空人类负责人(task-lifecycle 测试报告问题 1)。REST 上带 agent_owner 的新客户端也严格(见 assignments)。
45
+ */
46
+ strictOwner?: boolean;
40
47
  };
41
48
 
42
49
  const COLUMNS = new Set(["pool", "doing", "done"]);
@@ -78,8 +85,9 @@ type Row = {
78
85
  completed_at_approx: number | null;
79
86
  };
80
87
 
81
- function jsonError(error: string, status: number): Response {
82
- return Response.json({ ok: false, error }, { status });
88
+ // #472:错误码原样,统一补 field / message / hint(requirements-errors.ts,唯一出口)。
89
+ function jsonError(error: string, status: number, ctx?: ErrorContext): Response {
90
+ return Response.json(errorBody(error, ctx), { status });
83
91
  }
84
92
 
85
93
  // ── 任务的人员权限(RFC-038 §9,判定在 task-access.ts) ──
@@ -261,10 +269,10 @@ function projectNameTaken(networkId: string, name: string, except?: string): boo
261
269
  /** 卡片上的 project_id:null 清空;字符串必须是同一网络、未归档的项目。 */
262
270
  function projectRef(value: unknown, networkId: string): string | null {
263
271
  if (value === null) return null;
264
- if (typeof value !== "string" || !value) throw new Error("invalid_project");
272
+ if (typeof value !== "string" || !value) throw new RequirementFieldError("invalid_project", "project_id", value);
265
273
  const row = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, value);
266
- if (!row || row.network_id !== networkId) throw new Error("project_not_in_network");
267
- if (row.archived) throw new Error("project_archived");
274
+ if (!row || row.network_id !== networkId) throw new RequirementFieldError("project_not_in_network", "project_id", value);
275
+ if (row.archived) throw new RequirementFieldError("project_archived", "project_id", value);
268
276
  return row.project_id;
269
277
  }
270
278
 
@@ -512,15 +520,15 @@ const SELECT = "requirement_id, network_id, title, column_name, priority, due_on
512
520
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
513
521
 
514
522
  type PersonRef = { kind: 'user' | 'node'; id: string };
515
- function personRef(value: unknown, networkId: string, hidden: HiddenNode = null): PersonRef {
516
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_person');
523
+ function personRef(value: unknown, networkId: string, hidden: HiddenNode = null, field = 'owner'): PersonRef {
524
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RequirementFieldError('invalid_person', field);
517
525
  const row = value as Record<string, unknown>;
518
- if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new Error('invalid_person');
526
+ if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new RequirementFieldError('invalid_person', field);
519
527
  const exists = row.kind === 'user'
520
528
  ? db.get("SELECT 1 FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 AND m.user_id=?2", networkId, row.id)
521
529
  : db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
522
530
  // 没授权的节点与不存在的节点同一个错误,不给受限成员留 node_id 探测差异。
523
- if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new Error('person_not_in_network');
531
+ if (!exists || (row.kind === 'node' && hidden?.(row.id as string))) throw new RequirementFieldError('person_not_in_network', field);
524
532
  return { kind: row.kind, id: row.id };
525
533
  }
526
534
 
@@ -529,7 +537,7 @@ function personRef(value: unknown, networkId: string, hidden: HiddenNode = null)
529
537
  // 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
530
538
  // → 当成设置负责 Agent,负责人清空,照常 200,并回 owner_coerced_to_agent_owner: true。
531
539
  // 带了 agent_owner 的就是新客户端,保持严格:owner 是节点 → 400 owner_must_be_human。
532
- function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null) {
540
+ function assignments(body: Record<string, unknown>, networkId: string, current?: Row, hidden: HiddenNode = null, strictOwner = false) {
533
541
  let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
534
542
  let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
535
543
  let coerced: PersonRef | null = null;
@@ -538,7 +546,7 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
538
546
  if ('owner' in body) {
539
547
  owner = body.owner === null ? null : personRef(body.owner, networkId, hidden);
540
548
  if (owner && owner.kind !== 'user') {
541
- if ('agent_owner' in body) throw new Error('owner_must_be_human');
549
+ if (strictOwner || 'agent_owner' in body) throw new Error('owner_must_be_human');
542
550
  coerced = owner;
543
551
  agentOwner = owner;
544
552
  owner = null;
@@ -547,13 +555,13 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
547
555
  if (coerced && agentOwnerLocked) throw new Error('agent_owner_not_granted');
548
556
  if ('agent_owner' in body) {
549
557
  if (agentOwnerLocked) throw new Error('agent_owner_not_granted');
550
- agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden);
558
+ agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId, hidden, 'agent_owner');
551
559
  if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
552
560
  }
553
561
  let participants = current ? JSON.parse(current.participants_json || '[]') : [];
554
562
  if ('participants' in body) {
555
563
  if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
556
- const refs = body.participants.map(value => personRef(value, networkId, hidden));
564
+ const refs = body.participants.map(value => personRef(value, networkId, hidden, 'participants'));
557
565
  // 参与人是整体替换:把受限成员看不见的那些节点原样留下,别让他「保存」时静默删掉。
558
566
  const keptHidden = hidden ? participants.filter((ref: unknown) => isHiddenRef(ref, hidden)) : [];
559
567
  participants = [...new Map([...keptHidden, ...refs].map((ref: PersonRef) => [`${ref.kind}:${ref.id}`, ref])).values()];
@@ -741,7 +749,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
741
749
  const scoped = !!caller && isTaskScoped(caller.userId, networkId);
742
750
  let projectId: string | null = null;
743
751
  if (body.project_id !== undefined) {
744
- try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
752
+ try { projectId = projectRef(body.project_id, networkId); } catch (e) { return errorFrom(e); }
745
753
  // scoped 成员只能建进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
746
754
  if (!canUseProject(caller, networkId, projectId)) return jsonError("project_not_in_network", 400);
747
755
  }
@@ -760,7 +768,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
760
768
  if (ref) {
761
769
  const existing = rowByExternalRef(networkId, ref);
762
770
  // 同一个外部条目再建一次 = 冲突,回已有的 id(同步方改用 upsert,或按这个 id PATCH)。
763
- if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
771
+ if (existing) return Response.json({ ...errorBody("external_ref_exists"), existing_id: existing.requirement_id }, { status: 409 });
764
772
  }
765
773
  if (clientId) {
766
774
  const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
@@ -770,7 +778,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
770
778
  const id = `req_${crypto.randomUUID()}`;
771
779
  const createdAt = new Date().toISOString();
772
780
  let people;
773
- try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
781
+ try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
774
782
  const actor = JSON.stringify(actorOf(ctx));
775
783
  try {
776
784
  // 领号、插入、记「新建」这条动态在同一个事务里:插入被唯一索引挡下时,号和动态一起回滚,不留空洞。
@@ -790,7 +798,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
790
798
  // 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
791
799
  if (ref) {
792
800
  const existing = rowByExternalRef(networkId, ref);
793
- if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
801
+ if (existing) return Response.json({ ...errorBody("external_ref_exists"), existing_id: existing.requirement_id }, { status: 409 });
794
802
  }
795
803
  if (!clientId) return jsonError("insert_failed", 500);
796
804
  const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
@@ -805,7 +813,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
805
813
  // ── 修改(省略的字段保留原值) ──
806
814
  const PATCH_FIELDS = ["column", "issues", "tags", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id", "start"];
807
815
  function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
808
- if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
816
+ if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
809
817
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
810
818
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
811
819
  const caller = taskCaller(ctx);
@@ -856,10 +864,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
856
864
  assignee = body.assignee.trim().slice(0, 80);
857
865
  }
858
866
  let people;
859
- try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id)); } catch (e) { return jsonError((e as Error).message, 400); }
867
+ try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
860
868
  let projectId = row.project_id;
861
869
  if (has("project_id")) {
862
- try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
870
+ try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return errorFrom(e); }
863
871
  // scoped 成员只能挪进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
864
872
  if (projectId !== row.project_id && !canUseProject(caller, row.network_id, projectId)) return jsonError("project_not_in_network", 400);
865
873
  }
@@ -869,7 +877,7 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
869
877
  if (next === undefined) return jsonError("invalid_external_ref", 400);
870
878
  if (next && next !== row.external_ref) {
871
879
  const taken = rowByExternalRef(row.network_id, next);
872
- if (taken) return Response.json({ ok: false, error: "external_ref_exists", existing_id: taken.requirement_id }, { status: 409 });
880
+ if (taken) return Response.json({ ...errorBody("external_ref_exists"), existing_id: taken.requirement_id }, { status: 409 });
873
881
  }
874
882
  ref = next;
875
883
  }
@@ -950,7 +958,7 @@ function scopedRow(ctx: RequirementsRequestContext, id: string): Row | Response
950
958
  const rows = db.all<Row>(`${sql} LIMIT 2`, ...params);
951
959
  if (rows.length > 1) {
952
960
  const networks = rows.map(r => r.network_id);
953
- return Response.json({ ok: false, error: "ambiguous_seq", networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
961
+ return Response.json({ ...errorBody("ambiguous_seq"), networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
954
962
  }
955
963
  return rows[0] ?? jsonError("requirement_not_found", 404);
956
964
  }
@@ -1014,7 +1022,7 @@ function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unk
1014
1022
  where += ` AND (created_at < ?${a} OR (created_at = ?${a} AND requirement_id < ?${b}))`;
1015
1023
  }
1016
1024
  let rows: Row[];
1017
- if (!lq.terms.length) {
1025
+ if (!lq.terms.length && lq.tag === null) {
1018
1026
  rows = db.all<Row>(`SELECT ${SELECT} ${where}${LIST_ORDER} LIMIT ${lq.limit + 1}`, ...params);
1019
1027
  } else {
1020
1028
  // seq(任务短号,#2139):启动迁移 ensureRequirementSeq 在 SQLite / PostgreSQL 上都加这一列,SELECT 本来就带它。
@@ -1025,7 +1033,9 @@ function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unk
1025
1033
  let m = maps.get(r.network_id);
1026
1034
  if (!m) maps.set(r.network_id, m = searchNameMaps(ctx, r.network_id));
1027
1035
  const row = { name: r.title, description: r.description || "", assignee: r.assignee || "", tags: storedTags(r.tags_json), project_id: r.project_id, owner: parseRef(r.owner_json), agent_owner: parseRef(r.agent_owner_json), participants: parseRef(r.participants_json) ?? [] };
1028
- if (matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
1036
+ // #471 —— tag= 在这里按存好的标签数组精确比(JSON 列,SQLite / PostgreSQL 写法不同,所以不进 SQL)。
1037
+ if (lq.tag !== null && !row.tags.includes(lq.tag)) continue;
1038
+ if (!lq.terms.length || matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
1029
1039
  if (hits.length > lq.limit) break;
1030
1040
  }
1031
1041
  const byId = new Map(hits.length ? db.all<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id IN (${hits.map((_, i) => `?${i + 1}`).join(",")})`, ...hits).map(r => [r.requirement_id, r]) : []);
@@ -1436,7 +1446,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1436
1446
  if (req.method !== "PATCH") return jsonError("not_found", 404);
1437
1447
  let body: Record<string, unknown>;
1438
1448
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
1439
- if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
1449
+ if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
1440
1450
  const row = scopedRow(ctx, id);
1441
1451
  if (row instanceof Response) return row;
1442
1452
  return patchRequirement(ctx, row, body);
@@ -0,0 +1,238 @@
1
+ // #464 —— 定时任务被一次没结束的执行卡住:告警 + 超时放行。
2
+ //
3
+ // 钉住:
4
+ // 1. 默认值:超时 = clamp(6 × 间隔, 1h, 24h);环境变量能改。
5
+ // 2. 同一个挡路任务连续挡掉 3 次 → 给排程创建者发恰好 1 条通知(别的成员 0 条);第 4、5 次不再发;
6
+ // 挡路任务一换(下一次真派出去了)重新上膛。
7
+ // 3. 挡路任务开着超过超时 → 它变成 expired(行还在、收件箱行 acked、run 记 expired/task_expired、
8
+ // error_message 说明是调度器超时),这一次照常派发。超时前已发过「卡住」→ 不再发;没发过 → 发一条「已超时」。
9
+ // 4. 晚到的回复:send_reply 以 reply_task_terminal 拒掉,旧 run / 旧任务 / 新任务 / 新 run 都不变,创建者不多收东西。
10
+ // 5. GET /runs 带 blocked_by_task_id / blocked_by_state。
11
+ //
12
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/scheduled-stuck-http.test.ts
13
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
14
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
17
+ import { db } from "./db.js";
18
+ import { dispatchScheduledOccurrence, handleScheduledTaskRequest } from "./scheduled-tasks.js";
19
+ import { SCHEDULE_STUCK_NOTICE_KIND, stuckTimeoutMs } from "./scheduled-stuck.js";
20
+ import { registerTools } from "./tools.js";
21
+
22
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
23
+ if (!activeDbPath) throw new Error("scheduled-stuck requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
24
+
25
+ const stamp = Date.now();
26
+ const ALIAS = `stuck-node-${stamp}`;
27
+ const NODE_ID = `n_stuck_${stamp}`;
28
+ let NET = "", creatorId = "", otherId = "";
29
+ const ENV_KEYS = ["COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS", "COMMHUB_SCHEDULE_STUCK_TIMEOUT_FACTOR", "COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC", "COMMHUB_SCHEDULE_STUCK_TIMEOUT_CAP_SEC"];
30
+
31
+ function seedSchedule(name = "日报汇总"): any {
32
+ const scheduleId = `sched_${crypto.randomUUID()}`;
33
+ db.run(
34
+ `INSERT INTO scheduled_tasks
35
+ (schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, priority,
36
+ schedule_type, schedule_json, timezone, status, next_run_at)
37
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'scheduled task', 'normal', 'interval',
38
+ '{"type":"interval","every_seconds":60}', 'Asia/Shanghai', 'active', datetime('now', '+1 minute'))`,
39
+ [scheduleId, NET, creatorId, name, NODE_ID, ALIAS],
40
+ );
41
+ return db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
42
+ }
43
+
44
+ let slot = 0;
45
+ const nextSlot = () => new Date(Date.UTC(2026, 9, 2, 0, 0, 0) + (++slot) * 60_000).toISOString();
46
+ const fire = (schedule: any, now = new Date()) => dispatchScheduledOccurrence(schedule, nextSlot(), false, now);
47
+
48
+ const notices = (userId: string) => db.all<{ title: string; content: string; meta_json: string; from_session: string }>(
49
+ "SELECT title, content, meta_json, from_session FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
50
+ userId, NET, SCHEDULE_STUCK_NOTICE_KIND,
51
+ );
52
+ const runRow = (runId: string) => db.get<any>("SELECT * FROM scheduled_task_runs WHERE run_id = ?1", runId);
53
+ const taskRow = (taskId: string) => db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", taskId);
54
+ /** 把任务的开始时间往前拨(数据库里的 UTC 无时区串)。 */
55
+ const backdate = (taskId: string, ms: number) => db.run("UPDATE tasks SET created_at = ?1 WHERE task_id = ?2", [new Date(Date.now() - ms).toISOString().slice(0, 19).replace("T", " "), taskId]);
56
+
57
+ async function nodeReply(args: Record<string, unknown>): Promise<any> {
58
+ const mcp = new McpServer({ name: "sched-stuck", version: "0" }) as any;
59
+ const handlers: Record<string, (a: any) => Promise<any>> = {};
60
+ const original = mcp.tool.bind(mcp);
61
+ mcp.tool = (name: string, ...rest: any[]) => {
62
+ const handler = rest.at(-1);
63
+ if (typeof handler === "function") handlers[name] = handler;
64
+ return original(name, ...rest);
65
+ };
66
+ registerTools(mcp, undefined, NET, creatorId, ALIAS, true, null);
67
+ const out = await handlers.send_reply(args);
68
+ return JSON.parse(out.content[0].text);
69
+ }
70
+
71
+ beforeAll(() => {
72
+ const creator = register(`stuck_creator_${stamp}`, "StuckCreator123!", undefined, "seed");
73
+ expect(creator.ok).toBe(true);
74
+ NET = creator.network_id!; creatorId = creator.user!.user_id;
75
+ const other = register(`stuck_other_${stamp}`, "StuckOther123!", undefined, "seed");
76
+ otherId = other.user!.user_id;
77
+ expect(addNetworkMember(NET, otherId, "member", creatorId, { agentAccess: "all" }).ok).toBe(true);
78
+ expect(createNetworkTokenForNode(creatorId, NET, ALIAS).ok).toBe(true);
79
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)", [NODE_ID, ALIAS, NET]);
80
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_stuck_${stamp}`, ALIAS, NODE_ID, NET]);
81
+ });
82
+ beforeEach(() => {
83
+ for (const k of ENV_KEYS) delete process.env[k];
84
+ db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_STUCK_NOTICE_KIND]);
85
+ // 上一个用例留下的开着的任务不许挡这一个(每个用例自己的排程,只按 schedule 查,保险起见也关掉)
86
+ db.run("UPDATE tasks SET status = 'replied' WHERE network_id = ?1 AND status IN ('created', 'delivered', 'acked', 'running')", [NET]);
87
+ });
88
+ afterEach(() => { for (const k of ENV_KEYS) delete process.env[k]; });
89
+ afterAll(() => { db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [NET, SCHEDULE_STUCK_NOTICE_KIND]); });
90
+
91
+ describe("#464 defaults", () => {
92
+ test("timeout = clamp(6 × interval, 1h, 24h)", () => {
93
+ const iv = (s: number) => JSON.stringify({ type: "interval", every_seconds: s });
94
+ expect(stuckTimeoutMs(iv(60))).toBe(3_600_000);
95
+ expect(stuckTimeoutMs(iv(600))).toBe(3_600_000);
96
+ expect(stuckTimeoutMs(iv(1800))).toBe(3 * 3_600_000);
97
+ expect(stuckTimeoutMs(iv(3600))).toBe(6 * 3_600_000);
98
+ expect(stuckTimeoutMs(iv(86_400))).toBe(24 * 3_600_000);
99
+ expect(stuckTimeoutMs(JSON.stringify({ type: "daily", time: "09:00" }))).toBe(24 * 3_600_000);
100
+ expect(stuckTimeoutMs(JSON.stringify({ type: "once", run_at: "2026-10-02T00:00:00Z" }))).toBe(3_600_000);
101
+ process.env.COMMHUB_SCHEDULE_STUCK_TIMEOUT_FACTOR = "0";
102
+ process.env.COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC = "20";
103
+ expect(stuckTimeoutMs(iv(60))).toBe(20_000);
104
+ process.env.COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC = "junk";
105
+ expect(stuckTimeoutMs(iv(60))).toBe(3_600_000);
106
+ });
107
+ });
108
+
109
+ describe("#464 stuck notice", () => {
110
+ test("3rd consecutive skip behind the same task → exactly one notice to the creator, none to others", () => {
111
+ const s = seedSchedule();
112
+ const first = fire(s);
113
+ expect(first.status).toBe("delivered");
114
+ const skips = [fire(s), fire(s)];
115
+ expect(notices(creatorId).length).toBe(0);
116
+ skips.push(fire(s), fire(s), fire(s));
117
+ expect(skips.map((r) => r.status)).toEqual(["skipped", "skipped", "skipped", "skipped", "skipped"]);
118
+ const mine = notices(creatorId);
119
+ expect(mine.length).toBe(1);
120
+ expect(notices(otherId).length).toBe(0);
121
+ const n = mine[0];
122
+ expect(n.from_session).toBe(ALIAS);
123
+ expect(n.title).toBe("定时任务被卡住");
124
+ for (const frag of ["日报汇总", first.taskId!, ALIAS, "(Asia/Shanghai)", "3 次", "1 小时"]) expect(n.content).toContain(frag);
125
+ expect(JSON.parse(n.meta_json).schedule_stuck).toEqual({ reason: "stuck", schedule_id: s.schedule_id, blocked_by_task_id: first.taskId, skips: 3 });
126
+ });
127
+
128
+ test("re-armed once a run is dispatched again: a second episode notifies again", () => {
129
+ const s = seedSchedule();
130
+ const first = fire(s);
131
+ for (let i = 0; i < 4; i++) fire(s);
132
+ db.run("UPDATE tasks SET status = 'replied', completed_at = datetime('now') WHERE task_id = ?1", [first.taskId]);
133
+ const second = fire(s);
134
+ expect(second.status).toBe("delivered");
135
+ for (let i = 0; i < 3; i++) fire(s);
136
+ const mine = notices(creatorId);
137
+ expect(mine.length).toBe(2);
138
+ // 两条通知同一秒写入,created_at 排不出先后:按挡路任务比集合。
139
+ expect(mine.map((m) => JSON.parse(m.meta_json).schedule_stuck.blocked_by_task_id).sort()).toEqual([first.taskId, second.taskId].sort());
140
+ });
141
+
142
+ test("COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS changes N", () => {
143
+ process.env.COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS = "1";
144
+ const s = seedSchedule();
145
+ fire(s); fire(s);
146
+ expect(notices(creatorId).length).toBe(1);
147
+ });
148
+
149
+ test("schedule without a creator → no notice, skips still recorded", () => {
150
+ const s = seedSchedule();
151
+ db.run("UPDATE scheduled_tasks SET created_by = NULL WHERE schedule_id = ?1", [s.schedule_id]);
152
+ const row = db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", s.schedule_id);
153
+ fire(row); for (let i = 0; i < 4; i++) expect(fire(row).status).toBe("skipped");
154
+ expect(notices(creatorId).length).toBe(0);
155
+ });
156
+ });
157
+
158
+ describe("#464 stuck timeout", () => {
159
+ test("blocker past the timeout is expired (row kept) and this occurrence dispatches; no second notice", () => {
160
+ const s = seedSchedule();
161
+ const first = fire(s);
162
+ db.run("UPDATE tasks SET status = 'running' WHERE task_id = ?1", [first.taskId]);
163
+ for (let i = 0; i < 3; i++) fire(s);
164
+ expect(notices(creatorId).length).toBe(1);
165
+
166
+ backdate(first.taskId!, 59 * 60_000);
167
+ expect(fire(s).status).toBe("skipped"); // 59 分钟 < 1 小时
168
+ backdate(first.taskId!, 61 * 60_000);
169
+ const next = fire(s);
170
+ expect(next.status).toBe("delivered");
171
+ expect(next.taskId).not.toBe(first.taskId);
172
+
173
+ const old = taskRow(first.taskId!);
174
+ expect(old.status).toBe("expired");
175
+ expect(old.content).toBe("scheduled task");
176
+ expect(db.get<any>("SELECT acked FROM inbox WHERE task_id = ?1", first.taskId).acked).toBeTruthy();
177
+ const oldRun = runRow(first.runId);
178
+ expect(oldRun.status).toBe("expired");
179
+ expect(oldRun.error_code).toBe("task_expired");
180
+ expect(oldRun.error_message).toContain("timed out by the scheduler");
181
+ expect(db.get<any>("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ?1 AND to_status = 'expired' AND actor = 'hub-scheduler'", first.taskId).n).toBe(1);
182
+ expect(notices(creatorId).length).toBe(1); // 每段卡住恰好一条
183
+ });
184
+
185
+ test("timed out before N skips (long interval) → one 'timed out' notice instead", () => {
186
+ const s = seedSchedule("每日巡检");
187
+ const first = fire(s);
188
+ expect(fire(s).status).toBe("skipped");
189
+ backdate(first.taskId!, 2 * 3_600_000);
190
+ expect(fire(s).status).toBe("delivered");
191
+ const mine = notices(creatorId);
192
+ expect(mine.length).toBe(1);
193
+ expect(mine[0].title).toBe("定时任务已超时放行");
194
+ expect(mine[0].content).toContain(first.taskId!);
195
+ expect(JSON.parse(mine[0].meta_json).schedule_stuck.reason).toBe("timed_out");
196
+ });
197
+
198
+ test("a late reply to the expired task is refused and changes nothing", async () => {
199
+ const s = seedSchedule();
200
+ const first = fire(s);
201
+ backdate(first.taskId!, 2 * 3_600_000);
202
+ const next = fire(s);
203
+ expect(next.status).toBe("delivered");
204
+ const inboxBefore = db.get<any>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1", NET).n;
205
+ const out = await nodeReply({ alias: "scheduler", text: "late answer", in_reply_to: first.taskId, status: "replied", from_session: ALIAS });
206
+ expect(out.ok).toBe(false);
207
+ expect(out.error).toBe("reply_task_terminal");
208
+ expect(out.reply_queued).toBe(false);
209
+ expect(taskRow(first.taskId!).status).toBe("expired");
210
+ expect(runRow(first.runId).status).toBe("expired");
211
+ expect(taskRow(next.taskId!).status).toBe("delivered");
212
+ expect(runRow(next.runId).status).toBe("delivered");
213
+ expect(db.get<any>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1", NET).n).toBe(inboxBefore);
214
+ // 新那条照常能被回复,run 照常收尾
215
+ const ok = await nodeReply({ alias: "scheduler", text: "on time", in_reply_to: next.taskId, status: "replied", from_session: ALIAS });
216
+ expect(ok.ok).toBe(true);
217
+ expect(runRow(next.runId).status).toBe("replied");
218
+ });
219
+ });
220
+
221
+ describe("#464 history clarity", () => {
222
+ test("GET /runs exposes blocked_by_task_id and blocked_by_state on skipped rows", async () => {
223
+ const s = seedSchedule();
224
+ const first = fire(s);
225
+ const second = fire(s);
226
+ const url = new URL(`http://hub.test/api/scheduled-tasks/${s.schedule_id}/runs`);
227
+ const res = await handleScheduledTaskRequest({
228
+ req: new Request(url, { method: "GET" }), url,
229
+ auth: { userId: creatorId, networkId: NET, username: "creator" },
230
+ isAdmin: false, isNodeToken: false, scope: { networkId: NET, networkIds: [NET] } as any,
231
+ });
232
+ const body = await res!.json() as any;
233
+ const skipped = body.runs.find((r: any) => r.run_id === second.runId);
234
+ expect(skipped.status).toBe("skipped");
235
+ expect(skipped.blocked_by_task_id).toBe(first.taskId);
236
+ expect(skipped.blocked_by_state).toBe("not_received");
237
+ });
238
+ });