@sleep2agi/commhub-server 0.9.0-preview.70 → 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.
@@ -1,10 +1,12 @@
1
1
  // 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
+ import { createHash } from "node:crypto";
4
+ import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
3
5
  import { db } from "./db.js";
4
6
  import { normalizeTags, storedTags } from "./requirement-tags.js";
5
7
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
6
8
  import { isAgentRestricted, visibleAgents } from "./agent-access.js";
7
- import { ensureRequirementIndexes, ensureRequirementProjects, migrateRequirementAgentOwners, migrateRequirementPriorityCheck } from "./requirements-migrate.js";
9
+ import { ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
8
10
 
9
11
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
10
12
  // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
@@ -12,6 +14,7 @@ migrateRequirementPriorityCheck(db);
12
14
  migrateRequirementAgentOwners(db);
13
15
  ensureRequirementProjects(db);
14
16
  ensureRequirementIndexes(db);
17
+ ensureRequirementSeq(db);
15
18
 
16
19
  type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
17
20
 
@@ -52,10 +55,12 @@ type Row = {
52
55
  column_name: string;
53
56
  priority: string;
54
57
  due_on: string | null;
58
+ start_on: string | null;
55
59
  assignee: string | null;
56
60
  issues_json: string | null;
57
61
  tags_json: string | null;
58
62
  created_at: string;
63
+ seq: number | null;
59
64
  };
60
65
 
61
66
  function jsonError(error: string, status: number): Response {
@@ -281,10 +286,14 @@ function toPublic(row: Row) {
281
286
  project_id: row.project_id || null,
282
287
  participants: JSON.parse(row.participants_json || '[]'),
283
288
  id: row.requirement_id,
289
+ // 短号:每个网络自己的 #1、#2…(不回收)。界面显示 #N;GET / PATCH / MCP 的 id 也收「#N」。旧 App 忽略它。
290
+ seq: row.seq == null ? null : Number(row.seq),
284
291
  name: row.title,
285
292
  priority: row.priority,
286
293
  assignee: row.assignee || "",
287
294
  due: row.due_on || "",
295
+ // 开始(可空,甘特图用)。形状同 due;没设 = ""。旧 App 不认识这个字段,忽略即可。
296
+ start: row.start_on || "",
288
297
  column: row.column_name,
289
298
  createdAt: row.created_at,
290
299
  updatedAt: row.updated_at || row.created_at,
@@ -353,7 +362,7 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
353
362
  };
354
363
  }
355
364
 
356
- 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, " +
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, " +
357
366
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
358
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";
359
368
 
@@ -427,7 +436,29 @@ function operationOf(req: Request, url: URL): string {
427
436
  return "create";
428
437
  }
429
438
 
430
- export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest"] as const;
439
+ /**
440
+ * GET /api/requirements 的条件请求:ETag = 响应体的哈希,`If-None-Match` 对上就回 304 空体。
441
+ * app 每 15 s 轮询整张表(生产 500 行 708 KB / gzip 163 KB,中国 → 美国约 0.65 s 纯传输),
442
+ * 绝大多数轮询里表根本没变。按**响应体**算,所以对这个调用方可见的任何字段变了都会变
443
+ * (同一张表不同成员看到的不同,ETag 也不同)。不带 If-None-Match 的旧客户端照旧拿 200 全量。
444
+ */
445
+ export function conditionalJson(req: Request, payload: unknown): Response {
446
+ const body = JSON.stringify(payload);
447
+ const etag = `W/"${createHash("sha256").update(body).digest("base64url").slice(0, 27)}"`;
448
+ // private:按用户可见范围生成,不能被共享缓存复用;no-cache:每次都要回来验证,不会拿旧表当新的。
449
+ const headers = { ETag: etag, "Cache-Control": "private, no-cache" };
450
+ if (ifNoneMatchHits(req.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers });
451
+ return new Response(body, { headers: { ...headers, "Content-Type": "application/json;charset=utf-8" } });
452
+ }
453
+
454
+ function ifNoneMatchHits(header: string | null, etag: string): boolean {
455
+ if (!header) return false;
456
+ const bare = (tag: string) => tag.trim().replace(/^W\//, "");
457
+ return header.split(",").some(tag => tag.trim() === "*" || bare(tag) === bare(etag));
458
+ }
459
+
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;
431
462
 
432
463
  // ── 子需求 ──
433
464
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -534,6 +565,8 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
534
565
  if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
535
566
  const due = typeof body.due === "string" ? normalizeDue(body.due) : "";
536
567
  if (due === null) return jsonError("invalid_due", 400);
568
+ const start = typeof body.start === "string" ? normalizeDue(body.start) : "";
569
+ if (start === null) return jsonError("invalid_start", 400);
537
570
  const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
538
571
  const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
539
572
  if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
@@ -575,12 +608,13 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
575
608
  try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
576
609
  const actor = JSON.stringify(actorOf(ctx));
577
610
  try {
578
- db.run(
611
+ // 领号和插入在同一个事务里:插入被唯一索引挡下时,号一起回滚,不留空洞。
612
+ db.transaction(() => db.run(
579
613
  `INSERT INTO requirements
580
- (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)
581
- 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)`,
582
- [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)],
583
- );
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
+ ));
584
618
  } catch {
585
619
  // 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
586
620
  if (ref) {
@@ -597,7 +631,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
597
631
  }
598
632
 
599
633
  // ── 修改(省略的字段保留原值) ──
600
- const PATCH_FIELDS = ["column", "issues", "tags", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id"];
634
+ const PATCH_FIELDS = ["column", "issues", "tags", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id", "start"];
601
635
  function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
602
636
  if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
603
637
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
@@ -628,6 +662,13 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
628
662
  if (next === null) return jsonError("invalid_due", 400);
629
663
  due = next;
630
664
  }
665
+ let start = row.start_on || "";
666
+ if (has("start")) {
667
+ if (typeof body.start !== "string") return jsonError("invalid_start", 400);
668
+ const next = normalizeDue(body.start);
669
+ if (next === null) return jsonError("invalid_start", 400);
670
+ start = next;
671
+ }
631
672
  let assignee = row.assignee || "";
632
673
  if (has("assignee")) {
633
674
  if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
@@ -673,10 +714,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
673
714
  const updatedAt = new Date().toISOString();
674
715
  db.run(
675
716
  `UPDATE requirements SET column_name = ?1, updated_at = ?2, title = ?7, priority = ?8, due_on = ?9, assignee = ?10, owner_json = ?4, participants_json = ?5, issues_json = ?6,
676
- agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14, external_ref = ?15, external_url = ?16, archived = ?17, updated_by_json = ?18, parent_id = ?19, tags_json = ?20
717
+ agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14, external_ref = ?15, external_url = ?16, archived = ?17, updated_by_json = ?18, parent_id = ?19, tags_json = ?20, start_on = ?21
677
718
  WHERE requirement_id = ?3`,
678
719
  [has("column") ? body.column : row.column_name, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, has("issues") ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
679
- has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, JSON.stringify(actorOf(ctx)), parentId, has("tags") ? JSON.stringify(tags) : row.tags_json],
720
+ has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, JSON.stringify(actorOf(ctx)), parentId, has("tags") ? JSON.stringify(tags) : row.tags_json, start || null],
680
721
  );
681
722
  const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
682
723
  return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
@@ -692,14 +733,30 @@ function withLegacyOwner(requirement: ReturnType<typeof toPublic>, coerced: Pers
692
733
  return { ok: true, requirement: { ...requirement, owner: coerced }, owner_coerced_to_agent_owner: true };
693
734
  }
694
735
 
695
- function scopedRow(ctx: RequirementsRequestContext, id: string): Row | undefined {
696
- const params: unknown[] = [id];
697
- 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`;
698
750
  sql = addHumanNetworkScope(sql, params, ctx.scope);
699
- 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);
700
757
  }
701
758
 
702
- /** 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。 */
703
760
  function listFilters(url: URL, sql: string, params: unknown[], ctx?: RequirementsRequestContext): string | Response {
704
761
  const q = url.searchParams;
705
762
  const status = q.get("status");
@@ -725,6 +782,11 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
725
782
  if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
726
783
  sql += ` AND updated_at >= ?${params.push(new Date(ms).toISOString())}`;
727
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
+ }
728
790
  const ref = q.get("external_ref");
729
791
  if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
730
792
  const parent = q.get("parent_id");
@@ -736,6 +798,55 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
736
798
  return sql;
737
799
  }
738
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
+
739
850
  /** 这个节点对调用者在他作用域内的某个受限网络里是否隐藏。 */
740
851
  function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string): boolean {
741
852
  const restricted = ctx.scope.agentRestriction?.networkIds ?? [];
@@ -778,13 +889,18 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
778
889
 
779
890
  if (url.pathname === "/api/requirements" && req.method === "GET") {
780
891
  const params: unknown[] = [];
781
- let sql = `SELECT ${SELECT} FROM requirements WHERE 1=1`;
892
+ // 只拼 FROM … WHERE:同一组条件既用来读整行,也用来(搜索时)先读轻量列。
893
+ let sql = "FROM requirements WHERE 1=1";
782
894
  sql = addHumanNetworkScope(sql, params, ctx.scope);
783
895
  const filtered = listFilters(url, sql, params, ctx);
784
896
  if (typeof filtered !== "string") return filtered;
785
- 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);
786
901
  // capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
787
- return Response.json({ 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 });
788
904
  }
789
905
 
790
906
  if (url.pathname === "/api/requirements" && req.method === "POST") {
@@ -825,7 +941,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
825
941
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
826
942
  if (typeof body.done !== "boolean") return jsonError("invalid_done", 400);
827
943
  const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
828
- if (!row) return jsonError("requirement_not_found", 404);
944
+ if (row instanceof Response) return row;
829
945
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
830
946
  // 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
831
947
  const items = storedChecklist(row.checklist_json);
@@ -843,12 +959,12 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
843
959
  const id = decodeURIComponent(match[1]);
844
960
  if (req.method === "GET") {
845
961
  const row = scopedRow(ctx, id);
846
- 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) });
847
963
  }
848
964
  if (req.method === "DELETE") {
849
965
  // 删除只给人(节点令牌在入口已被拒):Agent 用 archived 归档。
850
966
  const row = scopedRow(ctx, id);
851
- if (!row) return jsonError("requirement_not_found", 404);
967
+ if (row instanceof Response) return row;
852
968
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
853
969
  // 子需求不跟着删:先解挂(变成顶层),再删父卡。
854
970
  db.run("UPDATE requirements SET parent_id = NULL WHERE parent_id = ?1", [row.requirement_id]);
@@ -860,6 +976,6 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
860
976
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
861
977
  if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
862
978
  const row = scopedRow(ctx, id);
863
- if (!row) return jsonError("requirement_not_found", 404);
979
+ if (row instanceof Response) return row;
864
980
  return patchRequirement(ctx, row, body);
865
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
@@ -1,14 +1,15 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { tmpdir } from "node:os";
5
6
  import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
6
7
  import { db } from "./db.js";
7
8
  import { assertScheduledTaskBackendSupported, nextOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
8
9
 
9
10
  const dir = mkdtempSync(join(tmpdir(), "anet-scheduler-"));
10
- const activeDbPath = process.env.COMMHUB_DB;
11
- 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");
12
13
  let server: any;
13
14
  let base = "";
14
15
  let ownerToken = "";
@@ -26,6 +27,31 @@ async function api(token: string, path: string, init?: RequestInit) {
26
27
  return { status: res.status, body: await res.json() as any };
27
28
  }
28
29
 
30
+ // #2135 —— 每条测试自己建它要断言的数据:不读前面测试留下的 schedule / run 行,也不靠执行顺序。
31
+ // 单跑任何一条(-t)、从 server/ 或仓根跑,结果都一样。
32
+ async function createSchedule(name: string, extra: Record<string, unknown> = {}) {
33
+ const res = await api(ownerToken, "/api/scheduled-tasks", {
34
+ method: "POST",
35
+ body: JSON.stringify({
36
+ network_id: networkId,
37
+ name,
38
+ target_node_id: nodeId,
39
+ task: `${name} task`,
40
+ timezone: "UTC",
41
+ schedule: { type: "interval", every_seconds: 60 },
42
+ ...extra,
43
+ }),
44
+ });
45
+ expect(res.status).toBe(201);
46
+ return res.body.schedule as { schedule_id: string; revision: number; [key: string]: any };
47
+ }
48
+
49
+ /** Make exactly this schedule due at `at` (default: a minute ago). */
50
+ function makeDue(scheduleId: string, at = new Date(Date.now() - 60_000).toISOString()): string {
51
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [at, scheduleId]);
52
+ return at;
53
+ }
54
+
29
55
  // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
30
56
  // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
31
57
  // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
@@ -66,8 +92,12 @@ afterAll(() => {
66
92
  });
67
93
 
68
94
  describe("Hub scheduled task API and dispatcher", () => {
69
- let scheduleId = "";
70
- let revision = 0;
95
+ // runDueScheduledTasks() sweeps every due schedule in the DB. Park whatever an earlier test
96
+ // left due (e.g. the race schedule when its workers could not start), so each test's
97
+ // processed counts only ever see the schedules it made due itself.
98
+ beforeEach(() => {
99
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE next_run_at IS NOT NULL", [new Date(Date.now() + 24 * 3600_000).toISOString()]);
100
+ });
71
101
 
72
102
  test("schedule math supports timezone-aware daily and weekly forms", () => {
73
103
  const daily = nextOccurrence({ type: "daily", time: "09:30" }, "Asia/Shanghai", new Date("2026-08-09T02:00:00Z"));
@@ -90,16 +120,21 @@ describe("Hub scheduled task API and dispatcher", () => {
90
120
  });
91
121
 
92
122
  test("scheduler startup rejects a backend without real transactions", () => {
93
- const original = db.dialect;
123
+ const original = db.atomicTransactions;
94
124
  try {
95
- (db as any).dialect = "postgres";
125
+ (db as any).atomicTransactions = false;
96
126
  expect(() => assertScheduledTaskBackendSupported()).toThrow("scheduled_tasks_require_transactional_sqlite_backend");
97
127
  } finally {
98
- (db as any).dialect = original;
128
+ (db as any).atomicTransactions = original;
99
129
  }
100
130
  expect(() => assertScheduledTaskBackendSupported()).not.toThrow();
101
- const misfireColumn = db.all<any>("PRAGMA table_info(scheduled_tasks)").find((column: any) => column.name === "misfire_policy");
102
- 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
+ }
103
138
  });
104
139
 
105
140
  test("owner creates; viewer and node token cannot mutate", async () => {
@@ -130,11 +165,10 @@ describe("Hub scheduled task API and dispatcher", () => {
130
165
  expect(created.body.schedule.misfire_policy).toBe("catch_up_once");
131
166
  expect(created.body.schedule.created_by).toBeUndefined();
132
167
  expect(created.body.schedule.schedule_json).toBeUndefined();
133
- scheduleId = created.body.schedule.schedule_id;
134
- revision = created.body.schedule.revision;
135
168
  });
136
169
 
137
170
  test("network scope hides schedules from foreign users and viewers may read", async () => {
171
+ const scheduleId = (await createSchedule("Scope probe")).schedule_id;
138
172
  const visible = await api(viewerToken, `/api/scheduled-tasks?network_id=${encodeURIComponent(networkId)}`);
139
173
  expect(visible.status).toBe(200);
140
174
  expect(visible.body.schedules.map((x: any) => x.schedule_id)).toContain(scheduleId);
@@ -145,9 +179,9 @@ describe("Hub scheduled task API and dispatcher", () => {
145
179
  expect(hidden.status).toBe(403);
146
180
  });
147
181
 
148
- test("due occurrence creates ordinary inbox/task/run atomically and is idempotent", () => {
149
- const due = new Date(Date.now() - 120_000).toISOString();
150
- db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due, scheduleId]);
182
+ test("due occurrence creates ordinary inbox/task/run atomically and is idempotent", async () => {
183
+ const scheduleId = (await createSchedule("Due occurrence probe")).schedule_id;
184
+ makeDue(scheduleId, new Date(Date.now() - 120_000).toISOString());
151
185
  expect(runDueScheduledTasks().processed).toBe(1);
152
186
  const run = db.get<any>("SELECT * FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY created_at DESC LIMIT 1", scheduleId)!;
153
187
  expect(["delivered", "queued"]).toContain(run.status);
@@ -254,10 +288,17 @@ describe("Hub scheduled task API and dispatcher", () => {
254
288
  // Test-only fault injection at the second durable write. The inbox row has
255
289
  // already been attempted when this aborts, so any missing transaction
256
290
  // boundary leaves an observable half-state.
257
- db.run(`CREATE TRIGGER test601_abort_scheduled_task_insert
258
- BEFORE INSERT ON tasks
259
- WHEN NEW.meta_json LIKE '%${atomicScheduleId}%'
260
- 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
+ }
261
302
  const failed = runDueScheduledTasks();
262
303
  expect(failed.failed).toBe(1);
263
304
  expect(db.get("SELECT run_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeNull();
@@ -265,7 +306,8 @@ describe("Hub scheduled task API and dispatcher", () => {
265
306
  expect(db.get("SELECT task_id FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", atomicScheduleId)).toBeNull();
266
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);
267
308
 
268
- 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()");
269
311
  expect(runDueScheduledTasks().processed).toBe(1);
270
312
  expect(db.get("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeTruthy();
271
313
  });
@@ -289,11 +331,14 @@ describe("Hub scheduled task API and dispatcher", () => {
289
331
 
290
332
  const raceDir = mkdtempSync(join(dir, "race-"));
291
333
  const gate = join(raceDir, "go");
292
- const workerPath = "tests/test601-hub-scheduled-tasks/race-worker.ts";
334
+ // Resolved from this file, not from the current directory (#2135): `bun test` from server/
335
+ // used to look for tests/… under server/ and never start the workers.
336
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
337
+ const workerPath = join(repoRoot, "tests", "test601-hub-scheduled-tasks", "race-worker.ts");
293
338
  const dbPath = activeDbPath;
294
339
  const spawnWorker = (id: string) => Bun.spawn(
295
340
  ["bun", workerPath, dbPath, raceScheduleId, due, join(raceDir, `ready-${id}`), gate],
296
- { cwd: process.cwd(), stdout: "pipe", stderr: "pipe", env: { ...process.env, COMMHUB_DB: dbPath } },
341
+ { cwd: repoRoot, stdout: "pipe", stderr: "pipe", env: { ...process.env, COMMHUB_DB: dbPath } },
297
342
  );
298
343
  const waitReady = async (id: string) => {
299
344
  const readyPath = join(raceDir, `ready-${id}`);
@@ -326,16 +371,23 @@ describe("Hub scheduled task API and dispatcher", () => {
326
371
  expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE meta_json LIKE '%' || ?1 || '%'", raceScheduleId)!.count).toBe(1);
327
372
  }, 20_000);
328
373
 
329
- test("non-overlap skips while prior task is open; rename follows stable node_id", () => {
374
+ test("non-overlap skips while prior task is open; rename follows stable node_id", async () => {
375
+ // Its own node: the rename below must not change the node every other test targets.
376
+ const renameNodeId = `n_sched_rename_${Date.now()}`;
377
+ db.run("INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, 'Rename Probe', 'scheduler-rename-probe', 'codex-sdk', ?2)", [renameNodeId, networkId]);
378
+ db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, 'scheduler-rename-probe', 'idle', ?2, ?3)", [`r_rename_${Date.now()}`, renameNodeId, networkId]);
379
+ const scheduleId = (await createSchedule("Non-overlap probe", { target_node_id: renameNodeId })).schedule_id;
380
+ makeDue(scheduleId, new Date(Date.now() - 90_000).toISOString());
381
+ expect(runDueScheduledTasks().processed).toBe(1);
330
382
  const firstTask = db.get<{ task_id: string }>("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1 AND task_id IS NOT NULL LIMIT 1", scheduleId)!.task_id;
331
- const due2 = new Date(Date.now() - 60_000).toISOString();
332
- db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due2, scheduleId]);
383
+
384
+ const due2 = makeDue(scheduleId);
333
385
  expect(runDueScheduledTasks().processed).toBe(1);
334
386
  expect(db.get<any>("SELECT status FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", scheduleId, due2)!.status).toBe("skipped");
335
387
 
336
388
  db.run("UPDATE tasks SET status = 'replied', completed_at = datetime('now') WHERE task_id = ?1", [firstTask]);
337
- db.run("UPDATE nodes SET alias = 'scheduler-renamed' WHERE node_id = ?1 AND network_id = ?2", [nodeId, networkId]);
338
- db.run("UPDATE sessions SET alias = 'scheduler-renamed' WHERE node_id = ?1 AND network_id = ?2", [nodeId, networkId]);
389
+ db.run("UPDATE nodes SET alias = 'scheduler-renamed' WHERE node_id = ?1 AND network_id = ?2", [renameNodeId, networkId]);
390
+ db.run("UPDATE sessions SET alias = 'scheduler-renamed' WHERE node_id = ?1 AND network_id = ?2", [renameNodeId, networkId]);
339
391
  const due3 = new Date(Date.now() - 30_000).toISOString();
340
392
  db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due3, scheduleId]);
341
393
  expect(runDueScheduledTasks().processed).toBe(1);
@@ -344,6 +396,7 @@ describe("Hub scheduled task API and dispatcher", () => {
344
396
  });
345
397
 
346
398
  test("full edit validates target and policy, preserves cadence, and recomputes through DST-safe schedule math", async () => {
399
+ const scheduleId = (await createSchedule("Daily briefing to edit", { priority: "high", timezone: "Asia/Shanghai" })).schedule_id;
347
400
  // Pin a recognizable future occurrence so a metadata-only edit can prove
348
401
  // that it does not silently reset the interval cadence.
349
402
  const pinnedNext = new Date(Date.now() + 45 * 60_000).toISOString();
@@ -413,6 +466,7 @@ describe("Hub scheduled task API and dispatcher", () => {
413
466
  });
414
467
 
415
468
  test("two editors with one revision produce one winner and one refreshable conflict", async () => {
469
+ const scheduleId = (await createSchedule("Two editors probe")).schedule_id;
416
470
  const current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
417
471
  const [a, b] = await Promise.all([
418
472
  api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
@@ -523,8 +577,18 @@ describe("Hub scheduled task API and dispatcher", () => {
523
577
  });
524
578
 
525
579
  test("optimistic revision, pause/resume, run-now and cancel preserve history", async () => {
580
+ const scheduleId = (await createSchedule("History probe")).schedule_id;
581
+ // Its own history before the lifecycle calls: a run, a skipped overlap, and a second run.
582
+ makeDue(scheduleId, new Date(Date.now() - 180_000).toISOString());
583
+ expect(runDueScheduledTasks().processed).toBe(1);
584
+ makeDue(scheduleId, new Date(Date.now() - 120_000).toISOString());
585
+ expect(runDueScheduledTasks().processed).toBe(1);
586
+ db.run("UPDATE tasks SET status='replied' WHERE task_id IN (SELECT task_id FROM scheduled_task_runs WHERE schedule_id=?1)", [scheduleId]);
587
+ makeDue(scheduleId);
588
+ expect(runDueScheduledTasks().processed).toBe(1);
589
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1", scheduleId)!.n).toBe(3);
526
590
  const latest = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`);
527
- revision = latest.body.schedule.revision;
591
+ const revision = latest.body.schedule.revision;
528
592
  const stale = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "PATCH", body: JSON.stringify({ revision: revision - 1, status: "paused" }) });
529
593
  expect(stale.status).toBe(409);
530
594
  const paused = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "PATCH", body: JSON.stringify({ revision, status: "paused" }) });
@@ -57,8 +57,9 @@ const MISFIRE_POLICIES = new Set<MisfirePolicy>(["catch_up_once", "skip"]);
57
57
  export const MISFIRE_GRACE_MS = 60_000;
58
58
 
59
59
  export function assertScheduledTaskBackendSupported(): void {
60
- if (db.dialect !== "sqlite") {
61
- throw new Error("scheduled_tasks_require_transactional_sqlite_backend");
60
+ const refusal = db.transactionalFeaturesRefusal;
61
+ if (refusal !== null) {
62
+ throw new Error(`scheduled_tasks_require_transactional_sqlite_backend${refusal ? `: ${refusal}` : ""}`);
62
63
  }
63
64
  }
64
65