@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.30
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 +125 -7
- 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-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -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.ts +48 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +522 -32
- 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 +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -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 +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +96 -7
- package/src/require-explicit-test-db.ts +9 -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 +211 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3492 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +140 -0
- package/src/side-thread-command-transport.ts +309 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread.test.ts +1038 -0
- package/src/side-thread.ts +2241 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +398 -0
- package/src/task-diagnostic-http.test.ts +145 -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 +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2302 -169
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
6
|
+
import { db } from "./db.js";
|
|
7
|
+
import { assertScheduledTaskBackendSupported, nextOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
|
|
8
|
+
|
|
9
|
+
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");
|
|
12
|
+
let server: any;
|
|
13
|
+
let base = "";
|
|
14
|
+
let ownerToken = "";
|
|
15
|
+
let viewerToken = "";
|
|
16
|
+
let nodeToken = "";
|
|
17
|
+
let networkId = "";
|
|
18
|
+
let ownerId = "";
|
|
19
|
+
const nodeId = `n_sched_${Date.now()}`;
|
|
20
|
+
|
|
21
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
22
|
+
const res = await fetch(`${base}${path}`, {
|
|
23
|
+
...init,
|
|
24
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
|
|
25
|
+
});
|
|
26
|
+
return { status: res.status, body: await res.json() as any };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
const owner = register(`scheduler_owner_${Date.now()}`, "SchedulerOwner123!", undefined, "seed");
|
|
31
|
+
expect(owner.ok).toBe(true);
|
|
32
|
+
ownerToken = owner.token!;
|
|
33
|
+
networkId = owner.network_id!;
|
|
34
|
+
ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId)!.owner_id;
|
|
35
|
+
|
|
36
|
+
const viewer = register(`scheduler_viewer_${Date.now()}`, "SchedulerViewer123!", undefined, "seed");
|
|
37
|
+
expect(viewer.ok).toBe(true);
|
|
38
|
+
viewerToken = viewer.token!;
|
|
39
|
+
const viewerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", viewer.user!.username)!.user_id;
|
|
40
|
+
addNetworkMember(networkId, viewerId, "viewer", ownerId);
|
|
41
|
+
const ntok = createNetworkTokenForNode(ownerId, networkId, "scheduler-node");
|
|
42
|
+
expect(ntok.ok).toBe(true);
|
|
43
|
+
nodeToken = ntok.token!;
|
|
44
|
+
|
|
45
|
+
db.run(
|
|
46
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, 'Scheduler Node', 'scheduler-node', 'codex-sdk', ?2)`,
|
|
47
|
+
[nodeId, networkId],
|
|
48
|
+
);
|
|
49
|
+
db.run(
|
|
50
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, 'scheduler-node', 'idle', ?2, ?3)`,
|
|
51
|
+
[`r_${Date.now()}`, nodeId, networkId],
|
|
52
|
+
);
|
|
53
|
+
const mod: any = await import("./server.js");
|
|
54
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
55
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
try { server?.stop?.(true); } catch {}
|
|
60
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("Hub scheduled task API and dispatcher", () => {
|
|
64
|
+
let scheduleId = "";
|
|
65
|
+
let revision = 0;
|
|
66
|
+
|
|
67
|
+
test("schedule math supports timezone-aware daily and weekly forms", () => {
|
|
68
|
+
const daily = nextOccurrence({ type: "daily", time: "09:30" }, "Asia/Shanghai", new Date("2026-08-09T02:00:00Z"));
|
|
69
|
+
expect(daily?.toISOString()).toBe("2026-08-10T01:30:00.000Z");
|
|
70
|
+
const weekly = nextOccurrence({ type: "weekly", time: "09:30", weekdays: [1] }, "Asia/Shanghai", new Date("2026-08-09T02:00:00Z"));
|
|
71
|
+
expect(weekly?.toISOString()).toBe("2026-08-10T01:30:00.000Z");
|
|
72
|
+
|
|
73
|
+
// America/New_York skips 02:30 on 2026-03-08, so the next valid wall
|
|
74
|
+
// clock occurrence is the following day rather than an offset guess.
|
|
75
|
+
const springGap = nextOccurrence({ type: "daily", time: "02:30" }, "America/New_York", new Date("2026-03-08T05:00:00Z"));
|
|
76
|
+
expect(springGap?.toISOString()).toBe("2026-03-09T06:30:00.000Z");
|
|
77
|
+
|
|
78
|
+
// 01:30 occurs twice on the fall-back day. Before the first occurrence we
|
|
79
|
+
// select it; after it has run, the duplicate instant on the same local
|
|
80
|
+
// date is skipped so a daily schedule remains once-per-day.
|
|
81
|
+
const fallFirst = nextOccurrence({ type: "daily", time: "01:30" }, "America/New_York", new Date("2026-11-01T04:00:00Z"));
|
|
82
|
+
expect(fallFirst?.toISOString()).toBe("2026-11-01T05:30:00.000Z");
|
|
83
|
+
const fallAfterFirst = nextOccurrence({ type: "daily", time: "01:30" }, "America/New_York", fallFirst!);
|
|
84
|
+
expect(fallAfterFirst?.toISOString()).toBe("2026-11-02T06:30:00.000Z");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("scheduler startup rejects a backend without real transactions", () => {
|
|
88
|
+
const original = db.dialect;
|
|
89
|
+
try {
|
|
90
|
+
(db as any).dialect = "postgres";
|
|
91
|
+
expect(() => assertScheduledTaskBackendSupported()).toThrow("scheduled_tasks_require_transactional_sqlite_backend");
|
|
92
|
+
} finally {
|
|
93
|
+
(db as any).dialect = original;
|
|
94
|
+
}
|
|
95
|
+
expect(() => assertScheduledTaskBackendSupported()).not.toThrow();
|
|
96
|
+
const misfireColumn = db.all<any>("PRAGMA table_info(scheduled_tasks)").find((column: any) => column.name === "misfire_policy");
|
|
97
|
+
expect(misfireColumn?.dflt_value).toBe("'catch_up_once'");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("owner creates; viewer and node token cannot mutate", async () => {
|
|
101
|
+
const input = {
|
|
102
|
+
network_id: networkId,
|
|
103
|
+
name: "Daily briefing",
|
|
104
|
+
target_node_id: nodeId,
|
|
105
|
+
task: "Summarize the current release state",
|
|
106
|
+
priority: "high",
|
|
107
|
+
timezone: "Asia/Shanghai",
|
|
108
|
+
schedule: { type: "interval", every_seconds: 60 },
|
|
109
|
+
};
|
|
110
|
+
const viewer = await api(viewerToken, "/api/scheduled-tasks", { method: "POST", body: JSON.stringify(input) });
|
|
111
|
+
expect(viewer.status).toBe(403);
|
|
112
|
+
const node = await api(nodeToken, "/api/scheduled-tasks", { method: "POST", body: JSON.stringify(input) });
|
|
113
|
+
expect(node.status).toBe(403);
|
|
114
|
+
expect(node.body.error).toBe("user_token_required");
|
|
115
|
+
const nodeRead = await api(nodeToken, `/api/scheduled-tasks?network_id=${encodeURIComponent(networkId)}`);
|
|
116
|
+
expect(nodeRead.status).toBe(403);
|
|
117
|
+
expect(nodeRead.body.error).toBe("user_token_required");
|
|
118
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM scheduled_tasks WHERE name = 'Daily briefing'")!.count).toBe(0);
|
|
119
|
+
|
|
120
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", { method: "POST", body: JSON.stringify(input) });
|
|
121
|
+
expect(created.status).toBe(201);
|
|
122
|
+
expect(created.body.schedule.target_node_id).toBe(nodeId);
|
|
123
|
+
expect(created.body.schedule.target_alias).toBe("scheduler-node");
|
|
124
|
+
expect(created.body.schedule.schedule.type).toBe("interval");
|
|
125
|
+
expect(created.body.schedule.misfire_policy).toBe("catch_up_once");
|
|
126
|
+
expect(created.body.schedule.created_by).toBeUndefined();
|
|
127
|
+
expect(created.body.schedule.schedule_json).toBeUndefined();
|
|
128
|
+
scheduleId = created.body.schedule.schedule_id;
|
|
129
|
+
revision = created.body.schedule.revision;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("network scope hides schedules from foreign users and viewers may read", async () => {
|
|
133
|
+
const visible = await api(viewerToken, `/api/scheduled-tasks?network_id=${encodeURIComponent(networkId)}`);
|
|
134
|
+
expect(visible.status).toBe(200);
|
|
135
|
+
expect(visible.body.schedules.map((x: any) => x.schedule_id)).toContain(scheduleId);
|
|
136
|
+
|
|
137
|
+
const foreign = register(`scheduler_foreign_${Date.now()}`, "SchedulerForeign123!", undefined, "seed");
|
|
138
|
+
expect(foreign.ok).toBe(true);
|
|
139
|
+
const hidden = await api(foreign.token!, `/api/scheduled-tasks?network_id=${encodeURIComponent(networkId)}`);
|
|
140
|
+
expect(hidden.status).toBe(403);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("due occurrence creates ordinary inbox/task/run atomically and is idempotent", () => {
|
|
144
|
+
const due = new Date(Date.now() - 120_000).toISOString();
|
|
145
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due, scheduleId]);
|
|
146
|
+
expect(runDueScheduledTasks().processed).toBe(1);
|
|
147
|
+
const run = db.get<any>("SELECT * FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY created_at DESC LIMIT 1", scheduleId)!;
|
|
148
|
+
expect(["delivered", "queued"]).toContain(run.status);
|
|
149
|
+
expect(run.task_id).toBeTruthy();
|
|
150
|
+
const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", run.task_id)!;
|
|
151
|
+
expect(task.to_node_id).toBe(nodeId);
|
|
152
|
+
expect(task.to_name).toBe("scheduler-node");
|
|
153
|
+
expect(task.from_name).toBe("scheduler");
|
|
154
|
+
expect(JSON.parse(task.meta_json).scheduled_task_id).toBe(scheduleId);
|
|
155
|
+
expect(JSON.parse(task.meta_json).created_by).toBeUndefined();
|
|
156
|
+
expect(db.get<any>("SELECT id FROM inbox WHERE id = ?1", run.task_id)).toBeTruthy();
|
|
157
|
+
expect(runDueScheduledTasks().processed).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("misfire policy catches up once by default or skips an overdue occurrence", async () => {
|
|
161
|
+
const make = async (name: string, misfirePolicy?: string) => api(ownerToken, "/api/scheduled-tasks", {
|
|
162
|
+
method: "POST",
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
network_id: networkId,
|
|
165
|
+
name,
|
|
166
|
+
target_node_id: nodeId,
|
|
167
|
+
task: `misfire probe ${name}`,
|
|
168
|
+
timezone: "UTC",
|
|
169
|
+
schedule: { type: "interval", every_seconds: 60 },
|
|
170
|
+
...(misfirePolicy === undefined ? {} : { misfire_policy: misfirePolicy }),
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const invalid = await make("invalid misfire", "run_everything");
|
|
175
|
+
expect(invalid.status).toBe(400);
|
|
176
|
+
expect(invalid.body.error).toBe("invalid_misfire_policy");
|
|
177
|
+
|
|
178
|
+
const now = new Date();
|
|
179
|
+
const overdue = new Date(now.getTime() - 5 * 60_000).toISOString();
|
|
180
|
+
|
|
181
|
+
const catchUp = await make("catch up default");
|
|
182
|
+
expect(catchUp.status).toBe(201);
|
|
183
|
+
expect(catchUp.body.schedule.misfire_policy).toBe("catch_up_once");
|
|
184
|
+
const catchUpId = catchUp.body.schedule.schedule_id as string;
|
|
185
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [overdue, catchUpId]);
|
|
186
|
+
expect(runDueScheduledTasks(now).processed).toBe(1);
|
|
187
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", catchUpId)!.count).toBe(1);
|
|
188
|
+
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());
|
|
189
|
+
|
|
190
|
+
const skipped = await make("skip overdue", "skip");
|
|
191
|
+
expect(skipped.status).toBe(201);
|
|
192
|
+
expect(skipped.body.schedule.misfire_policy).toBe("skip");
|
|
193
|
+
const skippedId = skipped.body.schedule.schedule_id as string;
|
|
194
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [overdue, skippedId]);
|
|
195
|
+
expect(runDueScheduledTasks(now).processed).toBe(1);
|
|
196
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", skippedId)!.count).toBe(0);
|
|
197
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE meta_json LIKE '%' || ?1 || '%'", skippedId)!.count).toBe(0);
|
|
198
|
+
const skippedRun = db.get<any>("SELECT * FROM scheduled_task_runs WHERE schedule_id = ?1", skippedId)!;
|
|
199
|
+
expect(skippedRun.status).toBe("skipped");
|
|
200
|
+
expect(skippedRun.error_code).toBe("misfire_skipped");
|
|
201
|
+
const skippedRow = db.get<any>("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", skippedId)!;
|
|
202
|
+
expect(skippedRow.last_run_at).toBeNull();
|
|
203
|
+
expect(new Date(skippedRow.next_run_at).getTime()).toBeGreaterThan(now.getTime());
|
|
204
|
+
|
|
205
|
+
const withinGrace = await make("skip within grace", "skip");
|
|
206
|
+
const withinGraceId = withinGrace.body.schedule.schedule_id as string;
|
|
207
|
+
const recent = new Date(now.getTime() - 10_000).toISOString();
|
|
208
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [recent, withinGraceId]);
|
|
209
|
+
expect(runDueScheduledTasks(now).processed).toBe(1);
|
|
210
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", withinGraceId)!.count).toBe(1);
|
|
211
|
+
|
|
212
|
+
const once = await api(ownerToken, "/api/scheduled-tasks", {
|
|
213
|
+
method: "POST",
|
|
214
|
+
body: JSON.stringify({
|
|
215
|
+
network_id: networkId,
|
|
216
|
+
name: "skip missed once",
|
|
217
|
+
target_node_id: nodeId,
|
|
218
|
+
task: "this missed one-time task must never run",
|
|
219
|
+
timezone: "UTC",
|
|
220
|
+
misfire_policy: "skip",
|
|
221
|
+
schedule: { type: "once", run_at: new Date(Date.now() + 3600_000).toISOString() },
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
const onceId = once.body.schedule.schedule_id as string;
|
|
225
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [overdue, onceId]);
|
|
226
|
+
expect(runDueScheduledTasks(now).processed).toBe(1);
|
|
227
|
+
const onceRow = db.get<any>("SELECT status, next_run_at, last_run_at FROM scheduled_tasks WHERE schedule_id = ?1", onceId)!;
|
|
228
|
+
expect(onceRow).toEqual({ status: "completed", next_run_at: null, last_run_at: null });
|
|
229
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", onceId)!.count).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("dispatch failure rolls back occurrence, inbox, task, and schedule advance as one unit", async () => {
|
|
233
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
234
|
+
method: "POST",
|
|
235
|
+
body: JSON.stringify({
|
|
236
|
+
network_id: networkId,
|
|
237
|
+
name: "Atomic rollback probe",
|
|
238
|
+
target_node_id: nodeId,
|
|
239
|
+
task: "This dispatch must be all-or-nothing",
|
|
240
|
+
timezone: "UTC",
|
|
241
|
+
schedule: { type: "interval", every_seconds: 60 },
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
expect(created.status).toBe(201);
|
|
245
|
+
const atomicScheduleId = created.body.schedule.schedule_id as string;
|
|
246
|
+
const due = new Date(Date.now() - 10_000).toISOString();
|
|
247
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due, atomicScheduleId]);
|
|
248
|
+
|
|
249
|
+
// Test-only fault injection at the second durable write. The inbox row has
|
|
250
|
+
// already been attempted when this aborts, so any missing transaction
|
|
251
|
+
// boundary leaves an observable half-state.
|
|
252
|
+
db.run(`CREATE TRIGGER test601_abort_scheduled_task_insert
|
|
253
|
+
BEFORE INSERT ON tasks
|
|
254
|
+
WHEN NEW.meta_json LIKE '%${atomicScheduleId}%'
|
|
255
|
+
BEGIN SELECT RAISE(ABORT, 'test601_injected_task_failure'); END`);
|
|
256
|
+
const failed = runDueScheduledTasks();
|
|
257
|
+
expect(failed.failed).toBe(1);
|
|
258
|
+
expect(db.get("SELECT run_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeNull();
|
|
259
|
+
expect(db.get("SELECT id FROM inbox WHERE meta_json LIKE '%' || ?1 || '%'", atomicScheduleId)).toBeNull();
|
|
260
|
+
expect(db.get("SELECT task_id FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", atomicScheduleId)).toBeNull();
|
|
261
|
+
expect(db.get<{ next_run_at: string }>("SELECT next_run_at FROM scheduled_tasks WHERE schedule_id = ?1", atomicScheduleId)!.next_run_at).toBe(due);
|
|
262
|
+
|
|
263
|
+
db.run("DROP TRIGGER test601_abort_scheduled_task_insert");
|
|
264
|
+
expect(runDueScheduledTasks().processed).toBe(1);
|
|
265
|
+
expect(db.get("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeTruthy();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("two real Hub processes claim one occurrence exactly once", async () => {
|
|
269
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
270
|
+
method: "POST",
|
|
271
|
+
body: JSON.stringify({
|
|
272
|
+
network_id: networkId,
|
|
273
|
+
name: "Cross-process claim probe",
|
|
274
|
+
target_node_id: nodeId,
|
|
275
|
+
task: "Only one worker may create this task",
|
|
276
|
+
timezone: "UTC",
|
|
277
|
+
schedule: { type: "interval", every_seconds: 60 },
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
expect(created.status).toBe(201);
|
|
281
|
+
const raceScheduleId = created.body.schedule.schedule_id as string;
|
|
282
|
+
const due = new Date(Date.now() - 5_000).toISOString();
|
|
283
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due, raceScheduleId]);
|
|
284
|
+
|
|
285
|
+
const raceDir = mkdtempSync(join(dir, "race-"));
|
|
286
|
+
const gate = join(raceDir, "go");
|
|
287
|
+
const workerPath = "tests/test601-hub-scheduled-tasks/race-worker.ts";
|
|
288
|
+
const dbPath = activeDbPath;
|
|
289
|
+
const spawnWorker = (id: string) => Bun.spawn(
|
|
290
|
+
["bun", workerPath, dbPath, raceScheduleId, due, join(raceDir, `ready-${id}`), gate],
|
|
291
|
+
{ cwd: process.cwd(), stdout: "pipe", stderr: "pipe", env: { ...process.env, COMMHUB_DB: dbPath } },
|
|
292
|
+
);
|
|
293
|
+
const waitReady = async (id: string) => {
|
|
294
|
+
const readyPath = join(raceDir, `ready-${id}`);
|
|
295
|
+
const deadline = Date.now() + 8_000;
|
|
296
|
+
while (!existsSync(readyPath) && Date.now() < deadline) await Bun.sleep(10);
|
|
297
|
+
return existsSync(readyPath);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Start both DB connections before opening the claim gate. Initializing
|
|
301
|
+
// them sequentially isolates the occurrence race from the repository's
|
|
302
|
+
// pre-existing simultaneous-cold-start PRAGMA journal_mode=WAL race.
|
|
303
|
+
const workers = [spawnWorker("a")];
|
|
304
|
+
const readyA = await waitReady("a");
|
|
305
|
+
if (readyA) workers.push(spawnWorker("b"));
|
|
306
|
+
const readyB = readyA && await waitReady("b");
|
|
307
|
+
if (!readyA || !readyB) {
|
|
308
|
+
for (const worker of workers) worker.kill();
|
|
309
|
+
await Promise.all(workers.map((worker) => worker.exited));
|
|
310
|
+
const diagnostics = await Promise.all(workers.map(async (worker) => ({
|
|
311
|
+
stdout: await new Response(worker.stdout).text(),
|
|
312
|
+
stderr: await new Response(worker.stderr).text(),
|
|
313
|
+
})));
|
|
314
|
+
throw new Error(`race_workers_not_ready: ${JSON.stringify(diagnostics)}`);
|
|
315
|
+
}
|
|
316
|
+
writeFileSync(gate, "go\n", { mode: 0o600 });
|
|
317
|
+
const exits = (await Promise.all(workers.map((worker) => worker.exited))).sort((a, b) => a - b);
|
|
318
|
+
expect(exits).toEqual([0, 3]);
|
|
319
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM scheduled_task_runs WHERE schedule_id = ?1", raceScheduleId)!.count).toBe(1);
|
|
320
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", raceScheduleId)!.count).toBe(1);
|
|
321
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE meta_json LIKE '%' || ?1 || '%'", raceScheduleId)!.count).toBe(1);
|
|
322
|
+
}, 20_000);
|
|
323
|
+
|
|
324
|
+
test("non-overlap skips while prior task is open; rename follows stable node_id", () => {
|
|
325
|
+
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;
|
|
326
|
+
const due2 = new Date(Date.now() - 60_000).toISOString();
|
|
327
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due2, scheduleId]);
|
|
328
|
+
expect(runDueScheduledTasks().processed).toBe(1);
|
|
329
|
+
expect(db.get<any>("SELECT status FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", scheduleId, due2)!.status).toBe("skipped");
|
|
330
|
+
|
|
331
|
+
db.run("UPDATE tasks SET status = 'replied', completed_at = datetime('now') WHERE task_id = ?1", [firstTask]);
|
|
332
|
+
db.run("UPDATE nodes SET alias = 'scheduler-renamed' WHERE node_id = ?1 AND network_id = ?2", [nodeId, networkId]);
|
|
333
|
+
db.run("UPDATE sessions SET alias = 'scheduler-renamed' WHERE node_id = ?1 AND network_id = ?2", [nodeId, networkId]);
|
|
334
|
+
const due3 = new Date(Date.now() - 30_000).toISOString();
|
|
335
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [due3, scheduleId]);
|
|
336
|
+
expect(runDueScheduledTasks().processed).toBe(1);
|
|
337
|
+
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)!;
|
|
338
|
+
expect(renamedTask.to_name).toBe("scheduler-renamed");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("full edit validates target and policy, preserves cadence, and recomputes through DST-safe schedule math", async () => {
|
|
342
|
+
// Pin a recognizable future occurrence so a metadata-only edit can prove
|
|
343
|
+
// that it does not silently reset the interval cadence.
|
|
344
|
+
const pinnedNext = new Date(Date.now() + 45 * 60_000).toISOString();
|
|
345
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [pinnedNext, scheduleId]);
|
|
346
|
+
let current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
347
|
+
const metadataOnly = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
348
|
+
method: "PATCH",
|
|
349
|
+
body: JSON.stringify({
|
|
350
|
+
revision: current.revision,
|
|
351
|
+
name: "Edited briefing",
|
|
352
|
+
task: "Summarize only verified release changes",
|
|
353
|
+
priority: "low",
|
|
354
|
+
misfire_policy: "skip",
|
|
355
|
+
// Dashboard/mobile submit a complete form. Identical scheduling
|
|
356
|
+
// values still count as a metadata-only edit and must preserve cadence.
|
|
357
|
+
schedule: current.schedule,
|
|
358
|
+
timezone: current.timezone,
|
|
359
|
+
}),
|
|
360
|
+
});
|
|
361
|
+
expect(metadataOnly.status).toBe(200);
|
|
362
|
+
expect(metadataOnly.body.schedule.name).toBe("Edited briefing");
|
|
363
|
+
expect(metadataOnly.body.schedule.task_content).toBe("Summarize only verified release changes");
|
|
364
|
+
expect(metadataOnly.body.schedule.priority).toBe("low");
|
|
365
|
+
expect(metadataOnly.body.schedule.misfire_policy).toBe("skip");
|
|
366
|
+
expect(metadataOnly.body.schedule.next_run_at).toBe(pinnedNext);
|
|
367
|
+
|
|
368
|
+
// A schedule/timezone edit must reuse nextOccurrence's IANA/DST behavior.
|
|
369
|
+
const beforeEdit = new Date();
|
|
370
|
+
const dstEdit = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
371
|
+
method: "PATCH",
|
|
372
|
+
body: JSON.stringify({
|
|
373
|
+
revision: metadataOnly.body.schedule.revision,
|
|
374
|
+
schedule: { type: "daily", time: "01:30" },
|
|
375
|
+
timezone: "America/New_York",
|
|
376
|
+
}),
|
|
377
|
+
});
|
|
378
|
+
expect(dstEdit.status).toBe(200);
|
|
379
|
+
expect(dstEdit.body.schedule.schedule).toEqual({ type: "daily", time: "01:30" });
|
|
380
|
+
expect(dstEdit.body.schedule.timezone).toBe("America/New_York");
|
|
381
|
+
const expectedFloor = nextOccurrence({ type: "daily", time: "01:30" }, "America/New_York", beforeEdit)!;
|
|
382
|
+
expect(new Date(dstEdit.body.schedule.next_run_at).getTime()).toBe(expectedFloor.getTime());
|
|
383
|
+
|
|
384
|
+
const invalidPolicy = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
385
|
+
method: "PATCH",
|
|
386
|
+
body: JSON.stringify({ revision: dstEdit.body.schedule.revision, misfire_policy: "run_everything" }),
|
|
387
|
+
});
|
|
388
|
+
expect(invalidPolicy.status).toBe(400);
|
|
389
|
+
expect(invalidPolicy.body.error).toBe("invalid_misfire_policy");
|
|
390
|
+
|
|
391
|
+
const foreign = register(`scheduler_edit_foreign_${Date.now()}`, "SchedulerEditForeign123!", undefined, "seed");
|
|
392
|
+
expect(foreign.ok).toBe(true);
|
|
393
|
+
const foreignNetworkId = foreign.network_id!;
|
|
394
|
+
const foreignNodeId = `n_foreign_edit_${Date.now()}`;
|
|
395
|
+
db.run(
|
|
396
|
+
"INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, 'Foreign Edit Node', 'foreign-edit-node', 'codex-sdk', ?2)",
|
|
397
|
+
[foreignNodeId, foreignNetworkId],
|
|
398
|
+
);
|
|
399
|
+
const crossNetwork = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
400
|
+
method: "PATCH",
|
|
401
|
+
body: JSON.stringify({ revision: dstEdit.body.schedule.revision, target_node_id: foreignNodeId }),
|
|
402
|
+
});
|
|
403
|
+
expect(crossNetwork.status).toBe(404);
|
|
404
|
+
expect(crossNetwork.body.error).toBe("target_node_not_found");
|
|
405
|
+
current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
406
|
+
expect(current.target_node_id).toBe(nodeId);
|
|
407
|
+
expect(current.revision).toBe(dstEdit.body.schedule.revision);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("two editors with one revision produce one winner and one refreshable conflict", async () => {
|
|
411
|
+
const current = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
412
|
+
const [a, b] = await Promise.all([
|
|
413
|
+
api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
414
|
+
method: "PATCH",
|
|
415
|
+
body: JSON.stringify({ revision: current.revision, name: "Concurrent editor A" }),
|
|
416
|
+
}),
|
|
417
|
+
api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
418
|
+
method: "PATCH",
|
|
419
|
+
body: JSON.stringify({ revision: current.revision, name: "Concurrent editor B" }),
|
|
420
|
+
}),
|
|
421
|
+
]);
|
|
422
|
+
expect([a.status, b.status].sort()).toEqual([200, 409]);
|
|
423
|
+
const conflict = a.status === 409 ? a : b;
|
|
424
|
+
expect(conflict.body.error).toBe("revision_conflict");
|
|
425
|
+
const latest = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
426
|
+
expect(latest.revision).toBe(current.revision + 1);
|
|
427
|
+
expect(["Concurrent editor A", "Concurrent editor B"]).toContain(latest.name);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("optimistic revision, pause/resume, run-now and cancel preserve history", async () => {
|
|
431
|
+
const latest = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`);
|
|
432
|
+
revision = latest.body.schedule.revision;
|
|
433
|
+
const stale = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "PATCH", body: JSON.stringify({ revision: revision - 1, status: "paused" }) });
|
|
434
|
+
expect(stale.status).toBe(409);
|
|
435
|
+
const paused = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "PATCH", body: JSON.stringify({ revision, status: "paused" }) });
|
|
436
|
+
expect(paused.body.schedule.status).toBe("paused");
|
|
437
|
+
expect(paused.body.schedule.next_run_at).toBeNull();
|
|
438
|
+
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" }) });
|
|
439
|
+
expect(resumed.body.schedule.next_run_at).toBeTruthy();
|
|
440
|
+
|
|
441
|
+
db.run("UPDATE tasks SET status='replied' WHERE task_id IN (SELECT task_id FROM scheduled_task_runs WHERE schedule_id=?1)", [scheduleId]);
|
|
442
|
+
const manual = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/run-now?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
443
|
+
expect(manual.status).toBe(202);
|
|
444
|
+
expect(manual.body.taskId).toBeTruthy();
|
|
445
|
+
// Cancel via POST /cancel (the path the dashboard uses, since some
|
|
446
|
+
// reverse proxies rewrite DELETE to 405). Then verify the legacy
|
|
447
|
+
// DELETE endpoint is still accepted and idempotent when the row is
|
|
448
|
+
// already cancelled — both spellings must reach the same state.
|
|
449
|
+
const cancelled = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
450
|
+
expect(cancelled.status).toBe(200);
|
|
451
|
+
expect(cancelled.body.status).toBe("cancelled");
|
|
452
|
+
const cancelledRow = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
453
|
+
const revisionAfterCancel = cancelledRow.revision;
|
|
454
|
+
// Idempotency: DELETE on an already-cancelled row must be 200 with the
|
|
455
|
+
// same revision, not 409, and must not bump revision again.
|
|
456
|
+
const reCancelledDelete = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, { method: "DELETE" });
|
|
457
|
+
expect(reCancelledDelete.status).toBe(200);
|
|
458
|
+
expect(reCancelledDelete.body.status).toBe("cancelled");
|
|
459
|
+
const afterDelete = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
460
|
+
expect(afterDelete.revision).toBe(revisionAfterCancel);
|
|
461
|
+
// And POST /cancel on an already-cancelled row is also idempotent.
|
|
462
|
+
const reCancelledPost = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
463
|
+
expect(reCancelledPost.status).toBe(200);
|
|
464
|
+
expect(reCancelledPost.body.status).toBe("cancelled");
|
|
465
|
+
const afterPost = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
466
|
+
expect(afterPost.revision).toBe(revisionAfterCancel);
|
|
467
|
+
const resurrect = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`, {
|
|
468
|
+
method: "PATCH",
|
|
469
|
+
body: JSON.stringify({ revision: cancelledRow.revision, status: "active", name: "must not revive" }),
|
|
470
|
+
});
|
|
471
|
+
expect(resurrect.status).toBe(409);
|
|
472
|
+
expect(resurrect.body.error).toBe("schedule_cancelled");
|
|
473
|
+
const stillCancelled = (await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`)).body.schedule;
|
|
474
|
+
expect(stillCancelled.status).toBe("cancelled");
|
|
475
|
+
expect(stillCancelled.revision).toBe(cancelledRow.revision);
|
|
476
|
+
const runs = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}/runs?network_id=${encodeURIComponent(networkId)}`);
|
|
477
|
+
expect(runs.body.runs.length).toBeGreaterThanOrEqual(4);
|
|
478
|
+
});
|
|
479
|
+
});
|