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

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,125 @@
1
+ // 需求的可选开始字段(start,存 start_on):App 甘特图用。
2
+ // 先用「没有 start_on 列」的旧库形状建库、塞行,再走真实启动路径(db.ts + server.ts),看:
3
+ // 列被加上、存量行读出来 start = "" 且别的列原样;POST / PATCH / upsert 都能写;只 PATCH due 不动 start 和描述;
4
+ // 旧客户端(不带 start)的 PATCH 不清掉已有的 start;坏值 400 invalid_start;capabilities 带 start_date。
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { Database } from "bun:sqlite";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const dir = mkdtempSync(join(tmpdir(), "anet-req-start-"));
12
+ const file = join(dir, "hub.db");
13
+ process.env.COMMHUB_DB = file;
14
+
15
+ const legacy = new Database(file);
16
+ legacy.exec(`
17
+ CREATE TABLE IF NOT EXISTS requirements (
18
+ requirement_id TEXT PRIMARY KEY,
19
+ network_id TEXT NOT NULL,
20
+ title TEXT NOT NULL,
21
+ column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
22
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low', 'lowest')),
23
+ due_on TEXT,
24
+ assignee TEXT,
25
+ client_id TEXT,
26
+ issues_json TEXT,
27
+ created_by TEXT,
28
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
29
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
30
+ );
31
+ `);
32
+ legacy.exec("ALTER TABLE requirements ADD COLUMN description TEXT");
33
+ legacy.run("INSERT INTO requirements (requirement_id, network_id, title, due_on, description) VALUES ('r_old', 'net_legacy', '旧卡', '2026-10-01', '旧描述')");
34
+ expect(legacy.query("SELECT name FROM pragma_table_info('requirements') WHERE name = 'start_on'").all()).toEqual([]);
35
+ legacy.close();
36
+
37
+ let base = "";
38
+ let token = "";
39
+ let server: { port: number; stop?: (force?: boolean) => void };
40
+ let db: any;
41
+
42
+ async function api(path: string, init?: RequestInit) {
43
+ const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
44
+ return { status: res.status, body: await res.json() as any };
45
+ }
46
+
47
+ beforeAll(async () => {
48
+ ({ db } = await import("./db.js"));
49
+ const { register } = await import("./auth.js");
50
+ const owner = register(`start_owner_${Date.now()}`, "StartOwner123!", undefined, "seed");
51
+ token = owner.token!;
52
+ const mod: any = await import("./server.js");
53
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
54
+ base = `http://127.0.0.1:${server.port}`;
55
+ }, 30_000);
56
+
57
+ afterAll(() => {
58
+ try { server?.stop?.(true); } catch {}
59
+ try { rmSync(dir, { recursive: true, force: true }); } catch {}
60
+ });
61
+
62
+ describe("optional requirement start date", () => {
63
+ test("startup adds start_on to an old table; existing rows keep every value and read start = ''", () => {
64
+ const cols = (db.all("PRAGMA table_info(requirements)") as { name: string }[]).map(c => c.name);
65
+ expect(cols).toContain("start_on");
66
+ const row = db.get("SELECT title, due_on, description, start_on FROM requirements WHERE requirement_id = 'r_old'") as any;
67
+ expect(row).toEqual({ title: "旧卡", due_on: "2026-10-01", description: "旧描述", start_on: null });
68
+ });
69
+
70
+ test("create / PATCH / upsert write start; due-only PATCH and start-less PATCH keep it", async () => {
71
+ const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "有开始的", start: "2026-09-20", due: "2026-10-01", description: "别动我" }) });
72
+ expect(created.status).toBe(201);
73
+ expect(created.body.requirement.start).toBe("2026-09-20");
74
+ const id = created.body.requirement.id;
75
+
76
+ // 甘特图拖动只发 due:start 和描述原样
77
+ const dueOnly = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ due: "2026-10-05" }) });
78
+ expect(dueOnly.status).toBe(200);
79
+ expect(dueOnly.body.requirement.due).toBe("2026-10-05");
80
+ expect(dueOnly.body.requirement.start).toBe("2026-09-20");
81
+ expect(dueOnly.body.requirement.description).toBe("别动我");
82
+
83
+ // 旧客户端不认识 start:它的 PATCH 不带这个字段,不能清掉
84
+ const old = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: "改名了" }) });
85
+ expect(old.body.requirement.start).toBe("2026-09-20");
86
+
87
+ const instant = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "2026-09-21T09:30:00+08:00" }) });
88
+ expect(instant.body.requirement.start).toBe("2026-09-21T01:30:00Z");
89
+ const cleared = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "" }) });
90
+ expect(cleared.status).toBe(200);
91
+ expect(cleared.body.requirement.start).toBe("");
92
+ // 只带 start 的 PATCH 不是 empty_patch
93
+ const onlyStart = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: "2026-09-22" }) });
94
+ expect(onlyStart.status).toBe(200);
95
+
96
+ const up = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "同步来的", external_ref: "github:t/p#9", start: "2026-09-01" }) });
97
+ expect(up.status).toBe(201);
98
+ expect(up.body.requirement.start).toBe("2026-09-01");
99
+ const upAgain = await api("/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: "github:t/p#9", start: "2026-09-02" }) });
100
+ expect(upAgain.body.requirement.start).toBe("2026-09-02");
101
+
102
+ const noStart = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "没开始字段" }) });
103
+ expect(noStart.body.requirement.start).toBe("");
104
+ });
105
+
106
+ test("bad start values are 400 invalid_start and change nothing", async () => {
107
+ const created = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "坏值", start: "2026-09-20" }) });
108
+ const id = created.body.requirement.id;
109
+ for (const bad of ["2026-02-30", "tomorrow", "2026-09-20T10:00:00", 20260920]) {
110
+ const post = await api("/api/requirements", { method: "POST", body: JSON.stringify({ name: "x", start: bad }) });
111
+ const patch = await api(`/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ start: bad }) });
112
+ expect(patch.status).toBe(400);
113
+ expect(patch.body.error).toBe("invalid_start");
114
+ if (typeof bad === "string") {
115
+ expect(post.status).toBe(400);
116
+ expect(post.body.error).toBe("invalid_start");
117
+ }
118
+ }
119
+ const listed = await api("/api/requirements");
120
+ expect(listed.body.requirements.find((r: any) => r.id === id).start).toBe("2026-09-20");
121
+ expect(listed.body.capabilities).toContain("start_date");
122
+ // 旧卡在列表里 start = ""(不是缺字段:新 App 按 capabilities 判断支持与否)
123
+ expect(listed.body.requirements.every((r: any) => typeof r.start === "string")).toBe(true);
124
+ });
125
+ });
@@ -1,5 +1,6 @@
1
1
  // 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
+ import { createHash } from "node:crypto";
3
4
  import { db } from "./db.js";
4
5
  import { normalizeTags, storedTags } from "./requirement-tags.js";
5
6
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
@@ -52,6 +53,7 @@ type Row = {
52
53
  column_name: string;
53
54
  priority: string;
54
55
  due_on: string | null;
56
+ start_on: string | null;
55
57
  assignee: string | null;
56
58
  issues_json: string | null;
57
59
  tags_json: string | null;
@@ -285,6 +287,8 @@ function toPublic(row: Row) {
285
287
  priority: row.priority,
286
288
  assignee: row.assignee || "",
287
289
  due: row.due_on || "",
290
+ // 开始(可空,甘特图用)。形状同 due;没设 = ""。旧 App 不认识这个字段,忽略即可。
291
+ start: row.start_on || "",
288
292
  column: row.column_name,
289
293
  createdAt: row.created_at,
290
294
  updatedAt: row.updated_at || row.created_at,
@@ -353,7 +357,7 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
353
357
  };
354
358
  }
355
359
 
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, " +
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, " +
357
361
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
358
362
  "(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
363
 
@@ -427,7 +431,28 @@ function operationOf(req: Request, url: URL): string {
427
431
  return "create";
428
432
  }
429
433
 
430
- export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest"] as const;
434
+ /**
435
+ * GET /api/requirements 的条件请求:ETag = 响应体的哈希,`If-None-Match` 对上就回 304 空体。
436
+ * app 每 15 s 轮询整张表(生产 500 行 708 KB / gzip 163 KB,中国 → 美国约 0.65 s 纯传输),
437
+ * 绝大多数轮询里表根本没变。按**响应体**算,所以对这个调用方可见的任何字段变了都会变
438
+ * (同一张表不同成员看到的不同,ETag 也不同)。不带 If-None-Match 的旧客户端照旧拿 200 全量。
439
+ */
440
+ export function conditionalJson(req: Request, payload: unknown): Response {
441
+ const body = JSON.stringify(payload);
442
+ const etag = `W/"${createHash("sha256").update(body).digest("base64url").slice(0, 27)}"`;
443
+ // private:按用户可见范围生成,不能被共享缓存复用;no-cache:每次都要回来验证,不会拿旧表当新的。
444
+ const headers = { ETag: etag, "Cache-Control": "private, no-cache" };
445
+ if (ifNoneMatchHits(req.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers });
446
+ return new Response(body, { headers: { ...headers, "Content-Type": "application/json;charset=utf-8" } });
447
+ }
448
+
449
+ function ifNoneMatchHits(header: string | null, etag: string): boolean {
450
+ if (!header) return false;
451
+ const bare = (tag: string) => tag.trim().replace(/^W\//, "");
452
+ return header.split(",").some(tag => tag.trim() === "*" || bare(tag) === bare(etag));
453
+ }
454
+
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;
431
456
 
432
457
  // ── 子需求 ──
433
458
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -534,6 +559,8 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
534
559
  if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
535
560
  const due = typeof body.due === "string" ? normalizeDue(body.due) : "";
536
561
  if (due === null) return jsonError("invalid_due", 400);
562
+ const start = typeof body.start === "string" ? normalizeDue(body.start) : "";
563
+ if (start === null) return jsonError("invalid_start", 400);
537
564
  const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
538
565
  const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
539
566
  if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
@@ -577,9 +604,9 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
577
604
  try {
578
605
  db.run(
579
606
  `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)],
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],
583
610
  );
584
611
  } catch {
585
612
  // 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
@@ -597,7 +624,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
597
624
  }
598
625
 
599
626
  // ── 修改(省略的字段保留原值) ──
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"];
627
+ 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
628
  function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
602
629
  if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
603
630
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
@@ -628,6 +655,13 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
628
655
  if (next === null) return jsonError("invalid_due", 400);
629
656
  due = next;
630
657
  }
658
+ let start = row.start_on || "";
659
+ if (has("start")) {
660
+ if (typeof body.start !== "string") return jsonError("invalid_start", 400);
661
+ const next = normalizeDue(body.start);
662
+ if (next === null) return jsonError("invalid_start", 400);
663
+ start = next;
664
+ }
631
665
  let assignee = row.assignee || "";
632
666
  if (has("assignee")) {
633
667
  if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
@@ -673,10 +707,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
673
707
  const updatedAt = new Date().toISOString();
674
708
  db.run(
675
709
  `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
710
+ 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
711
  WHERE requirement_id = ?3`,
678
712
  [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],
713
+ 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
714
  );
681
715
  const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
682
716
  return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
@@ -784,7 +818,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
784
818
  if (typeof filtered !== "string") return filtered;
785
819
  const rows = db.all<Row>(`${filtered} ORDER BY created_at DESC LIMIT 500`, ...params).map(row => toPublicFor(ctx, row));
786
820
  // capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
787
- return Response.json({ ok: true, requirements: rows, capabilities: REQUIREMENT_CAPABILITIES });
821
+ return conditionalJson(ctx.req, { ok: true, requirements: rows, capabilities: REQUIREMENT_CAPABILITIES });
788
822
  }
789
823
 
790
824
  if (url.pathname === "/api/requirements" && req.method === "POST") {
@@ -1,6 +1,7 @@
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";
@@ -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,12 +120,12 @@ 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
131
  const misfireColumn = db.all<any>("PRAGMA table_info(scheduled_tasks)").find((column: any) => column.name === "misfire_policy");
@@ -130,11 +160,10 @@ describe("Hub scheduled task API and dispatcher", () => {
130
160
  expect(created.body.schedule.misfire_policy).toBe("catch_up_once");
131
161
  expect(created.body.schedule.created_by).toBeUndefined();
132
162
  expect(created.body.schedule.schedule_json).toBeUndefined();
133
- scheduleId = created.body.schedule.schedule_id;
134
- revision = created.body.schedule.revision;
135
163
  });
136
164
 
137
165
  test("network scope hides schedules from foreign users and viewers may read", async () => {
166
+ const scheduleId = (await createSchedule("Scope probe")).schedule_id;
138
167
  const visible = await api(viewerToken, `/api/scheduled-tasks?network_id=${encodeURIComponent(networkId)}`);
139
168
  expect(visible.status).toBe(200);
140
169
  expect(visible.body.schedules.map((x: any) => x.schedule_id)).toContain(scheduleId);
@@ -145,9 +174,9 @@ describe("Hub scheduled task API and dispatcher", () => {
145
174
  expect(hidden.status).toBe(403);
146
175
  });
147
176
 
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]);
177
+ test("due occurrence creates ordinary inbox/task/run atomically and is idempotent", async () => {
178
+ const scheduleId = (await createSchedule("Due occurrence probe")).schedule_id;
179
+ makeDue(scheduleId, new Date(Date.now() - 120_000).toISOString());
151
180
  expect(runDueScheduledTasks().processed).toBe(1);
152
181
  const run = db.get<any>("SELECT * FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY created_at DESC LIMIT 1", scheduleId)!;
153
182
  expect(["delivered", "queued"]).toContain(run.status);
@@ -289,11 +318,14 @@ describe("Hub scheduled task API and dispatcher", () => {
289
318
 
290
319
  const raceDir = mkdtempSync(join(dir, "race-"));
291
320
  const gate = join(raceDir, "go");
292
- const workerPath = "tests/test601-hub-scheduled-tasks/race-worker.ts";
321
+ // Resolved from this file, not from the current directory (#2135): `bun test` from server/
322
+ // used to look for tests/… under server/ and never start the workers.
323
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
324
+ const workerPath = join(repoRoot, "tests", "test601-hub-scheduled-tasks", "race-worker.ts");
293
325
  const dbPath = activeDbPath;
294
326
  const spawnWorker = (id: string) => Bun.spawn(
295
327
  ["bun", workerPath, dbPath, raceScheduleId, due, join(raceDir, `ready-${id}`), gate],
296
- { cwd: process.cwd(), stdout: "pipe", stderr: "pipe", env: { ...process.env, COMMHUB_DB: dbPath } },
328
+ { cwd: repoRoot, stdout: "pipe", stderr: "pipe", env: { ...process.env, COMMHUB_DB: dbPath } },
297
329
  );
298
330
  const waitReady = async (id: string) => {
299
331
  const readyPath = join(raceDir, `ready-${id}`);
@@ -326,16 +358,23 @@ describe("Hub scheduled task API and dispatcher", () => {
326
358
  expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE meta_json LIKE '%' || ?1 || '%'", raceScheduleId)!.count).toBe(1);
327
359
  }, 20_000);
328
360
 
329
- test("non-overlap skips while prior task is open; rename follows stable node_id", () => {
361
+ test("non-overlap skips while prior task is open; rename follows stable node_id", async () => {
362
+ // Its own node: the rename below must not change the node every other test targets.
363
+ const renameNodeId = `n_sched_rename_${Date.now()}`;
364
+ 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]);
365
+ 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]);
366
+ const scheduleId = (await createSchedule("Non-overlap probe", { target_node_id: renameNodeId })).schedule_id;
367
+ makeDue(scheduleId, new Date(Date.now() - 90_000).toISOString());
368
+ expect(runDueScheduledTasks().processed).toBe(1);
330
369
  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]);
370
+
371
+ const due2 = makeDue(scheduleId);
333
372
  expect(runDueScheduledTasks().processed).toBe(1);
334
373
  expect(db.get<any>("SELECT status FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", scheduleId, due2)!.status).toBe("skipped");
335
374
 
336
375
  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]);
376
+ db.run("UPDATE nodes SET alias = 'scheduler-renamed' WHERE node_id = ?1 AND network_id = ?2", [renameNodeId, networkId]);
377
+ db.run("UPDATE sessions SET alias = 'scheduler-renamed' WHERE node_id = ?1 AND network_id = ?2", [renameNodeId, networkId]);
339
378
  const due3 = new Date(Date.now() - 30_000).toISOString();
340
379
  db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due3, scheduleId]);
341
380
  expect(runDueScheduledTasks().processed).toBe(1);
@@ -344,6 +383,7 @@ describe("Hub scheduled task API and dispatcher", () => {
344
383
  });
345
384
 
346
385
  test("full edit validates target and policy, preserves cadence, and recomputes through DST-safe schedule math", async () => {
386
+ const scheduleId = (await createSchedule("Daily briefing to edit", { priority: "high", timezone: "Asia/Shanghai" })).schedule_id;
347
387
  // Pin a recognizable future occurrence so a metadata-only edit can prove
348
388
  // that it does not silently reset the interval cadence.
349
389
  const pinnedNext = new Date(Date.now() + 45 * 60_000).toISOString();
@@ -413,6 +453,7 @@ describe("Hub scheduled task API and dispatcher", () => {
413
453
  });
414
454
 
415
455
  test("two editors with one revision produce one winner and one refreshable conflict", async () => {
456
+ const scheduleId = (await createSchedule("Two editors probe")).schedule_id;
416
457
  const current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
417
458
  const [a, b] = await Promise.all([
418
459
  api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
@@ -523,8 +564,18 @@ describe("Hub scheduled task API and dispatcher", () => {
523
564
  });
524
565
 
525
566
  test("optimistic revision, pause/resume, run-now and cancel preserve history", async () => {
567
+ const scheduleId = (await createSchedule("History probe")).schedule_id;
568
+ // Its own history before the lifecycle calls: a run, a skipped overlap, and a second run.
569
+ makeDue(scheduleId, new Date(Date.now() - 180_000).toISOString());
570
+ expect(runDueScheduledTasks().processed).toBe(1);
571
+ makeDue(scheduleId, new Date(Date.now() - 120_000).toISOString());
572
+ expect(runDueScheduledTasks().processed).toBe(1);
573
+ db.run("UPDATE tasks SET status='replied' WHERE task_id IN (SELECT task_id FROM scheduled_task_runs WHERE schedule_id=?1)", [scheduleId]);
574
+ makeDue(scheduleId);
575
+ expect(runDueScheduledTasks().processed).toBe(1);
576
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1", scheduleId)!.n).toBe(3);
526
577
  const latest = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`);
527
- revision = latest.body.schedule.revision;
578
+ const revision = latest.body.schedule.revision;
528
579
  const stale = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "PATCH", body: JSON.stringify({ revision: revision - 1, status: "paused" }) });
529
580
  expect(stale.status).toBe(409);
530
581
  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