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

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,44 @@
1
+ // 需求卡的启动迁移。requirements.ts 载入时调用一次(列由 db.ts 的加列循环加上)。
2
+ import type { DbAdapter } from "./db-adapter";
3
+
4
+ /**
5
+ * 负责人 = 人类(owner, kind user);负责 Agent = 节点(agent_owner, kind node)。
6
+ * 之前 owner 可以是节点:这些卡把节点挪到 agent_owner、owner 置空。只动 agent_owner 还空着的行,
7
+ * 所以重复执行不会再改任何东西;不删行,不碰别的列。在 JS 里判 kind(不依赖 SQLite 的 json_extract)。
8
+ */
9
+ export function migrateRequirementAgentOwners(database: DbAdapter): { moved: number } {
10
+ const rows = database.all<{ requirement_id: string; owner_json: string | null }>(
11
+ "SELECT requirement_id, owner_json FROM requirements WHERE owner_json IS NOT NULL AND agent_owner_json IS NULL",
12
+ );
13
+ let moved = 0;
14
+ for (const row of rows) {
15
+ let owner: { kind?: unknown; id?: unknown } | null = null;
16
+ try { owner = JSON.parse(row.owner_json || "null"); } catch { continue; }
17
+ if (!owner || owner.kind !== "node" || typeof owner.id !== "string" || !owner.id) continue;
18
+ database.run(
19
+ "UPDATE requirements SET agent_owner_json = ?1, owner_json = NULL WHERE requirement_id = ?2 AND agent_owner_json IS NULL",
20
+ [JSON.stringify({ kind: "node", id: owner.id }), row.requirement_id],
21
+ );
22
+ moved += 1;
23
+ }
24
+ return { moved };
25
+ }
26
+
27
+ /**
28
+ * 项目表(需求卡的「项目」)。只建表、建索引,不塞任何项目 —— 军团项目 / TMAI 由 owner 在界面里建。
29
+ * CREATE ... IF NOT EXISTS:重复执行无副作用。放在这里而不是 db.ts,db.ts 的行号被文档钉着。
30
+ */
31
+ export function ensureRequirementProjects(database: DbAdapter): void {
32
+ database.exec(`
33
+ CREATE TABLE IF NOT EXISTS requirement_projects (
34
+ project_id TEXT PRIMARY KEY,
35
+ network_id TEXT NOT NULL,
36
+ name TEXT NOT NULL,
37
+ color TEXT NOT NULL,
38
+ sort INTEGER NOT NULL DEFAULT 0,
39
+ archived INTEGER NOT NULL DEFAULT 0,
40
+ created_at TEXT NOT NULL
41
+ );
42
+ CREATE INDEX IF NOT EXISTS idx_requirement_projects_network ON requirement_projects(network_id, sort);
43
+ `);
44
+ }
@@ -0,0 +1,126 @@
1
+ // 负责人 / 负责 Agent 拆分:一次性 Hub 进程的升级端到端。
2
+ // 真起两次 `bun run src/index.ts`(HOME=临时目录、随机端口、绝不是 9200、独立库):
3
+ // 第一次:注册、建节点、建一张「旧式」卡(owner 是节点 —— 用 SQL 写进去,因为新 Hub 已不接受);
4
+ // 再把 agent_owner_json 列删掉,库回到 #2065 时代的样子。
5
+ // 第二次:同一个库启动 = 升级。启动迁移把节点挪到 agent_owner,HTTP 读回;然后走一遍新规则。
6
+ import { afterAll, expect, test } from "bun:test";
7
+ import { Database } from "bun:sqlite";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, resolve } from "node:path";
11
+ import { spawn, type ChildProcess } from "node:child_process";
12
+
13
+ const home = mkdtempSync(join(tmpdir(), "anet-req-agent-owner-e2e-"));
14
+ const dbFile = join(home, "hub.db");
15
+ const port = 20000 + Math.floor(Math.random() * 20000);
16
+ if (port === 9200) throw new Error("9200 is the production hub port");
17
+ const base = `http://127.0.0.1:${port}`;
18
+ const serverDir = resolve(import.meta.dir, "..");
19
+ let proc: ChildProcess | null = null;
20
+
21
+ async function startHub() {
22
+ proc = spawn("bun", ["run", "src/index.ts"], {
23
+ cwd: serverDir,
24
+ env: { PATH: process.env.PATH || "", HOME: home, COMMHUB_DB: dbFile, COMMHUB_SERVER: "1", PORT: String(port), HOST: "127.0.0.1" },
25
+ stdio: ["ignore", "ignore", "pipe"],
26
+ });
27
+ let err = "";
28
+ proc.stderr?.on("data", d => { err += String(d); });
29
+ for (let i = 0; i < 100; i++) {
30
+ try { if ((await fetch(`${base}/health`)).ok) return; } catch {}
31
+ await new Promise(r => setTimeout(r, 150));
32
+ }
33
+ throw new Error(`hub did not start on ${port}: ${err.slice(-600)}`);
34
+ }
35
+ async function stopHub() {
36
+ if (!proc) return;
37
+ const p = proc;
38
+ proc = null;
39
+ await new Promise<void>(res => { p.once("exit", () => res()); p.kill("SIGTERM"); setTimeout(() => { p.kill("SIGKILL"); res(); }, 5000); });
40
+ }
41
+ afterAll(async () => { await stopHub(); try { rmSync(home, { recursive: true, force: true }); } catch {} });
42
+
43
+ async function api(token: string, path: string, init?: RequestInit) {
44
+ const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
45
+ return { status: res.status, body: await res.json() as any };
46
+ }
47
+
48
+ test("an upgraded hub moves node owners to agent_owner and then enforces human owner / agent executor", async () => {
49
+ await startHub();
50
+ const reg = await (await fetch(`${base}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "e2e_owner", password: "E2eOwner123!pass" }) })).json() as any;
51
+ expect(reg.ok).toBe(true);
52
+ const token = reg.token as string, net = reg.network_id as string, uid = reg.user.user_id as string;
53
+ const human = { kind: "user", id: uid }, agent = { kind: "node", id: "node_e2e_exec" };
54
+ {
55
+ const db = new Database(dbFile);
56
+ db.run("INSERT INTO nodes (node_id,node_name,alias,network_id) VALUES (?1,'e2e-exec','e2e-exec',?2)", [agent.id, net]);
57
+ db.close();
58
+ }
59
+ const card = await api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "旧式卡", network_id: net, participants: [human] }) });
60
+ expect(card.status).toBe(201);
61
+ const keep = await api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "人类负责人卡", network_id: net, owner: human }) });
62
+ expect(keep.status).toBe(201);
63
+ await stopHub();
64
+ {
65
+ // 退回旧库:节点当负责人,且没有 agent_owner_json 列
66
+ const db = new Database(dbFile);
67
+ db.run("UPDATE requirements SET owner_json = ?1 WHERE requirement_id = ?2", [JSON.stringify(agent), card.body.requirement.id]);
68
+ db.exec("ALTER TABLE requirements DROP COLUMN agent_owner_json");
69
+ const cols = (db.query("PRAGMA table_info(requirements)").all() as { name: string }[]).map(c => c.name);
70
+ expect(cols.includes("agent_owner_json")).toBe(false);
71
+ db.close();
72
+ }
73
+ await startHub();
74
+ const listed = await api(token, `/api/requirements?network_id=${net}`);
75
+ expect(listed.status).toBe(200);
76
+ expect(listed.body.requirements.length).toBe(2);
77
+ const upgraded = listed.body.requirements.find((r: any) => r.id === card.body.requirement.id);
78
+ expect(upgraded.owner).toBeNull();
79
+ expect(upgraded.agent_owner).toEqual(agent);
80
+ expect(upgraded.participants).toEqual([human]);
81
+ expect(upgraded.name).toBe("旧式卡");
82
+ const untouched = listed.body.requirements.find((r: any) => r.id === keep.body.requirement.id);
83
+ expect(untouched.owner).toEqual(human);
84
+ expect(untouched.agent_owner).toBeNull();
85
+
86
+ const path = `/api/requirements/${upgraded.id}?network_id=${net}`;
87
+ const both = await api(token, path, { method: "PATCH", body: JSON.stringify({ owner: human }) });
88
+ expect(both.status).toBe(200);
89
+ expect(both.body.requirement.owner).toEqual(human);
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");
92
+ expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ agent_owner: human }) })).body.error).toBe("agent_owner_must_be_agent");
93
+
94
+ // 预计完成:旧卡的全天值(升级前写的)原样读回;改成带时刻的,存 UTC
95
+ const dated = await api(token, path, { method: "PATCH", body: JSON.stringify({ due: "2026-10-01" }) });
96
+ expect(dated.body.requirement.due).toBe("2026-10-01");
97
+ // 描述 / 子任务:升级后的旧卡可写,单项勾选只动那一项
98
+ expect(upgraded.description).toBe("");
99
+ expect(upgraded.checklist).toEqual([]);
100
+ const withList = await api(token, path, { method: "PATCH", body: JSON.stringify({ description: "# 验收\n- 能用", checklist: [{ id: "one", text: "第一步" }, { id: "two", text: "第二步" }] }) });
101
+ expect(withList.status).toBe(200);
102
+ const ticked = await api(token, `/api/requirements/${upgraded.id}/checklist/two?network_id=${net}`, { method: "PATCH", body: JSON.stringify({ done: true }) });
103
+ expect(ticked.body.requirement.checklist).toEqual([{ id: "one", text: "第一步", done: false }, { id: "two", text: "第二步", done: true }]);
104
+
105
+ // 项目:升级后没有任何项目(不预置),建一个、给旧卡挂上
106
+ expect((await api(token, `/api/requirements/projects?network_id=${net}`)).body.projects).toEqual([]);
107
+ const proj = await api(token, `/api/requirements/projects?network_id=${net}`, { method: "POST", body: JSON.stringify({ name: "军团项目" }) });
108
+ expect(proj.status).toBe(201);
109
+ expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ project_id: proj.body.project.id }) })).body.requirement.project_id).toBe(proj.body.project.id);
110
+
111
+ // 第三次启动(同一个库):迁移不再改任何东西
112
+ await stopHub();
113
+ await startHub();
114
+ const again = (await api(token, `/api/requirements?network_id=${net}`)).body.requirements;
115
+ expect(again.length).toBe(2);
116
+ const same = again.find((r: any) => r.id === upgraded.id);
117
+ expect(same.owner).toEqual(human);
118
+ expect(same.agent_owner).toEqual(agent);
119
+ expect(same.description).toBe("# 验收\n- 能用");
120
+ expect(same.checklist.map((i: any) => i.done)).toEqual([false, true]);
121
+ expect(same.project_id).toBe(proj.body.project.id);
122
+ expect(same.due).toBe("2026-10-01");
123
+ const timed = await api(token, `/api/requirements/${upgraded.id}?network_id=${net}`, { method: "PATCH", body: JSON.stringify({ due: "2026-10-01T18:30:45+08:00" }) });
124
+ expect(timed.body.requirement.due).toBe("2026-10-01T10:30:45Z");
125
+ expect((await api(token, `/api/requirements/projects?network_id=${net}`)).body.projects.map((p: any) => p.name)).toEqual(["军团项目"]);
126
+ }, 60_000);
@@ -0,0 +1,76 @@
1
+ // 负责人 / 负责 Agent 拆分的启动迁移:旧库里 owner 是节点的卡,节点挪到 agent_owner、owner 置空。
2
+ // 用 #2065 时代的表结构(没有 agent_owner_json 列)先建库、塞行,再 import db.ts + requirements.ts(真实启动路径)。
3
+ import { afterAll, expect, test } from "bun:test";
4
+ import { Database } from "bun:sqlite";
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-agent-owner-"));
10
+ const file = join(dir, "hub.db");
11
+ process.env.COMMHUB_DB = file;
12
+
13
+ const legacy = new Database(file);
14
+ legacy.exec(`CREATE TABLE requirements (
15
+ requirement_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, title TEXT NOT NULL,
16
+ column_name TEXT NOT NULL DEFAULT 'pool', priority TEXT NOT NULL DEFAULT 'normal', due_on TEXT, assignee TEXT,
17
+ client_id TEXT, issues_json TEXT, created_by TEXT,
18
+ created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
19
+ owner_json TEXT, participants_json TEXT NOT NULL DEFAULT '[]')`);
20
+ const rows: [string, string | null, string][] = [
21
+ ["r_node_owner", JSON.stringify({ kind: "node", id: "node_x" }), JSON.stringify([{ kind: "user", id: "u1" }])],
22
+ ["r_user_owner", JSON.stringify({ kind: "user", id: "u1" }), "[]"],
23
+ ["r_no_owner", null, "[]"],
24
+ ["r_bad_json", "{not json", "[]"],
25
+ ["r_node_no_id", JSON.stringify({ kind: "node" }), "[]"],
26
+ ["r_node_owner_2", JSON.stringify({ kind: "node", id: "node_y" }), "[]"],
27
+ ];
28
+ for (const [id, owner, participants] of rows) {
29
+ legacy.run("INSERT INTO requirements (requirement_id, network_id, title, due_on, assignee, owner_json, participants_json) VALUES (?1, 'net', ?2, '2026-10-01', 'legacy', ?3, ?4)", [id, `卡 ${id}`, owner, participants]);
30
+ }
31
+ legacy.close();
32
+
33
+ afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch {} });
34
+
35
+ const snapshot = (db: any) => db.all("SELECT requirement_id, network_id, title, due_on, assignee, participants_json, owner_json, agent_owner_json, project_id FROM requirements ORDER BY requirement_id") as any[];
36
+
37
+ test("startup migration moves node owners to agent_owner, keeps every row, and is idempotent", async () => {
38
+ const { db } = await import("./db.js");
39
+ await import("./requirements.js"); // 真实启动路径:server.ts 载入 requirements.ts 时迁移
40
+ const { migrateRequirementAgentOwners } = await import("./requirements-migrate.js");
41
+ const after = snapshot(db);
42
+ expect(after.length).toBe(rows.length);
43
+ const by = Object.fromEntries(after.map(r => [r.requirement_id, r]));
44
+ expect(by.r_node_owner.owner_json).toBeNull();
45
+ expect(JSON.parse(by.r_node_owner.agent_owner_json)).toEqual({ kind: "node", id: "node_x" });
46
+ expect(by.r_node_owner.participants_json).toBe(JSON.stringify([{ kind: "user", id: "u1" }]));
47
+ expect(JSON.parse(by.r_node_owner_2.agent_owner_json)).toEqual({ kind: "node", id: "node_y" });
48
+ expect(JSON.parse(by.r_user_owner.owner_json)).toEqual({ kind: "user", id: "u1" });
49
+ expect(by.r_user_owner.agent_owner_json).toBeNull();
50
+ expect(by.r_no_owner.owner_json).toBeNull();
51
+ // 读不懂的旧值原样留着,不猜、不删
52
+ expect(by.r_bad_json.owner_json).toBe("{not json");
53
+ expect(by.r_node_no_id.owner_json).toBe(JSON.stringify({ kind: "node" }));
54
+ for (const r of after) {
55
+ expect(r.title).toBe(`卡 ${r.requirement_id}`);
56
+ expect(r.network_id).toBe("net");
57
+ expect(r.due_on).toBe("2026-10-01");
58
+ expect(r.assignee).toBe("legacy");
59
+ }
60
+ // 加列只加不改:旧行的描述 / 子任务是空
61
+ const cols = (db.all("PRAGMA table_info(requirements)") as { name: string }[]).map(c => c.name);
62
+ expect(cols.includes("description") && cols.includes("checklist_json") && cols.includes("agent_owner_json")).toBe(true);
63
+ const extra = db.all("SELECT description, checklist_json FROM requirements") as any[];
64
+ expect(extra.every(r => r.description === null && r.checklist_json === null)).toBe(true);
65
+ // 项目表:建了、是空的(不预置),重复执行不报错;卡片的 project_id 列只加不改
66
+ expect(cols.includes("project_id")).toBe(true);
67
+ expect((db.all("SELECT project_id FROM requirements WHERE project_id IS NOT NULL") as any[]).length).toBe(0);
68
+ expect((db.get("SELECT COUNT(*) AS n FROM requirement_projects") as any).n).toBe(0);
69
+ const { ensureRequirementProjects } = await import("./requirements-migrate.js");
70
+ ensureRequirementProjects(db);
71
+ ensureRequirementProjects(db);
72
+ expect((db.get("SELECT COUNT(*) AS n FROM requirement_projects") as any).n).toBe(0);
73
+ // 再跑一次(= 下次启动):什么都不动
74
+ expect(migrateRequirementAgentOwners(db).moved).toBe(0);
75
+ expect(snapshot(db)).toEqual(after);
76
+ });
@@ -2,6 +2,12 @@
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
3
  import { db } from "./db.js";
4
4
  import { addNetworkScope, canRestWriteNetwork, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
5
+ import { ensureRequirementProjects, migrateRequirementAgentOwners } from "./requirements-migrate.js";
6
+
7
+ // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
8
+ // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
9
+ migrateRequirementAgentOwners(db);
10
+ ensureRequirementProjects(db);
5
11
 
6
12
  type RequestAuth = { userId: string; networkId: string | null; username: string } | null;
7
13
 
@@ -20,6 +26,10 @@ const DUE = /^\d{4}-\d{2}-\d{2}$/;
20
26
 
21
27
  type Row = {
22
28
  owner_json: string | null;
29
+ agent_owner_json: string | null;
30
+ description: string | null;
31
+ checklist_json: string | null;
32
+ project_id: string | null;
23
33
  participants_json: string;
24
34
  requirement_id: string;
25
35
  network_id: string;
@@ -43,6 +53,26 @@ function dueOk(due: string): boolean {
43
53
  return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
44
54
  }
45
55
 
56
+ // 预计完成:两种形状都收。
57
+ // YYYY-MM-DD 全天(旧值原样保存、原样返回;客户端按查看者本地时区的那一天理解)
58
+ // YYYY-MM-DDTHH:MM[:SS][.fff](Z|±HH:MM) 精确到秒的时刻 → 统一存成 UTC「YYYY-MM-DDTHH:MM:SSZ」
59
+ // 不带时区的时刻拒收(无法知道是谁的本地时间)。返回 null = 不合法。
60
+ const DUE_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d{1,9})?(Z|[+-](\d{2}):(\d{2}))$/;
61
+ export function normalizeDue(raw: string): string | null {
62
+ const due = raw.trim();
63
+ if (!due) return "";
64
+ if (dueOk(due)) return due;
65
+ const m = DUE_DATETIME.exec(due);
66
+ if (!m) return null;
67
+ if (!dueOk(`${m[1]}-${m[2]}-${m[3]}`)) return null;
68
+ const [hh, mm, ss] = [Number(m[4]), Number(m[5]), Number(m[6] ?? 0)];
69
+ if (hh > 23 || mm > 59 || ss > 59) return null;
70
+ if (m[7] !== "Z" && (Number(m[8]) > 14 || Number(m[9]) > 59)) return null;
71
+ const ms = Date.parse(due);
72
+ if (!Number.isFinite(ms)) return null;
73
+ return new Date(Math.floor(ms / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
74
+ }
75
+
46
76
  const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
47
77
  const ISSUE_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/(\d+)\/?$/;
48
78
 
@@ -96,9 +126,143 @@ function storedIssues(raw: string | null) {
96
126
  }
97
127
  }
98
128
 
129
+ // ── 描述 / 子任务 ──
130
+ // 描述是 markdown 原文,整段替换。子任务是有序数组 [{id,text,done}]:PATCH 整个替换(排序、增删都走这里),
131
+ // 单项勾选走 PATCH /api/requirements/{id}/checklist/{itemId} {done} —— 只改那一项,app 和 Agent 同时操作
132
+ // 不会互相盖掉对方的整张清单。done 是显式值(不是「取反」),重复请求结果一样。
133
+ export const DESCRIPTION_MAX = 20_000;
134
+ export const CHECKLIST_MAX_ITEMS = 100;
135
+ export const CHECKLIST_TEXT_MAX = 500;
136
+ const CHECKLIST_ID = /^[A-Za-z0-9_-]{1,40}$/;
137
+ type ChecklistItem = { id: string; text: string; done: boolean };
138
+
139
+ function normalizeDescription(raw: unknown): string | null {
140
+ if (typeof raw !== "string") return null;
141
+ const text = raw.replace(/\r\n?/g, "\n");
142
+ return text.length > DESCRIPTION_MAX ? null : text;
143
+ }
144
+
145
+ /** 输入的 id 保留(客户端要靠它对上勾选);缺 id 的由 Hub 生成。重复 id、空文字、超长 → null(整体拒绝)。 */
146
+ function normalizeChecklist(raw: unknown): ChecklistItem[] | null {
147
+ if (!Array.isArray(raw) || raw.length > CHECKLIST_MAX_ITEMS) return null;
148
+ const seen = new Set<string>();
149
+ const out: ChecklistItem[] = [];
150
+ for (const value of raw) {
151
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
152
+ const item = value as Record<string, unknown>;
153
+ const id = item.id === undefined ? `ck_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}` : item.id;
154
+ if (typeof id !== "string" || !CHECKLIST_ID.test(id) || seen.has(id)) return null;
155
+ const text = typeof item.text === "string" ? item.text.replace(/[\r\n]+/g, " ").trim() : "";
156
+ if (!text || text.length > CHECKLIST_TEXT_MAX) return null;
157
+ if (item.done !== undefined && typeof item.done !== "boolean") return null;
158
+ seen.add(id);
159
+ out.push({ id, text, done: item.done === true });
160
+ }
161
+ return out;
162
+ }
163
+
164
+ function storedChecklist(raw: string | null): ChecklistItem[] {
165
+ if (!raw) return [];
166
+ try { return normalizeChecklist(JSON.parse(raw)) ?? []; } catch { return []; }
167
+ }
168
+
169
+ // ── 项目 ──
170
+ // 按网络隔离;名字 1–40 字,同一网络里未归档的项目不重名;颜色 #RRGGBB;sort 越小越靠前。
171
+ // 删除 = 删项目行 + 把引用它的卡片 project_id 置空(卡片本身不动);归档 = 保留引用,只是不能再被选。
172
+ type ProjectRow = { project_id: string; network_id: string; name: string; color: string; sort: number; archived: number; created_at: string };
173
+ const PROJECT_COLOR = /^#[0-9a-fA-F]{6}$/;
174
+ const PROJECT_PALETTE = ["#2563eb", "#16a34a", "#d97706", "#dc2626", "#7c3aed", "#0891b2", "#db2777", "#4b5563"];
175
+ const PROJECT_SELECT = "project_id, network_id, name, color, sort, archived, created_at";
176
+ const projectPublic = (row: ProjectRow) => ({ id: row.project_id, name: row.name, color: row.color, sort: row.sort, archived: !!row.archived, createdAt: row.created_at });
177
+ const projectName = (raw: unknown): string | null => {
178
+ if (typeof raw !== "string") return null;
179
+ const name = raw.replace(/[\r\n\t]+/g, " ").trim();
180
+ return name && name.length <= 40 ? name : null;
181
+ };
182
+ function projectNameTaken(networkId: string, name: string, except?: string): boolean {
183
+ return !!db.get("SELECT 1 FROM requirement_projects WHERE network_id = ?1 AND name = ?2 AND archived = 0 AND project_id != ?3", networkId, name, except ?? "");
184
+ }
185
+ /** 卡片上的 project_id:null 清空;字符串必须是同一网络、未归档的项目。 */
186
+ function projectRef(value: unknown, networkId: string): string | null {
187
+ if (value === null) return null;
188
+ if (typeof value !== "string" || !value) throw new Error("invalid_project");
189
+ const row = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, value);
190
+ if (!row || row.network_id !== networkId) throw new Error("project_not_in_network");
191
+ if (row.archived) throw new Error("project_archived");
192
+ return row.project_id;
193
+ }
194
+
195
+ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response> {
196
+ const { req, url } = ctx;
197
+ const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
198
+ if (!networkId) return jsonError("network_id_required", 400);
199
+ const one = url.pathname.match(/^\/api\/requirements\/projects\/([^/]+)$/);
200
+ if (url.pathname === "/api/requirements/projects" && req.method === "GET") {
201
+ const rows = db.all<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE network_id = ?1 ORDER BY sort, created_at`, networkId);
202
+ return Response.json({ ok: true, projects: rows.map(projectPublic) });
203
+ }
204
+ if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
205
+ let body: Record<string, unknown> = {};
206
+ if (req.method === "POST" || req.method === "PATCH") {
207
+ try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
208
+ }
209
+ if (url.pathname === "/api/requirements/projects" && req.method === "POST") {
210
+ const name = projectName(body.name);
211
+ if (!name) return jsonError("invalid_project_name", 400);
212
+ if (projectNameTaken(networkId, name)) return jsonError("project_name_taken", 409);
213
+ const count = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM requirement_projects WHERE network_id = ?1", networkId)?.n ?? 0;
214
+ if (count >= 200) return jsonError("too_many_projects", 400);
215
+ const color = body.color === undefined ? PROJECT_PALETTE[count % PROJECT_PALETTE.length] : body.color;
216
+ if (typeof color !== "string" || !PROJECT_COLOR.test(color)) return jsonError("invalid_project_color", 400);
217
+ const sort = body.sort === undefined ? count : body.sort;
218
+ if (!Number.isInteger(sort)) return jsonError("invalid_project_sort", 400);
219
+ const id = `proj_${crypto.randomUUID()}`;
220
+ db.run(`INSERT INTO requirement_projects (${PROJECT_SELECT}) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6)`, [id, networkId, name, color, sort, new Date().toISOString()]);
221
+ return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, id)!) }, { status: 201 });
222
+ }
223
+ if (!one) return jsonError("not_found", 404);
224
+ const current = db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, decodeURIComponent(one[1]));
225
+ if (!current || current.network_id !== networkId) return jsonError("project_not_found", 404);
226
+ if (req.method === "DELETE") {
227
+ // 先把卡片上的引用置空,再删项目:卡片一张不少。
228
+ db.run("UPDATE requirements SET project_id = NULL WHERE project_id = ?1", [current.project_id]);
229
+ db.run("DELETE FROM requirement_projects WHERE project_id = ?1", [current.project_id]);
230
+ return Response.json({ ok: true });
231
+ }
232
+ if (req.method !== "PATCH") return jsonError("not_found", 404);
233
+ let { name, color, sort, archived } = current;
234
+ if ("name" in body) {
235
+ const next = projectName(body.name);
236
+ if (!next) return jsonError("invalid_project_name", 400);
237
+ name = next;
238
+ }
239
+ if ("color" in body) {
240
+ if (typeof body.color !== "string" || !PROJECT_COLOR.test(body.color)) return jsonError("invalid_project_color", 400);
241
+ color = body.color;
242
+ }
243
+ if ("sort" in body) {
244
+ if (!Number.isInteger(body.sort)) return jsonError("invalid_project_sort", 400);
245
+ sort = body.sort as number;
246
+ }
247
+ if ("archived" in body) {
248
+ if (typeof body.archived !== "boolean") return jsonError("invalid_project_archived", 400);
249
+ archived = body.archived ? 1 : 0;
250
+ }
251
+ if (!archived && projectNameTaken(networkId, name, current.project_id)) return jsonError("project_name_taken", 409);
252
+ db.run("UPDATE requirement_projects SET name = ?1, color = ?2, sort = ?3, archived = ?4 WHERE project_id = ?5", [name, color, sort, archived, current.project_id]);
253
+ return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, current.project_id)!) });
254
+ }
255
+
99
256
  function toPublic(row: Row) {
100
257
  return {
101
258
  owner: row.owner_json ? JSON.parse(row.owner_json) : null,
259
+ // 负责 Agent(执行者)。字段总在:客户端靠它判断这个 Hub 分不分「负责人 / 负责 Agent」。
260
+ agent_owner: row.agent_owner_json ? JSON.parse(row.agent_owner_json) : null,
261
+ // 描述(markdown)与子任务。字段总在:客户端靠它判断这个 Hub 支不支持。
262
+ description: row.description || "",
263
+ checklist: storedChecklist(row.checklist_json),
264
+ // 项目(可空)。字段总在:客户端靠它判断这个 Hub 有没有项目。项目被删 → 置空;被归档 → 引用保留。
265
+ project_id: row.project_id || null,
102
266
  participants: JSON.parse(row.participants_json || '[]'),
103
267
  id: row.requirement_id,
104
268
  name: row.title,
@@ -127,7 +291,7 @@ function canWrite(ctx: RequirementsRequestContext, networkId: string | null): bo
127
291
  return !ctx.isNodeToken && canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
128
292
  }
129
293
 
130
- const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, created_at, owner_json, participants_json";
294
+ const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id";
131
295
 
132
296
  type PersonRef = { kind: 'user' | 'node'; id: string };
133
297
  function personRef(value: unknown, networkId: string): PersonRef {
@@ -141,21 +305,52 @@ function personRef(value: unknown, networkId: string): PersonRef {
141
305
  return { kind: row.kind, id: row.id };
142
306
  }
143
307
 
308
+ // 负责人只能是人类,负责 Agent 只能是节点;参与人两种都行。库里旧的节点负责人照样读出来(启动迁移会挪走),
309
+ // 只有这次请求显式写了 owner / agent_owner 才校验种类。
144
310
  function assignments(body: Record<string, unknown>, networkId: string, current?: Row) {
145
- const owner = 'owner' in body ? body.owner === null ? null : personRef(body.owner, networkId) : current?.owner_json ? JSON.parse(current.owner_json) : null;
311
+ let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
312
+ if ('owner' in body) {
313
+ owner = body.owner === null ? null : personRef(body.owner, networkId);
314
+ if (owner && owner.kind !== 'user') throw new Error('owner_must_be_human');
315
+ }
316
+ let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
317
+ if ('agent_owner' in body) {
318
+ agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId);
319
+ if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
320
+ }
146
321
  let participants = current ? JSON.parse(current.participants_json || '[]') : [];
147
322
  if ('participants' in body) {
148
323
  if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
149
324
  const refs = body.participants.map(value => personRef(value, networkId));
150
325
  participants = [...new Map(refs.map(ref => [`${ref.kind}:${ref.id}`, ref])).values()];
151
326
  }
152
- return { ownerJson: owner === null ? null : JSON.stringify(owner), participantsJson: JSON.stringify(participants) };
327
+ return {
328
+ ownerJson: owner === null ? null : JSON.stringify(owner),
329
+ agentOwnerJson: agentOwner === null ? null : JSON.stringify(agentOwner),
330
+ participantsJson: JSON.stringify(participants),
331
+ };
332
+ }
333
+
334
+ /** 节点令牌可用的操作。空 = 与之前一样全部拒绝(本 PR 不放宽)。候选:'read' | 'patch' | 'checklist_item'。 */
335
+ const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>();
336
+ function operationOf(req: Request, url: URL): string {
337
+ if (req.method === "GET") return "read";
338
+ if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return "projects";
339
+ if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
340
+ if (req.method === "PATCH") return "patch";
341
+ return "create";
153
342
  }
154
343
 
344
+ export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime"] as const;
345
+
155
346
  export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
156
347
  const { req, url } = ctx;
157
348
  if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
158
- if (ctx.isNodeToken) return jsonError("user_token_required", 403);
349
+ // 节点令牌(Agent)现在一律拒绝。以后要让 Agent 读写描述、勾子任务,把对应操作加进
350
+ // NODE_TOKEN_OPERATIONS(并给 canWrite 加上节点所属网络的判断)即可 —— 路由和请求体不用改。
351
+ if (ctx.isNodeToken && !NODE_TOKEN_OPERATIONS.has(operationOf(req, url))) return jsonError("user_token_required", 403);
352
+
353
+ if (url.pathname === "/api/requirements/projects" || url.pathname.startsWith("/api/requirements/projects/")) return handleProjects(ctx);
159
354
 
160
355
  if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
161
356
  const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
@@ -175,7 +370,8 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
175
370
  sql = addNetworkScope(sql, params, ctx.scope);
176
371
  sql += " ORDER BY created_at DESC LIMIT 500";
177
372
  const rows = db.all<Row>(sql, ...params).map(toPublic);
178
- return Response.json({ ok: true, requirements: rows });
373
+ // capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
374
+ return Response.json({ ok: true, requirements: rows, capabilities: REQUIREMENT_CAPABILITIES });
179
375
  }
180
376
 
181
377
  if (url.pathname === "/api/requirements" && req.method === "POST") {
@@ -188,14 +384,22 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
188
384
  if (!name || name.length > 80) return jsonError("invalid_name", 400);
189
385
  const priority = typeof body.priority === "string" ? body.priority : "normal";
190
386
  if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
191
- const due = typeof body.due === "string" ? body.due.trim() : "";
192
- if (due && !dueOk(due)) return jsonError("invalid_due", 400);
387
+ const due = typeof body.due === "string" ? normalizeDue(body.due) : "";
388
+ if (due === null) return jsonError("invalid_due", 400);
193
389
  const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
194
390
  const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
195
391
  if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
196
392
  const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
197
393
  const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
198
394
  if (issues === null) return jsonError("invalid_issues", 400);
395
+ const description = body.description === undefined ? "" : normalizeDescription(body.description);
396
+ if (description === null) return jsonError("invalid_description", 400);
397
+ const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
398
+ if (checklist === null) return jsonError("invalid_checklist", 400);
399
+ let projectId: string | null = null;
400
+ if (body.project_id !== undefined) {
401
+ try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
402
+ }
199
403
  if (clientId) {
200
404
  const existing = db.get<Row>(
201
405
  `SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`,
@@ -210,9 +414,9 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
210
414
  try {
211
415
  db.run(
212
416
  `INSERT INTO requirements
213
- (requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json)
214
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13)`,
215
- [id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson],
417
+ (requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id)
418
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17)`,
419
+ [id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId],
216
420
  );
217
421
  } catch {
218
422
  if (!clientId) return jsonError("insert_failed", 500);
@@ -227,6 +431,29 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
227
431
  return Response.json({ ok: true, requirement: toPublic(created) }, { status: 201 });
228
432
  }
229
433
 
434
+ const itemMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/checklist\/([^/]+)$/);
435
+ if (itemMatch) {
436
+ if (req.method !== "PATCH") return jsonError("not_found", 404);
437
+ let body: Record<string, unknown>;
438
+ try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
439
+ if (typeof body.done !== "boolean") return jsonError("invalid_done", 400);
440
+ const params: unknown[] = [decodeURIComponent(itemMatch[1])];
441
+ let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
442
+ sql = addNetworkScope(sql, params, ctx.scope);
443
+ const row = db.get<Row>(sql, ...params);
444
+ if (!row) return jsonError("requirement_not_found", 404);
445
+ if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
446
+ // 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
447
+ const items = storedChecklist(row.checklist_json);
448
+ const itemId = decodeURIComponent(itemMatch[2]);
449
+ const item = items.find(entry => entry.id === itemId);
450
+ if (!item) return jsonError("checklist_item_not_found", 404);
451
+ item.done = body.done;
452
+ db.run("UPDATE requirements SET checklist_json = ?1, updated_at = ?2 WHERE requirement_id = ?3", [JSON.stringify(items), new Date().toISOString(), row.requirement_id]);
453
+ const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
454
+ return Response.json({ ok: true, requirement: toPublic(updated) });
455
+ }
456
+
230
457
  const match = url.pathname.match(/^\/api\/requirements\/([^/]+)$/);
231
458
  if (!match || req.method !== "PATCH") return jsonError("not_found", 404);
232
459
  const id = decodeURIComponent(match[1]);
@@ -234,7 +461,18 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
234
461
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
235
462
  const hasColumn = 'column' in body;
236
463
  const hasIssues = Object.prototype.hasOwnProperty.call(body, "issues");
237
- if (!hasColumn && !hasIssues && !('owner' in body) && !('participants' in body)) return jsonError('empty_patch', 400);
464
+ const hasName = 'name' in body;
465
+ const hasPriority = 'priority' in body;
466
+ const hasDue = 'due' in body;
467
+ const hasAssignee = 'assignee' in body;
468
+ const hasDescription = 'description' in body;
469
+ const hasChecklist = 'checklist' in body;
470
+ const hasProject = 'project_id' in body;
471
+ if (!hasColumn && !hasIssues && !hasName && !hasPriority && !hasDue && !hasAssignee && !('owner' in body) && !('agent_owner' in body) && !('participants' in body) && !hasDescription && !hasChecklist && !hasProject) return jsonError('empty_patch', 400);
472
+ const description = hasDescription ? normalizeDescription(body.description) : null;
473
+ if (hasDescription && description === null) return jsonError("invalid_description", 400);
474
+ const checklist = hasChecklist ? normalizeChecklist(body.checklist) : null;
475
+ if (hasChecklist && checklist === null) return jsonError("invalid_checklist", 400);
238
476
  if (hasColumn && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
239
477
  const issues = hasIssues ? normalizeIssues(body.issues) : null;
240
478
  if (hasIssues && issues === null) return jsonError("invalid_issues", 400);
@@ -244,12 +482,39 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
244
482
  const row = db.get<Row>(sql, ...params);
245
483
  if (!row) return jsonError("requirement_not_found", 404);
246
484
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
485
+ let name = row.title;
486
+ if (hasName) {
487
+ name = typeof body.name === "string" ? body.name.trim() : "";
488
+ if (!name || name.length > 80) return jsonError("invalid_name", 400);
489
+ }
490
+ let priority = row.priority;
491
+ if (hasPriority) {
492
+ if (typeof body.priority !== "string" || !PRIORITIES.has(body.priority)) return jsonError("invalid_priority", 400);
493
+ priority = body.priority;
494
+ }
495
+ let due = row.due_on || "";
496
+ if (hasDue) {
497
+ if (typeof body.due !== "string") return jsonError("invalid_due", 400);
498
+ const next = normalizeDue(body.due);
499
+ if (next === null) return jsonError("invalid_due", 400);
500
+ due = next;
501
+ }
502
+ let assignee = row.assignee || "";
503
+ if (hasAssignee) {
504
+ if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
505
+ assignee = body.assignee.trim().slice(0, 80);
506
+ }
247
507
  let people;
248
508
  try { people = assignments(body, row.network_id, row); } catch (e) { return jsonError((e as Error).message, 400); }
509
+ let projectId = row.project_id;
510
+ if (hasProject) {
511
+ try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
512
+ }
249
513
  const updatedAt = new Date().toISOString();
250
514
  db.run(
251
- "UPDATE requirements SET column_name = ?1, updated_at = ?2, owner_json = ?4, participants_json = ?5, issues_json = ?6 WHERE requirement_id = ?3",
252
- [hasColumn ? body.column : row.column_name, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, hasIssues ? JSON.stringify(issues) : row.issues_json],
515
+ "UPDATE requirements SET column_name = ?1, updated_at = ?2, title = ?7, priority = ?8, due_on = ?9, assignee = ?10, owner_json = ?4, participants_json = ?5, issues_json = ?6, agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14 WHERE requirement_id = ?3",
516
+ [hasColumn ? body.column : row.column_name, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, hasIssues ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
517
+ hasDescription ? (description || null) : row.description, hasChecklist ? JSON.stringify(checklist) : row.checklist_json, projectId],
253
518
  );
254
519
  const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
255
520
  return Response.json({ ok: true, requirement: toPublic(updated) });