import path from "node:path"; import { readFile, writeFile } from "node:fs/promises"; import type { Paths } from "../lib/paths"; import type { JobRecord, JobStatus } from "./registry"; import { parseJobSpec, type JobSpec } from "./jobSpec"; // A serializable sidecar (`.meta.json`) written alongside each job's // `.log`. The in-memory registry keeps only the 100 most-recent finished // jobs and is wiped on restart, so once a job is evicted its `.log` is all // that's left and listJobs can only call it "archived". The sidecar preserves // the identifying/terminal fields so an archived job still shows its kind, // channel, status, duration, etc. Both writes are best-effort and must never // break a job (see writeJobMeta). export type JobMeta = { id: string; kind: string; queueKey: string; channelSlug?: string; videoId?: string; status: JobStatus; queuedAt: number; startedAt?: number; endedAt?: number; exitCode?: number; // Replay descriptor for replayable jobs, preserved so an archived (evicted // or post-restart) job can still be retried from disk. See jobSpec.ts. spec?: JobSpec; // Why a job ended `cancelled` without anyone pressing Cancel — written only // by the boot pass (bootQueuedJobs.ts) for a job that was still `queued` // when the server went down, or still `running` when the process that ran // it died. Absent on every other meta. cancelReason?: string; // The process that wrote this meta (`process.pid`). The boot pass reads it // to tell a job a dead process left `running` or `queued` from one another // LIVE process owns right now — `archilyzer run` writes into the same // `.jobs/`. Absent on metas written before release 17. pid?: number; }; export function metaPath(paths: Paths, id: string): string { return path.join(paths.jobsDir, `${id}.meta.json`); } // Serialize the meta subset of a record and write it. Non-throwing in the // spirit of ignoreFileStreamErrors: a failed sidecar write must never delay or // break the job, so errors are swallowed. export async function writeJobMeta( paths: Paths, record: JobRecord, ): Promise { try { const meta: JobMeta = { id: record.id, kind: record.kind, queueKey: record.queueKey, channelSlug: record.channelSlug, videoId: record.videoId, status: record.status, queuedAt: record.queuedAt, startedAt: record.startedAt, endedAt: record.endedAt, exitCode: record.exitCode, spec: record.spec, pid: process.pid, }; await writeFile(metaPath(paths, record.id), JSON.stringify(meta), "utf8"); } catch { /* best-effort: a failed sidecar write must never break a job */ } } // Read a job's sidecar, returning null on any error (missing file, malformed // JSON, or a payload missing the minimal identifying fields). export async function readJobMeta( paths: Paths, id: string, ): Promise { let raw: string; try { raw = await readFile(metaPath(paths, id), "utf8"); } catch { return null; } try { const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object") return null; const m = parsed as Record; if (typeof m.id !== "string" || typeof m.kind !== "string") return null; const meta = parsed as JobMeta; // Sanitize the replay descriptor: drop it if malformed so a stale sidecar // can't surface an un-runnable Retry. meta.spec = m.spec === undefined ? undefined : (parseJobSpec(m.spec) ?? undefined); return meta; } catch { return null; } }