agent-relay-server 0.9.0 → 0.10.1
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 +12 -14
- package/package.json +18 -1
- package/public/index.html +979 -2575
- package/public/manifest.webmanifest +6 -6
- package/public/sw.js +16 -10
- package/recipes/code-review.yaml +26 -0
- package/recipes/debug.yaml +20 -0
- package/recipes/feature.yaml +26 -0
- package/recipes/refactor.yaml +20 -0
- package/recipes/test.yaml +20 -0
- package/runner/src/adapter.ts +69 -0
- package/runner/src/config.ts +144 -0
- package/scripts/orchestrator-spawn-smoke.ts +2 -9
- package/src/agent-spawn.ts +2 -94
- package/src/automations.ts +774 -0
- package/src/bus-outbox.ts +75 -0
- package/src/bus.ts +439 -0
- package/src/cli.ts +251 -5
- package/src/commands-db.ts +160 -0
- package/src/config.ts +1 -1
- package/src/connectors.ts +29 -9
- package/src/daemon.ts +1 -0
- package/src/db.ts +241 -34
- package/src/events.ts +33 -0
- package/src/index.ts +94 -5
- package/src/recipe-db.ts +163 -0
- package/src/recipe-loader.ts +100 -0
- package/src/recipe-runner.ts +206 -0
- package/src/recipe-validator.ts +85 -0
- package/src/routes.ts +649 -155
- package/src/security.ts +128 -2
- package/src/sse.ts +42 -31
- package/src/token-db.ts +96 -0
- package/src/types.ts +1 -493
- package/src/upgrade.ts +14 -28
- package/public/dashboard/actions.js +0 -819
- package/public/dashboard/api.js +0 -336
- package/public/dashboard/app.js +0 -34
- package/public/dashboard/charts.js +0 -128
- package/public/dashboard/computed.js +0 -693
- package/public/dashboard/constants.js +0 -28
- package/public/dashboard/display.js +0 -345
- package/public/dashboard/state.js +0 -129
- package/public/dashboard/utils.js +0 -207
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
getAgent,
|
|
4
|
+
getDb,
|
|
5
|
+
getOrchestrator,
|
|
6
|
+
getTask,
|
|
7
|
+
ingestIntegrationEvent,
|
|
8
|
+
listAgents,
|
|
9
|
+
listOrchestrators,
|
|
10
|
+
ValidationError,
|
|
11
|
+
} from "./db";
|
|
12
|
+
import { createCommand } from "./commands-db";
|
|
13
|
+
import type {
|
|
14
|
+
AgentCard,
|
|
15
|
+
Automation,
|
|
16
|
+
AutomationCatchUpPolicy,
|
|
17
|
+
AutomationConcurrencyPolicy,
|
|
18
|
+
AutomationRun,
|
|
19
|
+
AutomationRunStatus,
|
|
20
|
+
AutomationTargetPolicy,
|
|
21
|
+
AutomationTaskTemplate,
|
|
22
|
+
Command,
|
|
23
|
+
CreateAutomationInput,
|
|
24
|
+
ManagedAgent,
|
|
25
|
+
Message,
|
|
26
|
+
Orchestrator,
|
|
27
|
+
Task,
|
|
28
|
+
TaskSeverity,
|
|
29
|
+
UpdateAutomationInput,
|
|
30
|
+
} from "./types";
|
|
31
|
+
|
|
32
|
+
const AUTOMATION_KIND = "scheduled_task";
|
|
33
|
+
const DEFAULT_TIMEZONE = "UTC";
|
|
34
|
+
const DEFAULT_CATCH_UP: AutomationCatchUpPolicy = "skip";
|
|
35
|
+
const DEFAULT_CONCURRENCY: AutomationConcurrencyPolicy = "skip";
|
|
36
|
+
const BLOCKING_RUN_STATUSES = new Set<AutomationRunStatus>(["dispatching", "waiting_agent", "running"]);
|
|
37
|
+
const OPEN_RUN_STATUSES = new Set<AutomationRunStatus>(["scheduled", "dispatching", "waiting_agent", "running"]);
|
|
38
|
+
const CLOSED_TASK_STATUS = new Set(["done", "failed", "canceled"]);
|
|
39
|
+
const MAX_CRON_SCAN_MINUTES = 366 * 24 * 60;
|
|
40
|
+
|
|
41
|
+
let initializedDb: unknown = null;
|
|
42
|
+
|
|
43
|
+
export interface AutomationDispatchResult {
|
|
44
|
+
automation: Automation;
|
|
45
|
+
run: AutomationRun;
|
|
46
|
+
task?: Task;
|
|
47
|
+
message?: Message;
|
|
48
|
+
command?: Command;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AutomationReconcileResult {
|
|
52
|
+
run: AutomationRun;
|
|
53
|
+
task?: Task;
|
|
54
|
+
command?: Command;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function db() {
|
|
58
|
+
return getDb();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ensureAutomationTables(): void {
|
|
62
|
+
const current = db();
|
|
63
|
+
if (initializedDb === current) return;
|
|
64
|
+
initializedDb = current;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseJson<T>(value: unknown, fallback: T): T {
|
|
68
|
+
if (typeof value !== "string" || !value) return fallback;
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(value) as T;
|
|
71
|
+
} catch {
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rowToAutomation(row: any): Automation {
|
|
77
|
+
return {
|
|
78
|
+
id: row.id,
|
|
79
|
+
kind: row.kind,
|
|
80
|
+
name: row.name,
|
|
81
|
+
description: row.description ?? undefined,
|
|
82
|
+
enabled: row.enabled === 1,
|
|
83
|
+
schedule: row.schedule,
|
|
84
|
+
timezone: row.timezone,
|
|
85
|
+
nextRunAt: row.next_run_at ?? undefined,
|
|
86
|
+
catchUpPolicy: row.catch_up_policy,
|
|
87
|
+
concurrencyPolicy: row.concurrency_policy,
|
|
88
|
+
orchestratorId: row.orchestrator_id,
|
|
89
|
+
targetPolicy: parseJson<AutomationTargetPolicy>(row.target_policy, { mode: "existing_agent", selector: {}, ifNoMatch: "fail" }),
|
|
90
|
+
taskTemplate: parseJson<AutomationTaskTemplate>(row.task_template, { title: row.name, body: "" }),
|
|
91
|
+
createdAt: row.created_at,
|
|
92
|
+
updatedAt: row.updated_at,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function rowToAutomationRun(row: any): AutomationRun {
|
|
97
|
+
return {
|
|
98
|
+
id: row.id,
|
|
99
|
+
automationId: row.automation_id,
|
|
100
|
+
status: row.status,
|
|
101
|
+
scheduledFor: row.scheduled_for,
|
|
102
|
+
startedAt: row.started_at ?? undefined,
|
|
103
|
+
finishedAt: row.finished_at ?? undefined,
|
|
104
|
+
orchestratorId: row.orchestrator_id,
|
|
105
|
+
targetAgentId: row.target_agent_id ?? undefined,
|
|
106
|
+
spawnedAgentId: row.spawned_agent_id ?? undefined,
|
|
107
|
+
taskId: row.task_id ?? undefined,
|
|
108
|
+
messageId: row.message_id ?? undefined,
|
|
109
|
+
error: row.error ?? undefined,
|
|
110
|
+
result: parseJson<Record<string, unknown> | undefined>(row.result, undefined),
|
|
111
|
+
meta: parseJson<Record<string, unknown>>(row.meta, {}),
|
|
112
|
+
createdAt: row.created_at,
|
|
113
|
+
updatedAt: row.updated_at,
|
|
114
|
+
shutdownRequestedAt: row.shutdown_requested_at ?? undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cleanString(value: unknown, field: string, opts: { required?: boolean; max?: number } = {}): string | undefined {
|
|
119
|
+
if (value === undefined || value === null) {
|
|
120
|
+
if (opts.required) throw new ValidationError(`${field} required`);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
124
|
+
const trimmed = value.trim();
|
|
125
|
+
if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
|
|
126
|
+
if (opts.max && trimmed.length > opts.max) throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
|
|
127
|
+
return trimmed || undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cleanStringArray(value: unknown, field: string): string[] | undefined {
|
|
131
|
+
if (value === undefined || value === null) return undefined;
|
|
132
|
+
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
|
|
133
|
+
return [...new Set(value.map((item) => cleanString(item, `${field} item`, { max: 100 })).filter(Boolean) as string[])];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function cleanBool(value: unknown, field: string): boolean | undefined {
|
|
137
|
+
if (value === undefined || value === null) return undefined;
|
|
138
|
+
if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback?: T[number]): T[number] {
|
|
143
|
+
if (value === undefined || value === null) {
|
|
144
|
+
if (fallback !== undefined) return fallback;
|
|
145
|
+
throw new ValidationError(`${field} required`);
|
|
146
|
+
}
|
|
147
|
+
if (typeof value !== "string" || !valid.includes(value)) throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
|
|
148
|
+
return value as T[number];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function cleanMeta(value: unknown, field = "metadata"): Record<string, unknown> | undefined {
|
|
152
|
+
if (value === undefined || value === null) return undefined;
|
|
153
|
+
if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${field} must be an object`);
|
|
154
|
+
if (JSON.stringify(value).length > 65_536) throw new ValidationError(`${field} is too large`);
|
|
155
|
+
return value as Record<string, unknown>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeTaskTemplate(value: unknown): AutomationTaskTemplate {
|
|
159
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ValidationError("taskTemplate must be an object");
|
|
160
|
+
const input = value as Record<string, unknown>;
|
|
161
|
+
return {
|
|
162
|
+
title: cleanString(input.title, "taskTemplate.title", { required: true, max: 240 })!,
|
|
163
|
+
body: cleanString(input.body, "taskTemplate.body", { required: true, max: 200_000 })!,
|
|
164
|
+
severity: cleanEnum(input.severity, "taskTemplate.severity", ["info", "warning", "critical"] as const, "info") as TaskSeverity,
|
|
165
|
+
dedupeKey: cleanString(input.dedupeKey, "taskTemplate.dedupeKey", { max: 240 }),
|
|
166
|
+
externalUrl: cleanString(input.externalUrl, "taskTemplate.externalUrl", { max: 1000 }),
|
|
167
|
+
metadata: cleanMeta(input.metadata, "taskTemplate.metadata"),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
|
|
172
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ValidationError("targetPolicy must be an object");
|
|
173
|
+
const input = value as Record<string, unknown>;
|
|
174
|
+
const mode = cleanEnum(input.mode, "targetPolicy.mode", ["existing_agent", "on_demand_agent"] as const);
|
|
175
|
+
if (mode === "existing_agent") {
|
|
176
|
+
const selectorInput = (typeof input.selector === "object" && input.selector !== null && !Array.isArray(input.selector))
|
|
177
|
+
? input.selector as Record<string, unknown>
|
|
178
|
+
: {};
|
|
179
|
+
return {
|
|
180
|
+
mode,
|
|
181
|
+
selector: {
|
|
182
|
+
provider: cleanString(selectorInput.provider, "targetPolicy.selector.provider", { max: 40 }) as "claude" | "codex" | undefined,
|
|
183
|
+
label: cleanString(selectorInput.label, "targetPolicy.selector.label", { max: 120 }),
|
|
184
|
+
tags: cleanStringArray(selectorInput.tags, "targetPolicy.selector.tags"),
|
|
185
|
+
capabilities: cleanStringArray(selectorInput.capabilities, "targetPolicy.selector.capabilities"),
|
|
186
|
+
},
|
|
187
|
+
ifNoMatch: cleanEnum(input.ifNoMatch, "targetPolicy.ifNoMatch", ["fail", "spawn"] as const, "fail"),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
mode,
|
|
192
|
+
provider: cleanEnum(input.provider, "targetPolicy.provider", ["claude", "codex"] as const, "codex"),
|
|
193
|
+
cwd: cleanString(input.cwd, "targetPolicy.cwd", { max: 500 }),
|
|
194
|
+
profile: cleanString(input.profile, "targetPolicy.profile", { max: 120 }),
|
|
195
|
+
approvalMode: cleanEnum(input.approvalMode, "targetPolicy.approvalMode", ["open", "guarded", "read-only"] as const, "guarded"),
|
|
196
|
+
keepAlive: cleanBool(input.keepAlive, "targetPolicy.keepAlive") ?? false,
|
|
197
|
+
shutdownAfterMs: typeof input.shutdownAfterMs === "number" && Number.isSafeInteger(input.shutdownAfterMs) && input.shutdownAfterMs >= 0
|
|
198
|
+
? input.shutdownAfterMs
|
|
199
|
+
: undefined,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeCreateInput(input: CreateAutomationInput): Required<Omit<CreateAutomationInput, "description">> & { description?: string } {
|
|
204
|
+
const schedule = cleanString(input.schedule, "schedule", { required: true, max: 120 })!;
|
|
205
|
+
const timezone = cleanString(input.timezone, "timezone", { max: 80 }) ?? DEFAULT_TIMEZONE;
|
|
206
|
+
validateCron(schedule, timezone);
|
|
207
|
+
return {
|
|
208
|
+
kind: AUTOMATION_KIND,
|
|
209
|
+
name: cleanString(input.name, "name", { required: true, max: 160 })!,
|
|
210
|
+
description: cleanString(input.description, "description", { max: 2000 }),
|
|
211
|
+
enabled: cleanBool(input.enabled, "enabled") ?? true,
|
|
212
|
+
schedule,
|
|
213
|
+
timezone,
|
|
214
|
+
catchUpPolicy: cleanEnum(input.catchUpPolicy, "catchUpPolicy", ["skip", "run_once", "run_all"] as const, DEFAULT_CATCH_UP),
|
|
215
|
+
concurrencyPolicy: cleanEnum(input.concurrencyPolicy, "concurrencyPolicy", ["skip", "queue", "replace"] as const, DEFAULT_CONCURRENCY),
|
|
216
|
+
orchestratorId: cleanString(input.orchestratorId, "orchestratorId", { required: true, max: 160 })!,
|
|
217
|
+
targetPolicy: normalizeTargetPolicy(input.targetPolicy),
|
|
218
|
+
taskTemplate: normalizeTaskTemplate(input.taskTemplate),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createAutomation(input: CreateAutomationInput, now = Date.now()): Automation {
|
|
223
|
+
ensureAutomationTables();
|
|
224
|
+
const normalized = normalizeCreateInput(input);
|
|
225
|
+
if (!getOrchestrator(normalized.orchestratorId)) throw new ValidationError(`orchestrator ${normalized.orchestratorId} not found`);
|
|
226
|
+
const id = randomUUID();
|
|
227
|
+
const nextRunAt = normalized.enabled ? nextScheduledAt(normalized.schedule, normalized.timezone, now) : undefined;
|
|
228
|
+
db().prepare(`
|
|
229
|
+
INSERT INTO automations (id, kind, name, description, enabled, schedule, timezone, next_run_at, catch_up_policy, concurrency_policy, orchestrator_id, target_policy, task_template, created_at, updated_at)
|
|
230
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
231
|
+
`).run(
|
|
232
|
+
id,
|
|
233
|
+
AUTOMATION_KIND,
|
|
234
|
+
normalized.name,
|
|
235
|
+
normalized.description ?? null,
|
|
236
|
+
normalized.enabled ? 1 : 0,
|
|
237
|
+
normalized.schedule,
|
|
238
|
+
normalized.timezone,
|
|
239
|
+
nextRunAt ?? null,
|
|
240
|
+
normalized.catchUpPolicy,
|
|
241
|
+
normalized.concurrencyPolicy,
|
|
242
|
+
normalized.orchestratorId,
|
|
243
|
+
JSON.stringify(normalized.targetPolicy),
|
|
244
|
+
JSON.stringify(normalized.taskTemplate),
|
|
245
|
+
now,
|
|
246
|
+
now,
|
|
247
|
+
);
|
|
248
|
+
return getAutomation(id)!;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function updateAutomation(id: string, input: UpdateAutomationInput, now = Date.now()): Automation | null {
|
|
252
|
+
ensureAutomationTables();
|
|
253
|
+
const existing = getAutomation(id);
|
|
254
|
+
if (!existing) return null;
|
|
255
|
+
const next: CreateAutomationInput = {
|
|
256
|
+
kind: AUTOMATION_KIND,
|
|
257
|
+
name: input.name ?? existing.name,
|
|
258
|
+
description: input.description === null ? undefined : input.description ?? existing.description,
|
|
259
|
+
enabled: input.enabled ?? existing.enabled,
|
|
260
|
+
schedule: input.schedule ?? existing.schedule,
|
|
261
|
+
timezone: input.timezone ?? existing.timezone,
|
|
262
|
+
catchUpPolicy: input.catchUpPolicy ?? existing.catchUpPolicy,
|
|
263
|
+
concurrencyPolicy: input.concurrencyPolicy ?? existing.concurrencyPolicy,
|
|
264
|
+
orchestratorId: input.orchestratorId ?? existing.orchestratorId,
|
|
265
|
+
targetPolicy: input.targetPolicy ?? existing.targetPolicy,
|
|
266
|
+
taskTemplate: input.taskTemplate ?? existing.taskTemplate,
|
|
267
|
+
};
|
|
268
|
+
const normalized = normalizeCreateInput(next);
|
|
269
|
+
if (!getOrchestrator(normalized.orchestratorId)) throw new ValidationError(`orchestrator ${normalized.orchestratorId} not found`);
|
|
270
|
+
const nextRunAt = normalized.enabled ? nextScheduledAt(normalized.schedule, normalized.timezone, now) : undefined;
|
|
271
|
+
db().prepare(`
|
|
272
|
+
UPDATE automations
|
|
273
|
+
SET name = ?, description = ?, enabled = ?, schedule = ?, timezone = ?, next_run_at = ?,
|
|
274
|
+
catch_up_policy = ?, concurrency_policy = ?, orchestrator_id = ?, target_policy = ?, task_template = ?, updated_at = ?
|
|
275
|
+
WHERE id = ?
|
|
276
|
+
`).run(
|
|
277
|
+
normalized.name,
|
|
278
|
+
normalized.description ?? null,
|
|
279
|
+
normalized.enabled ? 1 : 0,
|
|
280
|
+
normalized.schedule,
|
|
281
|
+
normalized.timezone,
|
|
282
|
+
nextRunAt ?? null,
|
|
283
|
+
normalized.catchUpPolicy,
|
|
284
|
+
normalized.concurrencyPolicy,
|
|
285
|
+
normalized.orchestratorId,
|
|
286
|
+
JSON.stringify(normalized.targetPolicy),
|
|
287
|
+
JSON.stringify(normalized.taskTemplate),
|
|
288
|
+
now,
|
|
289
|
+
id,
|
|
290
|
+
);
|
|
291
|
+
return getAutomation(id)!;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function deleteAutomation(id: string): boolean {
|
|
295
|
+
ensureAutomationTables();
|
|
296
|
+
return db().prepare("DELETE FROM automations WHERE id = ?").run(id).changes > 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function getAutomation(id: string): Automation | null {
|
|
300
|
+
ensureAutomationTables();
|
|
301
|
+
const row = db().prepare("SELECT * FROM automations WHERE id = ?").get(id) as any;
|
|
302
|
+
return row ? rowToAutomation(row) : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function listAutomations(): Automation[] {
|
|
306
|
+
ensureAutomationTables();
|
|
307
|
+
return (db().prepare("SELECT * FROM automations ORDER BY enabled DESC, next_run_at IS NULL, next_run_at ASC, name COLLATE NOCASE").all() as any[])
|
|
308
|
+
.map(rowToAutomation);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function listAutomationRuns(filter: { automationId?: string; status?: string; limit?: number } = {}): AutomationRun[] {
|
|
312
|
+
ensureAutomationTables();
|
|
313
|
+
const clauses: string[] = [];
|
|
314
|
+
const params: (string | number)[] = [];
|
|
315
|
+
if (filter.automationId) {
|
|
316
|
+
clauses.push("automation_id = ?");
|
|
317
|
+
params.push(filter.automationId);
|
|
318
|
+
}
|
|
319
|
+
if (filter.status) {
|
|
320
|
+
clauses.push("status = ?");
|
|
321
|
+
params.push(filter.status);
|
|
322
|
+
}
|
|
323
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
324
|
+
params.push(filter.limit ?? 100);
|
|
325
|
+
return (db().prepare(`SELECT * FROM automation_runs ${where} ORDER BY created_at DESC LIMIT ?`).all(...params) as any[])
|
|
326
|
+
.map(rowToAutomationRun);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function getAutomationRun(id: string): AutomationRun | null {
|
|
330
|
+
ensureAutomationTables();
|
|
331
|
+
const row = db().prepare("SELECT * FROM automation_runs WHERE id = ?").get(id) as any;
|
|
332
|
+
return row ? rowToAutomationRun(row) : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function runDueAutomations(now = Date.now()): AutomationDispatchResult[] {
|
|
336
|
+
ensureAutomationTables();
|
|
337
|
+
const results = dispatchQueuedAutomationRuns(now);
|
|
338
|
+
const due = (db().prepare("SELECT * FROM automations WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at ASC LIMIT 25").all(now) as any[])
|
|
339
|
+
.map(rowToAutomation);
|
|
340
|
+
for (const automation of due) {
|
|
341
|
+
if (hasActiveRun(automation.id)) {
|
|
342
|
+
if (automation.concurrencyPolicy === "replace") cancelActiveRuns(automation.id, now, "Replaced by newer scheduled run");
|
|
343
|
+
else {
|
|
344
|
+
if (automation.concurrencyPolicy === "queue") enqueueAutomationRun(automation, automation.nextRunAt ?? now, now);
|
|
345
|
+
rescheduleAutomation(automation, now);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
results.push(dispatchAutomation(automation, automation.nextRunAt ?? now, now));
|
|
350
|
+
rescheduleAutomation(automation, now);
|
|
351
|
+
}
|
|
352
|
+
return results;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function runAutomationNow(id: string, now = Date.now()): AutomationDispatchResult | null {
|
|
356
|
+
ensureAutomationTables();
|
|
357
|
+
const automation = getAutomation(id);
|
|
358
|
+
if (!automation) return null;
|
|
359
|
+
if (hasActiveRun(automation.id)) throw new ValidationError("automation already has an active run");
|
|
360
|
+
return dispatchAutomation(automation, now, now);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function reconcileAutomationRuns(now = Date.now()): AutomationReconcileResult[] {
|
|
364
|
+
ensureAutomationTables();
|
|
365
|
+
const rows = db().prepare(`
|
|
366
|
+
SELECT * FROM automation_runs
|
|
367
|
+
WHERE status IN ('scheduled', 'dispatching', 'waiting_agent', 'running') AND task_id IS NOT NULL
|
|
368
|
+
ORDER BY created_at ASC
|
|
369
|
+
`).all() as any[];
|
|
370
|
+
const results: AutomationReconcileResult[] = [];
|
|
371
|
+
for (const row of rows) {
|
|
372
|
+
const run = rowToAutomationRun(row);
|
|
373
|
+
const task = run.taskId ? getTask(run.taskId) : null;
|
|
374
|
+
if (!task) continue;
|
|
375
|
+
|
|
376
|
+
if (task.claimedBy && task.claimedBy !== run.targetAgentId) {
|
|
377
|
+
updateRun(run.id, {
|
|
378
|
+
status: task.status === "open" ? "waiting_agent" : "running",
|
|
379
|
+
targetAgentId: task.claimedBy,
|
|
380
|
+
spawnedAgentId: run.spawnedAgentId ?? task.claimedBy,
|
|
381
|
+
}, now);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!CLOSED_TASK_STATUS.has(task.status)) {
|
|
385
|
+
const updated = getAutomationRun(run.id);
|
|
386
|
+
if (updated) results.push({ run: updated, task });
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const status: AutomationRunStatus = task.status === "done" ? "succeeded" : task.status === "canceled" ? "canceled" : "failed";
|
|
391
|
+
updateRun(run.id, {
|
|
392
|
+
status,
|
|
393
|
+
finishedAt: now,
|
|
394
|
+
result: { taskStatus: task.status, result: task.result ?? null },
|
|
395
|
+
targetAgentId: task.claimedBy ?? run.targetAgentId,
|
|
396
|
+
spawnedAgentId: run.spawnedAgentId ?? task.claimedBy,
|
|
397
|
+
}, now);
|
|
398
|
+
const updated = getAutomationRun(run.id)!;
|
|
399
|
+
const command = maybeShutdownOnDemandAgent(updated, task, now);
|
|
400
|
+
results.push({ run: getAutomationRun(run.id)!, task, command });
|
|
401
|
+
}
|
|
402
|
+
return results;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function dispatchAutomation(automation: Automation, scheduledFor: number, now: number): AutomationDispatchResult {
|
|
406
|
+
const run = insertRun(automation, scheduledFor, now);
|
|
407
|
+
return dispatchAutomationRun(automation, run, now);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function dispatchAutomationRun(automation: Automation, run: AutomationRun, now: number): AutomationDispatchResult {
|
|
411
|
+
updateRun(run.id, { status: "dispatching" }, now);
|
|
412
|
+
try {
|
|
413
|
+
const orchestrator = requireOnlineOrchestrator(automation.orchestratorId);
|
|
414
|
+
if (automation.targetPolicy.mode === "existing_agent") {
|
|
415
|
+
const agent = resolveExistingAgent(automation.targetPolicy, orchestrator);
|
|
416
|
+
if (!agent && automation.targetPolicy.ifNoMatch !== "spawn") throw new ValidationError("no matching managed agent for automation");
|
|
417
|
+
if (agent) return createRunTask(automation, run, agent.id, now, { targetMode: "existing_agent" });
|
|
418
|
+
const fallbackPolicy: AutomationTargetPolicy = { mode: "on_demand_agent", provider: orchestrator.providers[0] ?? "codex", keepAlive: false };
|
|
419
|
+
return dispatchOnDemandAutomation(automation, run, orchestrator, fallbackPolicy, now);
|
|
420
|
+
}
|
|
421
|
+
return dispatchOnDemandAutomation(automation, run, orchestrator, automation.targetPolicy, now);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
updateRun(run.id, {
|
|
424
|
+
status: "failed",
|
|
425
|
+
finishedAt: now,
|
|
426
|
+
error: e instanceof Error ? e.message : String(e),
|
|
427
|
+
}, now);
|
|
428
|
+
return { automation, run: getAutomationRun(run.id)! };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function dispatchOnDemandAutomation(
|
|
433
|
+
automation: Automation,
|
|
434
|
+
run: AutomationRun,
|
|
435
|
+
orchestrator: Orchestrator,
|
|
436
|
+
policy: Extract<AutomationTargetPolicy, { mode: "on_demand_agent" }>,
|
|
437
|
+
now: number,
|
|
438
|
+
): AutomationDispatchResult {
|
|
439
|
+
if (!orchestrator.providers.includes(policy.provider)) throw new ValidationError(`orchestrator ${orchestrator.id} does not support provider ${policy.provider}`);
|
|
440
|
+
const label = automationRunLabel(automation.id, run.id);
|
|
441
|
+
const command = createCommand({
|
|
442
|
+
type: "agent.spawn",
|
|
443
|
+
source: "automation",
|
|
444
|
+
target: orchestrator.agentId,
|
|
445
|
+
params: {
|
|
446
|
+
action: "spawn",
|
|
447
|
+
provider: policy.provider,
|
|
448
|
+
cwd: policy.cwd || orchestrator.baseDir,
|
|
449
|
+
label,
|
|
450
|
+
approvalMode: policy.approvalMode ?? "guarded",
|
|
451
|
+
requestedBy: "automation",
|
|
452
|
+
requestedAt: now,
|
|
453
|
+
automationId: automation.id,
|
|
454
|
+
automationRunId: run.id,
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
const result = createRunTask(automation, run, `label:${label}`, now, {
|
|
458
|
+
targetMode: "on_demand_agent",
|
|
459
|
+
provider: policy.provider,
|
|
460
|
+
label,
|
|
461
|
+
});
|
|
462
|
+
updateRun(run.id, {
|
|
463
|
+
status: "waiting_agent",
|
|
464
|
+
meta: { ...result.run.meta, onDemandLabel: label, spawnCommandId: command.id },
|
|
465
|
+
}, now);
|
|
466
|
+
return { ...result, run: getAutomationRun(run.id)!, command };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function createRunTask(
|
|
470
|
+
automation: Automation,
|
|
471
|
+
run: AutomationRun,
|
|
472
|
+
target: string,
|
|
473
|
+
now: number,
|
|
474
|
+
meta: Record<string, unknown>,
|
|
475
|
+
): AutomationDispatchResult {
|
|
476
|
+
const template = automation.taskTemplate;
|
|
477
|
+
const result = ingestIntegrationEvent({
|
|
478
|
+
source: "automation",
|
|
479
|
+
type: "scheduled_task",
|
|
480
|
+
severity: template.severity ?? "info",
|
|
481
|
+
title: template.title,
|
|
482
|
+
body: template.body,
|
|
483
|
+
target,
|
|
484
|
+
channel: "automation",
|
|
485
|
+
dedupeKey: template.dedupeKey ? `${template.dedupeKey}:${run.id}` : `automation:${automation.id}:run:${run.id}`,
|
|
486
|
+
externalUrl: template.externalUrl,
|
|
487
|
+
metadata: {
|
|
488
|
+
...(template.metadata ?? {}),
|
|
489
|
+
automationId: automation.id,
|
|
490
|
+
automationRunId: run.id,
|
|
491
|
+
orchestratorId: automation.orchestratorId,
|
|
492
|
+
...meta,
|
|
493
|
+
},
|
|
494
|
+
}, "automation");
|
|
495
|
+
updateRun(run.id, {
|
|
496
|
+
status: meta.targetMode === "on_demand_agent" ? "waiting_agent" : "running",
|
|
497
|
+
startedAt: now,
|
|
498
|
+
targetAgentId: target.startsWith("label:") ? undefined : target,
|
|
499
|
+
taskId: result.task.id,
|
|
500
|
+
messageId: result.message?.id,
|
|
501
|
+
meta,
|
|
502
|
+
}, now);
|
|
503
|
+
return { automation, run: getAutomationRun(run.id)!, task: result.task, message: result.message };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function insertRun(automation: Automation, scheduledFor: number, now: number): AutomationRun {
|
|
507
|
+
const id = randomUUID();
|
|
508
|
+
db().prepare(`
|
|
509
|
+
INSERT INTO automation_runs (id, automation_id, status, scheduled_for, orchestrator_id, meta, created_at, updated_at)
|
|
510
|
+
VALUES (?, ?, 'dispatching', ?, ?, '{}', ?, ?)
|
|
511
|
+
`).run(id, automation.id, scheduledFor, automation.orchestratorId, now, now);
|
|
512
|
+
return getAutomationRun(id)!;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function enqueueAutomationRun(automation: Automation, scheduledFor: number, now: number): AutomationRun {
|
|
516
|
+
const existing = db().prepare("SELECT * FROM automation_runs WHERE automation_id = ? AND status = 'scheduled' AND scheduled_for = ?")
|
|
517
|
+
.get(automation.id, scheduledFor) as any;
|
|
518
|
+
if (existing) return rowToAutomationRun(existing);
|
|
519
|
+
const id = randomUUID();
|
|
520
|
+
db().prepare(`
|
|
521
|
+
INSERT INTO automation_runs (id, automation_id, status, scheduled_for, orchestrator_id, meta, created_at, updated_at)
|
|
522
|
+
VALUES (?, ?, 'scheduled', ?, ?, '{}', ?, ?)
|
|
523
|
+
`).run(id, automation.id, scheduledFor, automation.orchestratorId, now, now);
|
|
524
|
+
return getAutomationRun(id)!;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function dispatchQueuedAutomationRuns(now: number): AutomationDispatchResult[] {
|
|
528
|
+
const queued = (db().prepare(`
|
|
529
|
+
SELECT r.* FROM automation_runs r
|
|
530
|
+
JOIN automations a ON a.id = r.automation_id
|
|
531
|
+
WHERE r.status = 'scheduled' AND a.enabled = 1
|
|
532
|
+
ORDER BY r.scheduled_for ASC
|
|
533
|
+
LIMIT 25
|
|
534
|
+
`).all() as any[]).map(rowToAutomationRun);
|
|
535
|
+
const results: AutomationDispatchResult[] = [];
|
|
536
|
+
for (const run of queued) {
|
|
537
|
+
const automation = getAutomation(run.automationId);
|
|
538
|
+
if (!automation || hasActiveRun(automation.id)) continue;
|
|
539
|
+
results.push(dispatchAutomationRun(automation, run, now));
|
|
540
|
+
}
|
|
541
|
+
return results;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function updateRun(id: string, input: {
|
|
545
|
+
status?: AutomationRunStatus;
|
|
546
|
+
startedAt?: number;
|
|
547
|
+
finishedAt?: number;
|
|
548
|
+
targetAgentId?: string;
|
|
549
|
+
spawnedAgentId?: string;
|
|
550
|
+
taskId?: number;
|
|
551
|
+
messageId?: number;
|
|
552
|
+
error?: string;
|
|
553
|
+
result?: Record<string, unknown>;
|
|
554
|
+
meta?: Record<string, unknown>;
|
|
555
|
+
shutdownRequestedAt?: number;
|
|
556
|
+
}, now: number): void {
|
|
557
|
+
const current = getAutomationRun(id);
|
|
558
|
+
const meta = input.meta !== undefined ? input.meta : current?.meta;
|
|
559
|
+
db().prepare(`
|
|
560
|
+
UPDATE automation_runs
|
|
561
|
+
SET status = COALESCE(?, status),
|
|
562
|
+
started_at = COALESCE(?, started_at),
|
|
563
|
+
finished_at = COALESCE(?, finished_at),
|
|
564
|
+
target_agent_id = COALESCE(?, target_agent_id),
|
|
565
|
+
spawned_agent_id = COALESCE(?, spawned_agent_id),
|
|
566
|
+
task_id = COALESCE(?, task_id),
|
|
567
|
+
message_id = COALESCE(?, message_id),
|
|
568
|
+
control_message_id = control_message_id,
|
|
569
|
+
error = COALESCE(?, error),
|
|
570
|
+
result = COALESCE(?, result),
|
|
571
|
+
meta = COALESCE(?, meta),
|
|
572
|
+
shutdown_requested_at = COALESCE(?, shutdown_requested_at),
|
|
573
|
+
updated_at = ?
|
|
574
|
+
WHERE id = ?
|
|
575
|
+
`).run(
|
|
576
|
+
input.status ?? null,
|
|
577
|
+
input.startedAt ?? null,
|
|
578
|
+
input.finishedAt ?? null,
|
|
579
|
+
input.targetAgentId ?? null,
|
|
580
|
+
input.spawnedAgentId ?? null,
|
|
581
|
+
input.taskId ?? null,
|
|
582
|
+
input.messageId ?? null,
|
|
583
|
+
input.error ?? null,
|
|
584
|
+
input.result ? JSON.stringify(input.result) : null,
|
|
585
|
+
meta ? JSON.stringify(meta) : null,
|
|
586
|
+
input.shutdownRequestedAt ?? null,
|
|
587
|
+
now,
|
|
588
|
+
id,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function hasActiveRun(automationId: string): boolean {
|
|
593
|
+
const placeholders = [...BLOCKING_RUN_STATUSES].map(() => "?").join(",");
|
|
594
|
+
const row = db().prepare(`SELECT id FROM automation_runs WHERE automation_id = ? AND status IN (${placeholders}) LIMIT 1`)
|
|
595
|
+
.get(automationId, ...BLOCKING_RUN_STATUSES) as any;
|
|
596
|
+
return Boolean(row);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function cancelActiveRuns(automationId: string, now: number, reason: string): void {
|
|
600
|
+
const placeholders = [...OPEN_RUN_STATUSES].map(() => "?").join(",");
|
|
601
|
+
db().prepare(`UPDATE automation_runs SET status = 'canceled', finished_at = ?, error = ?, updated_at = ? WHERE automation_id = ? AND status IN (${placeholders})`)
|
|
602
|
+
.run(now, reason, now, automationId, ...OPEN_RUN_STATUSES);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function rescheduleAutomation(automation: Automation, now: number): void {
|
|
606
|
+
const next = automation.enabled ? nextScheduledAt(automation.schedule, automation.timezone, now) : undefined;
|
|
607
|
+
db().prepare("UPDATE automations SET next_run_at = ?, updated_at = ? WHERE id = ?").run(next ?? null, now, automation.id);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function requireOnlineOrchestrator(orchestratorId: string): Orchestrator {
|
|
611
|
+
const orchestrator = getOrchestrator(orchestratorId);
|
|
612
|
+
if (!orchestrator) throw new ValidationError(`orchestrator ${orchestratorId} not found`);
|
|
613
|
+
if (orchestrator.status !== "online") throw new ValidationError(`orchestrator ${orchestratorId} is ${orchestrator.status}`);
|
|
614
|
+
return orchestrator;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function resolveExistingAgent(policy: Extract<AutomationTargetPolicy, { mode: "existing_agent" }>, orchestrator: Orchestrator): AgentCard | null {
|
|
618
|
+
const managedIds = new Set(orchestrator.managedAgents.map((agent) => agent.agentId).filter(Boolean));
|
|
619
|
+
const candidates = listAgents()
|
|
620
|
+
.filter((agent) => agent.id !== "user" && agent.id !== "system" && agent.kind !== "channel" && agent.kind !== "orchestrator")
|
|
621
|
+
.filter((agent) => agent.ready && agent.status !== "offline")
|
|
622
|
+
.filter((agent) => managedIds.size === 0 || managedIds.has(agent.id))
|
|
623
|
+
.filter((agent) => automationAgentMatchesSelector(agent, policy.selector));
|
|
624
|
+
candidates.sort((a, b) => b.lastSeen - a.lastSeen);
|
|
625
|
+
return candidates[0] ?? null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function automationAgentMatchesSelector(agent: AgentCard, selector: Extract<AutomationTargetPolicy, { mode: "existing_agent" }>["selector"]): boolean {
|
|
629
|
+
if (selector.provider) {
|
|
630
|
+
const provider = typeof agent.meta?.provider === "string" ? agent.meta.provider : undefined;
|
|
631
|
+
if (provider !== selector.provider && !agent.tags.includes(selector.provider)) return false;
|
|
632
|
+
}
|
|
633
|
+
if (selector.label && agent.label !== selector.label) return false;
|
|
634
|
+
if (selector.tags?.length && !selector.tags.every((tag) => agent.tags.includes(tag))) return false;
|
|
635
|
+
if (selector.capabilities?.length && !selector.capabilities.every((cap) => agent.capabilities.includes(cap))) return false;
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function maybeShutdownOnDemandAgent(run: AutomationRun, task: Task, now: number): Command | undefined {
|
|
640
|
+
if (run.shutdownRequestedAt) return undefined;
|
|
641
|
+
const automation = getAutomation(run.automationId);
|
|
642
|
+
if (!automation || automation.targetPolicy.mode !== "on_demand_agent" || automation.targetPolicy.keepAlive) return undefined;
|
|
643
|
+
const agentId = task.claimedBy ?? run.targetAgentId ?? run.spawnedAgentId;
|
|
644
|
+
if (!agentId) return undefined;
|
|
645
|
+
const command = createCommand({
|
|
646
|
+
type: "agent.shutdown",
|
|
647
|
+
source: "automation",
|
|
648
|
+
target: agentId,
|
|
649
|
+
params: {
|
|
650
|
+
action: "shutdown",
|
|
651
|
+
agentId,
|
|
652
|
+
requestedBy: "automation",
|
|
653
|
+
requestedAt: now,
|
|
654
|
+
automationId: automation.id,
|
|
655
|
+
automationRunId: run.id,
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
updateRun(run.id, { shutdownRequestedAt: now, meta: { ...run.meta, shutdownCommandId: command.id } }, now);
|
|
659
|
+
return command;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function automationRunLabel(automationId: string, runId: string): string {
|
|
663
|
+
return `automation-${automationId.slice(0, 8)}-${runId.slice(0, 8)}`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function validateCron(expression: string, timezone: string): void {
|
|
667
|
+
parseCron(expression);
|
|
668
|
+
localParts(Date.now(), timezone);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function nextScheduledAt(expression: string, timezone: string, afterMs: number): number {
|
|
672
|
+
const cron = parseCron(expression);
|
|
673
|
+
let cursor = Math.floor(afterMs / 60_000) * 60_000 + 60_000;
|
|
674
|
+
for (let i = 0; i < MAX_CRON_SCAN_MINUTES; i += 1) {
|
|
675
|
+
const parts = localParts(cursor, timezone);
|
|
676
|
+
if (cronMatches(cron, parts)) return cursor;
|
|
677
|
+
cursor += 60_000;
|
|
678
|
+
}
|
|
679
|
+
throw new ValidationError("schedule has no matching time in the next year");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
interface CronSpec {
|
|
683
|
+
minute: Set<number>;
|
|
684
|
+
hour: Set<number>;
|
|
685
|
+
dayOfMonth: Set<number>;
|
|
686
|
+
month: Set<number>;
|
|
687
|
+
dayOfWeek: Set<number>;
|
|
688
|
+
domAny: boolean;
|
|
689
|
+
dowAny: boolean;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
interface LocalTimeParts {
|
|
693
|
+
minute: number;
|
|
694
|
+
hour: number;
|
|
695
|
+
dayOfMonth: number;
|
|
696
|
+
month: number;
|
|
697
|
+
dayOfWeek: number;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function parseCron(expression: string): CronSpec {
|
|
701
|
+
const parts = expression.trim().split(/\s+/);
|
|
702
|
+
if (parts.length !== 5) throw new ValidationError("schedule must be a 5-field cron expression");
|
|
703
|
+
const [minute, hour, dom, month, dow] = parts as [string, string, string, string, string];
|
|
704
|
+
return {
|
|
705
|
+
minute: parseCronField(minute, 0, 59, "minute"),
|
|
706
|
+
hour: parseCronField(hour, 0, 23, "hour"),
|
|
707
|
+
dayOfMonth: parseCronField(dom, 1, 31, "dayOfMonth"),
|
|
708
|
+
month: parseCronField(month, 1, 12, "month"),
|
|
709
|
+
dayOfWeek: parseCronField(dow.replace(/\b7\b/g, "0"), 0, 6, "dayOfWeek"),
|
|
710
|
+
domAny: dom === "*",
|
|
711
|
+
dowAny: dow === "*",
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function parseCronField(field: string, min: number, max: number, name: string): Set<number> {
|
|
716
|
+
const values = new Set<number>();
|
|
717
|
+
for (const rawPart of field.split(",")) {
|
|
718
|
+
const part = rawPart.trim();
|
|
719
|
+
if (!part) throw new ValidationError(`${name} cron field is empty`);
|
|
720
|
+
const [rangePart, stepRaw] = part.split("/");
|
|
721
|
+
const step = stepRaw === undefined ? 1 : Number(stepRaw);
|
|
722
|
+
if (!Number.isInteger(step) || step <= 0) throw new ValidationError(`${name} cron step must be a positive integer`);
|
|
723
|
+
let start: number;
|
|
724
|
+
let end: number;
|
|
725
|
+
if (rangePart === "*") {
|
|
726
|
+
start = min;
|
|
727
|
+
end = max;
|
|
728
|
+
} else if (rangePart?.includes("-")) {
|
|
729
|
+
const [startRaw, endRaw] = rangePart.split("-");
|
|
730
|
+
start = Number(startRaw);
|
|
731
|
+
end = Number(endRaw);
|
|
732
|
+
} else {
|
|
733
|
+
start = Number(rangePart);
|
|
734
|
+
end = start;
|
|
735
|
+
}
|
|
736
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
|
|
737
|
+
throw new ValidationError(`${name} cron field is out of range`);
|
|
738
|
+
}
|
|
739
|
+
for (let value = start; value <= end; value += step) values.add(value);
|
|
740
|
+
}
|
|
741
|
+
return values;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function localParts(timestamp: number, timezone: string): LocalTimeParts {
|
|
745
|
+
const dtf = new Intl.DateTimeFormat("en-US", {
|
|
746
|
+
timeZone: timezone,
|
|
747
|
+
minute: "numeric",
|
|
748
|
+
hour: "numeric",
|
|
749
|
+
hourCycle: "h23",
|
|
750
|
+
day: "numeric",
|
|
751
|
+
month: "numeric",
|
|
752
|
+
weekday: "short",
|
|
753
|
+
});
|
|
754
|
+
const parts = Object.fromEntries(dtf.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]));
|
|
755
|
+
const dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(parts.weekday ?? "");
|
|
756
|
+
if (dow < 0) throw new ValidationError(`invalid timezone: ${timezone}`);
|
|
757
|
+
return {
|
|
758
|
+
minute: Number(parts.minute),
|
|
759
|
+
hour: Number(parts.hour),
|
|
760
|
+
dayOfMonth: Number(parts.day),
|
|
761
|
+
month: Number(parts.month),
|
|
762
|
+
dayOfWeek: dow,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function cronMatches(cron: CronSpec, parts: LocalTimeParts): boolean {
|
|
767
|
+
if (!cron.minute.has(parts.minute) || !cron.hour.has(parts.hour) || !cron.month.has(parts.month)) return false;
|
|
768
|
+
const domMatch = cron.dayOfMonth.has(parts.dayOfMonth);
|
|
769
|
+
const dowMatch = cron.dayOfWeek.has(parts.dayOfWeek);
|
|
770
|
+
if (cron.domAny && cron.dowAny) return true;
|
|
771
|
+
if (cron.domAny) return dowMatch;
|
|
772
|
+
if (cron.dowAny) return domMatch;
|
|
773
|
+
return domMatch || dowMatch;
|
|
774
|
+
}
|