import { describeSchedule, firstFire, previewFires } from "./schedule.js"; import type { Engine } from "./engine.js"; import type { JobStore } from "./store.js"; import type { Job, Run } from "./types.js"; import { buildJob, normalizeSchedule, type JobInput } from "./validate.js"; import { summarizeRun, type JobDetail, type JobView, type RunSummary } from "../browser/protocol.js"; /** * High-level operations shared by the server plugin (with a live engine) and by * out-of-process clients (companion tools, CLI) that only touch the store. */ export class SchedulerService { constructor(readonly store: JobStore, readonly engine?: Engine, private readonly now: () => Date = () => new Date()) {} private async view(job: Job, bindings?: Record): Promise { const b = bindings ?? await this.store.getBindings(); return { job, running: this.engine?.activeRuns().filter((r) => r.jobId === job.id).map((r) => r.runId) ?? [], scheduleText: describeSchedule(job.schedule), bound: Object.prototype.hasOwnProperty.call(b, job.cwd), }; } async listJobs(filter?: { cwd?: string }): Promise { const bindings = await this.store.getBindings(); const jobs = (await this.store.listJobs()).filter((j) => !filter?.cwd || j.cwd === filter.cwd); return Promise.all(jobs.map((j) => this.view(j, bindings))); } async detail(id: string, runLimit = 50): Promise { const job = await this.mustGet(id); const runs = (await this.store.listRuns(id, runLimit)).map(summarizeRun); return { view: await this.view(job), runs, preview: previewFires(job.schedule, this.now(), 5).map((d) => d.toISOString()) }; } async recentRuns(limit = 30, filter?: { cwd?: string }): Promise { const runs = await this.store.listAllRuns(limit * 2); const jobs = filter?.cwd ? new Set((await this.store.listJobs()).filter((j) => j.cwd === filter.cwd).map((j) => j.id)) : undefined; return runs.filter((r) => !jobs || jobs.has(r.jobId)).slice(0, limit).map(summarizeRun); } async getRun(jobId: string, runId: string): Promise { const run = await this.store.getRun(jobId, runId); if (!run) throw new Error(`run '${runId}' not found`); return run; } /** Create (no id or unknown id) or replace an existing job definition. */ async upsert(input: JobInput, opts: { mustExist?: boolean; mustNotExist?: boolean } = {}): Promise { const id = input.id; const key = id ?? `new:${input.name}`; return this.store.withLock(`job:${id ?? key}`, async () => { const existing = id ? await this.store.getJob(id) : undefined; if (opts.mustExist && !existing) throw new Error(`job '${String(id)}' not found`); let job = buildJob(input, this.now(), existing); if (!existing) { if (opts.mustNotExist || !id) job = { ...job, id: await this.uniqueId(job.id) }; else if (await this.store.getJob(job.id)) throw new Error(`job '${job.id}' already exists`); } await this.store.saveJob(job); return job; }); } private async uniqueId(base: string): Promise { if (!(await this.store.getJob(base))) return base; for (let i = 2; i < 1000; i++) { const candidate = `${base.slice(0, 60)}-${i}`; if (!(await this.store.getJob(candidate))) return candidate; } throw new Error("cannot allocate job id"); } async setEnabled(id: string, enabled: boolean): Promise { return this.store.updateJob(id, (j) => { j.enabled = enabled; j.updatedAt = this.now().toISOString(); if (enabled) { // Re-anchor so a long pause does not produce an immediate misfire. const next = firstFire(j.schedule, this.now()); j.state.nextRunAt = next ? next.toISOString() : null; delete j.state.pendingMisfire; } }); } async reschedule(id: string, schedule: JobInput["schedule"]): Promise { const normalized = normalizeSchedule(schedule); return this.store.updateJob(id, (j) => { j.schedule = normalized; j.updatedAt = this.now().toISOString(); const next = firstFire(normalized, this.now()); j.state.nextRunAt = next ? next.toISOString() : null; delete j.state.retry; delete j.state.pendingMisfire; }); } async remove(id: string, withRuns = false): Promise { return this.store.deleteJob(id, withRuns); } /** Immediate with a live engine, otherwise picked up at the next tick of whichever host runs. */ async runNow(id: string): Promise<{ runId?: string; queued: boolean }> { if (this.engine) return { runId: await this.engine.runNow(id), queued: false }; await this.store.updateJob(id, (j) => { j.state.runRequestedAt = this.now().toISOString(); }); return { queued: true }; } async resolveMisfire(id: string, decision: "run" | "skip"): Promise<{ runId?: string }> { if (this.engine) { const runId = await this.engine.resolveMisfire(id, decision); return runId ? { runId } : {}; } await this.store.updateJob(id, (j) => { delete j.state.pendingMisfire; if (decision === "run") j.state.runRequestedAt = this.now().toISOString(); }); return {}; } cancelRun(runId: string): boolean { if (!this.engine) throw new Error("cancel requires the live scheduler host (PI WEB plugin)"); return this.engine.cancel(runId); } previewSchedule(schedule: JobInput["schedule"], count = 5): string[] { return previewFires(normalizeSchedule(schedule), this.now(), count).map((d) => d.toISOString()); } private async mustGet(id: string): Promise { const job = await this.store.getJob(id); if (!job) throw new Error(`job '${id}' not found`); return job; } }