/** * Scheduler Adapter โ€” systemd Backend * RFC-0101 ยง6 * * Uses systemd user timers as the scheduling backend. * Each scheduled task becomes a .timer + .service unit pair. * Works on any Linux system with systemd (no root required for user units). */ import { execSync } from "node:child_process"; import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import type { SchedulerAdapter, ScheduledTask } from "./interface.js"; // ScheduledTask duplicated here โ€” see interface.ts for details. /** * Install systemd user units (runs `systemctl --user`). * Must call this once before scheduling. */ export class SystemdAdapter implements SchedulerAdapter { readonly name = "systemd" as const; /** Per-task unit files live in ~/.config/systemd/user/ */ private readonly unitDir: string; private installed = false; constructor(unitDir?: string) { this.unitDir = unitDir ?? join(homedir(), ".config", "systemd", "user"); } async install(): Promise { // Verify systemd user mode is available try { execSync("systemctl --user show-environment >/dev/null 2>&1", { stdio: "pipe", }); } catch { throw new Error( "[SystemdAdapter] systemd user mode is not available. " + "Ensure systemd is running and $SYSTEMD_RUNTIME_DIRECTORY is set.", ); } // Ensure unit directory exists if (!existsSync(this.unitDir)) { mkdirSync(this.unitDir, { recursive: true }); } // Reload systemd to pick up new units try { execSync("systemctl --user daemon-reload", { stdio: "pipe" }); } catch { throw new Error("[SystemdAdapter] Failed to reload systemd daemon"); } this.installed = true; } async schedule(task: ScheduledTask): Promise { if (!this.installed) await this.install(); const id = task.id.replace(/[^a-zA-Z0-9_-]/g, "-"); const base = `pi-runtime-${id}`; const servicePath = join(this.unitDir, `${base}.service`); const timerPath = join(this.unitDir, `${base}.timer`); const scriptPath = join(this.unitDir, `${base}.sh`); // Build the run script const runScript = [ "#!/usr/bin/env bash", `# Generated by pi-harness-runtime โ€” ${new Date().toISOString()}`, "", `PI_HARNESS_TASK_ID="${task.id}" \\`, `PI_HARNESS_WORKER_ID="$(hostname)-$(id -u)" \\`, `PI_HARNESS_RUNTIME_ROOT="${process.env.PI_HARNESS_RUNTIME_ROOT ?? join(homedir(), ".pi", "harness")}" \\`, `exec "${process.env.PI_HARNESS_RUNTIME_ROOT}/bin/run-task.sh" \\`, ` --task-id "${task.id}" \\`, ` --worker-id "$PI_HARNESS_WORKER_ID"`, ].join("\n"); // Write script writeFileSync(scriptPath, runScript, { mode: 0o755 }); // Service unit: runs the script const service = [ "[Unit]", "Description=pi-harness-runtime task: ${task.id}", "[Service]", `ExecStart=${scriptPath}`, "Type=oneshot", "Environment=SYSTEMD_EXEC_PID=$$", "[Install]", "WantedBy=default.target", ].join("\n"); // Timer unit: schedules the service const timerContent = [ "[Unit]", `Description=pi-harness-runtime schedule: ${task.id}`, "[Timer]", this._toTimerLine(task.schedule), "Persistent=true", "[Install]", "WantedBy=timers.target", ].join("\n"); writeFileSync(servicePath, service); writeFileSync(timerPath, timerContent); // Reload and enable try { execSync("systemctl --user daemon-reload", { stdio: "pipe" }); execSync(`systemctl --user enable ${base}.timer`, { stdio: "pipe" }); execSync(`systemctl --user start ${base}.timer`, { stdio: "pipe" }); } catch (e) { const err = e as { message?: string }; throw new Error( `[SystemdAdapter] Failed to enable/start ${base}.timer: ${err?.message ?? String(e)}`, ); } } async unschedule(taskId: string): Promise { const id = taskId.replace(/[^a-zA-Z0-9_-]/g, "-"); const base = `pi-runtime-${id}`; const servicePath = join(this.unitDir, `${base}.service`); const timerPath = join(this.unitDir, `${base}.timer`); const scriptPath = join(this.unitDir, `${base}.sh`); // Stop and disable try { execSync(`systemctl --user stop ${base}.timer`, { stdio: "pipe" }); execSync(`systemctl --user disable ${base}.timer`, { stdio: "pipe" }); } catch { // Ignore if not running } // Remove files for (const p of [servicePath, timerPath, scriptPath]) { try { if (existsSync(p)) unlinkSync(p); } catch { // Ignore cleanup errors } } try { execSync("systemctl --user daemon-reload", { stdio: "pipe" }); } catch { // Ignore } } async listScheduled(): Promise { try { const out = execSync( "systemctl --user list-timers --all --no-legend --output json 2>/dev/null | grep pi-runtime- || true", { encoding: "utf8" }, ); if (!out.trim()) return []; let timers: Array<{ Unit?: string; Next?: string }> = []; try { timers = JSON.parse(out.trim()); } catch { return []; } return timers .filter((t) => t.Unit?.startsWith("pi-runtime-")) .map((t) => { const id = (t.Unit ?? "").replace(/^pi-runtime-(.+)\.timer$/, "$1"); return { id, taskTemplate: {}, schedule: { kind: "interval" as const, intervalMs: 0 }, enabled: true, } satisfies ScheduledTask; }); } catch { return []; } } /** Convert a schedule to a systemd.timer OnCalendar or OnUnitActiveSec line. */ private _toTimerLine(schedule: ScheduledTask["schedule"]): string { if (schedule.kind === "cron") { // Convert cron to systemd calendar format const parts = schedule.expression.trim().split(/\s+/); if (parts.length >= 5) { const [min, hour, dom, month] = parts; return `OnCalendar=${dom}.${month}.* ${hour}:${min.padStart(2, "0")}`; } return `OnCalendar=${schedule.expression}`; } if (schedule.kind === "interval") { const sec = Math.round(schedule.intervalMs / 1000); if (sec >= 86400) return `OnUnitActiveSec=${Math.round(sec / 86400)}d`; if (sec >= 3600) return `OnUnitActiveSec=${Math.round(sec / 3600)}h`; if (sec >= 60) return `OnUnitActiveSec=${Math.round(sec / 60)}min`; return `OnUnitActiveSec=${sec}s`; } if (schedule.kind === "once") { return `OnCalendar=${new Date(schedule.at).toISOString().split("T").join(" ")}`; } return "OnUnitActiveSec=1h"; // fallback } async healthCheck(): Promise<{ healthy: boolean; error?: string }> { try { execSync("systemctl --user show-environment >/dev/null 2>&1", { stdio: "pipe", }); return { healthy: true }; } catch { return { healthy: false, error: "systemd user mode not available" }; } } }