/** * Sidecar Lifecycle Manager — on-demand container wake-up via Sablier. * * Delegates container start / idle-stop to the Sablier service reachable at * SABLIER_URL (default http://sablier:10000 on the rspace-internal network). * Sablier uses the Docker Engine API on its own socket mount to start named * containers and stops them after the session TTL expires with no refresh. * * Public API is unchanged from the previous Docker-socket implementation so * callers in server/index.ts do not need to change. */ interface SidecarConfig { container: string; host: string; port: number; /** Max ms to block waiting for the container to become ready. */ healthTimeout: number; } const SIDECARS: Record = { "kicad-mcp": { container: "kicad-mcp", host: "kicad-mcp", port: 8809, healthTimeout: 45_000 }, "freecad-mcp": { container: "freecad-mcp", host: "freecad-mcp", port: 8808, healthTimeout: 30_000 }, "blender-worker":{ container: "blender-worker",host: "blender-worker",port: 8810, healthTimeout: 15_000 }, "ollama": { container: "ollama", host: "ollama", port: 11434, healthTimeout: 30_000 }, "scribus-novnc": { container: "scribus-novnc", host: "scribus-novnc", port: 8765, healthTimeout: 30_000 }, "open-notebook": { container: "open-notebook", host: "open-notebook", port: 5055, healthTimeout: 45_000 }, }; const SABLIER_URL = process.env.SABLIER_URL || "http://sablier:10000"; const SESSION_DURATION = process.env.SIDECAR_SESSION_DURATION || "5m"; let sablierReachable: boolean | null = null; async function probeSablier(): Promise { if (sablierReachable !== null) return sablierReachable; try { const res = await fetch(`${SABLIER_URL}/health`, { signal: AbortSignal.timeout(2000) }); sablierReachable = res.ok; } catch { sablierReachable = false; console.log("[sidecar] Sablier unreachable at", SABLIER_URL, "— lifecycle management disabled"); } return sablierReachable; } /** * GET /api/strategies/blocking — Sablier starts the named container, waits * for it to be ready (per its own health check policy), and returns 200. * 202 = still starting past our timeout; we proceed anyway and let the * caller's request retry logic handle the brief window. */ async function sablierWake(config: SidecarConfig): Promise { const qs = new URLSearchParams({ names: config.container, session_duration: SESSION_DURATION, timeout: `${Math.max(1, Math.floor(config.healthTimeout / 1000))}s`, }); const url = `${SABLIER_URL}/api/strategies/blocking?${qs.toString()}`; const res = await fetch(url, { signal: AbortSignal.timeout(config.healthTimeout + 5_000) }); if (!res.ok && res.status !== 202) { throw new Error(`Sablier wake returned ${res.status} for ${config.container}`); } } // ── Public API ── /** * Ensure the named sidecar is running and ready. Extends the Sablier session * TTL as a side effect. Silent no-op when Sablier isn't reachable (local dev). */ export async function ensureSidecar(name: string): Promise { const config = SIDECARS[name]; if (!config) throw new Error(`Unknown sidecar: ${name}`); if (!(await probeSablier())) return; try { await sablierWake(config); } catch (e) { console.warn(`[sidecar] Wake failed for ${name}:`, e instanceof Error ? e.message : e); } } /** * Refresh the session TTL without blocking on readiness — call after a * long-running operation completes so the sidecar stays warm for follow-ups. */ export function markSidecarUsed(name: string): void { const config = SIDECARS[name]; if (!config || sablierReachable === false) return; const qs = new URLSearchParams({ names: config.container, session_duration: SESSION_DURATION }); // Fire-and-forget; readiness already verified earlier via ensureSidecar. fetch(`${SABLIER_URL}/api/strategies/blocking?${qs.toString()}`, { signal: AbortSignal.timeout(2000) }) .catch(() => {}); } /** * Probe whether the sidecar's own HTTP port is accepting connections. * Used by health endpoints; falls back to "assume running" when Sablier is * unreachable so local dev health checks don't fail. */ export async function isSidecarRunning(name: string): Promise { const config = SIDECARS[name]; if (!config) return false; if (!(await probeSablier())) return true; try { const url = config.container === "blender-worker" ? `http://${config.host}:${config.port}/health` : `http://${config.host}:${config.port}/`; const res = await fetch(url, { signal: AbortSignal.timeout(1500) }); return res.status < 500; } catch { return false; } } /** * No-op in the Sablier era — idle shutdown is handled by Sablier's own * session expiration (SESSION_DURATION). Kept for API compatibility. */ export function startIdleWatcher(): void { probeSablier().then((ok) => { if (ok) console.log(`[sidecar] Lifecycle delegated to Sablier at ${SABLIER_URL} (ttl ${SESSION_DURATION})`); }); }