@sleep2agi/commhub-server 0.9.0-preview.7 → 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 +130 -10
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +423 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4385 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3446 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
7
|
+
import { db } from "./db.js";
|
|
8
|
+
import { assertScheduledTaskBackendSupported, nextOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-scheduler-"));
|
|
11
|
+
const activeDbPath = process.env.COMMHUB_DB;
|
|
12
|
+
if (!activeDbPath) throw new Error("test601 requires COMMHUB_DB before module import");
|
|
13
|
+
let server: any;
|
|
14
|
+
let base = "";
|
|
15
|
+
let ownerToken = "";
|
|
16
|
+
let viewerToken = "";
|
|
17
|
+
let nodeToken = "";
|
|
18
|
+
let networkId = "";
|
|
19
|
+
let ownerId = "";
|
|
20
|
+
const nodeId = `n_sched_${Date.now()}`;
|
|
21
|
+
|
|
22
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
23
|
+
const res = await fetch(`${base}${path}`, {
|
|
24
|
+
...init,
|
|
25
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
|
|
26
|
+
});
|
|
27
|
+
return { status: res.status, body: await res.json() as any };
|
|
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
|
+
|
|
55
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
56
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
57
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
58
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
const owner = register(`scheduler_owner_${Date.now()}`, "SchedulerOwner123!", undefined, "seed");
|
|
61
|
+
expect(owner.ok).toBe(true);
|
|
62
|
+
ownerToken = owner.token!;
|
|
63
|
+
networkId = owner.network_id!;
|
|
64
|
+
ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId)!.owner_id;
|
|
65
|
+
|
|
66
|
+
const viewer = register(`scheduler_viewer_${Date.now()}`, "SchedulerViewer123!", undefined, "seed");
|
|
67
|
+
expect(viewer.ok).toBe(true);
|
|
68
|
+
viewerToken = viewer.token!;
|
|
69
|
+
const viewerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", viewer.user!.username)!.user_id;
|
|
70
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
71
|
+
addNetworkMember(networkId, viewerId, "viewer", ownerId, { agentAccess: "all" });
|
|
72
|
+
const ntok = createNetworkTokenForNode(ownerId, networkId, "scheduler-node");
|
|
73
|
+
expect(ntok.ok).toBe(true);
|
|
74
|
+
nodeToken = ntok.token!;
|
|
75
|
+
|
|
76
|
+
db.run(
|
|
77
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, 'Scheduler Node', 'scheduler-node', 'codex-sdk', ?2)`,
|
|
78
|
+
[nodeId, networkId],
|
|
79
|
+
);
|
|
80
|
+
db.run(
|
|
81
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, 'scheduler-node', 'idle', ?2, ?3)`,
|
|
82
|
+
[`r_${Date.now()}`, nodeId, networkId],
|
|
83
|
+
);
|
|
84
|
+
const mod: any = await import("./server.js");
|
|
85
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
86
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
87
|
+
}, 30_000);
|
|
88
|
+
|
|
89
|
+
afterAll(() => {
|
|
90
|
+
try { server?.stop?.(true); } catch {}
|
|
91
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("Hub scheduled task API and dispatcher", () => {
|
|
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
|
+
});
|
|
101
|
+
|
|
102
|
+
test("schedule math supports timezone-aware daily and weekly forms", () => {
|
|
103
|
+
const daily = nextOccurrence({ type: "daily", time: "09:30" }, "Asia/Shanghai", new Date("2026-08-09T02:00:00Z"));
|
|
104
|
+
expect(daily?.toISOString()).toBe("2026-08-10T01:30:00.000Z");
|
|
105
|
+
const weekly = nextOccurrence({ type: "weekly", time: "09:30", weekdays: [1] }, "Asia/Shanghai", new Date("2026-08-09T02:00:00Z"));
|
|
106
|
+
expect(weekly?.toISOString()).toBe("2026-08-10T01:30:00.000Z");
|
|
107
|
+
|
|
108
|
+
// America/New_York skips 02:30 on 2026-03-08, so the next valid wall
|
|
109
|
+
// clock occurrence is the following day rather than an offset guess.
|
|
110
|
+
const springGap = nextOccurrence({ type: "daily", time: "02:30" }, "America/New_York", new Date("2026-03-08T05:00:00Z"));
|
|
111
|
+
expect(springGap?.toISOString()).toBe("2026-03-09T06:30:00.000Z");
|
|
112
|
+
|
|
113
|
+
// 01:30 occurs twice on the fall-back day. Before the first occurrence we
|
|
114
|
+
// select it; after it has run, the duplicate instant on the same local
|
|
115
|
+
// date is skipped so a daily schedule remains once-per-day.
|
|
116
|
+
const fallFirst = nextOccurrence({ type: "daily", time: "01:30" }, "America/New_York", new Date("2026-11-01T04:00:00Z"));
|
|
117
|
+
expect(fallFirst?.toISOString()).toBe("2026-11-01T05:30:00.000Z");
|
|
118
|
+
const fallAfterFirst = nextOccurrence({ type: "daily", time: "01:30" }, "America/New_York", fallFirst!);
|
|
119
|
+
expect(fallAfterFirst?.toISOString()).toBe("2026-11-02T06:30:00.000Z");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("scheduler startup rejects a backend without real transactions", () => {
|
|
123
|
+
const original = db.atomicTransactions;
|
|
124
|
+
try {
|
|
125
|
+
(db as any).atomicTransactions = false;
|
|
126
|
+
expect(() => assertScheduledTaskBackendSupported()).toThrow("scheduled_tasks_require_transactional_sqlite_backend");
|
|
127
|
+
} finally {
|
|
128
|
+
(db as any).atomicTransactions = original;
|
|
129
|
+
}
|
|
130
|
+
expect(() => assertScheduledTaskBackendSupported()).not.toThrow();
|
|
131
|
+
const misfireColumn = db.all<any>("PRAGMA table_info(scheduled_tasks)").find((column: any) => column.name === "misfire_policy");
|
|
132
|
+
expect(misfireColumn?.dflt_value).toBe("'catch_up_once'");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("owner creates; viewer and node token cannot mutate", async () => {
|
|
136
|
+
const input = {
|
|
137
|
+
network_id: networkId,
|
|
138
|
+
name: "Daily briefing",
|
|
139
|
+
target_node_id: nodeId,
|
|
140
|
+
task: "Summarize the current release state",
|
|
141
|
+
priority: "high",
|
|
142
|
+
timezone: "Asia/Shanghai",
|
|
143
|
+
schedule: { type: "interval", every_seconds: 60 },
|
|
144
|
+
};
|
|
145
|
+
const viewer = await api(viewerToken, "/api/scheduled-tasks", { method: "POST", body: JSON.stringify(input) });
|
|
146
|
+
expect(viewer.status).toBe(403);
|
|
147
|
+
const node = await api(nodeToken, "/api/scheduled-tasks", { method: "POST", body: JSON.stringify(input) });
|
|
148
|
+
expect(node.status).toBe(403);
|
|
149
|
+
expect(node.body.error).toBe("user_token_required");
|
|
150
|
+
const nodeRead = await api(nodeToken, `/api/scheduled-tasks?network_id=${encodeURIComponent(networkId)}`);
|
|
151
|
+
expect(nodeRead.status).toBe(403);
|
|
152
|
+
expect(nodeRead.body.error).toBe("user_token_required");
|
|
153
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM scheduled_tasks WHERE name = 'Daily briefing'")!.count).toBe(0);
|
|
154
|
+
|
|
155
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", { method: "POST", body: JSON.stringify(input) });
|
|
156
|
+
expect(created.status).toBe(201);
|
|
157
|
+
expect(created.body.schedule.target_node_id).toBe(nodeId);
|
|
158
|
+
expect(created.body.schedule.target_alias).toBe("scheduler-node");
|
|
159
|
+
expect(created.body.schedule.schedule.type).toBe("interval");
|
|
160
|
+
expect(created.body.schedule.misfire_policy).toBe("catch_up_once");
|
|
161
|
+
expect(created.body.schedule.created_by).toBeUndefined();
|
|
162
|
+
expect(created.body.schedule.schedule_json).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("network scope hides schedules from foreign users and viewers may read", async () => {
|
|
166
|
+
const scheduleId = (await createSchedule("Scope probe")).schedule_id;
|
|
167
|
+
const visible = await api(viewerToken, `/api/scheduled-tasks?network_id=${encodeURIComponent(networkId)}`);
|
|
168
|
+
expect(visible.status).toBe(200);
|
|
169
|
+
expect(visible.body.schedules.map((x: any) => x.schedule_id)).toContain(scheduleId);
|
|
170
|
+
|
|
171
|
+
const foreign = register(`scheduler_foreign_${Date.now()}`, "SchedulerForeign123!", undefined, "seed");
|
|
172
|
+
expect(foreign.ok).toBe(true);
|
|
173
|
+
const hidden = await api(foreign.token!, `/api/scheduled-tasks?network_id=${encodeURIComponent(networkId)}`);
|
|
174
|
+
expect(hidden.status).toBe(403);
|
|
175
|
+
});
|
|
176
|
+
|
|
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());
|
|
180
|
+
expect(runDueScheduledTasks().processed).toBe(1);
|
|
181
|
+
const run = db.get<any>("SELECT * FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY created_at DESC LIMIT 1", scheduleId)!;
|
|
182
|
+
expect(["delivered", "queued"]).toContain(run.status);
|
|
183
|
+
expect(run.task_id).toBeTruthy();
|
|
184
|
+
const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", run.task_id)!;
|
|
185
|
+
expect(task.to_node_id).toBe(nodeId);
|
|
186
|
+
expect(task.to_name).toBe("scheduler-node");
|
|
187
|
+
expect(task.from_name).toBe("scheduler");
|
|
188
|
+
expect(JSON.parse(task.meta_json).scheduled_task_id).toBe(scheduleId);
|
|
189
|
+
expect(JSON.parse(task.meta_json).created_by).toBeUndefined();
|
|
190
|
+
expect(db.get<any>("SELECT id FROM inbox WHERE id = ?1", run.task_id)).toBeTruthy();
|
|
191
|
+
expect(runDueScheduledTasks().processed).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("misfire policy catches up once by default or skips an overdue occurrence", async () => {
|
|
195
|
+
const make = async (name: string, misfirePolicy?: string) => api(ownerToken, "/api/scheduled-tasks", {
|
|
196
|
+
method: "POST",
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
network_id: networkId,
|
|
199
|
+
name,
|
|
200
|
+
target_node_id: nodeId,
|
|
201
|
+
task: `misfire probe ${name}`,
|
|
202
|
+
timezone: "UTC",
|
|
203
|
+
schedule: { type: "interval", every_seconds: 60 },
|
|
204
|
+
...(misfirePolicy === undefined ? {} : { misfire_policy: misfirePolicy }),
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const invalid = await make("invalid misfire", "run_everything");
|
|
209
|
+
expect(invalid.status).toBe(400);
|
|
210
|
+
expect(invalid.body.error).toBe("invalid_misfire_policy");
|
|
211
|
+
|
|
212
|
+
const now = new Date();
|
|
213
|
+
const overdue = new Date(now.getTime() - 5 * 60_000).toISOString();
|
|
214
|
+
|
|
215
|
+
const catchUp = await make("catch up default");
|
|
216
|
+
expect(catchUp.status).toBe(201);
|
|
217
|
+
expect(catchUp.body.schedule.misfire_policy).toBe("catch_up_once");
|
|
218
|
+
const catchUpId = catchUp.body.schedule.schedule_id as string;
|
|
219
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [overdue, catchUpId]);
|
|
220
|
+
expect(runDueScheduledTasks(now).processed).toBe(1);
|
|
221
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", catchUpId)!.count).toBe(1);
|
|
222
|
+
expect(new Date(db.get<{ next_run_at: string }>("SELECT next_run_at FROM scheduled_tasks WHERE schedule_id = ?1", catchUpId)!.next_run_at).getTime()).toBeGreaterThan(now.getTime());
|
|
223
|
+
|
|
224
|
+
const skipped = await make("skip overdue", "skip");
|
|
225
|
+
expect(skipped.status).toBe(201);
|
|
226
|
+
expect(skipped.body.schedule.misfire_policy).toBe("skip");
|
|
227
|
+
const skippedId = skipped.body.schedule.schedule_id as string;
|
|
228
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [overdue, skippedId]);
|
|
229
|
+
expect(runDueScheduledTasks(now).processed).toBe(1);
|
|
230
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", skippedId)!.count).toBe(0);
|
|
231
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE meta_json LIKE '%' || ?1 || '%'", skippedId)!.count).toBe(0);
|
|
232
|
+
const skippedRun = db.get<any>("SELECT * FROM scheduled_task_runs WHERE schedule_id = ?1", skippedId)!;
|
|
233
|
+
expect(skippedRun.status).toBe("skipped");
|
|
234
|
+
expect(skippedRun.error_code).toBe("misfire_skipped");
|
|
235
|
+
const skippedRow = db.get<any>("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", skippedId)!;
|
|
236
|
+
expect(skippedRow.last_run_at).toBeNull();
|
|
237
|
+
expect(new Date(skippedRow.next_run_at).getTime()).toBeGreaterThan(now.getTime());
|
|
238
|
+
|
|
239
|
+
const withinGrace = await make("skip within grace", "skip");
|
|
240
|
+
const withinGraceId = withinGrace.body.schedule.schedule_id as string;
|
|
241
|
+
const recent = new Date(now.getTime() - 10_000).toISOString();
|
|
242
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [recent, withinGraceId]);
|
|
243
|
+
expect(runDueScheduledTasks(now).processed).toBe(1);
|
|
244
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", withinGraceId)!.count).toBe(1);
|
|
245
|
+
|
|
246
|
+
const once = await api(ownerToken, "/api/scheduled-tasks", {
|
|
247
|
+
method: "POST",
|
|
248
|
+
body: JSON.stringify({
|
|
249
|
+
network_id: networkId,
|
|
250
|
+
name: "skip missed once",
|
|
251
|
+
target_node_id: nodeId,
|
|
252
|
+
task: "this missed one-time task must never run",
|
|
253
|
+
timezone: "UTC",
|
|
254
|
+
misfire_policy: "skip",
|
|
255
|
+
schedule: { type: "once", run_at: new Date(Date.now() + 3600_000).toISOString() },
|
|
256
|
+
}),
|
|
257
|
+
});
|
|
258
|
+
const onceId = once.body.schedule.schedule_id as string;
|
|
259
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [overdue, onceId]);
|
|
260
|
+
expect(runDueScheduledTasks(now).processed).toBe(1);
|
|
261
|
+
const onceRow = db.get<any>("SELECT status, next_run_at, last_run_at FROM scheduled_tasks WHERE schedule_id = ?1", onceId)!;
|
|
262
|
+
expect(onceRow).toEqual({ status: "completed", next_run_at: null, last_run_at: null });
|
|
263
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", onceId)!.count).toBe(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("dispatch failure rolls back occurrence, inbox, task, and schedule advance as one unit", async () => {
|
|
267
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
268
|
+
method: "POST",
|
|
269
|
+
body: JSON.stringify({
|
|
270
|
+
network_id: networkId,
|
|
271
|
+
name: "Atomic rollback probe",
|
|
272
|
+
target_node_id: nodeId,
|
|
273
|
+
task: "This dispatch must be all-or-nothing",
|
|
274
|
+
timezone: "UTC",
|
|
275
|
+
schedule: { type: "interval", every_seconds: 60 },
|
|
276
|
+
}),
|
|
277
|
+
});
|
|
278
|
+
expect(created.status).toBe(201);
|
|
279
|
+
const atomicScheduleId = created.body.schedule.schedule_id as string;
|
|
280
|
+
const due = new Date(Date.now() - 10_000).toISOString();
|
|
281
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due, atomicScheduleId]);
|
|
282
|
+
|
|
283
|
+
// Test-only fault injection at the second durable write. The inbox row has
|
|
284
|
+
// already been attempted when this aborts, so any missing transaction
|
|
285
|
+
// boundary leaves an observable half-state.
|
|
286
|
+
db.run(`CREATE TRIGGER test601_abort_scheduled_task_insert
|
|
287
|
+
BEFORE INSERT ON tasks
|
|
288
|
+
WHEN NEW.meta_json LIKE '%${atomicScheduleId}%'
|
|
289
|
+
BEGIN SELECT RAISE(ABORT, 'test601_injected_task_failure'); END`);
|
|
290
|
+
const failed = runDueScheduledTasks();
|
|
291
|
+
expect(failed.failed).toBe(1);
|
|
292
|
+
expect(db.get("SELECT run_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeNull();
|
|
293
|
+
expect(db.get("SELECT id FROM inbox WHERE meta_json LIKE '%' || ?1 || '%'", atomicScheduleId)).toBeNull();
|
|
294
|
+
expect(db.get("SELECT task_id FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", atomicScheduleId)).toBeNull();
|
|
295
|
+
expect(db.get<{ next_run_at: string }>("SELECT next_run_at FROM scheduled_tasks WHERE schedule_id = ?1", atomicScheduleId)!.next_run_at).toBe(due);
|
|
296
|
+
|
|
297
|
+
db.run("DROP TRIGGER test601_abort_scheduled_task_insert");
|
|
298
|
+
expect(runDueScheduledTasks().processed).toBe(1);
|
|
299
|
+
expect(db.get("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeTruthy();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("two real Hub processes claim one occurrence exactly once", async () => {
|
|
303
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
304
|
+
method: "POST",
|
|
305
|
+
body: JSON.stringify({
|
|
306
|
+
network_id: networkId,
|
|
307
|
+
name: "Cross-process claim probe",
|
|
308
|
+
target_node_id: nodeId,
|
|
309
|
+
task: "Only one worker may create this task",
|
|
310
|
+
timezone: "UTC",
|
|
311
|
+
schedule: { type: "interval", every_seconds: 60 },
|
|
312
|
+
}),
|
|
313
|
+
});
|
|
314
|
+
expect(created.status).toBe(201);
|
|
315
|
+
const raceScheduleId = created.body.schedule.schedule_id as string;
|
|
316
|
+
const due = new Date(Date.now() - 5_000).toISOString();
|
|
317
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due, raceScheduleId]);
|
|
318
|
+
|
|
319
|
+
const raceDir = mkdtempSync(join(dir, "race-"));
|
|
320
|
+
const gate = join(raceDir, "go");
|
|
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");
|
|
325
|
+
const dbPath = activeDbPath;
|
|
326
|
+
const spawnWorker = (id: string) => Bun.spawn(
|
|
327
|
+
["bun", workerPath, dbPath, raceScheduleId, due, join(raceDir, `ready-${id}`), gate],
|
|
328
|
+
{ cwd: repoRoot, stdout: "pipe", stderr: "pipe", env: { ...process.env, COMMHUB_DB: dbPath } },
|
|
329
|
+
);
|
|
330
|
+
const waitReady = async (id: string) => {
|
|
331
|
+
const readyPath = join(raceDir, `ready-${id}`);
|
|
332
|
+
const deadline = Date.now() + 8_000;
|
|
333
|
+
while (!existsSync(readyPath) && Date.now() < deadline) await Bun.sleep(10);
|
|
334
|
+
return existsSync(readyPath);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// Start both DB connections before opening the claim gate. Initializing
|
|
338
|
+
// them sequentially isolates the occurrence race from the repository's
|
|
339
|
+
// pre-existing simultaneous-cold-start PRAGMA journal_mode=WAL race.
|
|
340
|
+
const workers = [spawnWorker("a")];
|
|
341
|
+
const readyA = await waitReady("a");
|
|
342
|
+
if (readyA) workers.push(spawnWorker("b"));
|
|
343
|
+
const readyB = readyA && await waitReady("b");
|
|
344
|
+
if (!readyA || !readyB) {
|
|
345
|
+
for (const worker of workers) worker.kill();
|
|
346
|
+
await Promise.all(workers.map((worker) => worker.exited));
|
|
347
|
+
const diagnostics = await Promise.all(workers.map(async (worker) => ({
|
|
348
|
+
stdout: await new Response(worker.stdout).text(),
|
|
349
|
+
stderr: await new Response(worker.stderr).text(),
|
|
350
|
+
})));
|
|
351
|
+
throw new Error(`race_workers_not_ready: ${JSON.stringify(diagnostics)}`);
|
|
352
|
+
}
|
|
353
|
+
writeFileSync(gate, "go\n", { mode: 0o600 });
|
|
354
|
+
const exits = (await Promise.all(workers.map((worker) => worker.exited))).sort((a, b) => a - b);
|
|
355
|
+
expect(exits).toEqual([0, 3]);
|
|
356
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM scheduled_task_runs WHERE schedule_id = ?1", raceScheduleId)!.count).toBe(1);
|
|
357
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", raceScheduleId)!.count).toBe(1);
|
|
358
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE meta_json LIKE '%' || ?1 || '%'", raceScheduleId)!.count).toBe(1);
|
|
359
|
+
}, 20_000);
|
|
360
|
+
|
|
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);
|
|
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;
|
|
370
|
+
|
|
371
|
+
const due2 = makeDue(scheduleId);
|
|
372
|
+
expect(runDueScheduledTasks().processed).toBe(1);
|
|
373
|
+
expect(db.get<any>("SELECT status FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", scheduleId, due2)!.status).toBe("skipped");
|
|
374
|
+
|
|
375
|
+
db.run("UPDATE tasks SET status = 'replied', completed_at = datetime('now') WHERE task_id = ?1", [firstTask]);
|
|
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]);
|
|
378
|
+
const due3 = new Date(Date.now() - 30_000).toISOString();
|
|
379
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due3, scheduleId]);
|
|
380
|
+
expect(runDueScheduledTasks().processed).toBe(1);
|
|
381
|
+
const renamedTask = db.get<any>("SELECT t.* FROM tasks t JOIN scheduled_task_runs r ON r.task_id=t.task_id WHERE r.schedule_id=?1 AND r.scheduled_for=?2", scheduleId, due3)!;
|
|
382
|
+
expect(renamedTask.to_name).toBe("scheduler-renamed");
|
|
383
|
+
});
|
|
384
|
+
|
|
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;
|
|
387
|
+
// Pin a recognizable future occurrence so a metadata-only edit can prove
|
|
388
|
+
// that it does not silently reset the interval cadence.
|
|
389
|
+
const pinnedNext = new Date(Date.now() + 45 * 60_000).toISOString();
|
|
390
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [pinnedNext, scheduleId]);
|
|
391
|
+
let current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
392
|
+
const metadataOnly = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
393
|
+
method: "PATCH",
|
|
394
|
+
body: JSON.stringify({
|
|
395
|
+
revision: current.revision,
|
|
396
|
+
name: "Edited briefing",
|
|
397
|
+
task: "Summarize only verified release changes",
|
|
398
|
+
priority: "low",
|
|
399
|
+
misfire_policy: "skip",
|
|
400
|
+
// Dashboard/mobile submit a complete form. Identical scheduling
|
|
401
|
+
// values still count as a metadata-only edit and must preserve cadence.
|
|
402
|
+
schedule: current.schedule,
|
|
403
|
+
timezone: current.timezone,
|
|
404
|
+
}),
|
|
405
|
+
});
|
|
406
|
+
expect(metadataOnly.status).toBe(200);
|
|
407
|
+
expect(metadataOnly.body.schedule.name).toBe("Edited briefing");
|
|
408
|
+
expect(metadataOnly.body.schedule.task_content).toBe("Summarize only verified release changes");
|
|
409
|
+
expect(metadataOnly.body.schedule.priority).toBe("low");
|
|
410
|
+
expect(metadataOnly.body.schedule.misfire_policy).toBe("skip");
|
|
411
|
+
expect(metadataOnly.body.schedule.next_run_at).toBe(pinnedNext);
|
|
412
|
+
|
|
413
|
+
// A schedule/timezone edit must reuse nextOccurrence's IANA/DST behavior.
|
|
414
|
+
const beforeEdit = new Date();
|
|
415
|
+
const dstEdit = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
416
|
+
method: "PATCH",
|
|
417
|
+
body: JSON.stringify({
|
|
418
|
+
revision: metadataOnly.body.schedule.revision,
|
|
419
|
+
schedule: { type: "daily", time: "01:30" },
|
|
420
|
+
timezone: "America/New_York",
|
|
421
|
+
}),
|
|
422
|
+
});
|
|
423
|
+
expect(dstEdit.status).toBe(200);
|
|
424
|
+
expect(dstEdit.body.schedule.schedule).toEqual({ type: "daily", time: "01:30" });
|
|
425
|
+
expect(dstEdit.body.schedule.timezone).toBe("America/New_York");
|
|
426
|
+
const expectedFloor = nextOccurrence({ type: "daily", time: "01:30" }, "America/New_York", beforeEdit)!;
|
|
427
|
+
expect(new Date(dstEdit.body.schedule.next_run_at).getTime()).toBe(expectedFloor.getTime());
|
|
428
|
+
|
|
429
|
+
const invalidPolicy = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
430
|
+
method: "PATCH",
|
|
431
|
+
body: JSON.stringify({ revision: dstEdit.body.schedule.revision, misfire_policy: "run_everything" }),
|
|
432
|
+
});
|
|
433
|
+
expect(invalidPolicy.status).toBe(400);
|
|
434
|
+
expect(invalidPolicy.body.error).toBe("invalid_misfire_policy");
|
|
435
|
+
|
|
436
|
+
const foreign = register(`scheduler_edit_foreign_${Date.now()}`, "SchedulerEditForeign123!", undefined, "seed");
|
|
437
|
+
expect(foreign.ok).toBe(true);
|
|
438
|
+
const foreignNetworkId = foreign.network_id!;
|
|
439
|
+
const foreignNodeId = `n_foreign_edit_${Date.now()}`;
|
|
440
|
+
db.run(
|
|
441
|
+
"INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, 'Foreign Edit Node', 'foreign-edit-node', 'codex-sdk', ?2)",
|
|
442
|
+
[foreignNodeId, foreignNetworkId],
|
|
443
|
+
);
|
|
444
|
+
const crossNetwork = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
445
|
+
method: "PATCH",
|
|
446
|
+
body: JSON.stringify({ revision: dstEdit.body.schedule.revision, target_node_id: foreignNodeId }),
|
|
447
|
+
});
|
|
448
|
+
expect(crossNetwork.status).toBe(404);
|
|
449
|
+
expect(crossNetwork.body.error).toBe("target_node_not_found");
|
|
450
|
+
current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
451
|
+
expect(current.target_node_id).toBe(nodeId);
|
|
452
|
+
expect(current.revision).toBe(dstEdit.body.schedule.revision);
|
|
453
|
+
});
|
|
454
|
+
|
|
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;
|
|
457
|
+
const current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
458
|
+
const [a, b] = await Promise.all([
|
|
459
|
+
api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
460
|
+
method: "PATCH",
|
|
461
|
+
body: JSON.stringify({ revision: current.revision, name: "Concurrent editor A" }),
|
|
462
|
+
}),
|
|
463
|
+
api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
464
|
+
method: "PATCH",
|
|
465
|
+
body: JSON.stringify({ revision: current.revision, name: "Concurrent editor B" }),
|
|
466
|
+
}),
|
|
467
|
+
]);
|
|
468
|
+
expect([a.status, b.status].sort()).toEqual([200, 409]);
|
|
469
|
+
const conflict = a.status === 409 ? a : b;
|
|
470
|
+
expect(conflict.body.error).toBe("revision_conflict");
|
|
471
|
+
const latest = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
472
|
+
expect(latest.revision).toBe(current.revision + 1);
|
|
473
|
+
expect(["Concurrent editor A", "Concurrent editor B"]).toContain(latest.name);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Owner report 2026-09-29: editing a 2-minute schedule failed with
|
|
477
|
+
// revision_conflict whenever a run landed while the dialog was open, and the
|
|
478
|
+
// client dropped the draft. Runs are bookkeeping, not edits.
|
|
479
|
+
test("a scheduler run between GET and PATCH does not conflict with the edit", async () => {
|
|
480
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
481
|
+
method: "POST",
|
|
482
|
+
body: JSON.stringify({ network_id: networkId, name: "Run-during-edit", target_node_id: nodeId, task: "before edit", timezone: "Asia/Shanghai", schedule: { type: "interval", every_seconds: 120 } }),
|
|
483
|
+
});
|
|
484
|
+
expect(created.status).toBe(201);
|
|
485
|
+
const id = created.body.schedule.schedule_id;
|
|
486
|
+
const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
|
|
487
|
+
const opened = (await api(ownerToken, path)).body.schedule;
|
|
488
|
+
|
|
489
|
+
const firedAt = new Date(Date.now() - 1_000).toISOString();
|
|
490
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [firedAt, id]);
|
|
491
|
+
runDueScheduledTasks();
|
|
492
|
+
const afterRun = (await api(ownerToken, path)).body.schedule;
|
|
493
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1", id)!.n).toBe(1);
|
|
494
|
+
expect(afterRun.last_run_at).toBe(firedAt);
|
|
495
|
+
expect(new Date(afterRun.next_run_at).getTime()).toBeGreaterThan(Date.now());
|
|
496
|
+
expect(afterRun.revision).toBe(opened.revision);
|
|
497
|
+
|
|
498
|
+
const saved = await api(ownerToken, path, {
|
|
499
|
+
method: "PATCH",
|
|
500
|
+
body: JSON.stringify({ revision: opened.revision, task: "edited while it ran", name: opened.name, schedule: opened.schedule, timezone: opened.timezone }),
|
|
501
|
+
});
|
|
502
|
+
expect(saved.status).toBe(200);
|
|
503
|
+
expect(saved.body.schedule.task_content).toBe("edited while it ran");
|
|
504
|
+
expect(saved.body.schedule.revision).toBe(opened.revision + 1);
|
|
505
|
+
// Cadence edit-preservation must keep the scheduler's advanced slot, not
|
|
506
|
+
// resurrect the occurrence that already fired.
|
|
507
|
+
expect(saved.body.schedule.next_run_at).toBe(afterRun.next_run_at);
|
|
508
|
+
|
|
509
|
+
// A second device still holding the pre-edit revision is a true conflict.
|
|
510
|
+
const stale = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ revision: opened.revision, task: "other device" }) });
|
|
511
|
+
expect(stale.status).toBe(409);
|
|
512
|
+
expect(stale.body.error).toBe("revision_conflict");
|
|
513
|
+
await api(ownerToken, `/api/scheduled-tasks/${id}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("a run that lands while the PATCH body is in flight keeps the advanced next_run_at", async () => {
|
|
517
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
518
|
+
method: "POST",
|
|
519
|
+
body: JSON.stringify({ network_id: networkId, name: "Run-during-body", target_node_id: nodeId, task: "before", timezone: "Asia/Shanghai", schedule: { type: "interval", every_seconds: 120 } }),
|
|
520
|
+
});
|
|
521
|
+
const id = created.body.schedule.schedule_id;
|
|
522
|
+
const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
|
|
523
|
+
const firedAt = new Date(Date.now() - 1_000).toISOString();
|
|
524
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [firedAt, id]);
|
|
525
|
+
const opened = (await api(ownerToken, path)).body.schedule;
|
|
526
|
+
|
|
527
|
+
// The handler reads the row before awaiting the body. Hold the body open,
|
|
528
|
+
// run the scheduler, then release it — the PATCH now carries a stale
|
|
529
|
+
// row.next_run_at (the slot that just fired).
|
|
530
|
+
let release!: () => void;
|
|
531
|
+
const gate = new Promise<void>((r) => { release = r; });
|
|
532
|
+
const payload = new TextEncoder().encode(JSON.stringify({ revision: opened.revision, task: "edited mid-run" }));
|
|
533
|
+
const body = new ReadableStream({ async pull(c) { await gate; c.enqueue(payload); c.close(); } });
|
|
534
|
+
const pending = fetch(`${base}${path}`, { method: "PATCH", body, duplex: "half", headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" } } as any);
|
|
535
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
536
|
+
runDueScheduledTasks();
|
|
537
|
+
const advanced = db.get<{ next_run_at: string }>("SELECT next_run_at FROM scheduled_tasks WHERE schedule_id = ?1", id)!.next_run_at;
|
|
538
|
+
expect(advanced).not.toBe(firedAt);
|
|
539
|
+
release();
|
|
540
|
+
const res = await pending;
|
|
541
|
+
const saved = await res.json() as any;
|
|
542
|
+
expect(res.status).toBe(200);
|
|
543
|
+
expect(saved.schedule.task_content).toBe("edited mid-run");
|
|
544
|
+
expect(saved.schedule.next_run_at).toBe(advanced);
|
|
545
|
+
await api(ownerToken, `/api/scheduled-tasks/${id}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("one-shot auto-complete is a user-visible status change and still bumps revision", async () => {
|
|
549
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
550
|
+
method: "POST",
|
|
551
|
+
body: JSON.stringify({ network_id: networkId, name: "One-shot", target_node_id: nodeId, task: "once", timezone: "Asia/Shanghai", schedule: { type: "once", run_at: new Date(Date.now() + 3_600_000).toISOString() } }),
|
|
552
|
+
});
|
|
553
|
+
const id = created.body.schedule.schedule_id;
|
|
554
|
+
const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
|
|
555
|
+
const opened = (await api(ownerToken, path)).body.schedule;
|
|
556
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1_000).toISOString(), id]);
|
|
557
|
+
runDueScheduledTasks();
|
|
558
|
+
const done = (await api(ownerToken, path)).body.schedule;
|
|
559
|
+
expect(done.status).toBe("completed");
|
|
560
|
+
expect(done.revision).toBe(opened.revision + 1);
|
|
561
|
+
const edit = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ revision: opened.revision, task: "too late" }) });
|
|
562
|
+
expect(edit.status).toBe(409);
|
|
563
|
+
expect(edit.body.error).toBe("schedule_completed");
|
|
564
|
+
});
|
|
565
|
+
|
|
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);
|
|
577
|
+
const latest = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`);
|
|
578
|
+
const revision = latest.body.schedule.revision;
|
|
579
|
+
const stale = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "PATCH", body: JSON.stringify({ revision: revision - 1, status: "paused" }) });
|
|
580
|
+
expect(stale.status).toBe(409);
|
|
581
|
+
const paused = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "PATCH", body: JSON.stringify({ revision, status: "paused" }) });
|
|
582
|
+
expect(paused.body.schedule.status).toBe("paused");
|
|
583
|
+
expect(paused.body.schedule.next_run_at).toBeNull();
|
|
584
|
+
const resumed = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "PATCH", body: JSON.stringify({ revision: paused.body.schedule.revision, status: "active" }) });
|
|
585
|
+
expect(resumed.body.schedule.next_run_at).toBeTruthy();
|
|
586
|
+
|
|
587
|
+
db.run("UPDATE tasks SET status='replied' WHERE task_id IN (SELECT task_id FROM scheduled_task_runs WHERE schedule_id=?1)", [scheduleId]);
|
|
588
|
+
const manual = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/run-now?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
589
|
+
expect(manual.status).toBe(202);
|
|
590
|
+
expect(manual.body.taskId).toBeTruthy();
|
|
591
|
+
// Cancel via POST /cancel (the path the dashboard uses, since some
|
|
592
|
+
// reverse proxies rewrite DELETE to 405). Then verify the legacy
|
|
593
|
+
// DELETE endpoint is still accepted and idempotent when the row is
|
|
594
|
+
// already cancelled — both spellings must reach the same state.
|
|
595
|
+
const cancelled = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
596
|
+
expect(cancelled.status).toBe(200);
|
|
597
|
+
expect(cancelled.body.status).toBe("cancelled");
|
|
598
|
+
const cancelledRow = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
599
|
+
const revisionAfterCancel = cancelledRow.revision;
|
|
600
|
+
// Idempotency: DELETE on an already-cancelled row must be 200 with the
|
|
601
|
+
// same revision, not 409, and must not bump revision again.
|
|
602
|
+
const reCancelledDelete = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "DELETE" });
|
|
603
|
+
expect(reCancelledDelete.status).toBe(200);
|
|
604
|
+
expect(reCancelledDelete.body.status).toBe("cancelled");
|
|
605
|
+
const afterDelete = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
606
|
+
expect(afterDelete.revision).toBe(revisionAfterCancel);
|
|
607
|
+
// And POST /cancel on an already-cancelled row is also idempotent.
|
|
608
|
+
const reCancelledPost = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
609
|
+
expect(reCancelledPost.status).toBe(200);
|
|
610
|
+
expect(reCancelledPost.body.status).toBe("cancelled");
|
|
611
|
+
const afterPost = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
612
|
+
expect(afterPost.revision).toBe(revisionAfterCancel);
|
|
613
|
+
const resurrect = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
614
|
+
method: "PATCH",
|
|
615
|
+
body: JSON.stringify({ revision: cancelledRow.revision, status: "active", name: "must not revive" }),
|
|
616
|
+
});
|
|
617
|
+
expect(resurrect.status).toBe(409);
|
|
618
|
+
expect(resurrect.body.error).toBe("schedule_cancelled");
|
|
619
|
+
const stillCancelled = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
620
|
+
expect(stillCancelled.status).toBe("cancelled");
|
|
621
|
+
expect(stillCancelled.revision).toBe(cancelledRow.revision);
|
|
622
|
+
const runs = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/runs?network_id=${encodeURIComponent(networkId)}`);
|
|
623
|
+
expect(runs.body.runs.length).toBeGreaterThanOrEqual(4);
|
|
624
|
+
});
|
|
625
|
+
});
|