diff --git a/paperforge/plugin/src/services/managed-runtime.ts b/paperforge/plugin/src/services/managed-runtime.ts new file mode 100644 index 00000000..0e5b6d0d --- /dev/null +++ b/paperforge/plugin/src/services/managed-runtime.ts @@ -0,0 +1,1012 @@ +/** + * ManagedRuntime — single, machine-local PaperForge runtime. + * + * Public seam (Issue #77): + * ManagedRuntime, RuntimeHealth, RuntimeRun, + * resolveRuntimeCommand, runtimeActionsForHealth + * + * Lifecycle states: + * not_installed → ensure() → needs_repair / ready + * ready → stale cache → unknown + * unknown → status() → ready / needs_repair + * needs_repair → ensure() → ready / needs_repair + * + * Design: immutable version slots under ~/.paperforge/runtime/{os-arch}/, + * one atomically-replaced active-runtime.json at the runtime root. + * Machine-local, shared across vaults. No credentials or vault paths. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { execFile as cpExecFile, execFileSync as cpExecFileSync } from "child_process"; +import * as os from "os"; + +// ── Public types ── + +export type RuntimeState = + | "ready" + | "not_installed" + | "needs_repair" + | "unknown" + | "unavailable"; + +export interface ErrorInfo { + readonly code: string; + readonly message: string; + readonly platformAction: string; +} +export interface WarningInfo { + readonly code: string; + readonly message: string; + readonly platformAction?: string; +} + +export interface RuntimeHealth { + readonly state: RuntimeState; + readonly pythonPath: string | null; + readonly version: string | null; + readonly source: "venv" | "system" | "manual" | "none"; + readonly error: ErrorInfo | null; + readonly lastVerifiedAt: string | null; + readonly stale: boolean; + readonly warnings: readonly WarningInfo[]; + readonly previousVersion: string | null; + readonly previousPythonPath: string | null; +} + +export interface StatusOptions { + readonly allowStale?: boolean; +} + +export interface EnsureOptions { + readonly version?: string; + readonly force?: boolean; + readonly signal?: AbortSignal; +} + +export interface RuntimeRun { + readonly command: string; + readonly args: readonly string[]; +} + +export interface RuntimeUiAction { + readonly verb: string; + readonly label: string; +} + +export interface RuntimeAction { + readonly id: string; + readonly label: string; + readonly primary: boolean; + readonly destructive: boolean; +} + +// ── Internal DI types ── + +export interface FsOps { + existsSync(p: string): boolean; + readFileSync(p: string, encoding?: string | null): string; + writeFileSync(p: string, data: string | NodeJS.ArrayBufferView, encoding?: string | null): void; + renameSync(oldP: string, newP: string): void; + mkdirSync(p: string, opts?: { recursive?: boolean }): string | undefined; + rmSync(p: string, opts?: { recursive?: boolean; force?: boolean }): void; + readdirSync(p: string, opts?: { withFileTypes?: boolean }): fs.Dirent[]; +} + +export type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void; +export type ExecFileFn = (command: string, args: readonly string[], opts: { timeout?: number; encoding?: string; signal?: AbortSignal }, cb: ExecFileCallback) => void; +export type ExecFileSyncFn = (command: string, args: readonly string[], opts: { encoding: string; timeout: number }) => string; + +// ── Constants ── + +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const MIN_PYTHON_NEW = "3.11"; +const MIN_PYTHON_LEGACY = "3.10"; + +/** ES2018-compatible Promise.withResolvers polyfill. */ +function deferred(): { promise: Promise; resolve: (value: T) => void; reject: (err: unknown) => void } { + let resolve!: (value: T) => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// ── Version helpers ── + +function parsePythonVersion(output: string): string | null { + const m = output.match(/Python\s+(\d+\.\d+(?:\.\d+)?)/); + if (m) return m[1]; + const m2 = output.match(/Python\s+(\d+\.\d+)/); + if (m2) return m2[1] + ".0"; + return null; +} + +function compareVersions(a: string, b: string): number { + const ap = a.split(".").map(Number); + const bp = b.split(".").map(Number); + for (let i = 0; i < Math.max(ap.length, bp.length); i++) { + const an = ap[i] ?? 0; + const bn = bp[i] ?? 0; + if (an !== bn) return an - bn; + } + return 0; +} + +function isAtLeast(version: string, minVersion: string): boolean { + return compareVersions(version, minVersion) >= 0; +} + +// ── Platform helpers ── + +function detectContainer(): boolean { + try { + if (fs.existsSync("/.dockerenv")) return true; + if (fs.existsSync("/run/.containerenv")) return true; + const cgroup = fs.readFileSync("/proc/1/cgroup", "utf-8"); + if (cgroup.includes("docker") || cgroup.includes("flatpak") || cgroup.includes("snap")) return true; + } catch { + // ignore + } + return false; +} + +function detectFlatpak(): boolean { + return process.env.FLATPAK_ID !== undefined || + (process.env.XDG_DATA_DIRS ?? "").includes("flatpak") || + false; +} + +function detectSnap(): boolean { + return process.env.SNAP !== undefined || + process.env.SNAP_NAME !== undefined || + false; +} + +export function getOsArch(osPlatform: string, osArch: string): string { + const platMap: Record = { + win32: "windows", + darwin: "macos", + linux: "linux", + }; + return `${platMap[osPlatform] ?? osPlatform}-${osArch}`; +} + +/** Determine whether the current environment is containerised. Exported for testing. */ +export function isContainerEnv(): boolean { + return detectContainer(); +} + +/** Determine whether the current environment is Flatpak. Exported for testing. */ +export function isFlatpakEnv(): boolean { + return detectFlatpak(); +} + +/** Determine whether the current environment is Snap. Exported for testing. */ +export function isSnapEnv(): boolean { + return detectSnap(); +} + +// ── Canonical actions per health state ── + +/** Return internal actions with id/primary/destructive. Used by DI tests. */ +export function runtimeActionsForHealth(health: RuntimeHealth): readonly RuntimeAction[]; +/** Return UI actions with verb/label. Used by settings rendering. */ +export function runtimeActionsForHealth(health: RuntimeHealth, targetVersion: string, running: boolean): readonly RuntimeUiAction[]; +export function runtimeActionsForHealth(health: RuntimeHealth, targetVersion?: string, running?: boolean): readonly RuntimeAction[] | readonly RuntimeUiAction[] { + // 3-param UI path (settings rendering) + if (targetVersion !== undefined || running !== undefined) { + if (running) { + return [{ verb: "stop", label: "Stop" }]; + } + switch (health.state) { + case "not_installed": + return [{ verb: "install", label: "Install Runtime" }]; + case "needs_repair": { + const actions: RuntimeUiAction[] = [ + { verb: "repair", label: "Repair Runtime" }, + ]; + if (health.pythonPath) { + actions.push({ verb: "rollback", label: "Rollback" }); + } + return actions; + } + case "ready": { + const actions: RuntimeUiAction[] = [ + { verb: "status", label: "Check Status" }, + { verb: "update", label: "Update Runtime" }, + ]; + if (health.previousVersion) { + actions.push({ verb: "rollback", label: "Rollback" }); + } + return actions; + } + case "unknown": + return [{ verb: "retry", label: "Retry" }]; + case "unavailable": + return [{ verb: "setup", label: "Manual Setup" }]; + default: + return [{ verb: "retry", label: "Retry" }]; + } + } + + // 1-param internal path (unchanged, for DI tests) + switch (health.state) { + case "not_installed": + return [{ id: "install", label: "Install Runtime", primary: true, destructive: false }]; + case "needs_repair": { + const actions: RuntimeAction[] = [ + { id: "repair", label: "Repair Runtime", primary: true, destructive: false }, + ]; + if (health.pythonPath) { + actions.push({ id: "rollback", label: "Rollback", primary: false, destructive: false }); + } + return actions; + } + case "ready": + return [ + { id: "status", label: "Check Status", primary: false, destructive: false }, + { id: "update", label: "Update Runtime", primary: false, destructive: false }, + ]; + case "unknown": + return [{ id: "probe", label: "Refresh Status", primary: true, destructive: false }]; + case "unavailable": + return [{ id: "setup", label: "Manual Setup", primary: true, destructive: false }]; + default: + return [{ id: "probe", label: "Refresh Status", primary: true, destructive: false }]; + } +} + +// ── Resolve runtime command from health ── + +export function resolveRuntimeCommand(health: RuntimeHealth): RuntimeRun | null { + if (health.state !== "ready" || !health.pythonPath) return null; + return { command: health.pythonPath, args: [] }; +} + +// ── ManagedRuntime class ── + +export class ManagedRuntime { + private readonly runtimeDir: string; + private readonly pointerPath: string; + private readonly pluginVersion: string; + private readonly osPlatform: string; + private readonly osArch: string; + private _cache: RuntimeHealth | null = null; + private _cacheTime: number = 0; + + // DI: injectable fs, execFile, execFileSync for testing + private readonly _fs: FsOps; + private readonly _execFile: ExecFileFn; + private readonly _execFileSync: ExecFileSyncFn; + + // Public canonical root (Issue #77) + public readonly rootDir: string; + public readonly triplet: string; + + constructor(opts: { + // Old path — DI-first, full control + runtimeDir?: string; + pluginVersion?: string; + // New path — auto-compute canonical root (Issue #77) + version?: string; + platform?: string; + arch?: string; + // Common overrides + osPlatform?: string; + osArch?: string; + fs?: FsOps; + execFile?: ExecFileFn; + execFileSync?: ExecFileSyncFn; + }) { + const platform = opts.osPlatform ?? opts.platform ?? process.platform; + const arch = opts.osArch ?? opts.arch ?? process.arch; + this.osPlatform = platform; + this.osArch = arch; + // triplet is the raw platform-arch (e.g. "win32-x64"), not the getOsArch mapped value + this.triplet = `${platform}-${arch}`; + + if (opts.runtimeDir) { + this.runtimeDir = opts.runtimeDir; + this.rootDir = path.dirname(opts.runtimeDir); + this.pluginVersion = opts.pluginVersion ?? opts.version ?? "0.0.0"; + } else { + const home = os.homedir(); + this.rootDir = path.join(home, ".paperforge", "runtime"); + // runtime dir uses getOsArch-mapped value (e.g. "windows-x64") + this.runtimeDir = path.join(this.rootDir, getOsArch(platform, arch)); + this.pluginVersion = opts.version ?? opts.pluginVersion ?? "0.0.0"; + } + + // Pointer lives at the runtime root (parent of os-arch dir) + this.pointerPath = path.join(this.rootDir, "active-runtime.json"); + this._fs = opts.fs ?? (fs as unknown as FsOps); + this._execFile = opts.execFile ?? (cpExecFile as unknown as ExecFileFn); + this._execFileSync = opts.execFileSync ?? (cpExecFileSync as unknown as ExecFileSyncFn); + } + + // ── Sync: fails closed on cold/stale cache ── + + current(): RuntimeHealth { + if (!this._cache) { + return { + state: "unknown", + pythonPath: null, + version: null, + source: "none", + error: null, + lastVerifiedAt: null, + stale: true, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }; + } + const isStale = Date.now() - this._cacheTime > CACHE_TTL_MS; + if (isStale) { + // Never return 'ready' from stale cache — fail closed + return { ...this._cache, state: "unknown", stale: true }; + } + return { ...this._cache, stale: false }; + } + + // ── Async probe ── + + async status(opts?: StatusOptions): Promise { + // Fresh cache fast-path + if (this._cache) { + const isStale = Date.now() - this._cacheTime > CACHE_TTL_MS; + if (!isStale && this._cache.state === "ready") { + return { ...this._cache, stale: false }; + } + if (isStale && opts?.allowStale) { + return { ...this._cache, stale: true }; + } + } + + // Read pointer file to find active runtime + let pointerVersion: string | null = null; + let pointerPythonPath: string | null = null; + let pointerPrevVersion: string | null = null; + let pointerPrevPythonPath: string | null = null; + let pointerWarnings: WarningInfo[] = []; + try { + const raw = this._fs.readFileSync(this.pointerPath, "utf-8"); + const ptr: Record = JSON.parse(raw); + pointerVersion = typeof ptr.version === "string" ? ptr.version : null; + const pp = typeof ptr.pythonPath === "string" ? ptr.pythonPath : null; + pointerPythonPath = pp ? path.resolve(path.dirname(this.pointerPath), pp) : null; + pointerPrevVersion = typeof ptr.previousVersion === "string" ? ptr.previousVersion : null; + pointerPrevPythonPath = typeof ptr.previousPythonPath === "string" ? ptr.previousPythonPath : null; + pointerWarnings = Array.isArray(ptr.warnings) ? ptr.warnings as WarningInfo[] : []; + } catch { + // No pointer → not installed + return this._setCache({ + state: "not_installed", + pythonPath: null, + version: null, + source: "none", + error: null, + lastVerifiedAt: null, + stale: false, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }); + } + + // Pointer exists but missing pythonPath + if (!pointerPythonPath) { + return this._setCache({ + state: "needs_repair", + pythonPath: null, + version: pointerVersion, + source: "none", + error: { + code: "POINTER_MISSING_PATH", + message: "Active runtime pointer has no pythonPath", + platformAction: "Reinstall runtime", + }, + lastVerifiedAt: null, + stale: false, + warnings: pointerWarnings, + previousVersion: pointerPrevVersion, + previousPythonPath: pointerPrevPythonPath, + }); + } + + // Interpreter file missing + if (!this._fs.existsSync(pointerPythonPath)) { + return this._setCache({ + state: "needs_repair", + pythonPath: pointerPythonPath, + version: pointerVersion, + source: "none", + error: { + code: "PYTHON_NOT_FOUND", + message: "Python executable not found at pointer path", + platformAction: "Reinstall runtime", + }, + lastVerifiedAt: null, + stale: false, + warnings: pointerWarnings, + previousVersion: pointerPrevVersion, + previousPythonPath: pointerPrevPythonPath, + }); + } + + // Run isolated import probe + try { + const result = await this._probe(pointerPythonPath); + + // Check Python version and add Release-N warning for 3.10 + const probeWarnings = [...pointerWarnings]; + try { + const pyVerOut = this._execFileSync(pointerPythonPath, ["--version"], { + encoding: "utf-8", + timeout: 5000, + }); + const pyVer = parsePythonVersion(pyVerOut); + if (pyVer && isAtLeast(pyVer, MIN_PYTHON_LEGACY) && !isAtLeast(pyVer, MIN_PYTHON_NEW)) { + // Only add if not already present + if (!probeWarnings.some((w) => w.code === "PYTHON_310_DEPRECATED")) { + probeWarnings.push({ + code: "PYTHON_310_DEPRECATED", + message: `Python ${pyVer} is running. Python 3.10 support enters legacy phase in Release N — upgrade to Python ${MIN_PYTHON_NEW}+ recommended.`, + platformAction: `Upgrade to Python ${MIN_PYTHON_NEW}+`, + }); + } + } + } catch { + // Version check is best-effort; ignore failures + } + + return this._setCache({ + state: "ready", + pythonPath: pointerPythonPath, + version: result.version ?? pointerVersion, + source: "venv", + error: null, + lastVerifiedAt: new Date().toISOString(), + stale: false, + warnings: probeWarnings, + previousVersion: pointerPrevVersion, + previousPythonPath: pointerPrevPythonPath, + }); + } catch (probeErr: unknown) { + const msg = probeErr instanceof Error ? probeErr.message : String(probeErr); + return this._setCache({ + state: "needs_repair", + pythonPath: pointerPythonPath, + version: pointerVersion, + source: "venv", + error: { + code: "PROBE_FAILED", + message: msg, + platformAction: "Repair runtime", + }, + lastVerifiedAt: null, + stale: false, + warnings: pointerWarnings, + previousVersion: pointerPrevVersion, + previousPythonPath: pointerPrevPythonPath, + }); + } + } + + // ── Ensure a working runtime ── + + async ensure(opts?: EnsureOptions): Promise { + const version = opts?.version ?? this.pluginVersion; + const force = opts?.force ?? false; + const signal = opts?.signal; + + if (signal?.aborted) return this._abortedHealth(); + + // Quick path: if already ready and not forced, just re-probe + if (!force) { + const cur = this.current(); + if (cur.state === "ready" && !cur.stale) { + const probeResult = await this.status(); + if (probeResult.state === "ready") return probeResult; + } + } + + if (signal?.aborted) return this._abortedHealth(); + + // Step 1: Resolve bootstrap Python + let bootstrap: { path: string; version: string }; + try { + bootstrap = this._resolveBootstrapPython(); + } catch { + // No Python found — check for containerised environments + if (detectFlatpak() || detectSnap()) { + return this._setCache({ + state: "unavailable", + pythonPath: null, + version: null, + source: "none", + error: { + code: "FLATPAK_SNAP_UNSUPPORTED", + message: "Flatpak and Snap are not supported. Install Python 3.11+ natively.", + platformAction: "Install Python 3.11+ from python.org or package manager", + }, + lastVerifiedAt: null, + stale: false, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }); + } + + const osArchStr = getOsArch(this.osPlatform, this.osArch); + const isMac = this.osPlatform === "darwin"; + const macTriplets = ["macos-x64", "macos-arm64"]; + const validatedTriplets = ["windows-x64", "linux-x64"]; + + if (isMac && macTriplets.includes(osArchStr)) { + return this._setCache({ + state: "unavailable", + pythonPath: null, + version: null, + source: "none", + error: { + code: "NO_PYTHON", + message: "No Python 3.11+ found. macOS auto-download disabled until signed/notarized artifacts exist.", + platformAction: "Install Python 3.11+ from python.org or Homebrew", + }, + lastVerifiedAt: null, + stale: false, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }); + } + + if (validatedTriplets.includes(osArchStr)) { + return this._setCache({ + state: "unavailable", + pythonPath: null, + version: null, + source: "none", + error: { + code: "NO_PYTHON", + message: "No Python 3.11+ found and automatic download failed.", + platformAction: "Install Python 3.11+ manually", + }, + lastVerifiedAt: null, + stale: false, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }); + } + + // Unsupported triplet + return this._setCache({ + state: "unavailable", + pythonPath: null, + version: null, + source: "none", + error: { + code: "FALLBACK_UNAVAILABLE", + message: "No Python found and this platform has no validated fallback.", + platformAction: "Install Python 3.11+ manually from python.org", + }, + lastVerifiedAt: null, + stale: false, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }); + } + + if (signal?.aborted) return this._abortedHealth(); + + // Step 2: Compatibility gate — version check + const isExistingInstall = this._currentSlotExists(version); + + if (!isAtLeast(bootstrap.version, MIN_PYTHON_LEGACY)) { + return this._setCache({ + state: "unavailable", + pythonPath: null, + version: bootstrap.version, + source: "none", + error: { + code: "PYTHON_TOO_OLD", + message: `Python ${bootstrap.version} is too old. Python 3.10+ required.`, + platformAction: "Install Python 3.10+", + }, + lastVerifiedAt: null, + stale: false, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }); + } + + // New installs / repairs require 3.11+ + if (!isExistingInstall && !isAtLeast(bootstrap.version, MIN_PYTHON_NEW)) { + return this._setCache({ + state: "needs_repair", + pythonPath: null, + version: bootstrap.version, + source: "none", + error: { + code: "PYTHON_VERSION_WARNING", + message: `Python ${bootstrap.version} found. New installations require Python ${MIN_PYTHON_NEW}+.`, + platformAction: `Upgrade to Python ${MIN_PYTHON_NEW}+`, + }, + lastVerifiedAt: null, + stale: false, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }); + } + + // Step 2.5: Rollback/retained-slot fast path — if the version slot + // already exists, we are not forcing a rebuild, AND the currently + // active pointer points to a different version, verify the retained + // immutable slot and atomically rewrite the pointer without creating + // a new venv or running pip. This preserves the slot exactly as it + // was built and is never a bootstrap / venv / pip operation. + if (isExistingInstall && !force) { + // Determine whether this is a rollback (active version != requested) + let isRollback = false; + try { + const curRaw = this._fs.readFileSync(this.pointerPath, "utf-8"); + const curPtr: Record = JSON.parse(curRaw); + const activeVer = typeof curPtr.version === "string" ? curPtr.version : null; + isRollback = activeVer !== null && activeVer !== version; + } catch { + // No pointer — not a rollback + } + + if (isRollback) { + + const rollbackSlotDir = path.join(this.runtimeDir, `v${version}`); + const rollbackVenvDir = path.join(rollbackSlotDir, "venv"); + const rollbackPythonExe = this.osPlatform === "win32" + ? path.join(rollbackVenvDir, "Scripts", "python.exe") + : path.join(rollbackVenvDir, "bin", "python"); + + // Verify the retained slot's Python can import paperforge + try { + await this._probe(rollbackPythonExe, signal); + } catch (probeErr: unknown) { + if (probeErr instanceof Error && probeErr.name === "AbortError") { + return this._abortedHealth(); + } + const msg = probeErr instanceof Error ? probeErr.message : String(probeErr); + return this._setCache({ + state: "needs_repair", + pythonPath: rollbackPythonExe, + version, + source: "venv", + error: { + code: "RETAINED_SLOT_PROBE_FAILED", + message: `Retained slot v${version} failed verification: ${msg}`, + platformAction: "Repair runtime", + }, + lastVerifiedAt: null, + stale: false, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }); + } + + // Load old pointer for rollback info + let prevVersion: string | null = null; + let prevPythonPath: string | null = null; + try { + const oldRaw = this._fs.readFileSync(this.pointerPath, "utf-8"); + const oldPtr: Record = JSON.parse(oldRaw); + prevVersion = typeof oldPtr.version === "string" ? oldPtr.version : null; + prevPythonPath = typeof oldPtr.pythonPath === "string" ? oldPtr.pythonPath : null; + } catch { + // No previous pointer — first install + } + + // Atomically write pointer: write temp, rename + const pointerDir = path.dirname(this.pointerPath); + if (!this._fs.existsSync(pointerDir)) { + this._fs.mkdirSync(pointerDir, { recursive: true }); + } + + const relativePyPath = path.relative(path.dirname(this.pointerPath), rollbackPythonExe); + const pointerContent = JSON.stringify( + { + schema_version: 1, + version, + pythonPath: relativePyPath, + activatedAt: new Date().toISOString(), + previousVersion: prevVersion, + previousPythonPath: prevPythonPath, + }, + null, + 2, + ); + + const tmpPath = this.pointerPath + ".tmp"; + this._fs.writeFileSync(tmpPath, pointerContent, "utf-8"); + this._fs.renameSync(tmpPath, this.pointerPath); + + // Update cache + const health: RuntimeHealth = { + state: "ready", + pythonPath: rollbackPythonExe, + version, + source: "venv", + error: null, + lastVerifiedAt: new Date().toISOString(), + stale: false, + warnings: [], + previousVersion: prevVersion, + previousPythonPath: prevPythonPath, + }; + this._cache = health; + this._cacheTime = Date.now(); + + // Cleanup old slots (best-effort, keep 2) + this._cleanupOldSlots(version); + + return health; + } + } + + if (signal?.aborted) return this._abortedHealth(); + + + // Step 3: Build immutable version slot + const slotDir = force + ? path.join(this.runtimeDir, `v${version}_build2`) + : path.join(this.runtimeDir, `v${version}`); + const venvDir = path.join(slotDir, "venv"); + const pythonExe = this.osPlatform === "win32" + ? path.join(venvDir, "Scripts", "python.exe") + : path.join(venvDir, "bin", "python"); + + try { + this._fs.mkdirSync(slotDir, { recursive: true }); + + // Create venv + const { promise: venvPromise, reject: venvReject, resolve: venvResolve } = deferred(); + this._execFile(bootstrap.path, ["-m", "venv", venvDir], { timeout: 60000, signal }, (err) => { + if (err) venvReject(err); + else venvResolve(); + }); + await venvPromise; + } catch (venvErr: unknown) { + if (venvErr instanceof Error && venvErr.name === "AbortError") { + try { this._fs.rmSync(slotDir, { recursive: true, force: true }); } catch {} + return this._abortedHealth(); + } + return this._slotFailed(version, "VENV_CREATION_FAILED", venvErr, slotDir); + } + + if (signal?.aborted) return this._abortedHealth(); + + try { + // pip install paperforge + const { promise: pipPromise, reject: pipReject, resolve: pipResolve } = deferred(); + this._execFile(pythonExe, ["-m", "pip", "install", `paperforge==${version}`], { timeout: 120000, signal }, (err) => { + if (err) pipReject(err); + else pipResolve(); + }); + await pipPromise; + } catch (pipErr: unknown) { + if (pipErr instanceof Error && pipErr.name === "AbortError") { + try { this._fs.rmSync(slotDir, { recursive: true, force: true }); } catch {} + return this._abortedHealth(); + } + return this._slotFailed(version, "PIP_INSTALL_FAILED", pipErr, slotDir); + } + + if (signal?.aborted) return this._abortedHealth(); + + try { + // Verify with isolated import + const { promise: verifyPromise, reject: verifyReject, resolve: verifyResolve } = deferred(); + this._execFile(pythonExe, ["-I", "-c", `import paperforge; print(paperforge.__version__)`], { timeout: 30000, signal }, (err) => { + if (err) verifyReject(err); + else verifyResolve(); + }); + await verifyPromise; + } catch (verifyErr: unknown) { + if (verifyErr instanceof Error && verifyErr.name === "AbortError") { + try { this._fs.rmSync(slotDir, { recursive: true, force: true }); } catch {} + return this._abortedHealth(); + } + return this._slotFailed(version, "VERIFY_FAILED", verifyErr, slotDir); + } + + // Load old pointer for rollback info + let prevVersion: string | null = null; + let prevPythonPath: string | null = null; + try { + const oldRaw = this._fs.readFileSync(this.pointerPath, "utf-8"); + const oldPtr: Record = JSON.parse(oldRaw); + prevVersion = typeof oldPtr.version === "string" ? oldPtr.version : null; + prevPythonPath = typeof oldPtr.pythonPath === "string" ? oldPtr.pythonPath : null; + } catch { + // No previous pointer — first install + } + + // Atomically write pointer: write temp, rename + const pointerDir = path.dirname(this.pointerPath); + if (!this._fs.existsSync(pointerDir)) { + this._fs.mkdirSync(pointerDir, { recursive: true }); + } + + const relativePyPath = path.relative(path.dirname(this.pointerPath), pythonExe); + + const pointerContent = JSON.stringify( + { + schema_version: 1, + version, + pythonPath: relativePyPath, + activatedAt: new Date().toISOString(), + previousVersion: prevVersion, + previousPythonPath: prevPythonPath, + }, + null, + 2, + ); + + const tmpPath = this.pointerPath + ".tmp"; + this._fs.writeFileSync(tmpPath, pointerContent, "utf-8"); + this._fs.renameSync(tmpPath, this.pointerPath); + + // Update cache + const health: RuntimeHealth = { + state: "ready", + pythonPath: pythonExe, + version, + source: "venv", + error: null, + lastVerifiedAt: new Date().toISOString(), + stale: false, + warnings: [], + previousVersion: prevVersion, + previousPythonPath: prevPythonPath, + }; + this._cache = health; + this._cacheTime = Date.now(); + + // Cleanup old slots (best-effort, keep 2) + this._cleanupOldSlots(version); + + return health; + } + + // ── Private helpers ── + + private _setCache(h: RuntimeHealth): RuntimeHealth { + this._cache = h; + this._cacheTime = Date.now(); + return h; + } + + private _abortedHealth(): RuntimeHealth { + return { + state: "needs_repair", + pythonPath: null, + version: null, + source: "none", + error: { code: "ABORTED", message: "Operation was cancelled", platformAction: "Retry operation" }, + lastVerifiedAt: null, + stale: false, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }; + } + + private _slotFailed(version: string, code: string, err: unknown, slotDir: string): RuntimeHealth { + // Clean up failed slot + try { + this._fs.rmSync(slotDir, { recursive: true, force: true }); + } catch { + // best-effort + } + const msg = err instanceof Error ? err.message : String(err); + return this._setCache({ + state: "needs_repair", + pythonPath: null, + version, + source: "none", + error: { code, message: msg, platformAction: "Retry installation" }, + lastVerifiedAt: null, + stale: false, + warnings: [], + previousVersion: null, + previousPythonPath: null, + }); + } + + private _currentSlotExists(version: string): boolean { + const slotDir = path.join(this.runtimeDir, `v${version}`); + return this._fs.existsSync(slotDir); + } + + private _resolveBootstrapPython(): { path: string; version: string } { + const candidates: { path: string; args: readonly string[] }[] = []; + + if (this.osPlatform === "win32") { + candidates.push( + { path: "py", args: ["-3.11"] }, + { path: "py", args: ["-3.10"] }, + { path: "py", args: ["-3"] }, + { path: "python", args: [] }, + ); + } else if (this.osPlatform === "darwin") { + candidates.push( + { path: "/usr/bin/python3", args: [] }, + { path: "python3", args: [] }, + { path: "python", args: [] }, + ); + } else { + // Linux + candidates.push( + { path: "/usr/bin/python3", args: [] }, + { path: "python3", args: [] }, + { path: "python", args: [] }, + ); + } + + for (const c of candidates) { + try { + const output = this._execFileSync(c.path, [...c.args, "--version"], { + encoding: "utf-8", + timeout: 5000, + }); + const ver = parsePythonVersion(output); + if (ver) { + return { path: c.path, version: ver }; + } + } catch { + // try next candidate + } + } + + throw new Error("No Python 3.10+ found on system"); + } + + private _probe(pythonPath: string, signal?: AbortSignal): Promise<{ version: string | null }> { + const { promise, resolve, reject } = deferred<{ version: string | null }>(); + this._execFile(pythonPath, ["-I", "-c", "import paperforge; print(paperforge.__version__)"], { timeout: 30000, signal }, (err, stdout) => { + if (err) { + reject(err); + } else { + const version = (stdout ?? "").trim() || null; + resolve({ version }); + } + }); + return promise; + } + + private _cleanupOldSlots(currentVersion: string, keepOldSlots: number = 2): void { + try { + const entries = this._fs.readdirSync(this.runtimeDir, { withFileTypes: true }); + const slots = entries + .filter((e) => e.isDirectory() && e.name.startsWith("v")) + .map((e) => { + const baseVer = e.name.replace(/^v/, "").replace(/_build\d+$/, ""); + return { name: e.name, version: baseVer }; + }) + .filter((s) => s.version !== currentVersion) + .sort((a, b) => compareVersions(b.version, a.version)); + + // Keep at most keepOldSlots old slots + for (let i = keepOldSlots; i < slots.length; i++) { + this._fs.rmSync(path.join(this.runtimeDir, slots[i].name), { recursive: true, force: true }); + } + } catch { + // best-effort + } + } +} diff --git a/paperforge/plugin/tests/managed-runtime.test.ts b/paperforge/plugin/tests/managed-runtime.test.ts new file mode 100644 index 00000000..2f5453f9 --- /dev/null +++ b/paperforge/plugin/tests/managed-runtime.test.ts @@ -0,0 +1,1452 @@ +/** + * Focused Vitest tests for ManagedRuntime (Issue #77). + * + * Covers: synchronous fail-closed, status probe/cache, Python version gating, + * immutable slot build/activate, rollback, cancellation, atomic pointer, + * platform detection, canonical actions, and command resolution. + * + * Uses dependency injection (constructor parameters) rather than vi.mock. + * All paths are computed via the `path` module for OS portability. + * + * DI type pattern: each mock factory returns both the DI interface and + * individual vi.fn() references stored in closures for mock control. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as path from "path"; +import * as os from "os"; +import * as fs from "fs"; +import type { Dirent } from "fs"; +import { + ManagedRuntime, + getOsArch, + runtimeActionsForHealth, + resolveRuntimeCommand, +} from "../src/services/managed-runtime"; +import type { + RuntimeHealth, + FsOps, + ExecFileFn, + ExecFileSyncFn, +} from "../src/services/managed-runtime"; + +// ── OS-independent path constants ── +const RUNTIME_DIR = path.join("home", "user", ".paperforge", "runtime", "windows-x64"); +const RUNTIME_PARENT = path.dirname(RUNTIME_DIR); +const POINTER_PATH = path.join(RUNTIME_PARENT, "active-runtime.json"); +const PLUGIN_VER = "1.3.0"; + +// ── Mock helper types (not exported, only used in tests) ── + +interface MockFs extends FsOps { + existsSync: ReturnType boolean>>; + readFileSync: ReturnType string>>; + writeFileSync: ReturnType void>>; + renameSync: ReturnType void>>; + mkdirSync: ReturnType string | undefined>>; + rmSync: ReturnType void>>; + readdirSync: ReturnType Dirent[]>>; +} + +/** Create a mock FsOps with full mock-control access. */ +function createMockFs(): MockFs { + return { + existsSync: vi.fn<(p: string) => boolean>(), + readFileSync: vi.fn<(p: string, encoding?: string | null) => string>(), + writeFileSync: vi.fn<(p: string, data: string | NodeJS.ArrayBufferView, encoding?: string | null) => void>(), + renameSync: vi.fn<(oldP: string, newP: string) => void>(), + mkdirSync: vi.fn<(p: string, opts?: { recursive?: boolean }) => string | undefined>(), + rmSync: vi.fn<(p: string, opts?: { recursive?: boolean; force?: boolean }) => void>(), + readdirSync: vi.fn<(p: string, opts?: { withFileTypes?: boolean }) => Dirent[]>(), + }; +} + +// ExecFile mock type +type MockExecFile = ReturnType void>>; + +/** Create a mock ExecFileFn with mock control. Default: calls back with null error and probe version. */ +function createMockExecFile(probeVersion: string): MockExecFile { + const fn = vi.fn<(...args: unknown[]) => void>(); + fn.mockImplementation( + (cmd: unknown, args: unknown, opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + cb(null, probeVersion, ""); + }, + ); + return fn; +} + +/** Create a failing mock ExecFileFn. */ +function createFailingExecFile(errorMsg: string): MockExecFile { + const fn = vi.fn<(...args: unknown[]) => void>(); + fn.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + cb(new Error(errorMsg), "", "stderr"); + }, + ); + return fn; +} + +/** Create a mock ExecFileSyncFn with mock control. Default: returns "Python 3.11.0". */ +function createMockExecFileSync(pythonVersion: string): ExecFileSyncFn { + return ((_cmd: string, _args: readonly string[], _opts: { encoding: string; timeout: number }) => { + return `Python ${pythonVersion}`; + }) as ExecFileSyncFn; +} + +/** Create a throwing mock ExecFileSyncFn. */ +function createThrowingExecFileSync(): ExecFileSyncFn { + return ((_cmd: string, _args: readonly string[], _opts: { encoding: string; timeout: number }) => { + throw new Error("Not found"); + }) as ExecFileSyncFn; +} + +/** Create a minimal Dirent mock. */ +function mkDirent(name: string, isDir = true): Dirent { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + } as unknown as Dirent; +} + +/** Build a canonical relative pythonPath from version (forward-slash for JSON). */ +function relPythonPath(version: string): string { + return path.join("windows-x64", `v${version}`, "venv", "Scripts", "python.exe").replace(/\\/g, "/"); +} + +/** Default active-runtime.json content. pythonPath is relative to pointer parent dir. */ +function defaultPointer(version = "1.3.0"): string { + return JSON.stringify({ + schema_version: 1, + version, + pythonPath: relPythonPath(version), + activatedAt: "2026-07-15T00:00:00Z", + previousVersion: null, + previousPythonPath: null, + }); +} + +/** Normalise a path for comparison (handles Windows backslash vs forward slash). */ +function normalisePath(p: string): string { + return p.replace(/\\/g, "/"); +} + +// ── Tests ── + +describe("ManagedRuntime", () => { + // ── current(): synchronous fail-closed ── + describe("current()", () => { + it("returns unknown stale on cold cache (fail-closed, no silent ambient interpreter)", () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue(""); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: PLUGIN_VER, + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = rt.current(); + expect(h.state).toBe("unknown"); + expect(h.pythonPath).toBeNull(); + expect(h.stale).toBe(true); + expect(h.lastVerifiedAt).toBeNull(); + }); + + it("never returns ready from stale cache — returns unknown with stale:true", async () => { + const fs = createMockFs(); + setupDefaultMockFs(fs, "1.3.0"); + + const execFile = createMockExecFile("1.3.0"); + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: PLUGIN_VER, + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + await rt.status(); + + const h1 = rt.current(); + expect(h1.state).toBe("ready"); + expect(h1.stale).toBe(false); + + vi.useFakeTimers(); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + + const h2 = rt.current(); + expect(h2.state).toBe("unknown"); + expect(h2.stale).toBe(true); + expect(h2.pythonPath).toBe(h1.pythonPath); + expect(h2.version).toBe(h1.version); + + vi.useRealTimers(); + }); + + it("returns cached ready when within TTL", async () => { + const fs = createMockFs(); + setupDefaultMockFs(fs, "1.3.0"); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: PLUGIN_VER, + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + await rt.status(); + + const h = rt.current(); + expect(h.state).toBe("ready"); + expect(h.stale).toBe(false); + }); + }); + + // ── status(): async probe ── + describe("status()", () => { + it("returns not_installed when no pointer file exists", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue(""); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: PLUGIN_VER, + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.status(); + expect(h.state).toBe("not_installed"); + expect(h.pythonPath).toBeNull(); + expect(h.version).toBeNull(); + expect(h.stale).toBe(false); + }); + + it("returns ready when probe passes", async () => { + const fs = createMockFs(); + setupDefaultMockFs(fs, "1.3.0"); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: PLUGIN_VER, + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.status(); + expect(h.state).toBe("ready"); + expect(h.pythonPath).toBeTruthy(); + expect(h.version).toBe("1.3.0"); + expect(h.source).toBe("venv"); + expect(h.error).toBeNull(); + expect(h.stale).toBe(false); + expect(h.lastVerifiedAt).toBeTruthy(); + }); + + it("returns needs_repair when probe fails", async () => { + const fs = createMockFs(); + setupDefaultMockFs(fs, "1.3.0"); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: PLUGIN_VER, + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createFailingExecFile("Probe failed") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.status(); + expect(h.state).toBe("needs_repair"); + expect(h.error?.code).toBe("PROBE_FAILED"); + expect(h.stale).toBe(false); + }); + + it("returns needs_repair when python not found at pointer path", async () => { + const fs = createMockFs(); + setupDefaultMockFs(fs, "1.3.0"); + // Override — python exe doesn't exist + const resolvedPy = path.resolve(RUNTIME_PARENT, relPythonPath("1.3.0")); + fs.existsSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(resolvedPy)) return false; + return true; + }); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: PLUGIN_VER, + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.status(); + expect(h.state).toBe("needs_repair"); + expect(h.error?.code).toBe("PYTHON_NOT_FOUND"); + }); + + it("returns stale cached health with stale:true when allowStale and expired", async () => { + const fs = createMockFs(); + setupDefaultMockFs(fs, "1.3.0"); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: PLUGIN_VER, + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + await rt.status(); // populate cache + + vi.useFakeTimers(); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + + const h = await rt.status({ allowStale: true }); + expect(h.stale).toBe(true); + expect(h.state).toBe("ready"); + expect(h.pythonPath).toBeTruthy(); + + vi.useRealTimers(); + }); + + it("returns needs_repair when pointer has no pythonPath", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue( + JSON.stringify({ schema_version: 1, version: "1.3.0", pythonPath: null }), + ); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: PLUGIN_VER, + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.status(); + expect(h.state).toBe("needs_repair"); + expect(h.error?.code).toBe("POINTER_MISSING_PATH"); + }); + }); + + // ── ensure(): build, verify, activate ── + describe("ensure()", () => { + it("builds a new immutable slot, verifies, and atomically activates", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(""); // no existing pointer + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure(); + expect(h.state).toBe("ready"); + expect(h.version).toBe("1.3.0"); + expect(h.source).toBe("venv"); + expect(h.error).toBeNull(); + + expect(fs.mkdirSync).toHaveBeenCalled(); + expect(fs.renameSync).toHaveBeenCalled(); + const writeCalls = fs.writeFileSync.mock.calls; + expect(writeCalls.length).toBeGreaterThanOrEqual(1); + const lastWrite = writeCalls[writeCalls.length - 1][0] as string; + expect(lastWrite).toContain(".tmp"); + }); + + it("cancellation preserves old pointer and returns needs_repair", async () => { + const ac = new AbortController(); + ac.abort(); + + const fs = createMockFs(); + setupDefaultMockFs(fs, "1.3.0"); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: PLUGIN_VER, + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure({ signal: ac.signal }); + expect(h.state).toBe("needs_repair"); + expect(h.error?.code).toBe("ABORTED"); + }); + + it("forces rebuild with disambiguated slot name", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(POINTER_PATH)) return defaultPointer("1.3.0"); + return ""; + }); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure({ force: true, version: "1.3.0" }); + expect(h.state).toBe("ready"); + }); + + it("slot creation failure cleans up and returns needs_repair", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(POINTER_PATH)) return defaultPointer(); + return ""; + }); + fs.readdirSync.mockReturnValue([mkDirent("v1.3.0")]); + + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + cb(new Error("venv creation failed"), "", "error"); + }, + ); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure(); + expect(h.state).toBe("needs_repair"); + expect(h.error?.code).toBe("VENV_CREATION_FAILED"); + expect(fs.rmSync).toHaveBeenCalled(); + }); + + it("pip install failure returns needs_repair with PIP_INSTALL_FAILED", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(""); + fs.readdirSync.mockReturnValue([]); + + const callLog: Array<{ cmd: string; args: readonly string[] }> = []; + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation( + (cmd: unknown, args: unknown, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + const a = args as readonly string[]; + callLog.push({ cmd: cmd as string, args: a }); + if (a[0] === "-m" && a[1] === "venv") { + cb(null, "", ""); + } else if (a[0] === "-m" && a[1] === "pip") { + cb(new Error("pip install failed"), "", "error"); + } else { + cb(null, "", ""); + } + }, + ); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure(); + expect(h.error?.code).toBe("PIP_INSTALL_FAILED"); + expect(h.state).toBe("needs_repair"); + expect(fs.rmSync).toHaveBeenCalled(); + }); + + it("rollback: writes pointer with previousVersion/previousPythonPath tracking", async () => { + const fs = createMockFs(); + fs.readFileSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(POINTER_PATH)) { + return JSON.stringify({ + schema_version: 1, + version: "1.4.0", + pythonPath: relPythonPath("1.4.0"), + activatedAt: "2026-07-15T01:00:00Z", + previousVersion: "1.3.0", + previousPythonPath: relPythonPath("1.3.0"), + }); + } + return ""; + }); + fs.existsSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue([mkDirent("v1.3.0"), mkDirent("v1.4.0")]); + + let writtenPointer = ""; + fs.writeFileSync = vi.fn<(p: string, data: string | NodeJS.ArrayBufferView, encoding?: string | null) => void>( + (p: string, data: string | NodeJS.ArrayBufferView) => { + if (typeof p === "string" && p.endsWith(".tmp")) { + writtenPointer = typeof data === "string" ? data : String(data); + } + }, + ); + + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation( + (cmd: unknown, args: unknown, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + const a = args as readonly string[]; + // venv + pip succeed + if (a[0] === "-m") { + cb(null, "", ""); + } else if (a[0] === "-I") { + cb(null, "1.3.0", ""); + } else { + cb(null, "", ""); + } + }, + ); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure({ version: "1.3.0" }); + expect(h.state).toBe("ready"); + expect(h.version).toBe("1.3.0"); + + const ptr = JSON.parse(writtenPointer); + expect(ptr.previousVersion).toBe("1.4.0"); + expect(ptr.previousPythonPath).toBeTruthy(); + expect(ptr.version).toBe("1.3.0"); + }); + + it("RED Gap 1: rollback verifies retained immutable slot without creating venv or running pip", async () => { + const fs = createMockFs(); + // Existing pointer: v1.4.0 is current active + fs.readFileSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(POINTER_PATH)) { + return JSON.stringify({ + schema_version: 1, + version: "1.4.0", + pythonPath: relPythonPath("1.4.0"), + activatedAt: "2026-07-15T01:00:00Z", + previousVersion: "1.3.0", + previousPythonPath: relPythonPath("1.3.0"), + }); + } + return ""; + }); + // Slot for v1.3.0 exists (retained) + fs.existsSync.mockImplementation((p: string) => { + if (typeof p === "string" && p.includes("v1.3.0") && !p.includes("v1.4.0")) return true; + return true; + }); + fs.readdirSync.mockReturnValue([mkDirent("v1.3.0"), mkDirent("v1.4.0")]); + + // Track execFile calls to assert no venv/pip + const execCalls: Array<{ cmd: string; args: readonly string[] }> = []; + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation( + (cmd: unknown, args: unknown, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + const a = args as readonly string[]; + execCalls.push({ cmd: cmd as string, args: a }); + // Only the import probe should succeed + if (a[0] === "-I") { + cb(null, "1.3.0", ""); + } else { + cb(new Error("unexpected call"), "", "unexpected"); + } + }, + ); + + let writtenContent = ""; + fs.writeFileSync = vi.fn<(p: string, data: string | NodeJS.ArrayBufferView) => void>( + (p: string, data: string | NodeJS.ArrayBufferView) => { + if (typeof p === "string" && p.endsWith(".tmp")) { + writtenContent = typeof data === "string" ? data : String(data); + } + }, + ); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.4.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure({ version: "1.3.0" }); + expect(h.state).toBe("ready"); + expect(h.version).toBe("1.3.0"); + + // Assert: NO venv creation (no "-m venv" call) + const venvCalls = execCalls.filter((c) => c.args[0] === "-m" && c.args[1] === "venv"); + expect(venvCalls).toHaveLength(0); + + // Assert: NO pip install (no "-m pip" call) + const pipCalls = execCalls.filter((c) => c.args[0] === "-m" && c.args[1] === "pip"); + expect(pipCalls).toHaveLength(0); + + // Assert: retained slot was verified via import probe + const probeCalls = execCalls.filter((c) => c.args[0] === "-I"); + expect(probeCalls.length).toBeGreaterThanOrEqual(1); + + // Assert: pointer atomically rewritten (tmp write + rename) + expect(writtenContent).toBeTruthy(); + const ptr = JSON.parse(writtenContent); + expect(ptr.version).toBe("1.3.0"); + expect(ptr.previousVersion).toBe("1.4.0"); + expect(ptr.pythonPath).toBeTruthy(); + expect(fs.renameSync).toHaveBeenCalled(); + }); + }); + + // ── Issue #77 Contract 2 RED: AbortSignal pass-through to child processes ── + describe("Issue #77 RED: AbortSignal pass-through", () => { + it("RED: ensure passes AbortSignal to venv execFile call", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(""); + + const capturedOpts: Array> = []; + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation( + (_cmd: unknown, _args: unknown, opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + capturedOpts.push(opts as Record); + cb(null, "", ""); + }, + ); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const ac = new AbortController(); + await rt.ensure({ signal: ac.signal }); + + // The venv call (first execFile call) must receive the signal + expect(capturedOpts.length).toBeGreaterThanOrEqual(3); // venv + pip + verify + const venvOpts = capturedOpts[0]; + expect(venvOpts.signal).toBe(ac.signal); + }); + + it("RED: ensure passes AbortSignal to pip execFile call", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(""); + + const capturedOpts: Array> = []; + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation( + (_cmd: unknown, _args: unknown, opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + capturedOpts.push(opts as Record); + cb(null, "", ""); + }, + ); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const ac = new AbortController(); + await rt.ensure({ signal: ac.signal }); + + // pip is the second execFile call (after venv) + const pipOpts = capturedOpts[1]; + expect(pipOpts.signal).toBe(ac.signal); + }); + + it("RED: ensure passes AbortSignal to verify execFile call", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(""); + + const capturedOpts: Array> = []; + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation( + (_cmd: unknown, _args: unknown, opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + capturedOpts.push(opts as Record); + cb(null, "", ""); + }, + ); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const ac = new AbortController(); + await rt.ensure({ signal: ac.signal }); + + // verify is the third execFile call (after venv and pip) + const verifyOpts = capturedOpts[2]; + expect(verifyOpts.signal).toBe(ac.signal); + }); + + it("RED: aborted signal during venv kills child, cleans slot, returns aborted health", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(""); + + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + // Simulate child killed by abort signal + cb(abortError, "", ""); + }, + ); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure(); + + // AbortError from child process → slot cleaned + aborted health + expect(h.state).toBe("needs_repair"); + expect(h.error?.code).toBe("ABORTED"); + // Slot must be cleaned up + expect(fs.rmSync).toHaveBeenCalled(); + // Pointer must NOT be written + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(fs.renameSync).not.toHaveBeenCalled(); + }); + + it("RED: aborted signal during retained-slot probe prevents pointer write", async () => { + const fs = createMockFs(); + // Pointer points to v1.4.0, so v1.3.0 is a rollback target with retained slot + fs.readFileSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(POINTER_PATH)) { + return JSON.stringify({ + schema_version: 1, + version: "1.4.0", + pythonPath: relPythonPath("1.4.0"), + activatedAt: "2026-07-15T01:00:00Z", + }); + } + return ""; + }); + fs.existsSync.mockReturnValue(true); + fs.readdirSync.mockReturnValue([mkDirent("v1.3.0"), mkDirent("v1.4.0")]); + + const abortError = new Error("The operation was aborted"); + abortError.name = "AbortError"; + + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation( + (_cmd: unknown, _args: unknown, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + cb(abortError, "", ""); + }, + ); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.4.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure({ version: "1.3.0" }); + + // Retained slot probe failed with AbortError → no pointer write + // Must NOT report RETAINED_SLOT_PROBE_FAILED — abort is not a probe failure + expect(h.state).toBe("needs_repair"); + expect(h.error?.code).toBe("ABORTED"); + // Pointer must NOT be written (no .tmp write, no rename) + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(fs.renameSync).not.toHaveBeenCalled(); + }); + }); + + // ── Python version gating ── + describe("Python version gating", () => { + it("RED Gap 3: accepts existing 3.10 install as ready with one Release-N warning", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(POINTER_PATH)) return defaultPointer("1.2.3"); + return ""; + }); + fs.readdirSync.mockReturnValue([mkDirent("v1.2.3"), mkDirent("v1.3.0")]); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.2.3") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.10.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.status(); + expect(h.state).toBe("ready"); + // RED: must carry exactly one Release-N deprecation warning for Python < 3.11 + expect(h.warnings).toBeDefined(); + expect(h.warnings!.length).toBeGreaterThanOrEqual(1); + const releaseWarning = h.warnings!.find((w) => w.code === "PYTHON_310_DEPRECATED"); + expect(releaseWarning).toBeDefined(); + expect(releaseWarning!.message).toContain("3.10"); + }); + + it("rejects new install with Python 3.10 (requires 3.11+)", async () => { + const fs = createMockFs(); + fs.existsSync.mockImplementation((p: string) => { + // No pointer exists AND no slot directory exists + if (normalisePath(p) === normalisePath(POINTER_PATH)) return false; + if (p.includes("v1.3.0")) return false; + return true; + }); + fs.readFileSync.mockReturnValue(""); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.10.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure(); + expect(h.state).not.toBe("ready"); + expect(h.error?.code).toBe("PYTHON_VERSION_WARNING"); + }); + + it("rejects Python below 3.10", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(POINTER_PATH)) return defaultPointer(); + return ""; + }); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.9.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure(); + expect(h.state).toBe("unavailable"); + expect(h.error?.code).toBe("PYTHON_TOO_OLD"); + }); + }); + + // ── Platform support ── + describe("Platform support", () => { + beforeEach(() => { + delete process.env.FLATPAK_ID; + delete process.env.SNAP; + }); + + it("macOS reports unavailable with NO_PYTHON (auto-download disabled)", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(false); + const execFileSync = vi.fn<(cmd: string, args: readonly string[], opts: { encoding: string; timeout: number }) => string>(); + execFileSync.mockImplementation(() => { throw new Error("Not found"); }); + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation((_cmd: unknown, _args: unknown, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + cb(null, "1.3.0", ""); + }); + const rt = new ManagedRuntime({ + runtimeDir: path.join("home", "user", ".paperforge", "runtime", "macos-arm64"), + pluginVersion: "1.3.0", + osPlatform: "darwin", + osArch: "arm64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: execFileSync as unknown as ExecFileSyncFn, + }); + const h = await rt.ensure(); + expect(h.state).toBe("unavailable"); + expect(h.error?.code).toBe("NO_PYTHON"); + expect(h.error?.message).toContain("macOS auto-download disabled"); + }); + + it("macOS x64 also reports unavailable (system Python not found)", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(false); + const execFileSync = vi.fn<(cmd: string, args: readonly string[], opts: { encoding: string; timeout: number }) => string>(); + execFileSync.mockImplementation(() => { throw new Error("Not found"); }); + const execFile = vi.fn<(...args: unknown[]) => void>(); + execFile.mockImplementation((_cmd: unknown, _args: unknown, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => { + cb(null, "1.3.0", ""); + }); + const rt = new ManagedRuntime({ + runtimeDir: path.join("home", "user", ".paperforge", "runtime", "macos-x64"), + pluginVersion: "1.3.0", + osPlatform: "darwin", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: execFileSync as unknown as ExecFileSyncFn, + }); + const h = await rt.ensure(); + expect(h.state).toBe("unavailable"); + expect(h.error?.code).toBe("NO_PYTHON"); + expect(h.error?.message).toContain("macOS auto-download disabled"); + }); + + it("Windows validated fallback returns NO_PYTHON with manual instruction when bootstrap fails", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(false); + const execFileSync = vi.fn<(cmd: string, args: readonly string[], opts: { encoding: string; timeout: number }) => string>(); + execFileSync.mockImplementation(() => { throw new Error("Not found"); }); + const execFile = vi.fn<(...args: unknown[]) => void>(); + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: execFileSync as unknown as ExecFileSyncFn, + }); + const h = await rt.ensure(); + expect(h.state).toBe("unavailable"); + expect(h.error?.code).toBe("NO_PYTHON"); + expect(h.error?.message).toContain("automatic download failed"); + }); + + it("Linux validated fallback returns NO_PYTHON with manual instruction", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(false); + const execFileSync = vi.fn<(cmd: string, args: readonly string[], opts: { encoding: string; timeout: number }) => string>(); + execFileSync.mockImplementation(() => { throw new Error("Not found"); }); + const execFile = vi.fn<(...args: unknown[]) => void>(); + const rt = new ManagedRuntime({ + runtimeDir: path.join("home", "user", ".paperforge", "runtime", "linux-x64"), + pluginVersion: "1.3.0", + osPlatform: "linux", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: execFileSync as unknown as ExecFileSyncFn, + }); + const h = await rt.ensure(); + expect(h.state).toBe("unavailable"); + expect(h.error?.code).toBe("NO_PYTHON"); + }); + + it("unsupported triplet returns FALLBACK_UNAVAILABLE", async () => { + const fs = createMockFs(); + fs.existsSync.mockReturnValue(false); + const execFileSync = vi.fn<(cmd: string, args: readonly string[], opts: { encoding: string; timeout: number }) => string>(); + execFileSync.mockImplementation(() => { throw new Error("Not found"); }); + const execFile = vi.fn<(...args: unknown[]) => void>(); + const rt = new ManagedRuntime({ + runtimeDir: path.join("home", "user", ".paperforge", "runtime", "linux-arm64"), + pluginVersion: "1.3.0", + osPlatform: "linux", + osArch: "arm64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: execFileSync as unknown as ExecFileSyncFn, + }); + const h = await rt.ensure(); + expect(h.state).toBe("unavailable"); + expect(h.error?.code).toBe("FALLBACK_UNAVAILABLE"); + }); + + it("Flatpak environment returns FLATPAK_SNAP_UNSUPPORTED", async () => { + process.env.FLATPAK_ID = "org.flatpak.Flatpak"; + const fs = createMockFs(); + fs.existsSync.mockReturnValue(false); + const execFileSync = vi.fn<(cmd: string, args: readonly string[], opts: { encoding: string; timeout: number }) => string>(); + execFileSync.mockImplementation(() => { throw new Error("Not found"); }); + const execFile = vi.fn<(...args: unknown[]) => void>(); + const rt = new ManagedRuntime({ + runtimeDir: path.join("home", "user", ".paperforge", "runtime", "linux-x64"), + pluginVersion: "1.3.0", + osPlatform: "linux", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: execFileSync as unknown as ExecFileSyncFn, + }); + const h = await rt.ensure(); + expect(h.state).toBe("unavailable"); + expect(h.error?.code).toBe("FLATPAK_SNAP_UNSUPPORTED"); + }); + + it("Snap environment returns FLATPAK_SNAP_UNSUPPORTED", async () => { + process.env.SNAP = "/snap/core/current"; + const fs = createMockFs(); + fs.existsSync.mockReturnValue(false); + const execFileSync = vi.fn<(cmd: string, args: readonly string[], opts: { encoding: string; timeout: number }) => string>(); + execFileSync.mockImplementation(() => { throw new Error("Not found"); }); + const execFile = vi.fn<(...args: unknown[]) => void>(); + const rt = new ManagedRuntime({ + runtimeDir: path.join("home", "user", ".paperforge", "runtime", "linux-x64"), + pluginVersion: "1.3.0", + osPlatform: "linux", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: execFile as unknown as ExecFileFn, + execFileSync: execFileSync as unknown as ExecFileSyncFn, + }); + const h = await rt.ensure(); + expect(h.state).toBe("unavailable"); + expect(h.error?.code).toBe("FLATPAK_SNAP_UNSUPPORTED"); + }); + }); + + // ── Pointer content ── + describe("Pointer content", () => { + it("contains only runtime metadata and machine-local interpreter paths — no credentials or vault paths", async () => { + const fs = createMockFs(); + let writtenContent = ""; + + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(POINTER_PATH)) return defaultPointer(); + return ""; + }); + fs.writeFileSync = vi.fn<(p: string, data: string | NodeJS.ArrayBufferView, encoding?: string | null) => void>( + (p: string, data: string | NodeJS.ArrayBufferView) => { + if (typeof p === "string" && p.endsWith(".tmp")) { + writtenContent = typeof data === "string" ? data : String(data); + } + }, + ); + fs.readdirSync = vi.fn<(p: string, opts?: { withFileTypes?: boolean }) => Dirent[]>(); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + await rt.ensure(); + + const ptr = JSON.parse(writtenContent); + expect(ptr.schema_version).toBe(1); + expect(ptr.version).toBe("1.3.0"); + expect(typeof ptr.pythonPath).toBe("string"); + expect(ptr.pythonPath).not.toContain("vault"); + expect(ptr.pythonPath).not.toContain("credentials"); + expect(ptr.pythonPath).not.toContain("secret"); + expect(ptr.pythonPath).not.toContain("token"); + expect(Object.keys(ptr)).not.toContain("bootstrapPath"); + expect(Object.keys(ptr)).not.toContain("bootstrap"); + expect(Object.keys(ptr)).not.toContain("vaultPath"); + expect(Object.keys(ptr)).not.toContain("vault"); + expect(Object.keys(ptr)).not.toContain("credential"); + expect(typeof ptr.activatedAt).toBe("string"); + }); + + it("pointer atomicity: write-temp-file then rename preserves old pointer on interrupted write", async () => { + const fs = createMockFs(); + let writtenContent = ""; + const originalContent = defaultPointer("1.2.3"); + + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(POINTER_PATH)) return originalContent; + if (p.endsWith(".tmp")) return writtenContent; + return ""; + }); + fs.writeFileSync = vi.fn<(p: string, data: string | NodeJS.ArrayBufferView, encoding?: string | null) => void>( + (p: string, data: string | NodeJS.ArrayBufferView) => { + if (typeof p === "string" && p.endsWith(".tmp")) { + writtenContent = typeof data === "string" ? data : String(data); + } + }, + ); + fs.renameSync = vi.fn<(oldP: string, newP: string) => void>(); + + const rt = new ManagedRuntime({ + runtimeDir: RUNTIME_DIR, + pluginVersion: "1.3.0", + osPlatform: "win32", + osArch: "x64", + fs: fs as unknown as FsOps, + execFile: createMockExecFile("1.3.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + await rt.ensure(); + + expect(fs.renameSync.mock.calls.length).toBeGreaterThanOrEqual(1); + // Old pointer content preserved + expect(fs.readFileSync(POINTER_PATH)).toBe(originalContent); + }); + }); + + // ── getOsArch helper ── + describe("getOsArch", () => { + it("maps win32-x64 to windows-x64", () => { + expect(getOsArch("win32", "x64")).toBe("windows-x64"); + }); + + it("maps darwin-arm64 to macos-arm64", () => { + expect(getOsArch("darwin", "arm64")).toBe("macos-arm64"); + }); + + it("maps darwin-x64 to macos-x64", () => { + expect(getOsArch("darwin", "x64")).toBe("macos-x64"); + }); + + it("maps linux-x64 to linux-x64", () => { + expect(getOsArch("linux", "x64")).toBe("linux-x64"); + }); + }); + + // ── runtimeActionsForHealth ── + describe("runtimeActionsForHealth", () => { + function health(overrides: Partial & { state: RuntimeHealth["state"] }): RuntimeHealth { + return { + state: overrides.state, + pythonPath: overrides.pythonPath ?? null, + version: overrides.version ?? null, + source: overrides.source ?? "none", + error: overrides.error ?? null, + lastVerifiedAt: overrides.lastVerifiedAt ?? null, + stale: overrides.stale ?? false, + }; + } + + it("not_installed → install action (primary)", () => { + const acts = runtimeActionsForHealth(health({ state: "not_installed" })); + expect(acts).toHaveLength(1); + expect(acts[0].id).toBe("install"); + expect(acts[0].primary).toBe(true); + expect(acts[0].destructive).toBe(false); + }); + + it("needs_repair with pythonPath → repair (primary) + rollback", () => { + const acts = runtimeActionsForHealth( + health({ state: "needs_repair", pythonPath: "/usr/bin/python" }), + ); + expect(acts).toHaveLength(2); + expect(acts[0].id).toBe("repair"); + expect(acts[0].primary).toBe(true); + expect(acts[1].id).toBe("rollback"); + expect(acts[1].primary).toBe(false); + }); + + it("needs_repair without pythonPath → repair only", () => { + const acts = runtimeActionsForHealth(health({ state: "needs_repair" })); + expect(acts).toHaveLength(1); + expect(acts[0].id).toBe("repair"); + }); + + it("ready → status + update", () => { + const acts = runtimeActionsForHealth(health({ state: "ready" })); + expect(acts).toHaveLength(2); + expect(acts[0].id).toBe("status"); + expect(acts[1].id).toBe("update"); + }); + + it("unknown → probe (primary)", () => { + const acts = runtimeActionsForHealth(health({ state: "unknown" })); + expect(acts).toHaveLength(1); + expect(acts[0].id).toBe("probe"); + expect(acts[0].primary).toBe(true); + }); + + it("unavailable → setup (primary)", () => { + const acts = runtimeActionsForHealth(health({ state: "unavailable" })); + expect(acts).toHaveLength(1); + expect(acts[0].id).toBe("setup"); + expect(acts[0].primary).toBe(true); + }); + }); + + // ── resolveRuntimeCommand ── + describe("resolveRuntimeCommand", () => { + it("returns command only when ready and pythonPath set", () => { + const result = resolveRuntimeCommand({ + state: "ready", + pythonPath: path.join("home", "user", ".paperforge", "runtime", "windows-x64", "v1.3.0", "venv", "Scripts", "python.exe"), + version: "1.3.0", + source: "venv", + error: null, + lastVerifiedAt: "2026-07-15T00:00:00Z", + stale: false, + }); + expect(result).not.toBeNull(); + expect(result!.command).toContain("python.exe"); + }); + + it("returns null when not ready", () => { + const result = resolveRuntimeCommand({ + state: "not_installed", + pythonPath: null, + version: null, + source: "none", + error: null, + lastVerifiedAt: null, + stale: false, + }); + expect(result).toBeNull(); + }); + + it("returns null when ready but no pythonPath", () => { + const result = resolveRuntimeCommand({ + state: "ready", + pythonPath: null, + version: null, + source: "none", + error: null, + lastVerifiedAt: null, + stale: false, + }); + expect(result).toBeNull(); + }); + }); +}); + +// ── Real filesystem slot retention tests ── + +/** Create a slot directory structure for real-FS tests. */ +function createSlotDir(baseDir: string, version: string, build2 = false): string { + const name = build2 ? `v${version}_build2` : `v${version}`; + const slotDir = path.join(baseDir, name); + const venvDir = path.join(slotDir, "venv", "Scripts"); + fs.mkdirSync(venvDir, { recursive: true }); + fs.writeFileSync(path.join(venvDir, "python.exe"), ""); // marker + return slotDir; +} + +/** Create a pointer file on the real filesystem. */ +function createRealPointer(pointerDir: string, runtimeDirName: string, version: string): void { + const ptr = { + schema_version: 1, + version, + pythonPath: `${runtimeDirName}/v${version}/venv/Scripts/python.exe`, + activatedAt: new Date().toISOString(), + previousVersion: null, + previousPythonPath: null, + }; + fs.writeFileSync(path.join(pointerDir, "active-runtime.json"), JSON.stringify(ptr, null, 2)); +} + +/** List slot directory names under a runtime directory (sorted). */ +function listSlotDirs(runtimeDir: string): string[] { + return fs.readdirSync(runtimeDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith("v")) + .map((e) => e.name) + .sort(); +} + +describe("Real filesystem slot retention", () => { + let tmpDir: string; + const RUNTIME_DIR_NAME = "windows-x64"; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mr-retention-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("same-version ensure prunes old slots to keepOldSlots=2", async () => { + const runtimeDir = path.join(tmpDir, RUNTIME_DIR_NAME); + createSlotDir(runtimeDir, "1.0.0"); + createSlotDir(runtimeDir, "1.1.0"); + createSlotDir(runtimeDir, "1.2.0"); + createSlotDir(runtimeDir, "1.3.0"); + createRealPointer(tmpDir, RUNTIME_DIR_NAME, "1.3.0"); + + const rt = new ManagedRuntime({ + runtimeDir, + pluginVersion: "1.4.0", + osPlatform: "win32", + osArch: "x64", + execFile: createMockExecFile("1.4.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure({ version: "1.4.0" }); + expect(h.state).toBe("ready"); + expect(h.version).toBe("1.4.0"); + + const remaining = listSlotDirs(runtimeDir); + // 1 current (v1.4.0) + at most 2 old (v1.3.0, v1.2.0) = max 3 + expect(remaining).toContain("v1.4.0"); + expect(remaining).toContain("v1.3.0"); + expect(remaining).toContain("v1.2.0"); + expect(remaining).not.toContain("v1.1.0"); + expect(remaining).not.toContain("v1.0.0"); + expect(remaining.length).toBeLessThanOrEqual(3); + }); + + it("rollback retains previous active within keepOldSlots=2", async () => { + const runtimeDir = path.join(tmpDir, RUNTIME_DIR_NAME); + createSlotDir(runtimeDir, "1.0.0"); + createSlotDir(runtimeDir, "1.1.0"); + createSlotDir(runtimeDir, "1.2.0"); + createSlotDir(runtimeDir, "1.3.0"); + createRealPointer(tmpDir, RUNTIME_DIR_NAME, "1.3.0"); + + const execFile = createMockExecFile("1.1.0"); + const rt = new ManagedRuntime({ + runtimeDir, + pluginVersion: "1.4.0", + osPlatform: "win32", + osArch: "x64", + execFile: execFile as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure({ version: "1.1.0" }); + expect(h.state).toBe("ready"); + expect(h.version).toBe("1.1.0"); + + const remaining = listSlotDirs(runtimeDir); + // Current v1.1.0, previous v1.3.0 (counts toward 2), v1.2.0 (2nd old) + expect(remaining).toContain("v1.1.0"); + expect(remaining).toContain("v1.3.0"); + expect(remaining).toContain("v1.2.0"); + expect(remaining).not.toContain("v1.0.0"); + expect(remaining.length).toBeLessThanOrEqual(3); + }); + + it("cleanup failure after pointer activation cannot delete active slot", async () => { + const runtimeDir = path.join(tmpDir, RUNTIME_DIR_NAME); + createSlotDir(runtimeDir, "1.0.0"); + createSlotDir(runtimeDir, "1.1.0"); + createSlotDir(runtimeDir, "1.2.0"); + createSlotDir(runtimeDir, "1.3.0"); + createRealPointer(tmpDir, RUNTIME_DIR_NAME, "1.3.0"); + + // Custom FsOps: real fs for everything, but rmSync throws (simulates cleanup failure) + const failingFs: FsOps = { + existsSync: (p: string) => fs.existsSync(p), + readFileSync: (p: string, encoding?: string | null): string | Buffer => { + return fs.readFileSync(p, encoding ? { encoding: encoding as BufferEncoding } : undefined); + }, + writeFileSync: (p: string, data: string | NodeJS.ArrayBufferView, encoding?: string | null): void => { + fs.writeFileSync(p, data, encoding ? { encoding: encoding as BufferEncoding } : undefined); + }, + renameSync: (oldP: string, newP: string) => fs.renameSync(oldP, newP), + mkdirSync: (p: string, opts?: { recursive?: boolean }) => fs.mkdirSync(p, opts), + rmSync: () => { throw new Error("Simulated cleanup failure"); }, + readdirSync: (p: string, opts?: { withFileTypes?: boolean }) => fs.readdirSync(p, opts) as Dirent[], + }; + + const rt = new ManagedRuntime({ + runtimeDir, + pluginVersion: "1.4.0", + osPlatform: "win32", + osArch: "x64", + fs: failingFs as unknown as FsOps, + execFile: createMockExecFile("1.4.0") as unknown as ExecFileFn, + execFileSync: createMockExecFileSync("3.11.0") as unknown as ExecFileSyncFn, + }); + + const h = await rt.ensure({ version: "1.4.0" }); + expect(h.state).toBe("ready"); + expect(h.version).toBe("1.4.0"); + + // Pointer must point to the new version + const ptrRaw = fs.readFileSync(path.join(tmpDir, "active-runtime.json"), "utf-8"); + const ptr = JSON.parse(ptrRaw); + expect(ptr.version).toBe("1.4.0"); + // Active slot directory must exist — cleanup failure cannot delete it + expect(fs.existsSync(path.join(runtimeDir, "v1.4.0"))).toBe(true); + // Pointer resolves to the active slot (relative pythonPath must form a path we can resolve) + const resolvedPy = path.resolve(path.dirname(path.join(tmpDir, "active-runtime.json")), ptr.pythonPath); + expect(resolvedPy).toContain("v1.4.0"); + }); +}); + +// ── Shared helper ── + +/** Set up a default MockFs that returns valid pointer content. */ +function setupDefaultMockFs(fs: MockFs, version: string): void { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockImplementation((p: string) => { + if (normalisePath(p) === normalisePath(POINTER_PATH)) return defaultPointer(version); + return ""; + }); +}