@sleep2agi/commhub-server 0.9.0-preview.71 → 0.9.0-preview.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db-adapter.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * - datetime('now'[, offset]) → to_char(... AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')
11
11
  * (timestamps stay TEXT on both backends, RFC-039 §4.1)
12
12
  * - BLOB → BYTEA
13
- * - INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
13
+ * - INTEGER PRIMARY KEY AUTOINCREMENT → BIGSERIAL PRIMARY KEY; INTEGER → BIGINT
14
14
  * - ON CONFLICT(col) DO UPDATE SET → ON CONFLICT(col) DO UPDATE SET (same syntax)
15
15
  */
16
16
 
@@ -125,6 +125,9 @@ export function sqliteToPostgres(sql: string): string {
125
125
  });
126
126
  // datetime('now') → UTC text of NOW()
127
127
  s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, pgUtcText("NOW()"));
128
+ // strftime('%Y-%m-%d %H:%M:%f', 'now') → the same text with milliseconds
129
+ s = s.replace(/strftime\s*\(\s*'%Y-%m-%d %H:%M:%f'\s*,\s*'now'\s*\)/gi,
130
+ "to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.MS')");
128
131
  // ── Parameter placeholders ──
129
132
  // ?1, ?2 → $1, $2 (positional params)
130
133
  s = s.replace(/\?(\d+)/g, (_, n) => `$${n}`);
@@ -132,8 +135,11 @@ export function sqliteToPostgres(sql: string): string {
132
135
  let idx = 0;
133
136
  s = s.replace(/\?(?!\d)/g, () => `$${++idx}`);
134
137
  // ── DDL translations ──
135
- // INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
136
- s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "SERIAL PRIMARY KEY");
138
+ // INTEGER PRIMARY KEY AUTOINCREMENT → BIGSERIAL PRIMARY KEY
139
+ s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "BIGSERIAL PRIMARY KEY");
140
+ // INTEGER → BIGINT: SQLite integers are 64-bit, and the Hub stores epoch
141
+ // milliseconds in INTEGER columns (a 32-bit PG integer overflows).
142
+ s = s.replace(/\bINTEGER\b/gi, "BIGINT");
137
143
  // BLOB → BYTEA
138
144
  s = s.replace(/\bBLOB\b/gi, "BYTEA");
139
145
  return s;
@@ -278,13 +284,19 @@ export class PgAdapter implements DbAdapter {
278
284
  return { changes: result.count };
279
285
  }
280
286
 
287
+ /** bun:sqlite also accepts get(sql, [a, b]); a lone array argument is the parameter list. */
288
+ private static bindArgs(params: any[]): any[] | undefined {
289
+ if (params.length === 1 && Array.isArray(params[0])) return params[0];
290
+ return params.length > 0 ? params : undefined;
291
+ }
292
+
281
293
  get<T = any>(sql: string, ...params: any[]): T | null {
282
- const result = this.querySync(sql, params.length > 0 ? params : undefined);
294
+ const result = this.querySync(sql, PgAdapter.bindArgs(params));
283
295
  return (result.rows?.[0] as T) ?? null;
284
296
  }
285
297
 
286
298
  all<T = any>(sql: string, ...params: any[]): T[] {
287
- const result = this.querySync(sql, params.length > 0 ? params : undefined);
299
+ const result = this.querySync(sql, PgAdapter.bindArgs(params));
288
300
  return (result.rows as T[]) ?? [];
289
301
  }
290
302
 
@@ -373,7 +385,54 @@ export function assertSafeTestDatabaseEnv(env: NodeJS.ProcessEnv = process.env):
373
385
  * explicit COMMHUB_DB (or a reviewed PostgreSQL DATABASE_URL), so forgetting
374
386
  * one fails before mkdir/open/write instead of touching the live Hub.
375
387
  */
388
+ /**
389
+ * RFC-039 §9 — the one reviewed way a `bun test` process may use PostgreSQL.
390
+ *
391
+ * The inherited-DATABASE_URL guard stays absolute. A test that means to run
392
+ * on PostgreSQL names a *different* variable, COMMHUB_TEST_PG_URL, which is
393
+ * honoured only under NODE_ENV=test and only for a literal loopback host and
394
+ * a database called `anet_…_test`, so it cannot reach a real Hub database by
395
+ * accident. Error messages never carry credentials.
396
+ */
397
+ export const TEST_PG_URL_ENV = "COMMHUB_TEST_PG_URL";
398
+ const TEST_PG_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
399
+ const TEST_PG_DB_RE = /^anet_[a-z0-9_]*_test$/;
400
+ const TEST_PG_QUERY_KEYS = new Set(["connect_timeout", "sslmode", "application_name"]);
401
+
402
+ /** A PostgreSQL URL with user and password replaced, safe to print. */
403
+ export function redactPgUrl(raw: string): string {
404
+ try {
405
+ const u = new URL(raw);
406
+ if (u.username || u.password) { u.username = "***"; u.password = ""; }
407
+ return u.toString();
408
+ } catch {
409
+ return "<unparseable URL>";
410
+ }
411
+ }
412
+
413
+ export function validateTestPgUrl(raw: string): string {
414
+ const refuse = (why: string) => new Error(
415
+ `[commhub] REFUSING ${TEST_PG_URL_ENV}=${redactPgUrl(raw)}: ${why}.\n` +
416
+ " It is test-only: postgres(ql)://<user>:<password>@127.0.0.1|localhost|[::1][:port]/anet_<name>_test",
417
+ );
418
+ let u: URL;
419
+ try { u = new URL(raw); } catch { throw refuse("not a URL"); }
420
+ if (u.protocol !== "postgres:" && u.protocol !== "postgresql:") throw refuse("scheme must be postgres:// or postgresql://");
421
+ if (!TEST_PG_HOSTS.has(u.hostname)) throw refuse("host must be literally 127.0.0.1, localhost or ::1");
422
+ const dbName = decodeURIComponent(u.pathname.replace(/^\//, ""));
423
+ if (!TEST_PG_DB_RE.test(dbName)) throw refuse("database name must match /^anet_[a-z0-9_]*_test$/");
424
+ for (const key of u.searchParams.keys()) {
425
+ if (!TEST_PG_QUERY_KEYS.has(key)) throw refuse(`query parameter "${key}" is not allowed`);
426
+ }
427
+ return raw;
428
+ }
429
+
376
430
  function resolveDatabaseTargetAfterGuard(env: NodeJS.ProcessEnv): DbTarget {
431
+ const testPgUrl = env[TEST_PG_URL_ENV];
432
+ if (env.NODE_ENV === "test" && testPgUrl) {
433
+ return { kind: "postgres", url: validateTestPgUrl(testPgUrl) };
434
+ }
435
+
377
436
  const dbUrl = env.DATABASE_URL;
378
437
  if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
379
438
  return { kind: "postgres", url: dbUrl };
package/src/db.ts CHANGED
@@ -2007,7 +2007,7 @@ export function syncScheduledRunForTask(taskId: string, expectedNetworkId?: stri
2007
2007
  `SELECT task_id, network_id, status, completed_at
2008
2008
  FROM tasks
2009
2009
  WHERE task_id = ?1
2010
- AND (?2 IS NULL OR network_id = ?2)`,
2010
+ AND (CAST(?2 AS TEXT) IS NULL OR network_id = ?2)`,
2011
2011
  taskId,
2012
2012
  expectedNetworkId ?? null,
2013
2013
  );
@@ -2073,3 +2073,9 @@ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus
2073
2073
  );
2074
2074
  } catch {}
2075
2075
  }
2076
+
2077
+ // RFC-039 F3: queries break created_at ties with SQLite's implicit rowid
2078
+ // (insertion order). PostgreSQL has none, so give these tables a real one.
2079
+ if (db.dialect === "postgres") {
2080
+ for (const table of ["nodes", "user_inbox"]) db.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS rowid BIGSERIAL`);
2081
+ }
@@ -372,7 +372,7 @@ describe("requirements stay on the hub", () => {
372
372
  expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
373
373
  const listed = await api(ownerToken, "/api/requirements");
374
374
  expect(listed.body.capabilities).toContain("due_datetime");
375
- expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date"]);
375
+ 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"]);
376
376
  });
377
377
 
378
378
  test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
@@ -109,6 +109,12 @@ describe("requirements MCP tools", () => {
109
109
  expect((await s.call("requirements_list", { parent_id: id })).requirements.map((r: any) => r.id)).toEqual([child.requirement.id]);
110
110
  expect((await s.call("requirements_list", { top_level: true, include_archived: true })).requirements.some((r: any) => r.id === child.requirement.id)).toBe(false);
111
111
  expect((await s.call("requirements_update", { id, parent_id: child.requirement.id })).error).toBe("parent_cycle");
112
+ // q= / limit / cursor 透传到 GET /api/requirements
113
+ expect((await s.call("requirements_list", { q: "子需求" })).requirements.map((r: any) => r.id)).toEqual([child.requirement.id]);
114
+ const page = await s.call("requirements_list", { limit: 1, include_archived: true });
115
+ expect(page.requirements.length).toBe(1);
116
+ expect(page.has_more).toBe(true);
117
+ expect((await s.call("requirements_list", { limit: 1, include_archived: true, cursor: page.next_cursor })).requirements[0].id).not.toBe(page.requirements[0].id);
112
118
  } finally { await s.close(); }
113
119
  });
114
120
 
@@ -81,3 +81,64 @@ export function migrateRequirementPriorityCheck(database: DbAdapter): { rebuilt:
81
81
  });
82
82
  return { rebuilt: true };
83
83
  }
84
+
85
+ /**
86
+ * 任务短号 seq:每个网络自己的 #1、#2…,界面显示和按 #N 查都用它。主键 requirement_id 不变。
87
+ * - 加可空列 seq;没有号的旧行按 created_at(再按 requirement_id)补号,接在该网络已有的最大号后面。
88
+ * - requirement_seq_counters 记每个网络发到过的最大号:删掉 / 归档的卡,号也不回收。
89
+ * - (network_id, seq) 唯一。重复执行只补还没号的行(回滚到旧 Hub 期间新建的卡)。
90
+ */
91
+ export function ensureRequirementSeq(database: DbAdapter): { backfilled: number } {
92
+ try { database.exec("ALTER TABLE requirements ADD COLUMN seq INTEGER"); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
93
+ database.exec("CREATE TABLE IF NOT EXISTS requirement_seq_counters (network_id TEXT PRIMARY KEY, last_seq INTEGER NOT NULL DEFAULT 0)");
94
+ const pending = database.all<{ requirement_id: string; network_id: string; created_at: string | null }>(
95
+ "SELECT requirement_id, network_id, created_at FROM requirements WHERE seq IS NULL",
96
+ );
97
+ if (pending.length) {
98
+ // datetime('now') 的旧默认值「YYYY-MM-DD HH:MM:SS」是 UTC,和 ISO 混在一起时按字符串排会错位,换成毫秒再排。
99
+ const at = (v: string | null) => {
100
+ const s = v ?? "";
101
+ const ms = Date.parse(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(" ", "T")}Z` : s);
102
+ return Number.isFinite(ms) ? ms : 0;
103
+ };
104
+ pending.sort((a, b) => a.network_id.localeCompare(b.network_id) || at(a.created_at) - at(b.created_at) || (a.requirement_id < b.requirement_id ? -1 : a.requirement_id > b.requirement_id ? 1 : 0));
105
+ database.transaction(() => {
106
+ const next = new Map<string, number>();
107
+ for (const row of pending) {
108
+ if (!next.has(row.network_id)) {
109
+ const max = database.get<{ m: number | null }>("SELECT MAX(seq) AS m FROM requirements WHERE network_id = ?1", row.network_id)?.m ?? 0;
110
+ const counter = database.get<{ last_seq: number }>("SELECT last_seq FROM requirement_seq_counters WHERE network_id = ?1", row.network_id)?.last_seq ?? 0;
111
+ next.set(row.network_id, Math.max(Number(max), Number(counter)));
112
+ }
113
+ const seq = next.get(row.network_id)! + 1;
114
+ next.set(row.network_id, seq);
115
+ database.run("UPDATE requirements SET seq = ?1 WHERE requirement_id = ?2 AND seq IS NULL", [seq, row.requirement_id]);
116
+ }
117
+ });
118
+ }
119
+ // 计数器不低于库里的最大号(补号之后、或计数器表是新建的)。
120
+ for (const row of database.all<{ network_id: string; m: number }>("SELECT network_id, MAX(seq) AS m FROM requirements WHERE seq IS NOT NULL GROUP BY network_id")) {
121
+ database.run(
122
+ `INSERT INTO requirement_seq_counters (network_id, last_seq) VALUES (?1, ?2)
123
+ ON CONFLICT(network_id) DO UPDATE SET last_seq = CASE WHEN excluded.last_seq > requirement_seq_counters.last_seq THEN excluded.last_seq ELSE requirement_seq_counters.last_seq END`,
124
+ [row.network_id, Number(row.m)],
125
+ );
126
+ }
127
+ database.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_network_seq ON requirements(network_id, seq) WHERE seq IS NOT NULL");
128
+ return { backfilled: pending.length };
129
+ }
130
+
131
+ /**
132
+ * 给新卡领下一个号。调用方把它和 INSERT 放在同一个事务里:插入失败(client_id / external_ref 撞了)号也一起回滚。
133
+ * 一条语句完成「读 + 加一 + 写回」,两个并发的新建拿不到同一个号;(network_id, seq) 唯一索引兜底。
134
+ */
135
+ export function nextRequirementSeq(database: DbAdapter, networkId: string): number {
136
+ const row = database.get<{ last_seq: number }>(
137
+ `INSERT INTO requirement_seq_counters (network_id, last_seq)
138
+ VALUES (?1, COALESCE((SELECT MAX(seq) FROM requirements WHERE network_id = ?1), 0) + 1)
139
+ ON CONFLICT(network_id) DO UPDATE SET last_seq = requirement_seq_counters.last_seq + 1
140
+ RETURNING last_seq`,
141
+ networkId,
142
+ );
143
+ return Number(row!.last_seq);
144
+ }
@@ -0,0 +1,182 @@
1
+ // GET /api/requirements 的 q= 服务端搜索与 limit / cursor 分页(requirements-search.ts)。一次性 hub:临时 DB、端口 0。
2
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-search-"));
8
+ process.env.COMMHUB_DB = join(dir, "hub.db");
9
+
10
+ let server: { port: number; stop?: (force?: boolean) => void };
11
+ let base = "";
12
+ let token = "";
13
+ let otherToken = "";
14
+ let net = "";
15
+ let db: any;
16
+
17
+ async function api(tok: string, path: string, init?: RequestInit) {
18
+ const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json", ...(init?.headers || {}) } });
19
+ return { status: res.status, body: await res.json() as any };
20
+ }
21
+ const list = (query: string, tok = token) => api(tok, `/api/requirements?network_id=${net}${query ? `&${query}` : ""}`);
22
+ const ids = async (query: string, tok = token) => {
23
+ const r = await list(query, tok);
24
+ expect(r.status).toBe(200);
25
+ return (r.body.requirements as any[]).map(x => x.name);
26
+ };
27
+
28
+ beforeAll(async () => {
29
+ ({ db } = await import("./db.js"));
30
+ const { register } = await import("./auth.js");
31
+ const owner = register(`search_owner_${Date.now()}`, "SearchOwner123!", undefined, "seed");
32
+ token = owner.token!;
33
+ net = owner.network_id!;
34
+ const other = register(`search_other_${Date.now()}`, "SearchOther123!", undefined, "seed");
35
+ otherToken = other.token!;
36
+ const mod: any = await import("./server.js");
37
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
38
+ base = `http://127.0.0.1:${server.port}`;
39
+ db.run("INSERT INTO nodes(node_id,node_name,alias,display_name,network_id) VALUES (?1,?2,?2,?3,?4)", ["n_search_a", "portal-cow", "示例-门户牛", net]);
40
+ const project = await api(token, "/api/requirements/projects", { method: "POST", body: JSON.stringify({ name: "示例门户项目", network_id: net }) });
41
+ const pid = project.body.project.id;
42
+ const mk = (name: string, extra: Record<string, unknown> = {}) => api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name, network_id: net, ...extra }) });
43
+ await mk("企业组织树权限设置");
44
+ await mk("Portal Token 管理 | 估算成本卡布局", { agent_owner: { kind: "node", id: "n_search_a" }, tags: ["前端"] });
45
+ await mk("Space 网络策略 | 历史 receipt 误报", { description: "看 ![截图](/api/files/abc123) 和 [链接文字](https://example.com/x),重启导致 binding 丢失" });
46
+ await mk("子项目的卡", { project_id: pid });
47
+ const arch = await mk("已归档的组织树旧方案");
48
+ await api(token, `/api/requirements/${arch.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ archived: true }) });
49
+ await api(otherToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "别的网络的组织树" }) });
50
+ }, 30_000);
51
+
52
+ afterAll(() => {
53
+ try { server?.stop?.(true); } catch {}
54
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
55
+ });
56
+
57
+ describe("q= server-side search (same semantics as the app's task search)", () => {
58
+ test("title substring, CJK, case-insensitive, full-width input", async () => {
59
+ expect(await ids("q=" + encodeURIComponent("组织树"))).toEqual(["企业组织树权限设置"]);
60
+ expect(await ids("q=portal")).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
61
+ expect(await ids("q=" + encodeURIComponent("PORTAL"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
62
+ });
63
+ test("agent owner display name, tags, project name, description text (not URLs)", async () => {
64
+ expect(await ids("q=" + encodeURIComponent("门户牛"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
65
+ expect(await ids("q=" + encodeURIComponent("前端"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
66
+ expect(await ids("q=" + encodeURIComponent("示例门户项目"))).toEqual(["子项目的卡"]);
67
+ expect(await ids("q=binding")).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
68
+ expect(await ids("q=" + encodeURIComponent("链接文字"))).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
69
+ expect(await ids("q=abc123")).toEqual([]);
70
+ expect(await ids("q=example.com")).toEqual([]);
71
+ });
72
+ test("space-separated terms are ANDed across fields", async () => {
73
+ expect(await ids("q=" + encodeURIComponent("成本 门户牛"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
74
+ expect(await ids("q=" + encodeURIComponent("成本 receipt"))).toEqual([]);
75
+ });
76
+ test("archived only with archived=true / include_archived=1; other networks never", async () => {
77
+ expect(await ids("q=" + encodeURIComponent("组织树"))).toEqual(["企业组织树权限设置"]);
78
+ expect(await ids("archived=true&q=" + encodeURIComponent("组织树"))).toEqual(["已归档的组织树旧方案"]);
79
+ expect((await ids("include_archived=1&q=" + encodeURIComponent("组织树"))).sort()).toEqual(["企业组织树权限设置", "已归档的组织树旧方案"].sort());
80
+ });
81
+ test("combines with the other filters", async () => {
82
+ expect(await ids("status=done&q=portal")).toEqual([]);
83
+ expect(await ids("agent_owner=node:n_search_a&q=" + encodeURIComponent("成本"))).toEqual(["Portal Token 管理 | 估算成本卡布局"]);
84
+ });
85
+ test("empty / whitespace q = no search; too long q = 400", async () => {
86
+ expect((await ids("q=")).length).toBe(4);
87
+ expect((await ids("q=%20%20")).length).toBe(4);
88
+ const r = await list("q=" + "x".repeat(201));
89
+ expect(r.status).toBe(400);
90
+ expect(r.body.error).toBe("invalid_q");
91
+ });
92
+ });
93
+
94
+ describe("q= also matches task ids: #N / N on seq (#2139), full id and 8+ char prefix", () => {
95
+ const idOf = async (name: string) => (await list("limit=1000&include_archived=1")).body.requirements.find((r: any) => r.name === name).id as string;
96
+ test("full id and id prefix (with or without req_), not shorter prefixes", async () => {
97
+ const id = await idOf("企业组织树权限设置");
98
+ const bare = id.replace(/^req_/, "");
99
+ expect(await ids("q=" + encodeURIComponent(id))).toEqual(["企业组织树权限设置"]);
100
+ expect(await ids("q=" + encodeURIComponent(id.toUpperCase()))).toEqual(["企业组织树权限设置"]);
101
+ expect(await ids("q=" + encodeURIComponent(bare.slice(0, 10)))).toEqual(["企业组织树权限设置"]);
102
+ expect(await ids("q=" + encodeURIComponent(`req_${bare.slice(0, 9)}`))).toEqual(["企业组织树权限设置"]);
103
+ // 7 位以下的前缀不算 ID(否则输入几个字母就搜出一堆)
104
+ expect(await ids("q=" + encodeURIComponent(`req_${bare.slice(0, 5)}`))).toEqual([]);
105
+ });
106
+ test("#N / full-width #N / padded #N match the task's seq (#2139); #N plus text is plain text; filters still apply", async () => {
107
+ const target = await idOf("Space 网络策略 | 历史 receipt 误报");
108
+ const n = db.get("SELECT seq FROM requirements WHERE requirement_id = ?1", target).seq as number;
109
+ expect(typeof n).toBe("number");
110
+ expect(await ids(`q=%23${n}`)).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
111
+ const fullWidth = `#${String(n).replace(/\d/g, d => String.fromCharCode(0xff10 + Number(d)))}`;
112
+ expect(await ids("q=" + encodeURIComponent(fullWidth))).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
113
+ expect(await ids("q=" + encodeURIComponent(` #${n} `))).toEqual(["Space 网络策略 | 历史 receipt 误报"]);
114
+ // 「#N receipt」整句不是 ID,只按文字「且」:没有卡同时含「#N」和「receipt」
115
+ expect(await ids("q=" + encodeURIComponent(`#${n} receipt`))).toEqual([]);
116
+ // ID 命中也受筛选管
117
+ expect(await ids(`status=done&q=%23${n}`)).toEqual([]);
118
+ });
119
+ });
120
+
121
+ describe("paging: limit / cursor / has_more / next_cursor", () => {
122
+ test("no params: legacy shape plus has_more=false, next_cursor=null, capabilities advertise search + paging", async () => {
123
+ const r = await list("");
124
+ expect(r.body.requirements.length).toBe(4);
125
+ expect(r.body.has_more).toBe(false);
126
+ expect(r.body.next_cursor).toBeNull();
127
+ expect(r.body.capabilities).toContain("search");
128
+ expect(r.body.capabilities).toContain("paging");
129
+ });
130
+ test("walking pages returns every row exactly once, newest first, including same-timestamp ties", async () => {
131
+ // 25 张,其中 10 张同一个 created_at(翻页边界落在并列上也不能漏 / 重)
132
+ const same = new Date(Date.now() + 60_000).toISOString();
133
+ for (let i = 0; i < 25; i++) {
134
+ const c = await api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name: `页-${String(i).padStart(2, "0")}`, network_id: net }) });
135
+ if (i >= 10 && i < 20) db.run("UPDATE requirements SET created_at = ?1 WHERE requirement_id = ?2", [same, c.body.requirement.id]);
136
+ }
137
+ const full = await ids("limit=1000");
138
+ expect(full.length).toBe(29);
139
+ const seen: string[] = [];
140
+ let cursor = "";
141
+ let pages = 0;
142
+ for (;;) {
143
+ const r = await list(`limit=7${cursor ? `&cursor=${cursor}` : ""}`);
144
+ expect(r.status).toBe(200);
145
+ seen.push(...r.body.requirements.map((x: any) => x.name));
146
+ pages++;
147
+ if (!r.body.has_more) { expect(r.body.next_cursor).toBeNull(); break; }
148
+ expect(typeof r.body.next_cursor).toBe("string");
149
+ cursor = r.body.next_cursor;
150
+ expect(pages).toBeLessThan(10);
151
+ }
152
+ expect(pages).toBe(5);
153
+ expect(seen).toEqual(full);
154
+ expect(new Set(seen).size).toBe(29);
155
+ });
156
+ test("q= pages too", async () => {
157
+ const first = await list("limit=4&q=" + encodeURIComponent("页-"));
158
+ expect(first.body.requirements.length).toBe(4);
159
+ expect(first.body.has_more).toBe(true);
160
+ const rest = await list(`limit=100&q=${encodeURIComponent("页-")}&cursor=${first.body.next_cursor}`);
161
+ expect(rest.body.requirements.length).toBe(21);
162
+ expect(rest.body.has_more).toBe(false);
163
+ });
164
+ test("default limit stays 500 (legacy clients see the same cap)", async () => {
165
+ const { DEFAULT_LIST_LIMIT } = await import("./requirements-search.js");
166
+ expect(DEFAULT_LIST_LIMIT).toBe(500);
167
+ });
168
+ test("bad limit / cursor → 400", async () => {
169
+ for (const q of ["limit=0", "limit=1001", "limit=abc", "limit=-1", "cursor=%%%", "cursor=bm9wZQ"]) {
170
+ const r = await list(q);
171
+ expect(r.status).toBe(400);
172
+ expect(["invalid_limit", "invalid_cursor"]).toContain(r.body.error);
173
+ }
174
+ });
175
+ test("ETag still works with the new fields", async () => {
176
+ const first = await fetch(`${base}/api/requirements?network_id=${net}&limit=5`, { headers: { Authorization: `Bearer ${token}` } });
177
+ const etag = first.headers.get("etag")!;
178
+ await first.text();
179
+ const again = await fetch(`${base}/api/requirements?network_id=${net}&limit=5`, { headers: { Authorization: `Bearer ${token}`, "If-None-Match": etag } });
180
+ expect(again.status).toBe(304);
181
+ });
182
+ });
@@ -0,0 +1,32 @@
1
+ // requirements-search.ts 的纯函数:任务 ID 匹配(与 App 的 task-short-id.ts matchesTaskId 同一规则)。
2
+ import { describe, expect, test } from "bun:test";
3
+ import { matchesTaskId, parseListQuery, taskIdQuery } from "./requirements-search.js";
4
+
5
+ const row = (id: string, seq: number | null) => ({ requirement_id: id, seq });
6
+
7
+ describe("matchesTaskId", () => {
8
+ test("#N / N match seq exactly (#7 does not hit #70)", () => {
9
+ expect(matchesTaskId(row("req_a", 7), "#7")).toBe(true);
10
+ expect(matchesTaskId(row("req_a", 7), "7")).toBe(true);
11
+ expect(matchesTaskId(row("req_a", 70), "#7")).toBe(false);
12
+ expect(matchesTaskId(row("req_a", null), "#7")).toBe(false);
13
+ });
14
+ test("full id and 8+ char prefix, with or without req_; shorter prefixes do not count", () => {
15
+ const id = "req_0a1b2c3d-4e5f-6789-abcd-ef0123456789";
16
+ expect(matchesTaskId(row(id, 1), id)).toBe(true);
17
+ expect(matchesTaskId(row(id, 1), "0a1b2c3d")).toBe(true);
18
+ expect(matchesTaskId(row(id, 1), "req_0a1b2c3d-4")).toBe(true);
19
+ expect(matchesTaskId(row(id, 1), "0a1b2c3")).toBe(false);
20
+ expect(matchesTaskId(row(id, 1), "")).toBe(false);
21
+ });
22
+ test("taskIdQuery: trim, NFKC (full-width #7 → #7), lowercase", () => {
23
+ expect(taskIdQuery(" #7 ")).toBe("#7");
24
+ expect(taskIdQuery("REQ_0A1B")).toBe("req_0a1b");
25
+ });
26
+ test("parseListQuery: idQuery only when there are terms", () => {
27
+ const q = (s: string) => parseListQuery(new URLSearchParams(s)) as any;
28
+ expect(q("q=%EF%BC%83%EF%BC%97").idQuery).toBe("#7");
29
+ expect(q("q=%20%20").idQuery).toBe("");
30
+ expect(q("").idQuery).toBe("");
31
+ });
32
+ });
@@ -0,0 +1,138 @@
1
+ // GET /api/requirements 的服务端搜索(q=)与分页(limit / cursor)。纯函数,requirements.ts 接线。
2
+ //
3
+ // 为什么要:列表一直是 `ORDER BY created_at DESC LIMIT 500`,App 的任务搜索只在读到的行里搜 ——
4
+ // 生产一个网络已经 ~400 张卡,过了 500 最老的那些就搜不到、也翻不到。
5
+ //
6
+ // 语义与 App 的任务搜索(agent-network-app src/task-search.ts)一致,两边搜同一个词得到同一批卡:
7
+ // · 字段:标题、描述(去掉 markdown 图片 / 链接的地址,链接文字保留)、负责人 / 负责 Agent / 参与人的显示名、
8
+ // 旧 assignee 文本、项目名、标签
9
+ // · NFKC(全角 → 半角)+ 小写;中文按子串;空白隔开的词是「且」,每个词可以落在不同字段,但不跨字段拼
10
+ // · 调用者看不见的节点(受限成员)在卡片上本来就被隐去,它的名字也不参与匹配 —— 否则 q= 就成了
11
+ // 「这张卡是不是某个没授权给我的 Agent 负责」的探测器
12
+ //
13
+ // 分页:按 (created_at DESC, requirement_id DESC) 排,cursor = 上一页最后一行的这两个值(不透明的 base64url)。
14
+ // 不带 limit / cursor / q 的旧请求:同样的 500 行、同样的顺序,响应只多两个字段 has_more / next_cursor。
15
+
16
+ export const DEFAULT_LIST_LIMIT = 500;
17
+ export const MAX_LIST_LIMIT = 1000;
18
+ export const MAX_QUERY_LENGTH = 200;
19
+
20
+ const fold = (s: string): string => s.normalize("NFKC").toLocaleLowerCase();
21
+
22
+ /** 搜索词:归一后按空白切开,去重、去空。 */
23
+ export function searchTerms(q: string): string[] {
24
+ return [...new Set(fold(q).split(/\s+/).filter(Boolean))];
25
+ }
26
+
27
+ /** 描述是 markdown:图片 / 链接的地址不参与搜索,链接文字保留;html 标签去掉。 */
28
+ export function descriptionText(md: string | null | undefined): string {
29
+ if (!md) return "";
30
+ return md
31
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
32
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
33
+ .replace(/<[^>]+>/g, " ");
34
+ }
35
+
36
+ type Ref = { kind?: unknown; id?: unknown } | null | undefined;
37
+ export type SearchableRow = {
38
+ name: string;
39
+ description: string;
40
+ assignee: string;
41
+ tags: readonly string[];
42
+ project_id: string | null;
43
+ owner: Ref;
44
+ agent_owner: Ref;
45
+ participants: readonly Ref[] | unknown;
46
+ };
47
+ export type NameMaps = {
48
+ /** `user:<id>` / `node:<id>` → 显示名(看不见的节点不在里面)。 */
49
+ people: ReadonlyMap<string, string>;
50
+ /** project_id → 项目名。 */
51
+ projects: ReadonlyMap<string, string>;
52
+ };
53
+
54
+ const refName = (ref: Ref, maps: NameMaps): string => {
55
+ if (!ref || typeof ref !== "object" || typeof ref.id !== "string" || (ref.kind !== "user" && ref.kind !== "node")) return "";
56
+ return maps.people.get(`${ref.kind}:${ref.id}`) ?? "";
57
+ };
58
+
59
+ /** 一张卡能被搜到的文字,字段之间用换行隔开(一个词不会跨两个字段拼出来)。 */
60
+ export function searchText(row: SearchableRow, maps: NameMaps): string {
61
+ const participants = Array.isArray(row.participants) ? row.participants as Ref[] : [];
62
+ return fold([
63
+ row.name,
64
+ descriptionText(row.description),
65
+ refName(row.owner, maps),
66
+ refName(row.agent_owner, maps),
67
+ ...participants.map(r => refName(r, maps)),
68
+ row.assignee,
69
+ row.project_id ? maps.projects.get(row.project_id) ?? "" : "",
70
+ ...row.tags,
71
+ ].filter(Boolean).join("\n"));
72
+ }
73
+
74
+ export function matchesTerms(row: SearchableRow, terms: readonly string[], maps: NameMaps): boolean {
75
+ if (!terms.length) return true;
76
+ const hay = searchText(row, maps);
77
+ return terms.every(t => hay.includes(t));
78
+ }
79
+
80
+ export type Cursor = { createdAt: string; id: string };
81
+
82
+ export function encodeCursor(c: Cursor): string {
83
+ return Buffer.from(JSON.stringify([c.createdAt, c.id]), "utf8").toString("base64url");
84
+ }
85
+
86
+ export function decodeCursor(raw: string): Cursor | null {
87
+ if (!raw || raw.length > 600 || !/^[A-Za-z0-9_-]+$/.test(raw)) return null;
88
+ try {
89
+ const v = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
90
+ if (!Array.isArray(v) || v.length !== 2 || typeof v[0] !== "string" || typeof v[1] !== "string" || !v[0] || !v[1]) return null;
91
+ return { createdAt: v[0], id: v[1] };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ export type ListQuery = { limit: number; cursor: Cursor | null; terms: string[]; /** 整句当任务 ID 去对(taskIdQuery);空 = 没有搜索。 */ idQuery: string };
98
+
99
+ /**
100
+ * 任务 ID(同 App 的 task-short-id.ts matchesTaskId):「#42」「42」精确对短号 seq;完整 id,或 8 位以上的
101
+ * id 前缀(带不带 req_ 都行)对主键。整句当一个 ID —— 和文字匹配是「或」,不拆成「ID 且文字」。
102
+ * 全角「#42」先 NFKC 归一(ID 本身都是 ASCII)。
103
+ */
104
+ export const taskIdQuery = (q: string): string => q.trim().normalize("NFKC").toLowerCase();
105
+ export const seqOfQuery = (idQuery: string): number | null => {
106
+ const m = /^#?(\d{1,15})$/.exec(idQuery);
107
+ return m ? Number(m[1]) : null;
108
+ };
109
+ export function matchesTaskId(row: { requirement_id: string; seq?: number | null }, idQuery: string): boolean {
110
+ if (!idQuery) return false;
111
+ const n = seqOfQuery(idQuery);
112
+ if (n !== null) return typeof row.seq === "number" && row.seq === n;
113
+ const id = row.requirement_id.toLowerCase();
114
+ if (idQuery === id) return true;
115
+ const bare = idQuery.replace(/^req_/, "");
116
+ return bare.length >= 8 && id.replace(/^req_/, "").startsWith(bare);
117
+ }
118
+
119
+ /** 解析 limit / cursor / q。不合法 → 错误码(400)。都没带 = 旧行为(500 行、第一页、不搜)。 */
120
+ export function parseListQuery(params: URLSearchParams): ListQuery | { error: string } {
121
+ let limit = DEFAULT_LIST_LIMIT;
122
+ const rawLimit = params.get("limit");
123
+ if (rawLimit !== null) {
124
+ if (!/^\d{1,5}$/.test(rawLimit)) return { error: "invalid_limit" };
125
+ limit = Number(rawLimit);
126
+ if (limit < 1 || limit > MAX_LIST_LIMIT) return { error: "invalid_limit" };
127
+ }
128
+ let cursor: Cursor | null = null;
129
+ const rawCursor = params.get("cursor");
130
+ if (rawCursor !== null && rawCursor !== "") {
131
+ cursor = decodeCursor(rawCursor);
132
+ if (!cursor) return { error: "invalid_cursor" };
133
+ }
134
+ const q = params.get("q") ?? "";
135
+ if (q.length > MAX_QUERY_LENGTH) return { error: "invalid_q" };
136
+ const terms = searchTerms(q);
137
+ return { limit, cursor, terms, idQuery: terms.length ? taskIdQuery(q) : "" };
138
+ }