import { isAbsolute } from "node:path"; import { firstFire, parseDuration, parseInstant, validateSchedule } from "./schedule.js"; import { DEFAULT_TZ, JOB_ID_PATTERN, type Job, type MisfirePolicy, type Predicate, type RetryPolicy, type Schedule, type Step, } from "./types.js"; /** Loose input accepted from tools, the console and JSON files. Durations may be "15m", "2h"... */ export interface JobInput { id?: string; name: string; description?: string; enabled?: boolean; cwd: string; schedule: Schedule | { at: string } | { cron: string; tz?: string } | { every: string | number; anchor?: string }; misfire?: MisfirePolicy; misfireGrace?: string | number; concurrency?: "forbid" | "allow"; timeout?: string | number; retry?: { maxAttempts: number; backoff: string | number; factor?: number; until?: string }; steps: unknown[]; tags?: string[]; createdBy?: string; } const STEP_ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/; const OPS = new Set([">", ">=", "<", "<=", "==", "!=", "contains", "exists"]); const AGGS = new Set(["first", "last", "max", "min", "avg", "sum", "count"]); export function slugify(name: string): string { const slug = name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase() .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48); return slug || "job"; } export function normalizeSchedule(input: JobInput["schedule"]): Schedule { const s = input as Record; let schedule: Schedule; if (s.kind === "at" || (s.kind === undefined && typeof s.at === "string")) { schedule = { kind: "at", at: parseInstant(String(s.at), "schedule.at").toISOString() }; } else if (s.kind === "cron" || (s.kind === undefined && typeof s.cron === "string")) { schedule = { kind: "cron", expr: String(s.expr ?? s.cron), tz: typeof s.tz === "string" ? s.tz : DEFAULT_TZ }; } else if (s.kind === "every" || (s.kind === undefined && s.every !== undefined)) { const everyMs = parseDuration((s.everyMs ?? s.every) as string | number); schedule = { kind: "every", everyMs, ...(typeof s.anchor === "string" ? { anchor: parseInstant(s.anchor, "schedule.anchor").toISOString() } : {}) }; } else { throw new Error("schedule must be {at}, {cron[,tz]} or {every[,anchor]}"); } validateSchedule(schedule); return schedule; } function normalizeRetry(input: JobInput["retry"]): RetryPolicy | undefined { if (!input) return undefined; if (!Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 1000) throw new Error("retry.maxAttempts must be 1..1000"); const backoffMs = parseDuration(input.backoff); if (backoffMs < 10_000) throw new Error("retry.backoff must be >= 10s"); return { maxAttempts: input.maxAttempts, backoffMs, ...(input.factor !== undefined ? { factor: Math.max(1, Math.min(10, input.factor)) } : {}), ...(input.until ? { until: parseInstant(input.until, "retry.until").toISOString() } : {}), }; } function str(v: unknown, field: string, max = 4000): string { if (typeof v !== "string" || v.length === 0 || v.length > max) throw new Error(`${field} must be a non-empty string (max ${max})`); return v; } function strRecord(v: unknown, field: string): Record | undefined { if (v === undefined) return undefined; if (typeof v !== "object" || v === null || Array.isArray(v)) throw new Error(`${field} must be an object`); for (const [k, val] of Object.entries(v)) if (typeof val !== "string") throw new Error(`${field}.${k} must be a string`); return v as Record; } function normalizePredicate(p: unknown, field: string): Predicate { if (typeof p !== "object" || p === null) throw new Error(`${field} must be an object`); const o = p as Record; const op = String(o.op); if (!OPS.has(op)) throw new Error(`${field}.op invalid`); if (o.aggregate !== undefined && !AGGS.has(String(o.aggregate))) throw new Error(`${field}.aggregate invalid`); const value = o.value; if (value !== undefined && value !== null && !["string", "number", "boolean"].includes(typeof value)) throw new Error(`${field}.value must be scalar`); return { path: str(o.path, `${field}.path`, 300), op: op as Predicate["op"], ...(o.aggregate !== undefined ? { aggregate: o.aggregate as NonNullable } : {}), ...(value !== undefined ? { value: value as NonNullable | null } : {}), }; } export function normalizeStep(raw: unknown, index: number, seen: Set): Step { if (typeof raw !== "object" || raw === null) throw new Error(`steps[${index}] must be an object`); const o = raw as Record; const id = String(o.id ?? `s${index + 1}`); if (!STEP_ID.test(id)) throw new Error(`steps[${index}].id invalid`); if (seen.has(id)) throw new Error(`duplicate step id '${id}'`); const base = { id, ...(o.when !== undefined ? { when: str(o.when, `steps.${id}.when`, 32) } : {}), ...(o.timeout !== undefined || o.timeoutMs !== undefined ? { timeoutMs: parseDuration((o.timeoutMs ?? o.timeout) as string | number) } : {}), ...(o.continueOnError === true ? { continueOnError: true } : {}), }; if (base.when !== undefined && !seen.has(base.when)) throw new Error(`steps.${id}.when must reference an earlier step`); seen.add(id); switch (o.type) { case "command": { if (!Array.isArray(o.argv) || o.argv.length === 0 || !o.argv.every((a) => typeof a === "string")) throw new Error(`steps.${id}.argv must be a non-empty string array`); const env = strRecord(o.env, `steps.${id}.env`); return { ...base, type: "command", argv: o.argv as string[], ...(typeof o.cwd === "string" ? { cwd: o.cwd } : {}), ...(env ? { env } : {}), ...(o.parseJson === true ? { parseJson: true } : {}), ...(Array.isArray(o.okExitCodes) ? { okExitCodes: o.okExitCodes.map(Number) } : {}), }; } case "http": { const headers = strRecord(o.headers, `steps.${id}.headers`); const method = (o.method ?? "GET") as string; if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) throw new Error(`steps.${id}.method invalid`); return { ...base, type: "http", url: str(o.url, `steps.${id}.url`, 2000), method: method as "GET", ...(headers ? { headers } : {}), ...(typeof o.body === "string" ? { body: o.body } : {}), ...(o.parseJson === true ? { parseJson: true } : {}), ...(Array.isArray(o.okStatus) ? { okStatus: o.okStatus.map(Number) } : {}), }; } case "condition": { const any = Array.isArray(o.any) ? o.any.map((p, i) => normalizePredicate(p, `steps.${id}.any[${i}]`)) : undefined; const all = Array.isArray(o.all) ? o.all.map((p, i) => normalizePredicate(p, `steps.${id}.all[${i}]`)) : undefined; if (!any?.length && !all?.length) throw new Error(`steps.${id} needs 'any' or 'all' predicates`); const onFalse = (o.onFalse ?? "continue") as string; if (!["continue", "stop", "retry"].includes(onFalse)) throw new Error(`steps.${id}.onFalse invalid`); return { ...base, type: "condition", ...(any ? { any } : {}), ...(all ? { all } : {}), onFalse: onFalse as "continue" }; } case "agent": { const mode = (o.mode ?? "auto") as string; if (!["auto", "hosted", "headless"].includes(mode)) throw new Error(`steps.${id}.mode invalid`); if (o.tools !== undefined && (!Array.isArray(o.tools) || !o.tools.every((t) => typeof t === "string"))) throw new Error(`steps.${id}.tools must be a string array`); return { ...base, type: "agent", prompt: str(o.prompt, `steps.${id}.prompt`, 32_000), mode: mode as "auto", ...(o.tools !== undefined ? { tools: o.tools as string[] } : {}), ...(typeof o.model === "string" ? { model: o.model } : {}), }; } case "notify": { const severity = (o.severity ?? "info") as string; if (!["info", "warning", "error"].includes(severity)) throw new Error(`steps.${id}.severity invalid`); return { ...base, type: "notify", severity: severity as "info", message: str(o.message, `steps.${id}.message`, 4000) }; } default: throw new Error(`steps[${index}].type must be command|http|condition|agent|notify`); } } /** Builds a validated Job. `existing` preserves identity/creation data on update. */ export function buildJob(input: JobInput, now: Date, existing?: Job): Job { const name = str(input.name, "name", 200); const id = existing?.id ?? input.id ?? slugify(name); if (!JOB_ID_PATTERN.test(id)) throw new Error("id must match ^[a-z0-9][a-z0-9-]{0,63}$"); const cwd = str(input.cwd, "cwd", 1000); if (!isAbsolute(cwd)) throw new Error("cwd must be an absolute path"); if (!Array.isArray(input.steps) || input.steps.length === 0 || input.steps.length > 20) throw new Error("steps must contain 1..20 steps"); const seen = new Set(); const steps = input.steps.map((s, i) => normalizeStep(s, i, seen)); const schedule = normalizeSchedule(input.schedule); const misfire = input.misfire ?? "skip"; if (!["skip", "run-once", "ask"].includes(misfire)) throw new Error("misfire must be skip|run-once|ask"); const retry = normalizeRetry(input.retry); const enabled = input.enabled ?? existing?.enabled ?? true; const scheduleChanged = !existing || JSON.stringify(existing.schedule) !== JSON.stringify(schedule); const next = firstFire(schedule, now); const job: Job = { version: 1, id, name, enabled, cwd, schedule, misfire, steps, createdAt: existing?.createdAt ?? now.toISOString(), updatedAt: now.toISOString(), state: scheduleChanged || !existing ? { ...(existing?.state ?? {}), nextRunAt: next ? next.toISOString() : null } : existing.state, }; if (scheduleChanged) { delete job.state.retry; delete job.state.pendingMisfire; } if (input.description) job.description = input.description; if (input.misfireGrace !== undefined) job.misfireGraceMs = parseDuration(input.misfireGrace); if (input.concurrency) job.concurrency = input.concurrency; if (input.timeout !== undefined) job.timeoutMs = parseDuration(input.timeout); if (retry) job.retry = retry; if (input.tags?.length) job.tags = input.tags.slice(0, 20).map(String); const createdBy = existing?.createdBy ?? input.createdBy; if (createdBy) job.createdBy = createdBy; return job; } /** Converts a stored job back to editable input (console "edit" / duplicate). */ export function jobToInput(job: Job): JobInput { const schedule = job.schedule.kind === "at" ? { at: job.schedule.at } : job.schedule.kind === "cron" ? { cron: job.schedule.expr, ...(job.schedule.tz ? { tz: job.schedule.tz } : {}) } : { every: job.schedule.everyMs, ...(job.schedule.anchor ? { anchor: job.schedule.anchor } : {}) }; return { id: job.id, name: job.name, enabled: job.enabled, cwd: job.cwd, schedule, misfire: job.misfire, steps: job.steps, ...(job.description ? { description: job.description } : {}), ...(job.misfireGraceMs !== undefined ? { misfireGrace: job.misfireGraceMs } : {}), ...(job.concurrency ? { concurrency: job.concurrency } : {}), ...(job.timeoutMs !== undefined ? { timeout: job.timeoutMs } : {}), ...(job.retry ? { retry: { maxAttempts: job.retry.maxAttempts, backoff: job.retry.backoffMs, ...(job.retry.factor ? { factor: job.retry.factor } : {}), ...(job.retry.until ? { until: job.retry.until } : {}) } } : {}), ...(job.tags ? { tags: job.tags } : {}), }; }