126 lines
4.9 KiB
TypeScript
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})`);
|
|
});
|
|
}
|