import { randomBytes } from "node:crypto"; import { constants } from "node:fs"; import { lstat, mkdir, open, readFile, readdir, rename, rm, unlink } from "node:fs/promises"; import { homedir, hostname } from "node:os"; import { join } from "node:path"; import { JOB_ID_PATTERN, RUN_ID_PATTERN, type Job, type Run } from "./types.js"; export function defaultStoreRoot(): string { return process.env.PI_SCHEDULER_HOME?.trim() || join(homedir(), ".pi-scheduler"); } export function newRunId(now: Date): string { const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); return `${stamp}-${randomBytes(4).toString("hex").slice(0, 6)}`; } export interface Binding { projectId: string; workspaceId: string; label?: string; updatedAt: string } const MAX_FILE = 2_000_000; /** File-backed store. All writes are atomic (tmp + rename). Safe for one writer per job at a time. */ export class JobStore { readonly root: string; private readonly locks = new Map>(); constructor(root = defaultStoreRoot()) { this.root = root; } private dir(...parts: string[]): string { return join(this.root, ...parts); } async init(): Promise { for (const d of ["jobs", "runs", "locks"]) await mkdir(this.dir(d), { recursive: true, mode: 0o700 }); } /** Serializes read-modify-write per key inside this process. */ async withLock(key: string, fn: () => Promise): Promise { const previous = this.locks.get(key) ?? Promise.resolve(); const current = previous.catch(() => undefined).then(fn); this.locks.set(key, current); try { return await current; } finally { if (this.locks.get(key) === current) this.locks.delete(key); } } private async writeJson(path: string, value: unknown): Promise { const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`; try { const file = await open(tmp, "wx", 0o600); try { await file.writeFile(JSON.stringify(value, null, 2)); await file.sync(); } finally { await file.close(); } await rename(tmp, path); } finally { await rm(tmp, { force: true }); } } private async readJson(path: string): Promise { let st; try { st = await lstat(path); } catch (e) { if (isNotFound(e)) return undefined; throw e; } if (!st.isFile() || st.size > MAX_FILE) throw new Error(`invalid store file ${path}`); const file = await open(path, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW)); try { return JSON.parse(await file.readFile("utf8")) as T; } finally { await file.close(); } } // ---- jobs --------------------------------------------------------------- private jobPath(id: string): string { if (!JOB_ID_PATTERN.test(id)) throw new Error(`invalid job id '${id}'`); return this.dir("jobs", `${id}.json`); } async getJob(id: string): Promise { return this.readJson(this.jobPath(id)); } async saveJob(job: Job): Promise { await this.writeJson(this.jobPath(job.id), job); } async deleteJob(id: string, withRuns = false): Promise { const path = this.jobPath(id); try { await unlink(path); } catch (e) { if (isNotFound(e)) return false; throw e; } if (withRuns) await rm(this.dir("runs", id), { recursive: true, force: true }); return true; } async listJobs(): Promise { const jobs: Job[] = []; let names: string[] = []; try { names = await readdir(this.dir("jobs")); } catch (e) { if (!isNotFound(e)) throw e; } for (const name of names) { if (!name.endsWith(".json")) continue; const id = name.slice(0, -5); if (!JOB_ID_PATTERN.test(id)) continue; try { const job = await this.getJob(id); if (job) jobs.push(job); } catch { /* skip corrupt file */ } } return jobs.sort((a, b) => a.id.localeCompare(b.id)); } /** Atomic per-job update inside this process. */ async updateJob(id: string, mutate: (job: Job) => Job | void): Promise { return this.withLock(`job:${id}`, async () => { const job = await this.getJob(id); if (!job) throw new Error(`job '${id}' not found`); const result = mutate(job) ?? job; await this.saveJob(result); return result; }); } // ---- runs --------------------------------------------------------------- private runPath(jobId: string, runId: string): string { if (!JOB_ID_PATTERN.test(jobId) || !RUN_ID_PATTERN.test(runId)) throw new Error("invalid run id"); return this.dir("runs", jobId, `${runId}.json`); } async saveRun(run: Run): Promise { await mkdir(this.dir("runs", run.jobId), { recursive: true, mode: 0o700 }); await this.writeJson(this.runPath(run.jobId, run.id), run); } async getRun(jobId: string, runId: string): Promise { return this.readJson(this.runPath(jobId, runId)); } /** Most recent first. */ async listRuns(jobId: string, limit = 50): Promise { let names: string[] = []; try { names = await readdir(this.dir("runs", jobId)); } catch (e) { if (isNotFound(e)) return []; throw e; } const ids = names.filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -5)).filter((n) => RUN_ID_PATTERN.test(n)).sort().reverse().slice(0, limit); const runs: Run[] = []; for (const id of ids) { try { const r = await this.getRun(jobId, id); if (r) runs.push(r); } catch { /* skip */ } } return runs; } async listAllRuns(limit = 100): Promise { const jobs = await this.listJobs(); const all = (await Promise.all(jobs.map((j) => this.listRuns(j.id, limit)))).flat(); return all.sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, limit); } /** Keeps the newest `keep` runs per job. */ async pruneRuns(jobId: string, keep: number): Promise { let names: string[] = []; try { names = await readdir(this.dir("runs", jobId)); } catch { return 0; } const old = names.filter((n) => n.endsWith(".json")).sort().reverse().slice(keep); await Promise.all(old.map((n) => rm(this.dir("runs", jobId, n), { force: true }))); return old.length; } // ---- leases (cross-process) --------------------------------------------- /** Tries to take the job lease. Returns a release function or undefined if held by someone else. */ async acquireLease(jobId: string, owner: string, ttlMs: number, now = new Date()): Promise<(() => Promise) | undefined> { const path = this.dir("locks", `${jobId}.lock`); if (!JOB_ID_PATTERN.test(jobId)) throw new Error("invalid job id"); const token = `${owner}#${randomBytes(6).toString("hex")}`; const body = JSON.stringify({ owner: token, host: hostname(), pid: process.pid, expiresAt: new Date(now.getTime() + ttlMs).toISOString() }); try { const file = await open(path, "wx", 0o600); try { await file.writeFile(body); } finally { await file.close(); } } catch (e) { if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; const current = await this.readJson<{ owner: string; expiresAt: string }>(path).catch(() => undefined); if (current && new Date(current.expiresAt).getTime() > now.getTime()) return undefined; // Stale lease: overwrite, then verify we won the race. await this.writeJson(path, JSON.parse(body)); const check = await this.readJson<{ owner: string }>(path).catch(() => undefined); if (check?.owner !== token) return undefined; } return async () => { const current = await this.readJson<{ owner: string }>(path).catch(() => undefined); if (current?.owner === token) await rm(path, { force: true }); }; } // ---- bindings (cwd -> pi-web workspace) ---------------------------------- async getBindings(): Promise> { return (await this.readJson>(this.dir("bindings.json"))) ?? {}; } async setBinding(path: string, binding: Binding): Promise { await this.withLock("bindings", async () => { const all = await this.getBindings(); const prev = all[path]; if (prev && prev.projectId === binding.projectId && prev.workspaceId === binding.workspaceId) return; all[path] = binding; await this.writeJson(this.dir("bindings.json"), all); }); } } export function isNotFound(e: unknown): boolean { return (e as NodeJS.ErrnoException)?.code === "ENOENT"; } export async function readTextIfExists(path: string): Promise { try { return await readFile(path, "utf8"); } catch (e) { if (isNotFound(e)) return undefined; throw e; } }