@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.
@@ -1,11 +1,12 @@
1
1
  // 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
3
  import { createHash } from "node:crypto";
4
+ import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
4
5
  import { db } from "./db.js";
5
6
  import { normalizeTags, storedTags } from "./requirement-tags.js";
6
7
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
7
8
  import { isAgentRestricted, visibleAgents } from "./agent-access.js";
8
- import { ensureRequirementIndexes, ensureRequirementProjects, migrateRequirementAgentOwners, migrateRequirementPriorityCheck } from "./requirements-migrate.js";
9
+ import { ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
9
10
 
10
11
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
11
12
  // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
@@ -13,6 +14,7 @@ migrateRequirementPriorityCheck(db);
13
14
  migrateRequirementAgentOwners(db);
14
15
  ensureRequirementProjects(db);
15
16
  ensureRequirementIndexes(db);
17
+ ensureRequirementSeq(db);
16
18
 
17
19
  type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
18
20
 
@@ -58,6 +60,7 @@ type Row = {
58
60
  issues_json: string | null;
59
61
  tags_json: string | null;
60
62
  created_at: string;
63
+ seq: number | null;
61
64
  };
62
65
 
63
66
  function jsonError(error: string, status: number): Response {
@@ -283,6 +286,8 @@ function toPublic(row: Row) {
283
286
  project_id: row.project_id || null,
284
287
  participants: JSON.parse(row.participants_json || '[]'),
285
288
  id: row.requirement_id,
289
+ // 短号:每个网络自己的 #1、#2…(不回收)。界面显示 #N;GET / PATCH / MCP 的 id 也收「#N」。旧 App 忽略它。
290
+ seq: row.seq == null ? null : Number(row.seq),
286
291
  name: row.title,
287
292
  priority: row.priority,
288
293
  assignee: row.assignee || "",
@@ -357,7 +362,7 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
357
362
  };
358
363
  }
359
364
 
360
- const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, start_on, " +
365
+ const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, start_on, seq, " +
361
366
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
362
367
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
363
368
 
@@ -452,7 +457,8 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
452
457
  return header.split(",").some(tag => tag.trim() === "*" || bare(tag) === bare(etag));
453
458
  }
454
459
 
455
- export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date"] as const;
460
+ // search:GET 认 q=(服务端搜索,语义同 App 的任务搜索);paging:认 limit / cursor,响应带 has_more / next_cursor。
461
+ export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging"] as const;
456
462
 
457
463
  // ── 子需求 ──
458
464
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -602,12 +608,13 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
602
608
  try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
603
609
  const actor = JSON.stringify(actorOf(ctx));
604
610
  try {
605
- db.run(
611
+ // 领号和插入在同一个事务里:插入被唯一索引挡下时,号一起回滚,不留空洞。
612
+ db.transaction(() => db.run(
606
613
  `INSERT INTO requirements
607
- (requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json, start_on)
608
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22, ?23)`,
609
- [id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags), start || null],
610
- );
614
+ (requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json, start_on, seq)
615
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22, ?23, ?24)`,
616
+ [id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags), start || null, nextRequirementSeq(db, networkId)],
617
+ ));
611
618
  } catch {
612
619
  // 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
613
620
  if (ref) {
@@ -726,14 +733,30 @@ function withLegacyOwner(requirement: ReturnType<typeof toPublic>, coerced: Pers
726
733
  return { ok: true, requirement: { ...requirement, owner: coerced }, owner_coerced_to_agent_owner: true };
727
734
  }
728
735
 
729
- function scopedRow(ctx: RequirementsRequestContext, id: string): Row | undefined {
730
- const params: unknown[] = [id];
731
- let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
736
+ /** 「#123」→ 123;别的形状(包括裸数字)→ null,仍按 requirement_id 查。 */
737
+ export function seqFromId(id: string): number | null {
738
+ const m = /^#([1-9]\d{0,14})$/.exec(id.trim());
739
+ return m ? Number(m[1]) : null;
740
+ }
741
+
742
+ /**
743
+ * 按 id 或「#N」找调用者作用域里的那张卡。短号只在一个网络里唯一:作用域跨多个网络、又各有一张 #N 时
744
+ * 不猜,回 409 ambiguous_seq(带 ?network_id= 再查)。
745
+ */
746
+ function scopedRow(ctx: RequirementsRequestContext, id: string): Row | Response {
747
+ const seq = seqFromId(id);
748
+ const params: unknown[] = [seq ?? id];
749
+ let sql = `SELECT ${SELECT} FROM requirements WHERE ${seq === null ? "requirement_id" : "seq"} = ?1`;
732
750
  sql = addHumanNetworkScope(sql, params, ctx.scope);
733
- return db.get<Row>(sql, ...params) ?? undefined;
751
+ const rows = db.all<Row>(`${sql} LIMIT 2`, ...params);
752
+ if (rows.length > 1) {
753
+ const networks = rows.map(r => r.network_id);
754
+ return Response.json({ ok: false, error: "ambiguous_seq", networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
755
+ }
756
+ return rows[0] ?? jsonError("requirement_not_found", 404);
734
757
  }
735
758
 
736
- /** GET 的筛选(同步方 / MCP 用):status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
759
+ /** GET 的筛选(同步方 / MCP 用):seq(短号)、status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
737
760
  function listFilters(url: URL, sql: string, params: unknown[], ctx?: RequirementsRequestContext): string | Response {
738
761
  const q = url.searchParams;
739
762
  const status = q.get("status");
@@ -759,6 +782,11 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
759
782
  if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
760
783
  sql += ` AND updated_at >= ?${params.push(new Date(ms).toISOString())}`;
761
784
  }
785
+ const seq = q.get("seq");
786
+ if (seq !== null) {
787
+ if (!/^[1-9]\d{0,14}$/.test(seq)) return jsonError("invalid_seq", 400);
788
+ sql += ` AND seq = ?${params.push(Number(seq))}`;
789
+ }
762
790
  const ref = q.get("external_ref");
763
791
  if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
764
792
  const parent = q.get("parent_id");
@@ -770,6 +798,55 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
770
798
  return sql;
771
799
  }
772
800
 
801
+ const LIST_ORDER = " ORDER BY created_at DESC, requirement_id DESC";
802
+ type LightRow = { requirement_id: string; seq?: number | null; network_id: string; created_at: string; title: string; description: string | null; assignee: string | null; tags_json: string | null; project_id: string | null; owner_json: string | null; agent_owner_json: string | null; participants_json: string | null };
803
+ const parseRef = (json: string | null) => { try { return json ? JSON.parse(json) : null; } catch { return null; } };
804
+
805
+ /**
806
+ * 列表的一页(from = listFilters 拼好的「FROM requirements WHERE …」)。没有搜索词:SQL 直接 LIMIT。有搜索词:先按同样的筛选 + 顺序读轻量列(不算子需求计数),
807
+ * 在这里逐行匹配,凑够 limit + 1 张就停,再按 id 读这一页的完整行。
808
+ */
809
+ function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unknown[], lq: ListQuery): { rows: Row[]; hasMore: boolean; nextCursor: string | null } {
810
+ const params = [...baseParams];
811
+ let where = from;
812
+ if (lq.cursor) {
813
+ const a = params.push(lq.cursor.createdAt), b = params.push(lq.cursor.id);
814
+ where += ` AND (created_at < ?${a} OR (created_at = ?${a} AND requirement_id < ?${b}))`;
815
+ }
816
+ let rows: Row[];
817
+ if (!lq.terms.length) {
818
+ rows = db.all<Row>(`SELECT ${SELECT} ${where}${LIST_ORDER} LIMIT ${lq.limit + 1}`, ...params);
819
+ } else {
820
+ // seq(任务短号,#2139):启动迁移 ensureRequirementSeq 在 SQLite / PostgreSQL 上都加这一列,SELECT 本来就带它。
821
+ const light = db.all<LightRow>(`SELECT requirement_id, seq, network_id, created_at, title, description, assignee, tags_json, project_id, owner_json, agent_owner_json, participants_json ${where}${LIST_ORDER}`, ...params);
822
+ const maps = new Map<string, NameMaps>();
823
+ const hits: string[] = [];
824
+ for (const r of light) {
825
+ let m = maps.get(r.network_id);
826
+ if (!m) maps.set(r.network_id, m = searchNameMaps(ctx, r.network_id));
827
+ const row = { name: r.title, description: r.description || "", assignee: r.assignee || "", tags: storedTags(r.tags_json), project_id: r.project_id, owner: parseRef(r.owner_json), agent_owner: parseRef(r.agent_owner_json), participants: parseRef(r.participants_json) ?? [] };
828
+ if (matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
829
+ if (hits.length > lq.limit) break;
830
+ }
831
+ const byId = new Map(hits.length ? db.all<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id IN (${hits.map((_, i) => `?${i + 1}`).join(",")})`, ...hits).map(r => [r.requirement_id, r]) : []);
832
+ rows = hits.map(id => byId.get(id)).filter((r): r is Row => !!r);
833
+ }
834
+ const hasMore = rows.length > lq.limit;
835
+ if (hasMore) rows = rows.slice(0, lq.limit);
836
+ const last = rows[rows.length - 1];
837
+ return { rows, hasMore, nextCursor: hasMore && last ? encodeCursor({ createdAt: last.created_at, id: last.requirement_id }) : null };
838
+ }
839
+
840
+ /** 搜索用的名字表(与 GET /api/requirements/people 同一个显示名规则);调用者看不见的节点不放进来。 */
841
+ function searchNameMaps(ctx: RequirementsRequestContext, networkId: string): NameMaps {
842
+ const hidden = hiddenNodeFilter(ctx, networkId);
843
+ const people = new Map<string, string>();
844
+ for (const u of db.all<{ id: string; name: string }>("SELECT u.user_id AS id, COALESCE(NULLIF(u.display_name,''), u.username) AS name FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1", networkId)) people.set(`user:${u.id}`, u.name);
845
+ for (const n of db.all<{ id: string; name: string }>("SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE network_id=?1", networkId)) if (!hidden?.(n.id)) people.set(`node:${n.id}`, n.name);
846
+ const projects = new Map(db.all<{ project_id: string; name: string }>("SELECT project_id, name FROM requirement_projects WHERE network_id=?1", networkId).map(p => [p.project_id, p.name] as const));
847
+ return { people, projects };
848
+ }
849
+
773
850
  /** 这个节点对调用者在他作用域内的某个受限网络里是否隐藏。 */
774
851
  function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string): boolean {
775
852
  const restricted = ctx.scope.agentRestriction?.networkIds ?? [];
@@ -812,13 +889,18 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
812
889
 
813
890
  if (url.pathname === "/api/requirements" && req.method === "GET") {
814
891
  const params: unknown[] = [];
815
- let sql = `SELECT ${SELECT} FROM requirements WHERE 1=1`;
892
+ // 只拼 FROM … WHERE:同一组条件既用来读整行,也用来(搜索时)先读轻量列。
893
+ let sql = "FROM requirements WHERE 1=1";
816
894
  sql = addHumanNetworkScope(sql, params, ctx.scope);
817
895
  const filtered = listFilters(url, sql, params, ctx);
818
896
  if (typeof filtered !== "string") return filtered;
819
- const rows = db.all<Row>(`${filtered} ORDER BY created_at DESC LIMIT 500`, ...params).map(row => toPublicFor(ctx, row));
897
+ // q= / limit / cursor(requirements-search.ts)。都不带 = 旧行为:最新 500 张、同样的顺序。
898
+ const lq = parseListQuery(url.searchParams);
899
+ if ("error" in lq) return jsonError(lq.error, 400);
900
+ const page = listPage(ctx, filtered, params, lq);
820
901
  // capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
821
- return conditionalJson(ctx.req, { ok: true, requirements: rows, capabilities: REQUIREMENT_CAPABILITIES });
902
+ // has_more / next_cursor:后面还有没有(带 cursor=next_cursor 再读一页)。旧客户端不认识,忽略即可。
903
+ return conditionalJson(ctx.req, { ok: true, requirements: page.rows.map(row => toPublicFor(ctx, row)), capabilities: REQUIREMENT_CAPABILITIES, has_more: page.hasMore, next_cursor: page.nextCursor });
822
904
  }
823
905
 
824
906
  if (url.pathname === "/api/requirements" && req.method === "POST") {
@@ -859,7 +941,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
859
941
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
860
942
  if (typeof body.done !== "boolean") return jsonError("invalid_done", 400);
861
943
  const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
862
- if (!row) return jsonError("requirement_not_found", 404);
944
+ if (row instanceof Response) return row;
863
945
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
864
946
  // 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
865
947
  const items = storedChecklist(row.checklist_json);
@@ -877,12 +959,12 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
877
959
  const id = decodeURIComponent(match[1]);
878
960
  if (req.method === "GET") {
879
961
  const row = scopedRow(ctx, id);
880
- return row ? Response.json({ ok: true, requirement: toPublicFor(ctx, row) }) : jsonError("requirement_not_found", 404);
962
+ return row instanceof Response ? row : Response.json({ ok: true, requirement: toPublicFor(ctx, row) });
881
963
  }
882
964
  if (req.method === "DELETE") {
883
965
  // 删除只给人(节点令牌在入口已被拒):Agent 用 archived 归档。
884
966
  const row = scopedRow(ctx, id);
885
- if (!row) return jsonError("requirement_not_found", 404);
967
+ if (row instanceof Response) return row;
886
968
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
887
969
  // 子需求不跟着删:先解挂(变成顶层),再删父卡。
888
970
  db.run("UPDATE requirements SET parent_id = NULL WHERE parent_id = ?1", [row.requirement_id]);
@@ -894,6 +976,6 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
894
976
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
895
977
  if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
896
978
  const row = scopedRow(ctx, id);
897
- if (!row) return jsonError("requirement_not_found", 404);
979
+ if (row instanceof Response) return row;
898
980
  return patchRequirement(ctx, row, body);
899
981
  }
package/src/retention.ts CHANGED
@@ -197,10 +197,13 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
197
197
  // DBs must run a one-time blocking `VACUUM` themselves (out of
198
198
  // scope for the sweeper, which deliberately stays non-
199
199
  // blocking).
200
+ // Both are SQLite PRAGMAs; on PostgreSQL autovacuum does this job and
201
+ // the two counters stay null (RFC-039 S3).
200
202
  let walCheckpointPagesMoved: number | null = null;
201
203
  let incrementalFreedPages: number | null = null;
202
204
  let errored = false;
203
- try {
205
+ const sqliteVacuum = db.dialect === "sqlite";
206
+ if (sqliteVacuum) try {
204
207
  // wal_checkpoint(TRUNCATE) returns a row { busy, log, checkpointed }.
205
208
  // We expose `log` (page count moved into the main DB) for ops visibility.
206
209
  const checkpoint = db.get<{ busy: number; log: number; checkpointed: number }>(
@@ -211,7 +214,7 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
211
214
  console.log(`[commhub retention] wal_checkpoint failed: ${e?.message ?? e}`);
212
215
  errored = true;
213
216
  }
214
- try {
217
+ if (sqliteVacuum) try {
215
218
  // incremental_vacuum is a no-op on databases not created with
216
219
  // PRAGMA auto_vacuum = INCREMENTAL. For existing prod DBs that
217
220
  // weren't, the operator would have to run a full one-time VACUUM
@@ -8,8 +8,8 @@ import { db } from "./db.js";
8
8
  import { assertScheduledTaskBackendSupported, nextOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
9
9
 
10
10
  const dir = mkdtempSync(join(tmpdir(), "anet-scheduler-"));
11
- const activeDbPath = process.env.COMMHUB_DB;
12
- if (!activeDbPath) throw new Error("test601 requires COMMHUB_DB before module import");
11
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
12
+ if (!activeDbPath) throw new Error("test601 requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
13
13
  let server: any;
14
14
  let base = "";
15
15
  let ownerToken = "";
@@ -128,8 +128,13 @@ describe("Hub scheduled task API and dispatcher", () => {
128
128
  (db as any).atomicTransactions = original;
129
129
  }
130
130
  expect(() => assertScheduledTaskBackendSupported()).not.toThrow();
131
- const misfireColumn = db.all<any>("PRAGMA table_info(scheduled_tasks)").find((column: any) => column.name === "misfire_policy");
132
- expect(misfireColumn?.dflt_value).toBe("'catch_up_once'");
131
+ if (db.dialect === "sqlite") {
132
+ const misfireColumn = db.all<any>("PRAGMA table_info(scheduled_tasks)").find((column: any) => column.name === "misfire_policy");
133
+ expect(misfireColumn?.dflt_value).toBe("'catch_up_once'");
134
+ } else {
135
+ const misfireColumn = db.get<any>("SELECT column_default FROM information_schema.columns WHERE table_name = 'scheduled_tasks' AND column_name = 'misfire_policy'");
136
+ expect(misfireColumn?.column_default).toBe("'catch_up_once'::text");
137
+ }
133
138
  });
134
139
 
135
140
  test("owner creates; viewer and node token cannot mutate", async () => {
@@ -283,10 +288,17 @@ describe("Hub scheduled task API and dispatcher", () => {
283
288
  // Test-only fault injection at the second durable write. The inbox row has
284
289
  // already been attempted when this aborts, so any missing transaction
285
290
  // boundary leaves an observable half-state.
286
- db.run(`CREATE TRIGGER test601_abort_scheduled_task_insert
287
- BEFORE INSERT ON tasks
288
- WHEN NEW.meta_json LIKE '%${atomicScheduleId}%'
289
- BEGIN SELECT RAISE(ABORT, 'test601_injected_task_failure'); END`);
291
+ if (db.dialect === "sqlite") {
292
+ db.run(`CREATE TRIGGER test601_abort_scheduled_task_insert
293
+ BEFORE INSERT ON tasks
294
+ WHEN NEW.meta_json LIKE '%${atomicScheduleId}%'
295
+ BEGIN SELECT RAISE(ABORT, 'test601_injected_task_failure'); END`);
296
+ } else {
297
+ db.exec(`CREATE FUNCTION test601_abort_scheduled_task_insert() RETURNS trigger AS $$
298
+ BEGIN RAISE EXCEPTION 'test601_injected_task_failure'; END; $$ LANGUAGE plpgsql;
299
+ CREATE TRIGGER test601_abort_scheduled_task_insert BEFORE INSERT ON tasks FOR EACH ROW
300
+ WHEN (NEW.meta_json LIKE '%${atomicScheduleId}%') EXECUTE FUNCTION test601_abort_scheduled_task_insert()`);
301
+ }
290
302
  const failed = runDueScheduledTasks();
291
303
  expect(failed.failed).toBe(1);
292
304
  expect(db.get("SELECT run_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeNull();
@@ -294,7 +306,8 @@ describe("Hub scheduled task API and dispatcher", () => {
294
306
  expect(db.get("SELECT task_id FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", atomicScheduleId)).toBeNull();
295
307
  expect(db.get<{ next_run_at: string }>("SELECT next_run_at FROM scheduled_tasks WHERE schedule_id = ?1", atomicScheduleId)!.next_run_at).toBe(due);
296
308
 
297
- db.run("DROP TRIGGER test601_abort_scheduled_task_insert");
309
+ if (db.dialect === "sqlite") db.run("DROP TRIGGER test601_abort_scheduled_task_insert");
310
+ else db.exec("DROP TRIGGER test601_abort_scheduled_task_insert ON tasks; DROP FUNCTION test601_abort_scheduled_task_insert()");
298
311
  expect(runDueScheduledTasks().processed).toBe(1);
299
312
  expect(db.get("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeTruthy();
300
313
  });
package/src/server.ts CHANGED
@@ -10,7 +10,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
10
10
  import { z } from "zod/v4";
11
11
  import { registerTools } from "./tools.js";
12
12
  import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
13
- import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork } from "./push.js";
13
+ import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
14
14
  import { assertNodeActive } from "./lifecycle-guard.js";
15
15
  import { pendingInboxCount } from "./inbox-count.js";
16
16
  import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
@@ -238,6 +238,16 @@ function isTmuxAllowedIP(ip: string): boolean {
238
238
  function isNodeCredential(resolved: { networkId: string | null } | null | undefined): boolean {
239
239
  return !!resolved?.networkId;
240
240
  }
241
+ // 人员列表的在线点:某人的第一条用户流连上 / 最后一条断开时,推给他所在每个网络里其他连着用户流的成员。
242
+ // 收件人本来就是该网络成员(用户流入口按成员资格鉴权),与 GET /api/networks/:id/humans 同一可见范围。
243
+ // 字段叫 member_user_id:pushUserEvent 会把 user_id 写成**收件人**自己。
244
+ onUserPresenceChange((userId, online, lastSeenAt) => {
245
+ const nets = db.all<{ network_id: string }>("SELECT network_id FROM network_members WHERE user_id = ?1", userId);
246
+ for (const { network_id } of nets) {
247
+ pushUserEventToNetwork(network_id, { type: "member_presence", member_user_id: userId, online, last_seen_at: lastSeenAt }, userId);
248
+ }
249
+ });
250
+
241
251
  function isHubAdminCredential(resolved: { networkId: string | null; user: { role: string } } | null | undefined): boolean {
242
252
  return !!resolved && !resolved.networkId && resolved.user.role === "admin";
243
253
  }
@@ -1606,7 +1616,11 @@ return Bun.serve({
1606
1616
  if (resolved.user.role !== "admin" && !getUserNetworkRole(resolved.user.user_id, netId)) {
1607
1617
  return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1608
1618
  }
1609
- return withCors(req, Response.json({ ok: true, network_id: netId, humans: getNetworkHumans(netId) }));
1619
+ const humans = getNetworkHumans(netId);
1620
+ // 在线状态(online / last_seen_at)只给用户令牌:节点令牌拿到的仍是原来的身份字段,不多得任何东西。
1621
+ if (isNodeCredential(resolved)) return withCors(req, Response.json({ ok: true, network_id: netId, humans }));
1622
+ const presence = getUserPresence(humans.map((h) => h.user_id));
1623
+ return withCors(req, Response.json({ ok: true, network_id: netId, humans: humans.map((h) => ({ ...h, ...presence.get(h.user_id)! })) }));
1610
1624
  }
1611
1625
 
1612
1626
  // ── V3.13: Network members + invites ──
@@ -3459,7 +3473,7 @@ return Bun.serve({
3459
3473
  db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
3460
3474
  if (node.alias) {
3461
3475
  db.run(
3462
- "DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (?2 IS NULL AND network_id IS NULL))",
3476
+ "DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (CAST(?2 AS TEXT) IS NULL AND network_id IS NULL))",
3463
3477
  [node.alias, node.network_id ?? null]
3464
3478
  );
3465
3479
  }
@@ -1,12 +1,22 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
- import { SQLiteAdapter } from "./db-adapter";
3
+ import { PgAdapter, SQLiteAdapter, resolveDatabaseTarget, type DbAdapter } from "./db-adapter";
4
4
  import { DurableSideThreadCommandPort, SideThreadCommandStore, handleSideThreadCommandRequest } from "./side-thread-command-transport";
5
5
  import { SideThreadCoordinator, SideThreadStore } from "./side-thread";
6
6
 
7
7
  function actor(tokenId = "tok-node") { return { tokenId, networkId: "net-1", nodeId: "node-1" }; }
8
+ // RFC-039 S4: with COMMHUB_TEST_PG_URL (validated by the adapter guard) the
9
+ // same tests run on PostgreSQL, each fixture on an emptied schema.
10
+ function freshDb(): DbAdapter {
11
+ if (!process.env.COMMHUB_TEST_PG_URL) return new SQLiteAdapter(new Database(":memory:"));
12
+ const target = resolveDatabaseTarget();
13
+ if (target.kind !== "postgres") throw new Error("COMMHUB_TEST_PG_URL did not select PostgreSQL");
14
+ const pg = new PgAdapter(target.url);
15
+ pg.exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public");
16
+ return pg;
17
+ }
8
18
  function fixture() {
9
- const db = new SQLiteAdapter(new Database(":memory:"));
19
+ const db = freshDb();
10
20
  db.exec("CREATE TABLE inbox(id TEXT PRIMARY KEY, content TEXT)");
11
21
  const store = new SideThreadCommandStore(db, () => 123);
12
22
  const port = new DurableSideThreadCommandPort({ store, networkForNode: (id) => id === "node-1" ? "net-1" : null,
@@ -450,6 +450,10 @@ export function installSideThreadSchema(database: DbAdapter): void {
450
450
  if (!/duplicate column|already exists/i.test(error?.message ?? ""))
451
451
  throw error;
452
452
  }
453
+ // RFC-039 F3: operations are listed in insertion order via SQLite's
454
+ // implicit rowid; PostgreSQL needs a real column for that.
455
+ if (database.dialect === "postgres")
456
+ database.exec("ALTER TABLE side_chat_operations ADD COLUMN IF NOT EXISTS rowid BIGSERIAL");
453
457
  }
454
458
 
455
459
  type SideChatRow = {
@@ -84,6 +84,22 @@ describe("SkillHub real Hub + SQLite", () => {
84
84
  expect(detail.created_by_user).toBeUndefined(); expect(detail.reviewed_by_user).toBeUndefined(); expect(detail.content_hash).toBeUndefined();
85
85
  });
86
86
 
87
+ test("query matches regardless of letter case (SQLite LIKE semantics, kept on PostgreSQL)", async () => {
88
+ for (const query of ["incident", "INCIDENT", "Handoff"]) {
89
+ const found = await tool(nodeToken, "list_skills", { network_id: networkId, query });
90
+ expect(found.skills.map((x: any) => x.skill_id)).toContain(skillId);
91
+ }
92
+ expect((await tool(nodeToken, "list_skills", { network_id: networkId, query: "no-such-skill" })).skills).toHaveLength(0);
93
+ });
94
+
95
+ test("% and _ in the query are literal characters, not wildcards (#2141)", async () => {
96
+ const ids = async (query: string) => (await tool(nodeToken, "list_skills", { network_id: networkId, query })).skills.map((x: any) => x.skill_id);
97
+ expect(await ids("_")).not.toContain(skillId); // was: matched everything
98
+ expect(await ids("%")).not.toContain(skillId);
99
+ expect(await ids("incident_handoff")).not.toContain(skillId); // `_` must not stand for the "-" in the slug
100
+ expect(await ids("incident-handoff")).toContain(skillId);
101
+ });
102
+
87
103
  test("foreign user cannot read or review another network", async () => {
88
104
  expect((await tool(foreignToken, "list_skills", { network_id: networkId })).ok).toBe(false);
89
105
  const review = await tool(foreignToken, "review_skill", { network_id: networkId, skill_id: skillId, decision: "rejected" });
@@ -0,0 +1,78 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { SQLiteAdapter } from "./db-adapter";
8
+
9
+ // #2151 — a cleanly stopped Hub must leave a complete .db: the -wal is absent
10
+ // or empty, and a copy of the .db file alone still has the data.
11
+ const dir = mkdtempSync(join(tmpdir(), "anet-wal-close-"));
12
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
13
+
14
+ const walBytes = (db: string) => (existsSync(`${db}-wal`) ? statSync(`${db}-wal`).size : 0);
15
+
16
+ function copyOnlyDb(db: string): Database {
17
+ const copy = join(dirname(db), `copy-of-${db.split("/").pop()}`);
18
+ copyFileSync(db, copy); // deliberately without -wal / -shm
19
+ return new Database(copy, { readonly: true });
20
+ }
21
+
22
+ describe("#2151 SQLite WAL is checkpointed on close", () => {
23
+ test("SQLiteAdapter.close() after >20 distinct statements leaves a complete .db", () => {
24
+ const path = join(dir, "adapter.db");
25
+ const raw = new Database(path);
26
+ raw.exec("PRAGMA journal_mode=WAL");
27
+ const db = new SQLiteAdapter(raw);
28
+ db.exec("CREATE TABLE t (x TEXT)");
29
+ db.run("INSERT INTO t VALUES (?1)", ["kept"]);
30
+ // bun:sqlite stops being able to close after more than 20 distinct query() strings.
31
+ for (let i = 0; i < 30; i++) db.get(`SELECT ${i} AS v`);
32
+ db.close();
33
+ expect(walBytes(path)).toBe(0);
34
+ const copy = copyOnlyDb(path);
35
+ expect(copy.query("SELECT x FROM t").all()).toEqual([{ x: "kept" }]);
36
+ copy.close();
37
+ });
38
+
39
+ test("a real Hub that served traffic and got SIGTERM leaves a complete .db", async () => {
40
+ const bin = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
41
+ const home = join(dir, "home");
42
+ const path = join(dir, "hub.db");
43
+ const port = String(27000 + Math.floor(Math.random() * 2000));
44
+ const base = `http://127.0.0.1:${port}`;
45
+ const hub = Bun.spawn(["bun", bin, "--port", port, "--host", "127.0.0.1", "--db", path], {
46
+ env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
47
+ });
48
+ try {
49
+ let up = false;
50
+ for (let i = 0; i < 150 && !up; i++) {
51
+ up = await fetch(`${base}/health`).then((r) => r.ok, () => false);
52
+ if (!up) await Bun.sleep(100);
53
+ }
54
+ expect(up).toBe(true);
55
+ const post = (p: string, body: unknown, token?: string) => fetch(`${base}${p}`, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
58
+ body: JSON.stringify(body),
59
+ }).then((r) => r.json() as Promise<any>);
60
+ const reg = await post("/api/auth/register", { username: "walcheck", password: "StrongPassw0rd" });
61
+ expect(reg.ok).toBe(true);
62
+ const login = await post("/api/auth/login", { username: "walcheck", password: "StrongPassw0rd" });
63
+ const net = await post("/api/networks", { name: "wal-net" }, login.token);
64
+ expect(net.ok).toBe(true);
65
+ const ntok = await post("/api/auth/node-token", { network_id: net.network_id, node_name: "wal-agent" }, login.token);
66
+ expect(ntok.ok).toBe(true);
67
+ expect(walBytes(path)).toBeGreaterThan(0); // the writes really are in the WAL before the stop
68
+ } finally {
69
+ hub.kill("SIGTERM");
70
+ await hub.exited;
71
+ }
72
+ expect(walBytes(path)).toBe(0);
73
+ const copy = copyOnlyDb(path);
74
+ expect(copy.query("SELECT COUNT(*) AS n FROM users WHERE username = 'walcheck'").get()).toEqual({ n: 1 });
75
+ expect(copy.query("SELECT COUNT(*) AS n FROM networks WHERE network_name = 'wal-net'").get()).toEqual({ n: 1 });
76
+ copy.close();
77
+ }, 40_000);
78
+ });
package/src/tools.ts CHANGED
@@ -357,8 +357,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
357
357
  FROM skillhub_skills WHERE network_id = ?1`;
358
358
  if (!showPending) sql += ` AND status = 'published'`;
359
359
  if (query?.trim()) {
360
- params.push(`%${query.trim()}%`);
361
- sql += ` AND (slug LIKE ?${params.length} OR name LIKE ?${params.length} OR description LIKE ?${params.length})`;
360
+ params.push(`%${query.trim().replace(/[\\%_]/g, (c) => `\\${c}`)}%`); // #2141: the query is text, not a pattern
361
+ const like = db.dialect === "postgres" ? "ILIKE" : "LIKE"; // RFC-039 F6: SQLite LIKE ignores ASCII case; keep that on PG
362
+ sql += ` AND (slug ${like} ?${params.length} ESCAPE '\\' OR name ${like} ?${params.length} ESCAPE '\\' OR description ${like} ?${params.length} ESCAPE '\\')`;
362
363
  }
363
364
  sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
364
365
  return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
@@ -5780,12 +5781,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5780
5781
  };
5781
5782
  const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
5782
5783
  const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
5784
+ const REQ_ID_DESC = "requirement id (req_…) or its short number \"#N\" (e.g. \"#42\", per network; a user token that sees several networks must also pass network_id, else 409 ambiguous_seq)";
5783
5785
 
5784
5786
  server.tool(
5785
5787
  "requirements_list",
5786
- "List requirement tasks in your network. Filters: status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived. Newest first, max 500. Each task carries children {total, done}.",
5788
+ "List requirement tasks in your network. Filters: seq (the per-network short number shown as #N), status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived; q = search title / description / owner, agent and participant names / project name / tags (space-separated terms are ANDed). Newest first, 500 per page by default (limit ≤ 1000); has_more + next_cursor → pass cursor for the next page. Each task carries children {total, done}.",
5787
5789
  {
5788
5790
  network_id: z.string().max(200).optional(),
5791
+ seq: z.number().int().positive().optional().describe("short number (#N) of one task in this network"),
5789
5792
  status: z.enum(["pool", "doing", "done"]).optional(),
5790
5793
  project_id: z.string().max(200).optional(),
5791
5794
  owner: z.string().max(210).optional(),
@@ -5794,11 +5797,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5794
5797
  external_ref: z.string().max(200).optional(),
5795
5798
  include_archived: z.boolean().optional(),
5796
5799
  parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5797
- top_level: z.boolean().optional(),
5800
+ top_level: z.boolean().optional(), q: z.string().max(200).optional(), limit: z.number().int().min(1).max(1000).optional(), cursor: z.string().max(600).optional(),
5798
5801
  },
5799
5802
  async (args) => {
5800
5803
  const q = new URLSearchParams();
5801
- for (const k of ["status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5804
+ for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id", "q", "limit", "cursor"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5802
5805
  if (args.include_archived) q.set("include_archived", "1");
5803
5806
  if (args.top_level) q.set("top_level", "1");
5804
5807
  return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
@@ -5807,8 +5810,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5807
5810
 
5808
5811
  server.tool(
5809
5812
  "requirements_get",
5810
- "Get one requirement task by id (with description, checklist, owners, project, external_ref).",
5811
- { id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
5813
+ "Get one requirement task by id or by its short number \"#N\" (with description, checklist, owners, project, external_ref, seq).",
5814
+ { id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional() },
5812
5815
  async ({ id, network_id }) => requirementsCall("GET", `/api/requirements/${encodeURIComponent(id)}`, network_id),
5813
5816
  );
5814
5817
 
@@ -5821,15 +5824,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5821
5824
 
5822
5825
  server.tool(
5823
5826
  "requirements_update",
5824
- "Patch a requirement task; omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
5825
- { id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
5827
+ "Patch a requirement task (id or \"#N\"); omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
5828
+ { id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
5826
5829
  async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(args, REQ_WRITE_KEYS)),
5827
5830
  );
5828
5831
 
5829
5832
  server.tool(
5830
5833
  "requirements_checklist_toggle",
5831
5834
  "Set one checklist item done / not done (only that item is written, so concurrent edits of other items are kept).",
5832
- { id: z.string().min(1).max(200), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
5835
+ { id: z.string().min(1).max(200).describe(REQ_ID_DESC), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
5833
5836
  async ({ id, item_id, done, network_id }) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(id)}/checklist/${encodeURIComponent(item_id)}`, network_id, { done }),
5834
5837
  );
5835
5838