@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.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/agent-access.ts +196 -2
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/auth.ts +76 -1
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +182 -60
- package/src/db.ts +39 -2
- package/src/pg-worker.ts +87 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements.ts +43 -9
- package/src/scheduled-tasks-http.test.ts +71 -20
- package/src/scheduled-tasks.ts +3 -2
- package/src/server.ts +117 -11
- package/src/side-thread-command-transport.test.ts +2 -2
- package/src/side-thread-command-transport.ts +4 -4
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/tools.ts +6 -5
|
@@ -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
|
+
});
|
package/src/requirements.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
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.
|
|
123
|
+
const original = db.atomicTransactions;
|
|
94
124
|
try {
|
|
95
|
-
(db as any).
|
|
125
|
+
(db as any).atomicTransactions = false;
|
|
96
126
|
expect(() => assertScheduledTaskBackendSupported()).toThrow("scheduled_tasks_require_transactional_sqlite_backend");
|
|
97
127
|
} finally {
|
|
98
|
-
(db as any).
|
|
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
|
|
150
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
332
|
-
|
|
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", [
|
|
338
|
-
db.run("UPDATE sessions SET alias = 'scheduler-renamed' WHERE node_id = ?1 AND network_id = ?2", [
|
|
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" }) });
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
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
|
|