rspace-online/server/sidecar-manager.ts

126 lines
4.9 KiB
TypeScript

/**
* 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<string, SidecarConfig> = {
"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<boolean> {
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<void> {
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<void> {
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<boolean> {
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})`);
});
}