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

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,263 @@
1
+ /**
2
+ * RFC-039 S5 — offline copy of a Hub's SQLite database into an EMPTY
3
+ * PostgreSQL database.
4
+ *
5
+ * commhub-server migrate-to-pg --from <sqlite file> --to <postgres url>
6
+ * [--dry-run] [--i-know-this-is-a-copy]
7
+ *
8
+ * Contract:
9
+ * - offline: no running process may have the source (or its -wal/-shm)
10
+ * open (checked through /proc on Linux). A stopped Hub normally leaves a
11
+ * -wal behind — that is expected, and its contents are included: the
12
+ * source is opened read-only and read through a `VACUUM INTO` snapshot in
13
+ * a private temp dir.
14
+ * - never the live Hub's database: a source whose real path is under
15
+ * ~/.commhub is refused unless --i-know-this-is-a-copy is given.
16
+ * - empty target only: the target's `public` schema must hold no tables,
17
+ * views, sequences or functions. The schema is then built by the Hub's
18
+ * own module graph (no second copy of the DDL).
19
+ * - one transaction: every table is copied and verified — row counts plus
20
+ * a SHA-256 over canonicalised rows — before COMMIT; any mismatch or
21
+ * error rolls everything back.
22
+ * - --dry-run does all of the above, rolls back, and drops what it created,
23
+ * leaving the target as it found it.
24
+ * - the target URL is printed with credentials removed.
25
+ */
26
+ import { Database } from "bun:sqlite";
27
+ import { createHash } from "node:crypto";
28
+ import { existsSync, mkdtempSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join, resolve, sep } from "node:path";
31
+ import { PgAdapter, redactPgUrl, type DbAdapter } from "./db-adapter.js";
32
+
33
+ export type MigrateOptions = {
34
+ from: string;
35
+ to: string;
36
+ dryRun?: boolean;
37
+ allowCommhubCopy?: boolean;
38
+ home?: string;
39
+ log?: (line: string) => void;
40
+ };
41
+
42
+ export type TableReport = { table: string; rows: number; sha256: string };
43
+ export type MigrateReport = { ok: true; dryRun: boolean; tables: TableReport[] };
44
+
45
+ class DryRunRollback extends Error {}
46
+
47
+ /** Refusals are thrown as MigrateRefused so the CLI can print them without a stack. */
48
+ export class MigrateRefused extends Error {}
49
+
50
+ function refuse(message: string): never {
51
+ throw new MigrateRefused(`[migrate-to-pg] REFUSING: ${message}`);
52
+ }
53
+
54
+ const quoteIdent = (name: string) => `"${name.replace(/"/g, '""')}"`;
55
+
56
+ /** One canonical text per value, identical for bun:sqlite and PgAdapter output. */
57
+ function canonical(value: unknown): unknown {
58
+ if (value === null || value === undefined) return null;
59
+ if (typeof value === "bigint") return `i:${value.toString()}`;
60
+ if (typeof value === "number") return Number.isInteger(value) ? `i:${value}` : `f:${value}`;
61
+ if (value instanceof Uint8Array) return `b:${Buffer.from(value).toString("hex")}`;
62
+ if (typeof value === "string" && /^-?\d+$/.test(value) && value.length > 15) return `i:${value}`; // unsafe int8 comes back as text
63
+ return `s:${String(value)}`;
64
+ }
65
+
66
+ /** Order-independent digest: hash each row, sort the row hashes, hash the list. */
67
+ function tableDigest(rows: Iterable<Record<string, unknown>>, cols: string[]): { rows: number; sha256: string } {
68
+ const rowHashes: string[] = [];
69
+ for (const row of rows) {
70
+ rowHashes.push(createHash("sha256").update(JSON.stringify(cols.map((c) => canonical(row[c])))).digest("hex"));
71
+ }
72
+ rowHashes.sort();
73
+ return { rows: rowHashes.length, sha256: createHash("sha256").update(rowHashes.join("\n")).digest("hex") };
74
+ }
75
+
76
+ function bindValue(value: unknown): unknown {
77
+ if (typeof value === "bigint") {
78
+ const n = Number(value);
79
+ return Number.isSafeInteger(n) ? n : value.toString();
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function checkSource(from: string, home: string, allowCommhubCopy: boolean): string {
85
+ const abs = resolve(from);
86
+ if (!existsSync(abs)) refuse(`source ${abs} does not exist`);
87
+ const real = realpathSync(abs);
88
+ if (!statSync(real).isFile()) refuse(`source ${real} is not a file`);
89
+ const commhubDir = join(home, ".commhub");
90
+ const guarded = existsSync(commhubDir) ? realpathSync(commhubDir) : resolve(commhubDir);
91
+ if ((real === guarded || real.startsWith(guarded + sep)) && !allowCommhubCopy) {
92
+ refuse(`source ${real} is under ${guarded}, the live Hub's directory. Stop the Hub, copy the database elsewhere, ` +
93
+ "and migrate the copy — or pass --i-know-this-is-a-copy if this file already is one");
94
+ }
95
+ const holders = processesHolding([real, `${real}-wal`, `${real}-shm`]);
96
+ if (holders === null) {
97
+ console.warn("[migrate-to-pg] cannot inspect open files on this platform — make sure the Hub using this database is stopped");
98
+ } else if (holders.length) {
99
+ refuse(`${real} is open in running process(es) ${holders.join(", ")} — stop the Hub first`);
100
+ }
101
+ return real;
102
+ }
103
+
104
+ /** PIDs (other than ours) with any of `paths` open; null where /proc is unavailable. */
105
+ function processesHolding(paths: string[]): number[] | null {
106
+ if (!existsSync("/proc/self/fd")) return null;
107
+ const wanted = new Set(paths);
108
+ const holders: number[] = [];
109
+ for (const entry of readdirSync("/proc")) {
110
+ const pid = Number(entry);
111
+ if (!Number.isInteger(pid) || pid === process.pid) continue;
112
+ let fds: string[];
113
+ try { fds = readdirSync(`/proc/${pid}/fd`); } catch { continue; } // gone, or another user's
114
+ for (const fd of fds) {
115
+ let target = "";
116
+ try { target = readlinkSync(`/proc/${pid}/fd/${fd}`); } catch { continue; }
117
+ if (wanted.has(target.replace(/ \(deleted\)$/, ""))) { holders.push(pid); break; }
118
+ }
119
+ }
120
+ return holders;
121
+ }
122
+
123
+ function publicObjectCount(pg: PgAdapter): number {
124
+ const row = pg.get<{ n: number }>(
125
+ `SELECT (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public')
126
+ + (SELECT COUNT(*) FROM information_schema.sequences WHERE sequence_schema = 'public')
127
+ + (SELECT COUNT(*) FROM information_schema.routines WHERE routine_schema = 'public') AS n`,
128
+ );
129
+ return Number(row?.n ?? 0);
130
+ }
131
+
132
+ function dropEverythingInPublic(pg: DbAdapter) {
133
+ // Only ever called on a target that was verified empty before we started,
134
+ // so everything here was created by this run.
135
+ pg.exec(`DO $$ DECLARE r record; BEGIN
136
+ FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
137
+ EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
138
+ END LOOP;
139
+ FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public' LOOP
140
+ EXECUTE 'DROP FUNCTION IF EXISTS ' || r.sig || ' CASCADE';
141
+ END LOOP;
142
+ FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
143
+ EXECUTE 'DROP SEQUENCE IF EXISTS public.' || quote_ident(r.sequencename) || ' CASCADE';
144
+ END LOOP;
145
+ END $$`);
146
+ }
147
+
148
+ export async function migrateSqliteToPg(opts: MigrateOptions): Promise<MigrateReport> {
149
+ const log = opts.log ?? ((line: string) => console.log(line));
150
+ const home = opts.home ?? process.env.HOME ?? "";
151
+ if (!/^postgres(ql)?:\/\//.test(opts.to)) refuse("--to must be a postgres:// or postgresql:// URL");
152
+ const source = checkSource(opts.from, home, !!opts.allowCommhubCopy);
153
+ log(`[migrate-to-pg] source ${source}`);
154
+ log(`[migrate-to-pg] target ${redactPgUrl(opts.to)}${opts.dryRun ? " (dry run)" : ""}`);
155
+
156
+ // Empty-target check on a connection of our own, before the schema exists.
157
+ const probe = new PgAdapter(opts.to);
158
+ const existing = publicObjectCount(probe);
159
+ probe.close();
160
+ if (existing > 0) refuse(`target schema "public" is not empty (${existing} tables/sequences/functions). Migrate only into a new, empty database`);
161
+
162
+ // Snapshot the source: a consistent read that cannot touch the original.
163
+ const snapDir = mkdtempSync(join(tmpdir(), "anet-migrate-"));
164
+ const snapshot = join(snapDir, "snapshot.db");
165
+ try {
166
+ const original = new Database(source, { readonly: true });
167
+ original.exec(`VACUUM INTO '${snapshot.replace(/'/g, "''")}'`);
168
+ original.close();
169
+ const src = new Database(snapshot, { readonly: true, safeIntegers: true });
170
+
171
+ // Build the target schema exactly as the Hub does: import its module graph
172
+ // with DATABASE_URL pointing at the target.
173
+ delete process.env.COMMHUB_DB;
174
+ process.env.DATABASE_URL = opts.to;
175
+ await import("./server.js");
176
+ const { db } = await import("./db.js");
177
+ if (db.dialect !== "postgres") throw new Error("schema build did not select PostgreSQL");
178
+
179
+ const tables = (src.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as { name: string }[])
180
+ .map((r) => r.name);
181
+ const report: TableReport[] = [];
182
+
183
+ try {
184
+ db.transaction(() => {
185
+ for (const table of tables) {
186
+ const srcCols = (src.query(`PRAGMA table_info(${quoteIdent(table)})`).all() as { name: string }[]).map((c) => c.name);
187
+ const tgtCols = new Set(db.all<{ column_name: string }>(
188
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?1", table,
189
+ ).map((c) => c.column_name));
190
+ const srcCount = Number((src.query(`SELECT COUNT(*) AS n FROM ${quoteIdent(table)}`).get() as { n: bigint | number }).n);
191
+ if (tgtCols.size === 0) {
192
+ if (srcCount > 0) throw new Error(`table ${table} (${srcCount} rows) does not exist in the Hub's PostgreSQL schema`);
193
+ log(`[migrate-to-pg] skip ${table}: not in the PostgreSQL schema, and empty in the source`);
194
+ continue;
195
+ }
196
+ const missing = srcCols.filter((c) => !tgtCols.has(c));
197
+ if (missing.length && srcCount > 0) throw new Error(`table ${table}: column(s) ${missing.join(", ")} do not exist in PostgreSQL`);
198
+ const cols = srcCols.filter((c) => tgtCols.has(c));
199
+ // SQLite's implicit rowid carries insertion order; PostgreSQL keeps it
200
+ // in a real `rowid` column on the tables that sort by it (RFC-039 F3).
201
+ const copyRowid = tgtCols.has("rowid") && !srcCols.includes("rowid");
202
+ const selectCols = [...(copyRowid ? ["rowid AS rowid"] : []), ...cols.map(quoteIdent)].join(", ");
203
+ const insertCols = [...(copyRowid ? ["rowid"] : []), ...cols];
204
+
205
+ // Triggers are off only while this table is being filled. Re-enabled
206
+ // in `finally` so no path — including a failed batch — leaves them
207
+ // off; a rollback also restores them (DDL is transactional in PG).
208
+ db.exec(`ALTER TABLE ${quoteIdent(table)} DISABLE TRIGGER USER`);
209
+ try {
210
+ db.run(`DELETE FROM ${quoteIdent(table)}`); // rows the schema bootstrap seeded; the source is authoritative
211
+ const perBatch = Math.max(1, Math.floor(30000 / insertCols.length));
212
+ const stmt = src.query(`SELECT ${selectCols} FROM ${quoteIdent(table)} ORDER BY rowid LIMIT ?1 OFFSET ?2`);
213
+ for (let offset = 0; offset < srcCount; offset += perBatch) {
214
+ const rows = stmt.all(perBatch, offset) as Record<string, unknown>[];
215
+ if (!rows.length) break;
216
+ const params: unknown[] = [];
217
+ const tuples = rows.map((row) => `(${insertCols.map((c) => { params.push(bindValue(row[c])); return `?${params.length}`; }).join(", ")})`);
218
+ db.run(`INSERT INTO ${quoteIdent(table)} (${insertCols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")}`, params);
219
+ }
220
+ } finally {
221
+ db.exec(`ALTER TABLE ${quoteIdent(table)} ENABLE TRIGGER USER`);
222
+ }
223
+
224
+ // Sequences behind BIGSERIAL columns continue after the copied ids.
225
+ for (const seq of db.all<{ column_name: string }>(
226
+ "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?1 AND column_default LIKE 'nextval(%'", table,
227
+ )) {
228
+ db.get(`SELECT setval(pg_get_serial_sequence(?1, ?2), COALESCE((SELECT MAX(${quoteIdent(seq.column_name)}) FROM ${quoteIdent(table)}), 0) + 1, false)`,
229
+ table, seq.column_name);
230
+ }
231
+
232
+ // Verify before COMMIT.
233
+ const hashCols = copyRowid ? ["rowid", ...cols] : cols;
234
+ const srcDigest = tableDigest(src.query(`SELECT ${selectCols} FROM ${quoteIdent(table)}`).all() as Record<string, unknown>[], hashCols);
235
+ const tgtDigest = tableDigest(db.all(`SELECT ${hashCols.map(quoteIdent).join(", ")} FROM ${quoteIdent(table)}`), hashCols);
236
+ if (srcDigest.rows !== tgtDigest.rows || srcDigest.sha256 !== tgtDigest.sha256) {
237
+ throw new Error(`verification failed for ${table}: source ${srcDigest.rows} rows ${srcDigest.sha256.slice(0, 12)}, ` +
238
+ `target ${tgtDigest.rows} rows ${tgtDigest.sha256.slice(0, 12)}`);
239
+ }
240
+ report.push({ table, rows: srcDigest.rows, sha256: srcDigest.sha256 });
241
+ log(`[migrate-to-pg] ${table}: ${srcDigest.rows} rows, sha256 ${srcDigest.sha256.slice(0, 16)} ✓`);
242
+ }
243
+ if (opts.dryRun) throw new DryRunRollback("dry run");
244
+ });
245
+ } catch (e) {
246
+ const rows = report.reduce((n, t) => n + t.rows, 0);
247
+ dropEverythingInPublic(db);
248
+ src.close();
249
+ if (!(e instanceof DryRunRollback)) {
250
+ const verify = e instanceof Error && e.message.startsWith("verification failed") ? "MISMATCH" : "NOT-REACHED";
251
+ log(`[migrate-to-pg] SUMMARY result=FAILED tables_verified=${report.length}/${tables.length} rows=${rows} verify=${verify} — rolled back, target left empty`);
252
+ throw e;
253
+ }
254
+ log(`[migrate-to-pg] SUMMARY result=DRY-RUN tables=${report.length} rows=${rows} verify=OK — rolled back, target left empty`);
255
+ return { ok: true, dryRun: true, tables: report };
256
+ }
257
+ src.close();
258
+ log(`[migrate-to-pg] SUMMARY result=COMMITTED tables=${report.length} rows=${report.reduce((n, t) => n + t.rows, 0)} verify=OK`);
259
+ return { ok: true, dryRun: false, tables: report };
260
+ } finally {
261
+ rmSync(snapDir, { recursive: true, force: true });
262
+ }
263
+ }
package/src/push.ts CHANGED
@@ -158,6 +158,10 @@ function closeClient(client: SSEClient, reason: string): void {
158
158
  // we're done with it.
159
159
  }
160
160
  console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
161
+ if (isUserStreamClient(client)) {
162
+ touchUserSeen(client.subscriberUserId!);
163
+ if (!isUserOnline(client.subscriberUserId!)) notifyPresence(client.subscriberUserId!, false);
164
+ }
161
165
  }
162
166
 
163
167
  /** Best-effort enqueue with backpressure guard. Returns:
@@ -308,8 +312,11 @@ function createStreamForKey(
308
312
  ...(opts.eventFilter ? { eventFilter: opts.eventFilter } : {}),
309
313
  };
310
314
 
315
+ const presenceUser = isUserStreamClient(client) ? client.subscriberUserId! : null;
316
+ const wasOnline = presenceUser ? isUserOnline(presenceUser) : true;
311
317
  if (!clients.has(key)) clients.set(key, []);
312
318
  clients.get(key)!.push(client);
319
+ if (presenceUser) touchUserSeen(presenceUser);
313
320
  console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
314
321
 
315
322
  // Send initial connected frame through the backpressure guard so
@@ -328,6 +335,7 @@ function createStreamForKey(
328
335
  return;
329
336
  }
330
337
  const result = tryEnqueueBytes(client, encoder.encode(`: keepalive\n\n`));
338
+ if (result === "ok" && isUserStreamClient(client)) touchUserSeen(client.subscriberUserId!);
331
339
  if (result === "dead") {
332
340
  // closeClient already cleared the timer; just make sure we
333
341
  // also prune from the map.
@@ -337,6 +345,8 @@ function createStreamForKey(
337
345
  (client.keepaliveTimer as any)?.unref?.();
338
346
 
339
347
  ensureLivenessSweep();
348
+ // 在线状态的广播放在 connected 帧之后:订阅方先拿到自己的 connected,再看到别人的变化。
349
+ if (presenceUser && !wasOnline) notifyPresence(presenceUser, true);
340
350
  },
341
351
  cancel() {
342
352
  // Consumer side hangup — straightforward path, mark closed and
@@ -523,6 +533,75 @@ export function pushUserEvent(
523
533
  if (needPrune) pruneClosed(key);
524
534
  }
525
535
 
536
+ // ── 人类成员在线状态(人员列表的在线点)──────────────────────────────────
537
+ // 判据就是上面这张用户流(/events/users/me)注册表:此刻有活着的用户流 = 在线。按**用户**算,
538
+ // 不按网络 —— 桌面端同一时刻只连当前网络的用户流,按网络算会把正在别的网络里的人画成离线。
539
+ // last_seen 只在内存:连上、每次 keepalive 成功、断开时刷新。Hub 重启后清空(未知 = null,
540
+ // 调用方不要把 null 画成「很久以前」)。
541
+
542
+ const userLastSeen = new Map<string, number>();
543
+ let presenceListener: ((userId: string, online: boolean, lastSeenAt: string | null) => void) | null = null;
544
+
545
+ function isUserStreamClient(client: SSEClient): boolean {
546
+ return !!client.subscriberUserId && client.key.startsWith(USER_KEY_PREFIX);
547
+ }
548
+
549
+ function touchUserSeen(userId: string): void {
550
+ userLastSeen.set(userId, Date.now());
551
+ }
552
+
553
+ function isUserOnline(userId: string): boolean {
554
+ for (const [key, arr] of clients) {
555
+ if (!key.startsWith(USER_KEY_PREFIX)) continue;
556
+ if (arr.some((c) => !c.closed && c.subscriberUserId === userId)) return true;
557
+ }
558
+ return false;
559
+ }
560
+
561
+ function lastSeenIso(userId: string): string | null {
562
+ const ms = userLastSeen.get(userId);
563
+ return ms === undefined ? null : new Date(ms).toISOString();
564
+ }
565
+
566
+ function notifyPresence(userId: string, online: boolean): void {
567
+ if (!presenceListener) return;
568
+ try {
569
+ presenceListener(userId, online, lastSeenIso(userId));
570
+ } catch (e: any) {
571
+ console.log(`[${ts()}] presence listener failed: ${e?.message || e}`);
572
+ }
573
+ }
574
+
575
+ /** 用户的第一条用户流连上 / 最后一条断开时回调(server.ts 用它向同网络成员推 member_presence)。 */
576
+ export function onUserPresenceChange(cb: ((userId: string, online: boolean, lastSeenAt: string | null) => void) | null): void {
577
+ presenceListener = cb;
578
+ }
579
+
580
+ /** 一批用户此刻的在线状态。鉴权由调用方负责。 */
581
+ export function getUserPresence(userIds: readonly string[]): Map<string, { online: boolean; last_seen_at: string | null }> {
582
+ const online = new Set<string>();
583
+ for (const [key, arr] of clients) {
584
+ if (!key.startsWith(USER_KEY_PREFIX)) continue;
585
+ for (const c of arr) if (!c.closed && c.subscriberUserId) online.add(c.subscriberUserId);
586
+ }
587
+ const out = new Map<string, { online: boolean; last_seen_at: string | null }>();
588
+ for (const uid of userIds) out.set(uid, { online: online.has(uid), last_seen_at: lastSeenIso(uid) });
589
+ return out;
590
+ }
591
+
592
+ /** 推给某网络里所有连着用户流的人(可排除一个)。鉴权由调用方负责。 */
593
+ export function pushUserEventToNetwork(networkId: string, event: Record<string, unknown>, exceptUserId?: string): number {
594
+ const prefix = `${USER_KEY_PREFIX}${networkId}:`;
595
+ const recipients: string[] = [];
596
+ for (const key of clients.keys()) {
597
+ if (!key.startsWith(prefix)) continue;
598
+ const uid = key.slice(prefix.length);
599
+ if (uid && uid !== exceptUserId) recipients.push(uid);
600
+ }
601
+ for (const uid of recipients) pushUserEvent(networkId, uid, event);
602
+ return recipients.length;
603
+ }
604
+
526
605
  /**
527
606
  * 多用户 Agent 权限:某用户在某网络的授权 / 成员资格变了,断开他在这个网络里**订阅时带了身份**
528
607
  * 的流(网络观察流、用户流),客户端重连时按新权限重新鉴权。返回断开的条数。
@@ -549,6 +628,7 @@ export function __resetSSEClientsForTest(): void {
549
628
  for (const c of arr) closeClient(c, "reset-for-test");
550
629
  }
551
630
  clients.clear();
631
+ userLastSeen.clear();
552
632
  if (livenessSweepTimer) {
553
633
  clearInterval(livenessSweepTimer);
554
634
  livenessSweepTimer = null;
@@ -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
+ });