@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,549 @@
|
|
|
1
|
+
import { db, logTaskEvent } from "./db.js";
|
|
2
|
+
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
3
|
+
import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
4
|
+
import { pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
5
|
+
import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
|
|
6
|
+
|
|
7
|
+
export type ScheduleSpec =
|
|
8
|
+
| { type: "once"; run_at: string }
|
|
9
|
+
| { type: "interval"; every_seconds: number }
|
|
10
|
+
| { type: "daily"; time: string }
|
|
11
|
+
| { type: "weekly"; time: string; weekdays: number[] };
|
|
12
|
+
|
|
13
|
+
export type MisfirePolicy = "catch_up_once" | "skip";
|
|
14
|
+
|
|
15
|
+
type ScheduledRow = {
|
|
16
|
+
schedule_id: string;
|
|
17
|
+
network_id: string;
|
|
18
|
+
created_by: string | null;
|
|
19
|
+
name: string;
|
|
20
|
+
target_node_id: string;
|
|
21
|
+
target_alias: string;
|
|
22
|
+
task_content: string;
|
|
23
|
+
priority: "high" | "normal" | "low";
|
|
24
|
+
schedule_type: ScheduleSpec["type"];
|
|
25
|
+
schedule_json: string;
|
|
26
|
+
timezone: string;
|
|
27
|
+
overlap_policy: "skip" | "allow";
|
|
28
|
+
misfire_policy: MisfirePolicy;
|
|
29
|
+
status: "active" | "paused" | "completed" | "cancelled";
|
|
30
|
+
next_run_at: string | null;
|
|
31
|
+
last_run_at: string | null;
|
|
32
|
+
revision: number;
|
|
33
|
+
created_at: string;
|
|
34
|
+
updated_at: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type RequestAuth = {
|
|
38
|
+
userId: string;
|
|
39
|
+
networkId: string | null;
|
|
40
|
+
username: string;
|
|
41
|
+
} | null;
|
|
42
|
+
|
|
43
|
+
export type ScheduledRequestContext = {
|
|
44
|
+
req: Request;
|
|
45
|
+
url: URL;
|
|
46
|
+
auth: RequestAuth;
|
|
47
|
+
isAdmin: boolean;
|
|
48
|
+
isNodeToken: boolean;
|
|
49
|
+
scope: RestNetworkScope;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const PRIORITIES = new Set(["high", "normal", "low"]);
|
|
53
|
+
const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
54
|
+
const OPEN_TASK_STATUSES = ["created", "delivered", "acked", "running"];
|
|
55
|
+
const MISFIRE_POLICIES = new Set<MisfirePolicy>(["catch_up_once", "skip"]);
|
|
56
|
+
export const MISFIRE_GRACE_MS = 60_000;
|
|
57
|
+
|
|
58
|
+
export function assertScheduledTaskBackendSupported(): void {
|
|
59
|
+
if (db.dialect !== "sqlite") {
|
|
60
|
+
throw new Error("scheduled_tasks_require_transactional_sqlite_backend");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function jsonError(error: string, status: number, extra: Record<string, unknown> = {}): Response {
|
|
65
|
+
return Response.json({ ok: false, error, ...extra }, { status });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function iso(date: Date): string {
|
|
69
|
+
return date.toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validTimezone(timezone: string): boolean {
|
|
73
|
+
try {
|
|
74
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseMisfirePolicy(raw: unknown, fallback: MisfirePolicy = "catch_up_once"): MisfirePolicy {
|
|
82
|
+
if (raw === undefined) return fallback;
|
|
83
|
+
if (typeof raw !== "string" || !MISFIRE_POLICIES.has(raw as MisfirePolicy)) throw new Error("invalid_misfire_policy");
|
|
84
|
+
return raw as MisfirePolicy;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function zonedParts(date: Date, timezone: string): { date: string; time: string; weekday: number } {
|
|
88
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
89
|
+
timeZone: timezone,
|
|
90
|
+
year: "numeric",
|
|
91
|
+
month: "2-digit",
|
|
92
|
+
day: "2-digit",
|
|
93
|
+
hourCycle: "h23",
|
|
94
|
+
hour: "2-digit",
|
|
95
|
+
minute: "2-digit",
|
|
96
|
+
weekday: "short",
|
|
97
|
+
}).formatToParts(date);
|
|
98
|
+
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value || "";
|
|
99
|
+
const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(value("weekday"));
|
|
100
|
+
return { date: `${value("year")}-${value("month")}-${value("day")}`, time: `${value("hour")}:${value("minute")}`, weekday };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Parse and validate the public schedule shape. No cron expressions: the
|
|
104
|
+
* supported forms are intentionally portable between Dashboard and mobile. */
|
|
105
|
+
export function parseScheduleSpec(raw: unknown, timezoneRaw: unknown): { spec: ScheduleSpec; timezone: string } {
|
|
106
|
+
if (!raw || typeof raw !== "object") throw new Error("schedule_required");
|
|
107
|
+
const obj = raw as Record<string, unknown>;
|
|
108
|
+
const type = obj.type;
|
|
109
|
+
const timezone = typeof timezoneRaw === "string" && timezoneRaw.trim() ? timezoneRaw.trim() : "UTC";
|
|
110
|
+
if (!validTimezone(timezone)) throw new Error("invalid_timezone");
|
|
111
|
+
|
|
112
|
+
if (type === "once") {
|
|
113
|
+
if (typeof obj.run_at !== "string") throw new Error("run_at_required");
|
|
114
|
+
const when = new Date(obj.run_at);
|
|
115
|
+
if (!Number.isFinite(when.getTime())) throw new Error("invalid_run_at");
|
|
116
|
+
return { spec: { type, run_at: iso(when) }, timezone };
|
|
117
|
+
}
|
|
118
|
+
if (type === "interval") {
|
|
119
|
+
const every = Number(obj.every_seconds);
|
|
120
|
+
if (!Number.isSafeInteger(every) || every < 60 || every > 365 * 86400) throw new Error("invalid_interval");
|
|
121
|
+
return { spec: { type, every_seconds: every }, timezone };
|
|
122
|
+
}
|
|
123
|
+
if (type === "daily") {
|
|
124
|
+
if (typeof obj.time !== "string" || !TIME_RE.test(obj.time)) throw new Error("invalid_time");
|
|
125
|
+
return { spec: { type, time: obj.time }, timezone };
|
|
126
|
+
}
|
|
127
|
+
if (type === "weekly") {
|
|
128
|
+
if (typeof obj.time !== "string" || !TIME_RE.test(obj.time)) throw new Error("invalid_time");
|
|
129
|
+
if (!Array.isArray(obj.weekdays) || obj.weekdays.length === 0) throw new Error("weekdays_required");
|
|
130
|
+
const weekdays = [...new Set(obj.weekdays.map(Number))].sort();
|
|
131
|
+
if (weekdays.some((d) => !Number.isSafeInteger(d) || d < 0 || d > 6)) throw new Error("invalid_weekdays");
|
|
132
|
+
return { spec: { type, time: obj.time, weekdays }, timezone };
|
|
133
|
+
}
|
|
134
|
+
throw new Error("invalid_schedule_type");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Return the first occurrence strictly after `after`. Daily/weekly scans UTC
|
|
138
|
+
* minutes and compares IANA-zone wall time, so DST gaps/folds follow the
|
|
139
|
+
* platform timezone database instead of a hand-written offset guess. */
|
|
140
|
+
export function nextOccurrence(spec: ScheduleSpec, timezone: string, after: Date): Date | null {
|
|
141
|
+
if (spec.type === "once") {
|
|
142
|
+
const when = new Date(spec.run_at);
|
|
143
|
+
return when.getTime() > after.getTime() ? when : null;
|
|
144
|
+
}
|
|
145
|
+
if (spec.type === "interval") return new Date(after.getTime() + spec.every_seconds * 1000);
|
|
146
|
+
|
|
147
|
+
// A wall-clock time can occur twice when DST falls back. If today's local
|
|
148
|
+
// occurrence already happened at or before `after`, skip every duplicate
|
|
149
|
+
// carrying that same local date. Otherwise a "daily 01:30" schedule runs
|
|
150
|
+
// twice on the fall-back day.
|
|
151
|
+
const afterLocal = zonedParts(after, timezone);
|
|
152
|
+
let alreadyOccurredDate: string | null = null;
|
|
153
|
+
const backward = new Date(Math.floor(after.getTime() / 60000) * 60000);
|
|
154
|
+
for (let i = 0; i <= 26 * 60; i++, backward.setTime(backward.getTime() - 60000)) {
|
|
155
|
+
const local = zonedParts(backward, timezone);
|
|
156
|
+
if (local.date === afterLocal.date && local.time === spec.time && (spec.type === "daily" || spec.weekdays.includes(local.weekday))) {
|
|
157
|
+
alreadyOccurredDate = local.date;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const cursor = new Date(Math.floor(after.getTime() / 60000) * 60000 + 60000);
|
|
163
|
+
const max = 8 * 24 * 60;
|
|
164
|
+
for (let i = 0; i < max; i++, cursor.setTime(cursor.getTime() + 60000)) {
|
|
165
|
+
const local = zonedParts(cursor, timezone);
|
|
166
|
+
if (local.time !== spec.time) continue;
|
|
167
|
+
if (local.date === alreadyOccurredDate) continue;
|
|
168
|
+
if (spec.type === "daily" || spec.weekdays.includes(local.weekday)) return new Date(cursor);
|
|
169
|
+
}
|
|
170
|
+
throw new Error("next_occurrence_unresolvable");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function decodeRow(row: ScheduledRow): Record<string, unknown> {
|
|
174
|
+
let schedule: unknown = null;
|
|
175
|
+
try { schedule = JSON.parse(row.schedule_json); } catch {}
|
|
176
|
+
const { created_by: _createdBy, schedule_json: _scheduleJson, ...publicRow } = row;
|
|
177
|
+
return { ...publicRow, schedule };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function scopedSchedule(scheduleId: string, scope: RestNetworkScope): ScheduledRow | null {
|
|
181
|
+
const params: unknown[] = [scheduleId];
|
|
182
|
+
let sql = `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`;
|
|
183
|
+
sql = addNetworkScope(sql, params, scope);
|
|
184
|
+
return db.get<ScheduledRow>(sql, ...params);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveWriteNetwork(body: Record<string, unknown>, ctx: ScheduledRequestContext): string | null {
|
|
188
|
+
if (ctx.auth?.networkId) return ctx.auth.networkId;
|
|
189
|
+
if (typeof body.network_id === "string" && body.network_id) return body.network_id;
|
|
190
|
+
return singleNetworkId(ctx.scope);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateTarget(networkId: string, nodeId: unknown): { node_id: string; alias: string } {
|
|
194
|
+
if (typeof nodeId !== "string" || nodeId.length < 2 || nodeId.length > 200) throw new Error("invalid_target_node_id");
|
|
195
|
+
const node = db.get<{ node_id: string; alias: string | null }>(
|
|
196
|
+
"SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2",
|
|
197
|
+
nodeId, networkId,
|
|
198
|
+
);
|
|
199
|
+
if (!node?.alias) throw new Error("target_node_not_found");
|
|
200
|
+
return { node_id: node.node_id, alias: node.alias };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type DispatchEvent = { alias: string; networkId: string; taskId: string; priority: string; state: "delivered" | "queued" };
|
|
204
|
+
|
|
205
|
+
export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: string, advanceSchedule: boolean, advanceAfter = new Date()): { runId: string; taskId?: string; status: string; event?: DispatchEvent } {
|
|
206
|
+
const runId = `srun_${crypto.randomUUID()}`;
|
|
207
|
+
let event: DispatchEvent | undefined;
|
|
208
|
+
let finalStatus = "failed";
|
|
209
|
+
let createdTaskId: string | undefined;
|
|
210
|
+
|
|
211
|
+
db.transaction(() => {
|
|
212
|
+
// UNIQUE(schedule_id, scheduled_for) is the cross-process claim. If two
|
|
213
|
+
// Hub processes race, one INSERT wins and the other transaction aborts.
|
|
214
|
+
db.run(
|
|
215
|
+
`INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, status)
|
|
216
|
+
VALUES (?1, ?2, ?3, ?4, 'claiming')`,
|
|
217
|
+
[runId, row.schedule_id, row.network_id, scheduledFor],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
if (row.overlap_policy === "skip") {
|
|
221
|
+
const placeholders = OPEN_TASK_STATUSES.map((_, i) => `?${i + 2}`).join(", ");
|
|
222
|
+
const open = db.get<{ task_id: string }>(
|
|
223
|
+
`SELECT r.task_id FROM scheduled_task_runs r
|
|
224
|
+
JOIN tasks t ON t.task_id = r.task_id
|
|
225
|
+
WHERE r.schedule_id = ?1 AND t.status IN (${placeholders})
|
|
226
|
+
ORDER BY r.created_at DESC LIMIT 1`,
|
|
227
|
+
row.schedule_id, ...OPEN_TASK_STATUSES,
|
|
228
|
+
);
|
|
229
|
+
if (open) {
|
|
230
|
+
finalStatus = "skipped";
|
|
231
|
+
db.run(
|
|
232
|
+
"UPDATE scheduled_task_runs SET status = 'skipped', error_code = 'previous_run_active', completed_at = datetime('now') WHERE run_id = ?1",
|
|
233
|
+
[runId],
|
|
234
|
+
);
|
|
235
|
+
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const node = db.get<{ node_id: string; alias: string | null }>(
|
|
241
|
+
"SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2",
|
|
242
|
+
row.target_node_id, row.network_id,
|
|
243
|
+
);
|
|
244
|
+
if (!node?.alias) {
|
|
245
|
+
finalStatus = "failed";
|
|
246
|
+
db.run(
|
|
247
|
+
"UPDATE scheduled_task_runs SET status = 'failed', error_code = 'target_node_not_found', error_message = 'The bound node no longer exists in this network', completed_at = datetime('now') WHERE run_id = ?1",
|
|
248
|
+
[runId],
|
|
249
|
+
);
|
|
250
|
+
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const lifecycle = assertNodeActive(node.alias, row.network_id);
|
|
254
|
+
if (!lifecycle.ok) {
|
|
255
|
+
finalStatus = "failed";
|
|
256
|
+
db.run(
|
|
257
|
+
"UPDATE scheduled_task_runs SET status = 'failed', error_code = 'target_not_active', error_message = ?1, completed_at = datetime('now') WHERE run_id = ?2",
|
|
258
|
+
[String((lifecycle as any).error || "target_not_active").slice(0, 500), runId],
|
|
259
|
+
);
|
|
260
|
+
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const taskId = crypto.randomUUID();
|
|
265
|
+
const session = db.get<{ status: string | null }>(
|
|
266
|
+
"SELECT status FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1",
|
|
267
|
+
row.target_node_id, row.network_id,
|
|
268
|
+
);
|
|
269
|
+
const deliveryState: "delivered" | "queued" = session && session.status !== "offline" ? "delivered" : "queued";
|
|
270
|
+
const metaJson = JSON.stringify({
|
|
271
|
+
scheduled_task_id: row.schedule_id,
|
|
272
|
+
scheduled_run_id: runId,
|
|
273
|
+
scheduled_for: scheduledFor,
|
|
274
|
+
auth_origin: "hub_scheduler",
|
|
275
|
+
});
|
|
276
|
+
db.run(
|
|
277
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
278
|
+
VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, 'scheduler', 'reply', ?6, ?7)`,
|
|
279
|
+
[taskId, node.alias, node.node_id, row.priority, row.task_content, row.network_id, metaJson],
|
|
280
|
+
);
|
|
281
|
+
db.run(
|
|
282
|
+
`INSERT INTO tasks (task_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, meta_json)
|
|
283
|
+
VALUES (?1, 'scheduler', ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', '+86400 seconds'), ?6, ?7)`,
|
|
284
|
+
[taskId, node.node_id, node.alias, row.priority, row.task_content, row.network_id, metaJson],
|
|
285
|
+
);
|
|
286
|
+
// Dispatch is not completion. Keep the run open until the exact bound
|
|
287
|
+
// task reaches replied/failed/cancelled/expired; db.ts owns that mirror.
|
|
288
|
+
db.run("UPDATE scheduled_task_runs SET task_id = ?1, status = ?2, completed_at = NULL WHERE run_id = ?3", [taskId, deliveryState, runId]);
|
|
289
|
+
db.run("UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3", [row.task_content.slice(0, 200), node.node_id, row.network_id]);
|
|
290
|
+
db.run("UPDATE scheduled_tasks SET target_alias = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3", [node.alias, scheduledFor, row.schedule_id]);
|
|
291
|
+
if (advanceSchedule) advance({ ...row, target_alias: node.alias }, scheduledFor, advanceAfter);
|
|
292
|
+
createdTaskId = taskId;
|
|
293
|
+
finalStatus = deliveryState;
|
|
294
|
+
event = { alias: node.alias, networkId: row.network_id, taskId, priority: row.priority, state: deliveryState };
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Everything below is a post-commit doorbell. A logging/SSE failure must
|
|
298
|
+
// never turn a durably-created task into an API-level failure: callers may
|
|
299
|
+
// retry an apparent failure and create a second manual occurrence.
|
|
300
|
+
try {
|
|
301
|
+
if (createdTaskId) logTaskEvent(createdTaskId, null, "delivered", "hub-scheduler", `schedule=${row.schedule_id} run=${runId}`);
|
|
302
|
+
if (event) {
|
|
303
|
+
const pending = db.get<{ cnt: number }>("SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND network_id = ?2 AND acked = 0", event.alias, event.networkId);
|
|
304
|
+
if (event.state === "delivered") pushEvent(event.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: event.priority, from: "scheduler" }, event.networkId);
|
|
305
|
+
pushNetworkObserverEvent(event.networkId, { type: "new_task", task_id: event.taskId, from: "scheduler", to: event.alias, status: event.state, priority: event.priority });
|
|
306
|
+
}
|
|
307
|
+
} catch (e: any) {
|
|
308
|
+
console.error(`[scheduled-tasks] post-commit notification failed schedule=${row.schedule_id} run=${runId}: ${e?.message || e}`);
|
|
309
|
+
}
|
|
310
|
+
return { runId, taskId: createdTaskId, status: finalStatus, event };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function advance(row: ScheduledRow, scheduledFor: string, after = new Date(), recordLastRun = true): void {
|
|
314
|
+
const spec = JSON.parse(row.schedule_json) as ScheduleSpec;
|
|
315
|
+
const next = nextOccurrence(spec, row.timezone, after);
|
|
316
|
+
const lastRunAt = recordLastRun ? scheduledFor : row.last_run_at;
|
|
317
|
+
if (spec.type === "once" || !next) {
|
|
318
|
+
db.run(
|
|
319
|
+
"UPDATE scheduled_tasks SET status = 'completed', next_run_at = NULL, last_run_at = ?1, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?2",
|
|
320
|
+
[lastRunAt, row.schedule_id],
|
|
321
|
+
);
|
|
322
|
+
} else {
|
|
323
|
+
db.run(
|
|
324
|
+
"UPDATE scheduled_tasks SET next_run_at = ?1, last_run_at = ?2, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?3",
|
|
325
|
+
[iso(next), lastRunAt, row.schedule_id],
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isMissedOccurrence(scheduledFor: string, now: Date): boolean {
|
|
331
|
+
const scheduledAt = new Date(scheduledFor).getTime();
|
|
332
|
+
return Number.isFinite(scheduledAt) && now.getTime() - scheduledAt > MISFIRE_GRACE_MS;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function skipScheduledMisfire(row: ScheduledRow, scheduledFor: string, now: Date): void {
|
|
336
|
+
const runId = `srun_${crypto.randomUUID()}`;
|
|
337
|
+
db.transaction(() => {
|
|
338
|
+
db.run(
|
|
339
|
+
`INSERT INTO scheduled_task_runs
|
|
340
|
+
(run_id, schedule_id, network_id, scheduled_for, status, error_code, error_message, completed_at)
|
|
341
|
+
VALUES (?1, ?2, ?3, ?4, 'skipped', 'misfire_skipped', 'The occurrence was overdue and the schedule is configured to skip missed runs', datetime('now'))`,
|
|
342
|
+
[runId, row.schedule_id, row.network_id, scheduledFor],
|
|
343
|
+
);
|
|
344
|
+
// A skipped occurrence is auditable but is not an execution: preserve
|
|
345
|
+
// last_run_at while moving the schedule directly to its next future slot.
|
|
346
|
+
advance(row, scheduledFor, now, false);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function runDueScheduledTasks(now = new Date(), limit = 100): { processed: number; failed: number } {
|
|
351
|
+
const due = db.all<ScheduledRow>(
|
|
352
|
+
`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE status = 'active' AND next_run_at IS NOT NULL AND next_run_at <= ?1 ORDER BY next_run_at LIMIT ?2`,
|
|
353
|
+
iso(now), limit,
|
|
354
|
+
);
|
|
355
|
+
let processed = 0;
|
|
356
|
+
let failed = 0;
|
|
357
|
+
for (const row of due) {
|
|
358
|
+
try {
|
|
359
|
+
// Re-read inside the claim path so a pause/cancel immediately before the
|
|
360
|
+
// tick wins. The unique occurrence key handles a second scheduler.
|
|
361
|
+
const current = db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1 AND status = 'active' AND next_run_at = ?2`, row.schedule_id, row.next_run_at);
|
|
362
|
+
if (!current?.next_run_at) continue;
|
|
363
|
+
if (current.misfire_policy === "skip" && isMissedOccurrence(current.next_run_at, now)) {
|
|
364
|
+
skipScheduledMisfire(current, current.next_run_at, now);
|
|
365
|
+
} else {
|
|
366
|
+
// catch_up_once is also the compatibility default for pre-migration
|
|
367
|
+
// rows. Advancing from `now` collapses any number of missed slots into
|
|
368
|
+
// one task instead of replaying an outage as a dispatch storm.
|
|
369
|
+
dispatchScheduledOccurrence(current, current.next_run_at, true, now);
|
|
370
|
+
}
|
|
371
|
+
processed++;
|
|
372
|
+
} catch (e: any) {
|
|
373
|
+
if (/UNIQUE|duplicate key/i.test(String(e?.message))) continue;
|
|
374
|
+
failed++;
|
|
375
|
+
console.error(`[scheduled-tasks] occurrence failed schedule=${row.schedule_id}: ${e?.message || e}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { processed, failed };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function startScheduledTaskScheduler(intervalMs?: number): ReturnType<typeof setInterval> {
|
|
382
|
+
const configured = Number(process.env.COMMHUB_SCHEDULER_TICK_MS);
|
|
383
|
+
const ms = intervalMs && intervalMs > 0 ? intervalMs : configured > 0 ? configured : 10_000;
|
|
384
|
+
// First tick is immediate so a restarted Hub converges without waiting one
|
|
385
|
+
// full cadence. Recurrences advance from "now", intentionally collapsing a
|
|
386
|
+
// long outage into one catch-up occurrence instead of a dispatch storm.
|
|
387
|
+
runDueScheduledTasks();
|
|
388
|
+
return setInterval(() => runDueScheduledTasks(), ms);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function bodyObject(req: Request): Promise<Record<string, unknown>> {
|
|
392
|
+
try {
|
|
393
|
+
const value = await req.json();
|
|
394
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
|
395
|
+
return value as Record<string, unknown>;
|
|
396
|
+
} catch {
|
|
397
|
+
throw new Error("invalid_json");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function writeAllowed(ctx: ScheduledRequestContext, networkId: string | null): boolean {
|
|
402
|
+
return !ctx.isNodeToken && canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): Promise<Response | null> {
|
|
406
|
+
const { req, url } = ctx;
|
|
407
|
+
if (url.pathname !== "/api/scheduled-tasks" && !url.pathname.startsWith("/api/scheduled-tasks/")) return null;
|
|
408
|
+
if (ctx.isNodeToken) return jsonError("user_token_required", 403);
|
|
409
|
+
|
|
410
|
+
if (url.pathname === "/api/scheduled-tasks" && req.method === "GET") {
|
|
411
|
+
const params: unknown[] = [];
|
|
412
|
+
let sql = `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE 1=1`;
|
|
413
|
+
sql = addNetworkScope(sql, params, ctx.scope);
|
|
414
|
+
const status = url.searchParams.get("status");
|
|
415
|
+
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
416
|
+
sql += " ORDER BY updated_at DESC LIMIT 500";
|
|
417
|
+
return Response.json({ ok: true, schedules: db.all<ScheduledRow>(sql, ...params).map(decodeRow) });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (url.pathname === "/api/scheduled-tasks" && req.method === "POST") {
|
|
421
|
+
let body: Record<string, unknown>;
|
|
422
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
423
|
+
const networkId = resolveWriteNetwork(body, ctx);
|
|
424
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
425
|
+
if (!writeAllowed(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
426
|
+
try {
|
|
427
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
428
|
+
const content = typeof body.task === "string" ? body.task.trim() : "";
|
|
429
|
+
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
430
|
+
if (!content || content.length > 10_000) throw new Error("invalid_task");
|
|
431
|
+
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
432
|
+
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
433
|
+
const misfirePolicy = parseMisfirePolicy(body.misfire_policy);
|
|
434
|
+
const target = validateTarget(networkId, body.target_node_id);
|
|
435
|
+
const { spec, timezone } = parseScheduleSpec(body.schedule, body.timezone);
|
|
436
|
+
const next = nextOccurrence(spec, timezone, new Date());
|
|
437
|
+
if (!next) throw new Error("schedule_has_no_future_occurrence");
|
|
438
|
+
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
439
|
+
db.run(
|
|
440
|
+
`INSERT INTO scheduled_tasks
|
|
441
|
+
(schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, priority, schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, next_run_at)
|
|
442
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'skip', ?12, ?13)`,
|
|
443
|
+
[scheduleId, networkId, ctx.auth?.userId ?? null, name, target.node_id, target.alias, content, priority, spec.type, JSON.stringify(spec), timezone, misfirePolicy, iso(next)],
|
|
444
|
+
);
|
|
445
|
+
const created = db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, scheduleId)!;
|
|
446
|
+
return Response.json({ ok: true, schedule: decodeRow(created) }, { status: 201 });
|
|
447
|
+
} catch (e: any) {
|
|
448
|
+
const code = String(e?.message || "invalid_schedule");
|
|
449
|
+
const status = code === "target_node_not_found" ? 404 : 400;
|
|
450
|
+
return jsonError(code, status);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const match = url.pathname.match(/^\/api\/scheduled-tasks\/([^/]+)(?:\/(runs|run-now|cancel))?$/);
|
|
455
|
+
if (!match) return jsonError("not_found", 404);
|
|
456
|
+
const scheduleId = decodeURIComponent(match[1]);
|
|
457
|
+
const sub = match[2] || null;
|
|
458
|
+
const row = scopedSchedule(scheduleId, ctx.scope);
|
|
459
|
+
if (!row) return jsonError("schedule_not_found", 404);
|
|
460
|
+
|
|
461
|
+
if (sub === "runs" && req.method === "GET") {
|
|
462
|
+
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
|
|
463
|
+
const runs = db.all(
|
|
464
|
+
"SELECT run_id, schedule_id, scheduled_for, task_id, status, error_code, error_message, created_at, completed_at FROM scheduled_task_runs WHERE schedule_id = ?1 AND network_id = ?2 ORDER BY created_at DESC LIMIT ?3",
|
|
465
|
+
row.schedule_id, row.network_id, limit,
|
|
466
|
+
);
|
|
467
|
+
return Response.json({ ok: true, runs });
|
|
468
|
+
}
|
|
469
|
+
if (!sub && req.method === "GET") return Response.json({ ok: true, schedule: decodeRow(row) });
|
|
470
|
+
if (!writeAllowed(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
471
|
+
|
|
472
|
+
if (sub === "run-now" && req.method === "POST") {
|
|
473
|
+
if (row.status === "cancelled") return jsonError("schedule_cancelled", 409);
|
|
474
|
+
try {
|
|
475
|
+
const result = dispatchScheduledOccurrence(row, iso(new Date()), false);
|
|
476
|
+
return Response.json({ ok: true, ...result }, { status: result.status === "failed" ? 409 : 202 });
|
|
477
|
+
} catch (e: any) {
|
|
478
|
+
return jsonError("dispatch_failed", 409, { message: String(e?.message || e).slice(0, 500) });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Cancel — accept two spellings for the same operation:
|
|
483
|
+
// DELETE /api/scheduled-tasks/:id (original)
|
|
484
|
+
// POST /api/scheduled-tasks/:id/cancel (added because some reverse
|
|
485
|
+
// proxies swallow DELETE and
|
|
486
|
+
// return 405 HTML; POST is
|
|
487
|
+
// universally allowed)
|
|
488
|
+
// Idempotent: re-cancelling an already-cancelled row is a 200 no-op, not a
|
|
489
|
+
// 409, because a client that just saw the row (still on screen as
|
|
490
|
+
// "cancelled" between polls) should get the same outcome whether it's the
|
|
491
|
+
// first click or a retry.
|
|
492
|
+
if ((!sub && req.method === "DELETE") || (sub === "cancel" && req.method === "POST")) {
|
|
493
|
+
if (row.status === "cancelled") return Response.json({ ok: true, status: "cancelled" });
|
|
494
|
+
db.run("UPDATE scheduled_tasks SET status = 'cancelled', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1", [row.schedule_id]);
|
|
495
|
+
return Response.json({ ok: true, status: "cancelled" });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!sub && req.method === "PATCH") {
|
|
499
|
+
let body: Record<string, unknown>;
|
|
500
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
501
|
+
// Cancellation/completion are terminal states. Editing must never become
|
|
502
|
+
// an implicit resurrection path (including by supplying status=active).
|
|
503
|
+
if (row.status === "cancelled") return jsonError("schedule_cancelled", 409);
|
|
504
|
+
if (row.status === "completed") return jsonError("schedule_completed", 409);
|
|
505
|
+
if (!Number.isSafeInteger(body.revision) || Number(body.revision) !== row.revision) return jsonError("revision_conflict", 409, { current_revision: row.revision });
|
|
506
|
+
try {
|
|
507
|
+
const name = body.name === undefined ? row.name : String(body.name).trim();
|
|
508
|
+
const content = body.task === undefined ? row.task_content : String(body.task).trim();
|
|
509
|
+
const priority = body.priority === undefined ? row.priority : String(body.priority);
|
|
510
|
+
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
511
|
+
if (!content || content.length > 10_000) throw new Error("invalid_task");
|
|
512
|
+
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
513
|
+
const target = body.target_node_id === undefined ? { node_id: row.target_node_id, alias: row.target_alias } : validateTarget(row.network_id, body.target_node_id);
|
|
514
|
+
const parsed = body.schedule === undefined
|
|
515
|
+
? { spec: JSON.parse(row.schedule_json) as ScheduleSpec, timezone: body.timezone === undefined ? row.timezone : String(body.timezone) }
|
|
516
|
+
: parseScheduleSpec(body.schedule, body.timezone === undefined ? row.timezone : body.timezone);
|
|
517
|
+
if (!validTimezone(parsed.timezone)) throw new Error("invalid_timezone");
|
|
518
|
+
const requestedStatus = body.status === undefined ? row.status : String(body.status);
|
|
519
|
+
if (!new Set(["active", "paused"]).has(requestedStatus)) throw new Error("invalid_status");
|
|
520
|
+
const misfirePolicy = parseMisfirePolicy(body.misfire_policy, row.misfire_policy);
|
|
521
|
+
// Editing descriptive fields must not silently reset the schedule's
|
|
522
|
+
// cadence. Recompute only when the scheduling inputs change, when a
|
|
523
|
+
// paused schedule resumes, or when repairing an impossible active row
|
|
524
|
+
// with no next occurrence. The recompute uses the same DST-safe helper
|
|
525
|
+
// as creation and dispatch advancement.
|
|
526
|
+
const scheduleJson = JSON.stringify(parsed.spec);
|
|
527
|
+
const schedulingChanged = scheduleJson !== row.schedule_json || parsed.timezone !== row.timezone;
|
|
528
|
+
const resumed = row.status !== "active" && requestedStatus === "active";
|
|
529
|
+
const next = requestedStatus !== "active"
|
|
530
|
+
? null
|
|
531
|
+
: schedulingChanged || resumed || !row.next_run_at
|
|
532
|
+
? nextOccurrence(parsed.spec, parsed.timezone, new Date())
|
|
533
|
+
: new Date(row.next_run_at);
|
|
534
|
+
if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
|
|
535
|
+
const updated = db.run(
|
|
536
|
+
`UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4,
|
|
537
|
+
priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9, next_run_at = ?10,
|
|
538
|
+
misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
|
|
539
|
+
[name, target.node_id, target.alias, content, priority, parsed.spec.type, scheduleJson, parsed.timezone, requestedStatus, next ? iso(next) : null, misfirePolicy, row.schedule_id, row.revision],
|
|
540
|
+
);
|
|
541
|
+
if (updated.changes !== 1) return jsonError("revision_conflict", 409);
|
|
542
|
+
return Response.json({ ok: true, schedule: decodeRow(db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, row.schedule_id)!) });
|
|
543
|
+
} catch (e: any) {
|
|
544
|
+
const code = String(e?.message || "invalid_schedule");
|
|
545
|
+
return jsonError(code, code === "target_node_not_found" ? 404 : 400);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return jsonError("method_not_allowed", 405);
|
|
549
|
+
}
|