@sleep2agi/commhub-server 0.9.0-preview.111 → 0.9.0-preview.113

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.
@@ -48,7 +48,7 @@ test("two-phase binding, daemon-bound pull, controllable map, adopted delete ref
48
48
  expect(await call(handlers("daemon").get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: true, node_id: CHILD, alias: "adoption-child", workdir: "/workspace/project" });
49
49
  expect(await call(handlers().get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: false });
50
50
  expect(await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).toMatchObject({ ok: true });
51
- expect((await call(handlers("daemon").list_my_children)).children).toContainEqual({ child_node_id: CHILD, alias: "adoption-child", lifecycle_state: "active", managed: "adopted" });
51
+ expect((await call(handlers("daemon").list_my_children)).children).toContainEqual({ child_node_id: CHILD, alias: "adoption-child", lifecycle_state: "active", managed: "adopted", binding_request_id: r.request_id });
52
52
  expect(buildControllableMap([], [{ node_id: CHILD, daemon_node_id: DAEMON }]).get(CHILD)).toBe(DAEMON);
53
53
  expect(await call(handlers().delete_node, { child_node_id: CHILD, confirm_alias: "adoption-child", network_id: NET })).toMatchObject({ ok: false, error: "adopted_node_delete_unsupported" });
54
54
  expect(db.get<any>("SELECT lifecycle_state FROM nodes WHERE node_id=?1", CHILD).lifecycle_state).toBe("active");
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { db } from "./db.js";
18
18
  import { canMessageAgent, type AgentRef } from "./agent-access.js";
19
+ import { resolveNodeCaller } from "./create-node.js";
19
20
 
20
21
  export type NodePermissionMode = "normal" | "readonly" | "restricted";
21
22
  export const NODE_PERMISSION_MODES: readonly NodePermissionMode[] = ["normal", "readonly", "restricted"];
@@ -71,23 +72,55 @@ function normalizeMode(raw: unknown): NodePermissionMode {
71
72
  /** 节点令牌 → 节点身份(一次带索引的查询)。不是节点令牌 / 查不到 → null。 */
72
73
  export function nodeIdentity(tokenId: string | null | undefined, networkId: string | null | undefined): NodeIdentity | null {
73
74
  if (!tokenId || !networkId) return null;
74
- const row = db.get<{ user_id: string | null; bound_node_id: string | null; name: string | null; owner_user_id: string | null; permission_mode: string | null; alias: string | null }>(
75
- `SELECT t.user_id, t.bound_node_id, t.name, n.owner_user_id, n.permission_mode, n.alias
76
- FROM api_tokens t LEFT JOIN nodes n ON n.node_id = t.bound_node_id
77
- WHERE t.token_id = ?1 AND t.network_id = ?2`,
75
+ const row = db.get<{ user_id: string | null; name: string | null }>(
76
+ `SELECT user_id, name FROM api_tokens WHERE token_id = ?1 AND network_id = ?2`,
78
77
  tokenId, networkId,
79
78
  );
80
79
  if (!row) return null;
81
- const aliases = new Set<string>();
82
- if (row.alias) aliases.add(row.alias);
83
- if (row.name?.startsWith("node:")) aliases.add(row.name.slice(5));
80
+ const resolved = resolveNodeCaller(tokenId);
81
+ // Revoked or not node-shaped: this lookup is not a second revocation gate.
82
+ if (!resolved.ok && resolved.reason === "not_a_node_token") {
83
+ const aliases = row.name?.startsWith("node:") ? [row.name.slice(5)] : [];
84
+ return {
85
+ networkId,
86
+ nodeId: null,
87
+ logKey: `token:${tokenId}`,
88
+ aliases,
89
+ ownerUserId: row.user_id,
90
+ mode: "normal",
91
+ };
92
+ }
93
+ if (!resolved.ok || resolved.networkId !== networkId) {
94
+ return {
95
+ networkId,
96
+ nodeId: null,
97
+ logKey: `token:${tokenId}`,
98
+ aliases: [],
99
+ ownerUserId: row.user_id,
100
+ mode: "normal",
101
+ };
102
+ }
103
+ if (resolved.kind === "unregistered") {
104
+ return {
105
+ networkId,
106
+ nodeId: null,
107
+ logKey: `token:${tokenId}`,
108
+ aliases: resolved.alias ? [resolved.alias] : [],
109
+ ownerUserId: row.user_id,
110
+ mode: "normal",
111
+ };
112
+ }
113
+ const node = db.get<{ owner_user_id: string | null; permission_mode: string | null }>(
114
+ `SELECT owner_user_id, permission_mode FROM nodes WHERE node_id = ?1 AND network_id = ?2`,
115
+ resolved.nodeId, networkId,
116
+ );
84
117
  return {
85
118
  networkId,
86
- nodeId: row.bound_node_id,
87
- logKey: row.bound_node_id ?? `token:${tokenId}`,
88
- aliases: [...aliases],
89
- ownerUserId: row.owner_user_id || row.user_id || null,
90
- mode: normalizeMode(row.permission_mode),
119
+ nodeId: resolved.nodeId,
120
+ logKey: resolved.nodeId,
121
+ aliases: [resolved.alias],
122
+ ownerUserId: node?.owner_user_id || row.user_id || null,
123
+ mode: normalizeMode(node?.permission_mode),
91
124
  };
92
125
  }
93
126
 
@@ -227,7 +227,7 @@ export function dueReminderFor(due: string, nowMs: number, tz: string, maxOverdu
227
227
  export function overdueSql(params: unknown[], nowMs: number, tz: string): string {
228
228
  const today = localDate(nowMs, tz);
229
229
  const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
230
- return `(due_on IS NOT NULL AND column_name <> 'done' AND ((length(due_on) = 10 AND due_on < ?${params.push(today)}) OR (length(due_on) > 10 AND due_on <= ?${params.push(nowIso)})))`;
230
+ return `(due_on IS NOT NULL AND column_name NOT IN ('done', 'abandoned') AND ((length(due_on) = 10 AND due_on < ?${params.push(today)}) OR (length(due_on) > 10 AND due_on <= ?${params.push(nowIso)})))`;
231
231
  }
232
232
  /** 没逾期、且在 tz 的「今天 + days」那天结束之前到期(0 = 今天内)。 */
233
233
  export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz: string): string {
@@ -235,7 +235,7 @@ export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz:
235
235
  const lastDay = addDays(today, days);
236
236
  const endIso = new Date(localMidnightMs(addDays(lastDay, 1), tz)).toISOString().replace(/\.\d{3}Z$/, "Z");
237
237
  const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
238
- return `(due_on IS NOT NULL AND column_name <> 'done' AND ((length(due_on) = 10 AND due_on >= ?${params.push(today)} AND due_on <= ?${params.push(lastDay)}) OR (length(due_on) > 10 AND due_on > ?${params.push(nowIso)} AND due_on < ?${params.push(endIso)})))`;
238
+ return `(due_on IS NOT NULL AND column_name NOT IN ('done', 'abandoned') AND ((length(due_on) = 10 AND due_on >= ?${params.push(today)} AND due_on <= ?${params.push(lastDay)}) OR (length(due_on) > 10 AND due_on > ?${params.push(nowIso)} AND due_on < ?${params.push(endIso)})))`;
239
239
  }
240
240
 
241
241
  // ── 扫描 ───────────────────────────────────────────────────
@@ -332,7 +332,7 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
332
332
  const rows = db.all<DueRow>(
333
333
  `SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
334
334
  FROM requirements
335
- WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name <> 'done' AND COALESCE(archived, 0) = 0${netFilter}${ownerFilter}`,
335
+ WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name NOT IN ('done', 'abandoned') AND COALESCE(archived, 0) = 0${netFilter}${ownerFilter}`,
336
336
  ...params,
337
337
  );
338
338
  const out: DueReminderSent[] = [];
@@ -12,7 +12,7 @@ import { isParticipant } from "./task-access.js";
12
12
  import { rewriteUnreadNoticeDm, sendNoticeDm } from "./human-dm.js";
13
13
 
14
14
  export const COALESCE_WINDOW_MS = 60_000;
15
- const COLUMN_LABEL: Record<string, string> = { pool: "需求池", doing: "进行中", done: "完成" };
15
+ const COLUMN_LABEL: Record<string, string> = { pool: "需求池", doing: "进行中", done: "完成", abandoned: "废弃" };
16
16
  const MAX_LINES = 20;
17
17
  const MAX_ITEM_CHARS = 40;
18
18
 
@@ -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))),