import { basename } from "node:path"; import { evaluateCondition, renderTemplate } from "./expr.js"; import { nextFireAfter } from "./schedule.js"; import { newRunId, type JobStore } from "./store.js"; import { DEFAULT_JOB_TIMEOUT_MS, DEFAULT_MISFIRE_GRACE_MS, MAX_OUTPUT_CHARS, type AgentStep, type Job, type Run, type RunStatus, type RunTrigger, type Step, type StepResult, } from "./types.js"; // --------------------------------------------------------------------------- // Runtime: host-specific side effects (server plugin, CLI, tests) // --------------------------------------------------------------------------- export interface ExecRequest { file: string; args: string[]; cwd: string; env?: Record; timeoutMs: number; signal: AbortSignal; } export interface ExecResult { exitCode: number | null; stdout: string; stderr: string } export interface AgentRequest { job: Job; run: Run; step: AgentStep; prompt: string; timeoutMs: number; signal: AbortSignal; } export interface AgentResult { ok: boolean; text: string; sessionId?: string; error?: string } export interface NotifyRequest { job: Job; run: Run; severity: "info" | "warning" | "error"; message: string } export interface Runtime { readonly hostName: string; exec(req: ExecRequest): Promise; agent(req: AgentRequest): Promise; notify(req: NotifyRequest): Promise; fetch?: typeof fetch; } export interface EngineOptions { tickMs?: number; keepRuns?: number; /** Executable basenames allowed in command steps; undefined = any. */ allowedExecutables?: string[]; now?: () => Date; log?: (level: "info" | "warn" | "error", message: string) => void; } export type EngineEvent = | { type: "run-started"; jobId: string; runId: string } | { type: "run-finished"; jobId: string; runId: string; status: RunStatus } | { type: "job-updated"; jobId: string }; // --------------------------------------------------------------------------- // Pure planning // --------------------------------------------------------------------------- export type TickPlan = | { action: "none" } | { action: "run"; trigger: RunTrigger; scheduledFor: string; attempt: number; nextRunAt: string | null | undefined } | { action: "skip-misfire"; scheduledFor: string; nextRunAt: string | null } | { action: "ask-misfire"; scheduledFor: string; nextRunAt: string | null }; /** Decides what to do with a job at `now`. `nextRunAt: undefined` = leave unchanged. */ export function planTick(job: Job, now: Date): TickPlan { if (job.state.runRequestedAt) { return { action: "run", trigger: "manual", scheduledFor: job.state.runRequestedAt, attempt: 1, nextRunAt: undefined }; } if (!job.enabled || job.state.pendingMisfire) return { action: "none" }; const retry = job.state.retry; if (retry && Date.parse(retry.at) <= now.getTime()) { return { action: "run", trigger: "retry", scheduledFor: retry.scheduledFor, attempt: retry.attempt, nextRunAt: undefined }; } if (!job.state.nextRunAt) return { action: "none" }; const due = new Date(job.state.nextRunAt); if (due.getTime() > now.getTime()) return { action: "none" }; const after = nextFireAfter(job.schedule, now); const nextRunAt = after ? after.toISOString() : null; const late = now.getTime() - due.getTime() > (job.misfireGraceMs ?? DEFAULT_MISFIRE_GRACE_MS); if (late) { if (job.misfire === "skip") return { action: "skip-misfire", scheduledFor: due.toISOString(), nextRunAt }; if (job.misfire === "ask") return { action: "ask-misfire", scheduledFor: due.toISOString(), nextRunAt }; return { action: "run", trigger: "misfire", scheduledFor: due.toISOString(), attempt: 1, nextRunAt }; } return { action: "run", trigger: "schedule", scheduledFor: due.toISOString(), attempt: 1, nextRunAt }; } /** Retry decision after a finished run. */ export function planRetry(job: Job, run: Run, now: Date): Job["state"]["retry"] | undefined { const policy = job.retry; if (!policy || (run.status !== "failed" && run.status !== "unmet")) return undefined; if (run.attempt >= policy.maxAttempts) return undefined; const delay = policy.backoffMs * Math.pow(policy.factor ?? 1, run.attempt - 1); const at = new Date(now.getTime() + delay); if (policy.until && at.getTime() > Date.parse(policy.until)) return undefined; return { attempt: run.attempt + 1, at: at.toISOString(), scheduledFor: run.scheduledFor }; } // --------------------------------------------------------------------------- // Engine // --------------------------------------------------------------------------- export class Engine { private readonly active = new Map(); private readonly listeners = new Set<(e: EngineEvent) => void>(); private readonly tasks = new Set>(); private timer: ReturnType | undefined; private ticking = false; private readonly now: () => Date; constructor(readonly store: JobStore, readonly runtime: Runtime, private readonly options: EngineOptions = {}) { this.now = options.now ?? (() => new Date()); } on(listener: (e: EngineEvent) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } private emit(e: EngineEvent): void { for (const l of this.listeners) { try { l(e); } catch { /* ignore */ } } } private log(level: "info" | "warn" | "error", message: string): void { this.options.log?.(level, message); } activeRuns(): { runId: string; jobId: string }[] { return [...this.active.entries()].map(([runId, v]) => ({ runId, jobId: v.jobId })); } async start(signal?: AbortSignal): Promise { await this.store.init(); await this.recoverInterrupted(); const tickMs = this.options.tickMs ?? 15_000; this.timer = setInterval(() => { void this.tick(); }, tickMs); this.timer.unref?.(); signal?.addEventListener("abort", () => { void this.stop(); }, { once: true }); void this.tick(); } async stop(): Promise { if (this.timer) clearInterval(this.timer); this.timer = undefined; for (const { controller } of this.active.values()) controller.abort(new Error("scheduler stopping")); await Promise.allSettled([...this.tasks]); } /** Marks runs left "running" by a dead process of this host as interrupted. */ private async recoverInterrupted(): Promise { for (const job of await this.store.listJobs()) { for (const run of await this.store.listRuns(job.id, 5)) { if (run.status === "running" && run.host === this.runtime.hostName && !this.active.has(run.id)) { run.status = "interrupted"; run.endedAt = this.now().toISOString(); run.error = "Host stopped before the run completed"; await this.store.saveRun(run); } } } } /** One scheduling pass. Returns the run ids started. */ async tick(): Promise { if (this.ticking) return []; this.ticking = true; const started: string[] = []; try { const now = this.now(); for (const job of await this.store.listJobs()) { try { const plan = planTick(job, now); if (plan.action === "none") continue; if (plan.action === "skip-misfire" || plan.action === "ask-misfire") { await this.store.updateJob(job.id, (j) => { j.state.nextRunAt = plan.nextRunAt; if (plan.action === "ask-misfire") j.state.pendingMisfire = { dueAt: plan.scheduledFor, detectedAt: now.toISOString() }; }); await this.recordSyntheticRun(job, plan.scheduledFor, "skipped", plan.action === "skip-misfire" ? "Run mancato (misfire=skip)" : "Run mancato: in attesa di decisione (misfire=ask)"); this.emit({ type: "job-updated", jobId: job.id }); continue; } const runId = await this.launch(job, plan.trigger, plan.scheduledFor, plan.attempt, plan.nextRunAt); if (runId) started.push(runId); } catch (e) { this.log("error", `tick ${job.id}: ${errorText(e)}`); } } } finally { this.ticking = false; } return started; } /** Manual trigger from console/tool. Does not change the regular schedule. */ async runNow(jobId: string): Promise { const job = await this.store.getJob(jobId); if (!job) throw new Error(`job '${jobId}' not found`); const runId = await this.launch(job, "manual", this.now().toISOString(), 1, undefined); if (!runId) throw new Error("job already running (concurrency=forbid) or lease held by another host"); return runId; } async resolveMisfire(jobId: string, decision: "run" | "skip"): Promise { let dueAt: string | undefined; const job = await this.store.updateJob(jobId, (j) => { dueAt = j.state.pendingMisfire?.dueAt; delete j.state.pendingMisfire; }); this.emit({ type: "job-updated", jobId }); if (decision === "skip") return undefined; return this.launch(job, "misfire", dueAt ?? this.now().toISOString(), 1, undefined); } cancel(runId: string): boolean { const entry = this.active.get(runId); if (!entry) return false; entry.controller.abort(new Error("cancelled by user")); return true; } /** Waits for all in-flight runs (tests, CLI). */ async idle(): Promise { await Promise.allSettled([...this.tasks]); } private async recordSyntheticRun(job: Job, scheduledFor: string, status: RunStatus, error: string): Promise { const now = this.now(); const run: Run = { version: 1, id: newRunId(now), jobId: job.id, trigger: "misfire", attempt: 1, scheduledFor, startedAt: now.toISOString(), endedAt: now.toISOString(), status, host: this.runtime.hostName, steps: [], error, }; await this.store.saveRun(run); } private async launch(job: Job, trigger: RunTrigger, scheduledFor: string, attempt: number, nextRunAt: string | null | undefined): Promise { const concurrency = job.concurrency ?? "forbid"; if (concurrency === "forbid" && [...this.active.values()].some((a) => a.jobId === job.id)) return undefined; const timeoutMs = job.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS; let release: (() => Promise) | undefined; if (concurrency === "forbid") { release = await this.store.acquireLease(job.id, this.runtime.hostName, timeoutMs + 60_000, this.now()); if (!release) return undefined; } const now = this.now(); const run: Run = { version: 1, id: newRunId(now), jobId: job.id, trigger, attempt, scheduledFor, startedAt: now.toISOString(), status: "running", host: this.runtime.hostName, steps: [], }; try { // Advance the schedule before executing: a crash must not re-fire the same slot. await this.store.updateJob(job.id, (j) => { if (nextRunAt !== undefined) j.state.nextRunAt = nextRunAt; if (trigger === "retry") delete j.state.retry; if (trigger === "manual") delete j.state.runRequestedAt; j.state.lastRunAt = run.startedAt; j.state.lastRunId = run.id; j.state.lastStatus = "running"; }); await this.store.saveRun(run); } catch (e) { await release?.(); throw e; } const controller = new AbortController(); this.active.set(run.id, { jobId: job.id, controller }); this.emit({ type: "run-started", jobId: job.id, runId: run.id }); const task = (async () => { try { const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]); await this.execute(job, run, signal); } catch (e) { run.status = controller.signal.aborted ? "cancelled" : "failed"; run.error = truncate(errorText(e), 4000); } finally { run.endedAt = this.now().toISOString(); this.active.delete(run.id); try { await this.store.saveRun(run); await this.store.updateJob(job.id, (j) => { j.state.lastStatus = run.status; const retry = planRetry(j, run, this.now()); if (retry) j.state.retry = retry; else if (run.trigger !== "manual") delete j.state.retry; }); await this.store.pruneRuns(job.id, this.options.keepRuns ?? 200); } catch (e) { this.log("error", `persist run ${run.id}: ${errorText(e)}`); } finally { await release?.().catch(() => undefined); } this.emit({ type: "run-finished", jobId: job.id, runId: run.id, status: run.status }); } })(); this.tasks.add(task); void task.finally(() => this.tasks.delete(task)); return run.id; } /** Executes the step pipeline, mutating `run`. */ private async execute(job: Job, run: Run, signal: AbortSignal): Promise { const context: { job: unknown; run: unknown; steps: Record } = { job: { id: job.id, name: job.name, cwd: job.cwd, tags: job.tags ?? [] }, run: { id: run.id, trigger: run.trigger, attempt: run.attempt, scheduledFor: run.scheduledFor, startedAt: run.startedAt }, steps: {}, }; run.status = "succeeded"; for (const step of job.steps) { signal.throwIfAborted(); const startedAt = this.now().toISOString(); if (step.when && context.steps[step.when]?.value !== true) { const skipped: StepResult = { id: step.id, type: step.type, status: "skipped", startedAt, endedAt: startedAt }; run.steps.push(skipped); context.steps[step.id] = skipped; continue; } let result: StepResult; try { result = await this.executeStep(job, run, step, context, signal); } catch (e) { if (signal.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("aborted"); result = { id: step.id, type: step.type, status: "failed", startedAt, endedAt: this.now().toISOString(), error: truncate(errorText(e), 4000) }; } result.startedAt = startedAt; result.endedAt = this.now().toISOString(); run.steps.push(result); context.steps[step.id] = result; await this.store.saveRun(run).catch(() => undefined); if (result.status === "failed" && !step.continueOnError) { run.status = "failed"; run.error = `step '${step.id}' failed${result.error ? `: ${result.error}` : ""}`; return; } if (step.type === "condition" && result.value === false) { if (step.onFalse === "stop") return; if (step.onFalse === "retry") { run.status = "unmet"; return; } } } } private async executeStep(job: Job, run: Run, step: Step, context: unknown, signal: AbortSignal): Promise { const base = { id: step.id, type: step.type, startedAt: "", endedAt: "" } as const; const timeoutMs = step.timeoutMs ?? job.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS; const stepSignal = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); switch (step.type) { case "command": { const argv = step.argv.map((a) => renderTemplate(a, context)); const file = argv[0] as string; this.assertExecutableAllowed(file); const env = step.env ? Object.fromEntries(Object.entries(step.env).map(([k, v]) => [k, renderTemplate(v, context)])) : undefined; const res = await this.runtime.exec({ file, args: argv.slice(1), cwd: step.cwd ?? job.cwd, ...(env ? { env } : {}), timeoutMs, signal: stepSignal }); const ok = (step.okExitCodes ?? [0]).includes(res.exitCode ?? -1); const result: StepResult = { ...base, status: ok ? "succeeded" : "failed", exitCode: res.exitCode, stdout: truncate(res.stdout), stderr: truncate(res.stderr) }; if (!ok) result.error = `exit code ${String(res.exitCode)}${res.stderr ? `: ${truncate(res.stderr.trim(), 500)}` : ""}`; if (step.parseJson && ok) result.json = parseJsonOrThrow(res.stdout, step.id); return result; } case "http": { const doFetch = this.runtime.fetch ?? fetch; const url = renderTemplate(step.url, context); const headers = step.headers ? Object.fromEntries(Object.entries(step.headers).map(([k, v]) => [k, renderTemplate(v, context)])) : undefined; const res = await doFetch(url, { method: step.method ?? "GET", ...(headers ? { headers } : {}), ...(step.body !== undefined ? { body: renderTemplate(step.body, context) } : {}), signal: stepSignal }); const text = await res.text(); const ok = step.okStatus ? step.okStatus.includes(res.status) : res.ok; const result: StepResult = { ...base, status: ok ? "succeeded" : "failed", httpStatus: res.status, stdout: truncate(text) }; if (!ok) result.error = `HTTP ${res.status}`; if (step.parseJson && ok) result.json = parseJsonOrThrow(text, step.id); return result; } case "condition": { const value = evaluateCondition(step, context); return { ...base, status: "succeeded", value }; } case "agent": { const prompt = renderTemplate(step.prompt, context); const res = await this.runtime.agent({ job, run, step, prompt, timeoutMs, signal: stepSignal }); return { ...base, status: res.ok ? "succeeded" : "failed", text: truncate(res.text), ...(res.sessionId ? { sessionId: res.sessionId } : {}), ...(res.error ? { error: truncate(res.error, 4000) } : {}), }; } case "notify": { const message = renderTemplate(step.message, context); await this.runtime.notify({ job, run, severity: step.severity ?? "info", message: truncate(message, 4000) }); return { ...base, status: "succeeded", text: message }; } } } private assertExecutableAllowed(file: string): void { const allow = this.options.allowedExecutables; if (!allow) return; const name = basename(file).replace(/\.(exe|cmd|bat|ps1)$/i, "").toLowerCase(); if (!allow.map((a) => a.toLowerCase()).includes(name)) throw new Error(`executable '${name}' not in allowlist (${allow.join(", ")})`); } } function parseJsonOrThrow(text: string, stepId: string): unknown { try { return JSON.parse(text); } catch { throw new Error(`step '${stepId}': output is not valid JSON`); } } export function truncate(text: string, max = MAX_OUTPUT_CHARS): string { return text.length > max ? `${text.slice(0, max)}\n…[truncated ${text.length - max} chars]` : text; } export function errorText(e: unknown): string { return e instanceof Error ? e.message : String(e); }