@sleep2agi/commhub-server 0.9.0-preview.112 → 0.9.0-preview.114

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,236 @@
1
+ // 任务状态「废弃」(abandoned)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ // test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
3
+ //
4
+ // 废弃是关闭态(同 done):不算开着、不逾期、不发到期提醒、不记完成时间;改进 / 改出都记动态;能改回 pool / doing。
5
+ // 旧 App(desktop ≤ 0.2.220)把不认识的状态当 pool —— 所以没声明 `X-Anet-Accept-Columns: abandoned`
6
+ // (或 `accept_columns=abandoned`)的调用者看到的是 done;MCP 总是声明,看到真值。
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-abandoned-"));
13
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
14
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
15
+ process.env.HOST = "127.0.0.1";
16
+ for (const k of ["COMMHUB_DUE_REMINDER_TZ", "COMMHUB_DUE_OVERDUE_MAX_DAYS", "COMMHUB_DUE_REMINDER_NETWORKS", "COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDERS_OWNERS"]) delete process.env[k];
17
+ const PW = "AbandonedPassw0rd!x";
18
+ const T0 = Date.UTC(2026, 9, 10, 2, 0, 0);
19
+ const DAY = 86_400_000;
20
+ const AWARE = { "X-Anet-Accept-Columns": "abandoned" };
21
+
22
+ let BASE = "";
23
+ let hub: any = null;
24
+ let NET = "";
25
+ let TOKEN = "";
26
+ let OWNER = "";
27
+ let db: any;
28
+ let due: typeof import("./requirement-due-reminders.js");
29
+
30
+ type R = { status: number; body: any };
31
+ async function send(method: string, path: string, payload?: unknown, headers: Record<string, string> = {}): Promise<R> {
32
+ const res = await fetch(`${BASE}${path}`, {
33
+ method,
34
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...headers },
35
+ body: payload === undefined ? undefined : JSON.stringify(payload),
36
+ }, 60_000);
37
+ const text = await res.text();
38
+ let body: any = null;
39
+ try { body = JSON.parse(text); } catch {}
40
+ return { status: res.status, body };
41
+ }
42
+ async function mcp(name: string, args: Record<string, unknown>): Promise<any> {
43
+ const res = await fetch(`${BASE}/mcp`, {
44
+ method: "POST",
45
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
46
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
47
+ }, 60_000);
48
+ const raw = await res.text();
49
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
50
+ const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
51
+ return JSON.parse(payload.result.content[0].text);
52
+ }
53
+ async function card(name: string, extra: Record<string, unknown> = {}): Promise<string> {
54
+ const r = await send("POST", "/api/requirements", { network_id: NET, name, owner: { kind: "user", id: OWNER }, ...extra });
55
+ expect(r.status).toBe(201);
56
+ return r.body.requirement.id as string;
57
+ }
58
+ const get = (id: string, headers: Record<string, string> = {}) => send("GET", `/api/requirements/${id}?network_id=${NET}`, undefined, headers);
59
+ const patch = (id: string, body: Record<string, unknown>, headers: Record<string, string> = {}) => send("PATCH", `/api/requirements/${id}?network_id=${NET}`, body, headers);
60
+ const list = (qs = "", headers: Record<string, string> = {}) => send("GET", `/api/requirements?network_id=${NET}${qs}`, undefined, headers);
61
+ const ids = (r: R) => (r.body.requirements as any[]).map(x => x.id);
62
+ const columnIn = (r: R, id: string) => (r.body.requirements as any[]).find(x => x.id === id)?.column;
63
+ const sentFor = (out: Array<{ requirement_id: string; kind: string }>, id: string) => out.filter(x => x.requirement_id === id).map(x => x.kind);
64
+ const columnEvents = async (id: string) => ((await send("GET", `/api/requirements/events?network_id=${NET}&requirement_id=${id}`, undefined, AWARE)).body.events as any[])
65
+ .filter(e => e.kind === "changed" && e.field === "column").map(e => [e.old, e.new]).reverse();
66
+
67
+ beforeAll(async () => {
68
+ ({ db } = await import("./db.js"));
69
+ const { register } = await import("./auth.js");
70
+ const r = register(`ab_owner_${Date.now()}`, PW);
71
+ expect(r.ok).toBe(true);
72
+ TOKEN = r.token!;
73
+ OWNER = r.user!.user_id;
74
+ NET = r.network_id!;
75
+ due = await import("./requirement-due-reminders.js");
76
+ const mod: any = await import("./server.js");
77
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
78
+ BASE = `http://127.0.0.1:${hub.port}`;
79
+ due.runDueReminders({ now: T0 - 30 * DAY }); // 到期提醒基线(同 requirement-due-reminders-http.test.ts)
80
+ }, 30_000);
81
+
82
+ afterAll(() => {
83
+ try { hub?.stop?.(true); } catch {}
84
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
85
+ });
86
+
87
+ describe("abandoned column", () => {
88
+ test("schema accepts abandoned; the legacy CHECK migration is idempotent (SQLite rebuild / PG constraint swap)", async () => {
89
+ const { migrateRequirementColumnCheck } = await import("./requirements-migrate.js");
90
+ if (db.dialect === "postgres") {
91
+ // 模拟旧 PG 库:换回旧约束,迁移后 abandoned 能写,再跑一次照样不报错。
92
+ db.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check");
93
+ db.exec("ALTER TABLE requirements ADD CONSTRAINT requirements_column_name_check CHECK(column_name IN ('pool', 'doing', 'done'))");
94
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(true);
95
+ // 已经认识 abandoned:不再动表(不是每次启动都删了重加)
96
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(false);
97
+ // 加约束失败要响亮地失败,而且是一条语句:旧约束原样留着,不会落到「没约束」
98
+ db.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check");
99
+ db.exec("ALTER TABLE requirements ADD CONSTRAINT requirements_column_name_check CHECK(column_name IN ('pool', 'doing', 'done', 'bogus'))");
100
+ db.run("INSERT INTO requirements (requirement_id, network_id, title, column_name) VALUES ('req_ab_bogus', ?1, 'bogus', 'bogus')", [NET]);
101
+ expect(() => migrateRequirementColumnCheck(db)).toThrow();
102
+ const def = (db.get("SELECT pg_get_constraintdef(c.oid) AS def FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid WHERE t.relname = 'requirements' AND c.conname = 'requirements_column_name_check'") as any)?.def ?? "";
103
+ expect(def).toContain("'bogus'");
104
+ db.run("DELETE FROM requirements WHERE requirement_id = 'req_ab_bogus'");
105
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(true);
106
+ } else {
107
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(false);
108
+ }
109
+ const id = await card("ab-schema");
110
+ expect((await patch(id, { column: "abandoned" }, AWARE)).status).toBe(200);
111
+ expect((await patch(id, { column: "cancelled" }, AWARE)).body.error).toBe("invalid_column");
112
+ expect((await list()).body.capabilities).toContain("column_abandoned");
113
+ }, 60_000);
114
+
115
+ test("set and restore: events recorded each way, no completedAt, restorable to pool and doing", async () => {
116
+ const id = await card("ab-set");
117
+ const a = await patch(id, { column: "abandoned" }, AWARE);
118
+ expect(a.status).toBe(200);
119
+ expect(a.body.requirement.column).toBe("abandoned");
120
+ expect(a.body.requirement.completedAt).toBeNull();
121
+ expect((await patch(id, { column: "doing" }, AWARE)).body.requirement.column).toBe("doing");
122
+ expect((await patch(id, { column: "abandoned" }, AWARE)).body.requirement.column).toBe("abandoned");
123
+ expect((await patch(id, { column: "pool" }, AWARE)).body.requirement.column).toBe("pool");
124
+ expect(await columnEvents(id)).toEqual([["pool", "abandoned"], ["abandoned", "doing"], ["doing", "abandoned"], ["abandoned", "pool"]]);
125
+ // 直接建在废弃列(带声明)也行
126
+ const created = await send("POST", "/api/requirements", { network_id: NET, name: "ab-born", column: "abandoned" }, AWARE);
127
+ expect(created.body.requirement.column).toBe("abandoned");
128
+ }, 60_000);
129
+
130
+ test("closed like done: not overdue, no due reminder, not counted as open in stats; restoring reopens it", async () => {
131
+ // id:到期提醒用假时钟 T0(10-10),到期 10-07 = 逾期 3 天;old:列表的 overdue 筛选用真时钟,到期给一个早就过了的日子。
132
+ const id = await card("ab-overdue", { due: "2026-10-07", column: "doing" });
133
+ const old = await card("ab-overdue-list", { due: "2020-01-01", column: "doing" });
134
+ expect(ids(await list("&overdue=1"))).toContain(old);
135
+ await patch(id, { column: "abandoned" }, AWARE);
136
+ await patch(old, { column: "abandoned" }, AWARE);
137
+ expect(ids(await list("&overdue=1"))).not.toContain(old);
138
+ expect(ids(await list("&overdue=0"))).toContain(old);
139
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual([]);
140
+ const stats = (await send("GET", `/api/requirements/stats?network_id=${NET}`)).body.totals;
141
+ const raw = db.all("SELECT column_name FROM requirements WHERE network_id = ?1 AND COALESCE(archived, 0) = 0", NET) as any[];
142
+ const n = (c: string) => raw.filter(r => r.column_name === c).length;
143
+ expect(stats.doing).toBe(n("doing"));
144
+ expect(stats.pool).toBe(n("pool"));
145
+ expect(stats.abandoned).toBe(n("abandoned"));
146
+ expect(n("abandoned")).toBeGreaterThan(0);
147
+ // 子任务进度不把废弃的子任务算进去
148
+ const parent = await card("ab-parent");
149
+ const kid = await card("ab-kid", { parent_id: parent });
150
+ await card("ab-kid2", { parent_id: parent });
151
+ await patch(kid, { column: "abandoned" }, AWARE);
152
+ // 声明了:废弃的子需求不算进度;没声明(旧 App 把它当 done 列出来):算进总数、算作完成,和它看到的列表对得上
153
+ expect((await get(parent, AWARE)).body.requirement.children).toEqual({ total: 1, done: 0 });
154
+ expect((await get(parent)).body.requirement.children).toEqual({ total: 2, done: 1 });
155
+ expect(columnIn(await list("&parent_id=" + parent), kid)).toBe("done");
156
+ // 改回 doing:又逾期、又提醒
157
+ await patch(id, { column: "doing" }, AWARE);
158
+ await patch(old, { column: "doing" }, AWARE);
159
+ expect(ids(await list("&overdue=1"))).toContain(old);
160
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual(["overdue"]);
161
+ }, 60_000);
162
+
163
+ test("old clients (no declaration) see abandoned as done; their writes cannot un-abandon it by echo", async () => {
164
+ const id = await card("ab-legacy");
165
+ await patch(id, { column: "abandoned" }, AWARE);
166
+ expect((await get(id)).body.requirement.column).toBe("done");
167
+ expect((await get(id, AWARE)).body.requirement.column).toBe("abandoned");
168
+ expect((await send("GET", `/api/requirements/${id}?network_id=${NET}&accept_columns=abandoned`)).body.requirement.column).toBe("abandoned");
169
+ expect(columnIn(await list(), id)).toBe("done");
170
+ expect(columnIn(await list("&view=summary"), id)).toBe("done");
171
+ expect(columnIn(await list("", AWARE), id)).toBe("abandoned");
172
+ // 列表缓存按声明分开:同一个查询串,先旧后新,不串值
173
+ expect(columnIn(await list(), id)).toBe("done");
174
+ expect(columnIn(await list("", AWARE), id)).toBe("abandoned");
175
+ // 旧客户端按 done 筛:带上废弃的(它眼里就是 done)
176
+ expect(ids(await list("&status=done"))).toContain(id);
177
+ expect(ids(await list("&status=done", AWARE))).not.toContain(id);
178
+ expect(ids(await list("&status=abandoned", AWARE))).toContain(id);
179
+ // 旧客户端改别的字段 / 回传它看到的 done:仍是废弃;响应里仍是 done
180
+ const p1 = await patch(id, { priority: "high" });
181
+ expect(p1.body.requirement.column).toBe("done");
182
+ const p2 = await patch(id, { column: "done" });
183
+ expect(p2.body.requirement.column).toBe("done");
184
+ expect((await get(id, AWARE)).body.requirement.column).toBe("abandoned");
185
+ // 旧客户端明确改回 pool:照办
186
+ expect((await patch(id, { column: "pool" })).body.requirement.column).toBe("pool");
187
+ // 新客户端从 abandoned 改成 done:真的完成
188
+ await patch(id, { column: "abandoned" }, AWARE);
189
+ const done = await patch(id, { column: "done" }, AWARE);
190
+ expect(done.body.requirement.column).toBe("done");
191
+ expect(done.body.requirement.completedAt).not.toBeNull();
192
+ }, 60_000);
193
+
194
+ test("events and last_event: undeclared callers see the projected column; done ↔ abandoned disappears for them", async () => {
195
+ const id = await card("ab-events");
196
+ await patch(id, { column: "abandoned" }, AWARE);
197
+ await patch(id, { column: "done" }, AWARE);
198
+ await patch(id, { column: "abandoned" }, AWARE);
199
+ const evs = async (headers: Record<string, string> = {}) => ((await send("GET", `/api/requirements/events?network_id=${NET}&requirement_id=${id}`, undefined, headers)).body.events as any[])
200
+ .filter(e => e.kind === "changed" && e.field === "column").map(e => [e.old, e.new]).reverse();
201
+ expect(await evs(AWARE)).toEqual([["pool", "abandoned"], ["abandoned", "done"], ["done", "abandoned"]]);
202
+ expect(await evs()).toEqual([["pool", "done"]]);
203
+ const legacyRaw = JSON.stringify((await send("GET", `/api/requirements/events?network_id=${NET}`)).body.events);
204
+ expect(legacyRaw).not.toContain("abandoned");
205
+ // 每行的 last_event:最新一条是 done → abandoned,旧客户端那里投影后前后一样 → 不给字段 / 摘要
206
+ const legacyRow = (await list()).body.requirements.find((r: any) => r.id === id);
207
+ expect(legacyRow.last_event.field).toBeNull();
208
+ expect(legacyRow.last_event.summary).toBeUndefined();
209
+ const awareRow = (await list("", AWARE)).body.requirements.find((r: any) => r.id === id);
210
+ expect(awareRow.last_event).toMatchObject({ field: "column", summary: "done → abandoned" });
211
+ await patch(id, { column: "doing" }, AWARE);
212
+ const back = (await list()).body.requirements.find((r: any) => r.id === id);
213
+ expect(back.last_event).toMatchObject({ field: "column", summary: "done → doing" });
214
+ expect(JSON.stringify((await list()).body.requirements)).not.toContain("abandoned");
215
+ }, 60_000);
216
+
217
+ test("checklist toggle response is projected for undeclared callers", async () => {
218
+ const id = await card("ab-check", { checklist: [{ id: "c1", text: "one" }] });
219
+ await patch(id, { column: "abandoned" }, AWARE);
220
+ const legacy = await send("PATCH", `/api/requirements/${id}/checklist/c1?network_id=${NET}`, { done: true });
221
+ expect(legacy.status).toBe(200);
222
+ expect(legacy.body.requirement.column).toBe("done");
223
+ const aware = await send("PATCH", `/api/requirements/${id}/checklist/c1?network_id=${NET}`, { done: false }, AWARE);
224
+ expect(aware.body.requirement.column).toBe("abandoned");
225
+ }, 60_000);
226
+
227
+ test("MCP declares support: requirements_update / get / list carry the real value", async () => {
228
+ const id = await card("ab-mcp");
229
+ const up = await mcp("requirements_update", { id, network_id: NET, status: "abandoned" });
230
+ expect(up.requirement.column).toBe("abandoned");
231
+ expect((await mcp("requirements_get", { id, network_id: NET })).requirement.column).toBe("abandoned");
232
+ const l = await mcp("requirements_list", { network_id: NET, status: "abandoned" });
233
+ expect(l.requirements.map((x: any) => x.id)).toContain(id);
234
+ expect((await mcp("requirements_update", { id, network_id: NET, column: "doing" })).requirement.column).toBe("doing");
235
+ }, 60_000);
236
+ });
@@ -54,7 +54,7 @@ const ENTRIES: Record<string, Entry> = {
54
54
  participants_conflict: { field: 'participants', message: 'participants (replace the whole list) cannot be combined with participants_add / participants_remove.', hint: 'Use participants_add / participants_remove to change a few people, or participants alone to replace the list.' },
55
55
  participants_add_remove_overlap: { field: 'participants_remove', message: 'The same person is in both participants_add and participants_remove.', hint: 'Send each person in only one of the two lists.' },
56
56
  too_many_participants: { field: 'participants_add', message: 'A task can have at most 100 participants.', hint: 'Remove some with participants_remove first.' },
57
- status_conflicts_with_column: { field: 'status', message: 'status is an alias of column, and the two values sent differ.', hint: 'Send only one of them (pool, doing or done).' },
57
+ status_conflicts_with_column: { field: 'status', message: 'status is an alias of column, and the two values sent differ.', hint: 'Send only one of them (pool, doing, done or abandoned).' },
58
58
  invalid_assignee: { field: 'assignee', message: 'assignee must be a string (legacy free-text field).', hint: 'Prefer owner / agent_owner.' },
59
59
  // ── 项目 ──
60
60
  invalid_project: { field: 'project_id', message: 'project_id must be a project id string or null.', hint: 'Call projects_list to get project ids (proj_…); null removes the task from its project.' },
@@ -77,14 +77,14 @@ const ENTRIES: Record<string, Entry> = {
77
77
  empty_patch: (ctx) => {
78
78
  const sent = ctx.sent ?? [];
79
79
  const writable = ctx.writable?.length ? ctx.writable.join(', ') : 'name, column, priority, due, start, description, checklist, tags, owner, agent_owner, participants, project_id, parent_id, external_ref, external_url, archived';
80
- const statusHint = sent.includes('status') ? 'To move a task between pool / doing / done, use column (status is the name of the list filter). ' : '';
80
+ const statusHint = sent.includes('status') ? 'To move a task between pool / doing / done / abandoned, use column (status is the name of the list filter). ' : '';
81
81
  return { message: sent.length ? `None of the keys sent (${sent.join(', ')}) is a writable field.` : 'The patch changes nothing.', hint: `${statusHint}Writable fields: ${writable}.` };
82
82
  },
83
83
  invalid_json: { message: 'The request body must be a JSON object.' },
84
84
  invalid_name: { field: 'name', message: 'name must be 1–80 characters.' },
85
85
  invalid_description: { field: 'description', message: 'description must be a string (markdown) of at most 20 000 characters.' },
86
- invalid_column: { field: 'column', message: 'column must be pool, doing or done.' },
87
- invalid_status: { field: 'status', message: 'status (list filter) must be pool, doing or done.' },
86
+ invalid_column: { field: 'column', message: 'column must be pool, doing, done or abandoned.' },
87
+ invalid_status: { field: 'status', message: 'status (list filter) must be pool, doing, done or abandoned.' },
88
88
  invalid_priority: { field: 'priority', message: 'priority must be high (P0), normal (P1), low (P2) or lowest (P3).' },
89
89
  invalid_due: { field: 'due', message: 'due is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
90
90
  invalid_start: { field: 'start', message: 'start is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
@@ -373,7 +373,7 @@ describe("requirements stay on the hub", () => {
373
373
  expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
374
374
  const listed = await api(ownerToken, "/api/requirements");
375
375
  expect(listed.body.capabilities).toContain("due_datetime");
376
- expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event"]);
376
+ expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"]);
377
377
  });
378
378
 
379
379
  test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
@@ -125,8 +125,8 @@ describe("view=summary", () => {
125
125
  const one = await send(admin.token, "GET", `/api/requirements/${card.id}?network_id=${NET}`);
126
126
  expect(one.body.requirement.description).toBe(f.description);
127
127
  // 比的是 summary 省掉的那部分(正文 / 子任务):两边一样多的 last_event(#506,每行约 150 B)不算,
128
- // 否则这张只有几行的小表上,固定开销会把比例拉过一半。
129
- const bare = (body: any) => JSON.stringify({ ...body, requirements: body.requirements.map(({ last_event: _l, ...r }: any) => r) }).length;
128
+ // 否则这张只有几行的小表上,固定开销会把比例拉过一半。capabilities 同理:两边一样、每加一个能力就长一截。
129
+ const bare = (body: any) => JSON.stringify({ ...body, capabilities: [], requirements: body.requirements.map(({ last_event: _l, ...r }: any) => r) }).length;
130
130
  expect(summary.body.requirements.every((r: any) => "last_event" in r)).toBe(true);
131
131
  expect(bare(summary.body)).toBeLessThan(bare(full.body) / 2);
132
132
  });
@@ -119,6 +119,40 @@ export function migrateRequirementPriorityCheck(database: DbAdapter): { rebuilt:
119
119
  return { rebuilt: true };
120
120
  }
121
121
 
122
+ /**
123
+ * 状态加 abandoned(废弃)。同 migrateRequirementPriorityCheck:旧库的 CHECK(column_name IN ('pool', 'doing', 'done'))
124
+ * SQLite 不能就地改,只重建一次;PostgreSQL 换掉默认名的列约束。已经放开的库什么都不做。存量值不改。
125
+ */
126
+ const OLD_COLUMN_CHECK = /CHECK\s*\(\s*column_name\s+IN\s*\(\s*'pool'\s*,\s*'doing'\s*,\s*'done'\s*\)\s*\)/i;
127
+ export const COLUMN_CHECK = "CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned'))";
128
+ export function migrateRequirementColumnCheck(database: DbAdapter): { rebuilt: boolean } {
129
+ if (database.dialect === "postgres") {
130
+ // 只在约束还不认识 abandoned(或不存在)时动;一条 ALTER TABLE 里先删后加 = 一个语句,原子,
131
+ // 失败(比如存量里有不合法的值)整条回滚、旧约束原样留着,并且直接抛出 —— 不吞错,启动就该停下来。
132
+ const def = database.get<{ def: string }>(
133
+ "SELECT pg_get_constraintdef(c.oid) AS def FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid WHERE t.relname = 'requirements' AND c.conname = 'requirements_column_name_check'",
134
+ )?.def ?? "";
135
+ if (def.includes("'abandoned'")) return { rebuilt: false };
136
+ database.exec(`ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check, ADD CONSTRAINT requirements_column_name_check ${COLUMN_CHECK}`);
137
+ return { rebuilt: true };
138
+ }
139
+ const table = database.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'");
140
+ if (!table?.sql || !OLD_COLUMN_CHECK.test(table.sql)) return { rebuilt: false };
141
+ const indexes = database.all<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'requirements' AND sql IS NOT NULL");
142
+ const createSql = table.sql
143
+ .replace(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?requirements["`]?/i, "CREATE TABLE requirements_migrated")
144
+ .replace(OLD_COLUMN_CHECK, COLUMN_CHECK);
145
+ database.transaction(() => {
146
+ database.exec("DROP TABLE IF EXISTS requirements_migrated");
147
+ database.exec(createSql);
148
+ database.exec("INSERT INTO requirements_migrated SELECT * FROM requirements");
149
+ database.exec("DROP TABLE requirements");
150
+ database.exec("ALTER TABLE requirements_migrated RENAME TO requirements");
151
+ for (const index of indexes) database.exec(index.sql);
152
+ });
153
+ return { rebuilt: true };
154
+ }
155
+
122
156
  /**
123
157
  * 任务短号 seq:每个网络自己的 #1、#2…,界面显示和按 #N 查都用它。主键 requirement_id 不变。
124
158
  * - 加可空列 seq;没有号的旧行按 created_at(再按 requirement_id)补号,接在该网络已有的最大号后面。
@@ -68,7 +68,7 @@ describe("P3 lowest priority", () => {
68
68
  test("startup rebuilds the legacy CHECK once and keeps every row and index", async () => {
69
69
  const sql = db.get("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'").sql as string;
70
70
  expect(sql).toContain("'lowest'");
71
- expect(sql).toContain("CHECK(column_name IN ('pool', 'doing', 'done'))");
71
+ expect(sql).toContain("CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned'))");
72
72
  const rows = db.all("SELECT requirement_id, network_id, title, priority, client_id, description, tags_json FROM requirements WHERE network_id = 'net_legacy' ORDER BY requirement_id") as any[];
73
73
  expect(rows.map(r => [r.requirement_id, r.priority])).toEqual([["r_high", "high"], ["r_low", "low"], ["r_normal", "normal"]]);
74
74
  for (const r of rows) {
@@ -93,7 +93,7 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
93
93
  const sparkDays = new Set(days.slice(-STATS_SPARK_DAYS));
94
94
  const daily = new Map(days.map(d => [d, 0]));
95
95
 
96
- let done = 0, approx = 0, created = 0, createdDone = 0, doing = 0, pool = 0, unattributed = 0;
96
+ let done = 0, approx = 0, created = 0, createdDone = 0, doing = 0, pool = 0, abandoned = 0, unattributed = 0;
97
97
  const byProject = new Map<string | null, number>();
98
98
  const byCompleter = new Map<string, { ref: Ref; n: number; spark: Map<string, number> }>();
99
99
  const recent: { row: StatsRow; ms: number; by: Ref | null }[] = [];
@@ -102,6 +102,7 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
102
102
  // 当前还开着的卡(不看时间范围;归档的不算「进行中」)。
103
103
  if (!row.archived && row.column_name === "doing") doing++;
104
104
  if (!row.archived && row.column_name === "pool") pool++;
105
+ if (!row.archived && row.column_name === "abandoned") abandoned++;
105
106
  const completedMs = row.column_name === "done" ? msOf(row.completed_at) : null;
106
107
  const createdMs = msOf(row.created_at);
107
108
  if (inRange(createdMs)) {
@@ -143,6 +144,8 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
143
144
  completion_rate: created ? createdDone / created : null,
144
145
  doing,
145
146
  pool,
147
+ // 废弃(关闭,不算开着也不算完成)。旧 App 忽略这个字段。
148
+ abandoned,
146
149
  },
147
150
  daily: days.map(date => ({ date, n: daily.get(date) ?? 0 })),
148
151
  by_project: [...byProject].map(([project_id, n]) => ({ project_id, n })).sort((a, b) => b.n - a.n || String(a.project_id).localeCompare(String(b.project_id))),
@@ -20,11 +20,12 @@ import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-noti
20
20
  import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
21
21
  import { dueReminderTimezone, dueWithinSql, ensureDueReminders, overdueSql } from "./requirement-due-reminders.js";
22
22
  import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
23
- import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
23
+ import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementColumnCheck, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
24
24
 
25
25
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
26
26
  // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
27
27
  migrateRequirementPriorityCheck(db);
28
+ migrateRequirementColumnCheck(db);
28
29
  migrateRequirementAgentOwners(db);
29
30
  ensureRequirementProjects(db);
30
31
  ensureRequirementIndexes(db);
@@ -52,7 +53,36 @@ export type RequirementsRequestContext = {
52
53
  strictOwner?: boolean;
53
54
  };
54
55
 
55
- const COLUMNS = new Set(["pool", "doing", "done"]);
56
+ const COLUMNS = new Set(["pool", "doing", "done", "abandoned"]);
57
+ /**
58
+ * 废弃(abandoned):和 done 一样是关闭态 —— 不算开着、不算逾期、不发到期提醒,不记完成时间;能改回 pool / doing。
59
+ * 旧客户端(desktop ≤ 0.2.220)只认 pool / doing / done,不认识的值一律当成 pool(会把废弃的卡摆回需求池、还标逾期)。
60
+ * 所以客户端没声明认识它时,响应里把 abandoned 投影成 done;声明方式二选一:
61
+ * 请求头 `X-Anet-Accept-Columns: abandoned`,或查询参数 `accept_columns=abandoned`。
62
+ * MCP(tools.ts)总是声明。文档:docs-site/docs/api/mcp-tools.md「需求池 / 任务看板」的 column 字段。
63
+ */
64
+ const ACCEPT_COLUMNS_HEADER = "x-anet-accept-columns";
65
+ function knowsAbandoned(ctx: RequirementsRequestContext): boolean {
66
+ const raw = `${ctx.req.headers.get(ACCEPT_COLUMNS_HEADER) ?? ""},${ctx.url.searchParams.get("accept_columns") ?? ""}`;
67
+ return raw.split(",").some(v => v.trim() === "abandoned");
68
+ }
69
+ /**
70
+ * 动态的旧客户端视图:column 改动的 old / new、created 的 new.column 都投影;投影后前后一样(done ↔ abandoned)= null(不回)。
71
+ */
72
+ function projectEvent<T extends { kind: string; field: string | null; old: unknown; new: unknown }>(ev: T, aware: boolean): T | null {
73
+ if (aware) return ev;
74
+ if (ev.kind === "created" && ev.new && typeof ev.new === "object" && typeof (ev.new as any).column === "string") {
75
+ return { ...ev, new: { ...(ev.new as object), column: projectColumn((ev.new as any).column, false) } };
76
+ }
77
+ if (ev.field !== "column") return ev;
78
+ const old = typeof ev.old === "string" ? projectColumn(ev.old, false) : ev.old;
79
+ const neu = typeof ev.new === "string" ? projectColumn(ev.new, false) : ev.new;
80
+ return old === neu ? null : { ...ev, old, new: neu };
81
+ }
82
+ /** 旧客户端看到的状态:abandoned → done,其余原样。 */
83
+ export function projectColumn(column: string, aware: boolean): string {
84
+ return column === "abandoned" && !aware ? "done" : column;
85
+ }
56
86
  // 界面上 P0–P3:high=P0 最高、normal=P1 普通、low=P2 低、lowest=P3 极低。存的值不变,旧客户端照常读写前三个。
57
87
  const PRIORITIES = new Set(["high", "normal", "low", "lowest"]);
58
88
  const DUE = /^\d{4}-\d{2}-\d{2}$/;
@@ -73,6 +103,7 @@ type Row = {
73
103
  parent_id: string | null;
74
104
  children_total: number | null;
75
105
  children_done: number | null;
106
+ children_abandoned?: number | null;
76
107
  participants_json: string;
77
108
  requirement_id: string;
78
109
  network_id: string;
@@ -511,7 +542,8 @@ function toPublic(row: Row) {
511
542
  updated_by: row.updated_by_json ? JSON.parse(row.updated_by_json) : null,
512
543
  // 子需求:parent_id(可空)和父卡上的子需求进度(未归档的子需求数 / 其中完成的)。
513
544
  parent_id: row.parent_id || null,
514
- children: { total: Number(row.children_total ?? 0), done: Number(row.children_done ?? 0) },
545
+ // 废弃的子需求不算进度(total 里减掉);旧客户端眼里它是 done,见 toPublicFor。
546
+ children: { total: Number(row.children_total ?? 0) - Number(row.children_abandoned ?? 0), done: Number(row.children_done ?? 0) },
515
547
  // 完成时间:进「完成」列的时刻,移出清空(不在「完成」列 = null)。completedAtApprox = 升级前就完成的卡,
516
548
  // 时刻是按 updated_at 补的近似值。completedBy = 谁移进「完成」的(近似值的卡为 null)。旧 App 忽略这三个字段。
517
549
  completedAt: row.completed_at || null,
@@ -563,7 +595,14 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
563
595
  if (!perms) permsByCtx.set(ctx, perms = taskPermissionsResolver(taskCaller(ctx)));
564
596
  // viewer_can:只对「只看相关任务」的调用者出现,客户端据此画只读锁、藏删除;不出现 = 与今天一样全能(旧 Hub 也不出现)。
565
597
  const can = perms(row);
566
- const pub = can ? { ...toPublic(row), viewer_can: can } : toPublic(row);
598
+ const base = toPublic(row);
599
+ if (!knowsAbandoned(ctx)) {
600
+ base.column = projectColumn(base.column, false);
601
+ // 旧客户端把废弃的子需求当 done 列出来:进度也按「算进总数、算作完成」给,和它看到的子需求列表对得上。
602
+ const abandoned = Number(row.children_abandoned ?? 0);
603
+ base.children = { total: base.children.total + abandoned, done: base.children.done + abandoned };
604
+ }
605
+ const pub = can ? { ...base, viewer_can: can } : base;
567
606
  const hidden = hiddenNodeFilter(ctx, row.network_id);
568
607
  if (!hidden) return pub;
569
608
  return {
@@ -579,6 +618,7 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
579
618
 
580
619
  const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, start_on, seq, completed_at, completed_by_json, completed_at_approx, " +
581
620
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
621
+ "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'abandoned') AS children_abandoned, " +
582
622
  "(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";
583
623
 
584
624
  type PersonRef = { kind: 'user' | 'node'; id: string };
@@ -730,7 +770,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
730
770
  // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
731
771
  // (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
732
772
  // events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
733
- export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event"] as const;
773
+ export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
734
774
 
735
775
  // ── 子需求 ──
736
776
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -1017,7 +1057,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
1017
1057
  archived = body.archived ? 1 : 0;
1018
1058
  }
1019
1059
  const updatedAt = new Date().toISOString();
1020
- const column = has("column") ? String(body.column) : row.column_name;
1060
+ // 旧客户端眼里废弃的卡是 done:它回传 column=done 不是「改成完成」,保持废弃。
1061
+ const column = !has("column") ? row.column_name
1062
+ : row.column_name === "abandoned" && body.column === "done" && !knowsAbandoned(ctx) ? "abandoned"
1063
+ : String(body.column);
1021
1064
  const actor = JSON.stringify(actorOf(ctx));
1022
1065
  // 完成时间:移进「完成」= 此刻 + 这次的操作者;移出 = 清空;留在「完成」(done → done、改别的字段)= 原样不动。
1023
1066
  const completed = column !== "done" ? { at: null, by: null, approx: 0 }
@@ -1083,7 +1126,9 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
1083
1126
  const status = q.get("status");
1084
1127
  if (status !== null) {
1085
1128
  if (!COLUMNS.has(status)) return jsonError("invalid_status", 400);
1086
- sql += ` AND column_name = ?${params.push(status)}`;
1129
+ // 旧客户端把废弃的卡看成 done:它按 done 筛时也要带上废弃的,否则同一张卡在列表里有、筛出来没有。
1130
+ if (status === "done" && ctx && !knowsAbandoned(ctx)) sql += " AND column_name IN ('done', 'abandoned')";
1131
+ else sql += ` AND column_name = ?${params.push(status)}`;
1087
1132
  }
1088
1133
  const project = q.get("project_id");
1089
1134
  if (project !== null) sql += project === "none" ? " AND project_id IS NULL" : ` AND project_id = ?${params.push(project)}`;
@@ -1319,7 +1364,9 @@ function lastEventsFor(ctx: RequirementsRequestContext, rows: readonly Row[]): M
1319
1364
  ...ids,
1320
1365
  );
1321
1366
  if (!events.length) return out;
1322
- const pubs = events.map(eventPublic);
1367
+ const aware = knowsAbandoned(ctx);
1368
+ // 投影后成了 done → done 的那条:当作没有可展示的字段(同下面隐去节点的处理)。
1369
+ const pubs = events.map(e => { const ev = eventPublic(e); return projectEvent(ev, aware) ?? { ...ev, field: null, old: null, new: null }; });
1323
1370
  const userIds = new Set<string>(), nodeIds = new Set<string>();
1324
1371
  for (const ev of pubs) if (isRef(ev.actor)) (ev.actor.kind === "user" ? userIds : nodeIds).add(ev.actor.id);
1325
1372
  // 名字规则同 GET /api/requirements/people 的 name(没设显示名 → 用户名 / alias / node_name)。
@@ -1384,6 +1431,7 @@ function listEvents(ctx: RequirementsRequestContext): Response {
1384
1431
  const serverTime = new Date().toISOString();
1385
1432
  const caller = taskCaller(ctx);
1386
1433
  const hidden = hiddenNodeFilter(ctx, networkId);
1434
+ const aware = knowsAbandoned(ctx);
1387
1435
  const seen = new Map<string, boolean>();
1388
1436
  const canSee = (id: string): boolean => {
1389
1437
  if (!caller) return true;
@@ -1399,7 +1447,8 @@ function listEvents(ctx: RequirementsRequestContext): Response {
1399
1447
  };
1400
1448
  const shown = (row: EventRow) => {
1401
1449
  if (!canSee(row.requirement_id)) return null;
1402
- const ev = eventPublic(row);
1450
+ const ev = projectEvent(eventPublic(row), aware);
1451
+ if (!ev) return null;
1403
1452
  if (!hidden) return ev;
1404
1453
  const mask = (ref: unknown) => (isHiddenRef(ref, hidden) ? null : ref);
1405
1454
  const masked = { ...ev, actor: mask(ev.actor) };
@@ -1479,7 +1528,7 @@ function listCacheAcl(ctx: RequirementsRequestContext): string | null {
1479
1528
 
1480
1529
  function listCacheKey(ctx: RequirementsRequestContext): string {
1481
1530
  const sorted = [...ctx.url.searchParams.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
1482
- return JSON.stringify({ u: ctx.auth?.userId ?? null, n: ctx.auth?.networkId ?? null, a: ctx.isAdmin, t: ctx.isNodeToken, s: ctx.scope, q: sorted });
1531
+ return JSON.stringify({ u: ctx.auth?.userId ?? null, n: ctx.auth?.networkId ?? null, a: ctx.isAdmin, t: ctx.isNodeToken, s: ctx.scope, q: sorted, c: knowsAbandoned(ctx) });
1483
1532
  }
1484
1533
 
1485
1534
  /** Test-only. */
@@ -58,7 +58,7 @@ const GOLDEN_RESPONSE_KEYS = {
58
58
  "status", "content", "result", "in_reply_to", "requires_response", "scope",
59
59
  "created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at",
60
60
  "completed_at", "expires_at", "network_id", "parent_task_id", "meta_json",
61
- "thread_id", "turn_id",
61
+ "thread_id", "turn_id", "late_replies",
62
62
  ],
63
63
  audit: [
64
64
  "id", "user_id", "username", "action", "target_type", "target_id", "detail",
@@ -49,7 +49,7 @@ export const COMPLETION_REST_COLUMNS = [
49
49
  // `schedule_json` are intentionally selected for server-side auth/decoding,
50
50
  // then removed by decodeRow before the REST response is serialized.
51
51
  export const SCHEDULED_TASK_STORAGE_COLUMNS = [
52
- "schedule_id", "network_id", "created_by", "name", "target_node_id",
52
+ "schedule_id", "network_id", "created_by", "created_by_node_id", "name", "target_node_id",
53
53
  "target_alias", "task_content", "priority", "schedule_type", "schedule_json",
54
54
  "timezone", "overlap_policy", "misfire_policy", "status", "next_run_at",
55
55
  "last_run_at", "revision", "created_at", "updated_at",