@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.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +222 -11
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +8 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/auth.ts +98 -7
- package/src/db-adapter-guard.test.ts +63 -0
- package/src/db-adapter-translate.test.ts +123 -0
- package/src/db-adapter.ts +246 -65
- package/src/db.ts +46 -3
- 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-mcp.test.ts +6 -0
- package/src/requirements-migrate.ts +61 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +192 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements.ts +139 -23
- package/src/retention.ts +5 -2
- package/src/scheduled-tasks-http.test.ts +93 -29
- package/src/scheduled-tasks.ts +3 -2
- package/src/server.ts +118 -12
- package/src/side-thread-command-transport.test.ts +14 -4
- package/src/side-thread-command-transport.ts +4 -4
- package/src/side-thread.ts +4 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/tools.ts +16 -13
package/src/requirements.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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) })
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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,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.
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
150
|
-
|
|
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.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
332
|
-
|
|
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", [
|
|
338
|
-
db.run("UPDATE sessions SET alias = 'scheduler-renamed' WHERE node_id = ?1 AND network_id = ?2", [
|
|
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" }) });
|
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
|
|