import { aI as readStackEnv, b as annotateAddonProfileAvailability, a7 as getAddonProfiles, a6 as getAddonProfileSelection, a as addonProfileId, be as writeVoiceVars, ar as listEnabledAddonIds, F as composeStop, n as buildComposeOptions, b2 as setAddonProfileSelection, b1 as setAddonEnabled, a5 as getAddonProfileAvailability, G as composeUp, ax as parseComposeStderr } from './src-CidJbygh.js';
import { g as getState } from './state-fxN45gBp.js';
import { d as getRequestId, r as requireAdmin, k as jsonResponse, e as errorResponse } from './helpers-kzwksNq1.js';
import { w as withSerialQueue } from './serial-queue-D9FEpYVv.js';
import { existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { execFile } from 'node:child_process';
import { connect } from 'node:net';
import './chunk-CLZ62Ad-.js';
import 'node:module';
import './provider-constants-DPrj3yBX.js';
import './dist-DOFcG0Zp.js';
import 'buffer';
import 'fs';
import 'path';
import 'node:url';
import 'node:crypto';
import 'events';
import 'node:events';
import 'node:stream';
import 'node:string_decoder';
import 'assert';
import 'zlib';
import 'node:assert';
import 'node:fs/promises';
import 'node:os';
import './endpoints-UdQGgKsR.js';

//#region src/lib/server/voice-errors.ts
/**
* Translate raw docker / compose stderr into operator-actionable copy.
*
* Lives in $lib/server so the route can import it without violating
* SvelteKit's +server.ts export rules (handlers + `_`-prefixed only).
*
* Pattern matches are intentionally case-insensitive and tolerant of
* compose-CLI prefix decoration. Order matters: more specific patterns
* first.
*/
function translateDockerError(stderr) {
	const raw = (stderr ?? "").trim();
	if (!raw) return "Docker reported an unknown error (no stderr).";
	if (/spawn .*docker.*ENOENT|no such file or directory.*docker|docker:\s*command not found/i.test(raw)) return "Docker isn't installed on this host. Install Docker Engine or Docker Desktop, then retry.";
	if (/pull access denied|manifest unknown|repository does not exist|not found: manifest unknown/i.test(raw)) return "The voice image for this profile isn't published yet. Try the CPU profile.";
	if (/failed to resolve reference|dial tcp.*lookup .+docker\.io|i\/o timeout.*registry|connection refused.*registry/i.test(raw)) return "Can't reach Docker Hub. Check your internet connection, corporate firewall, or configure a registry mirror.";
	if (/no space left on device|insufficient.*disk space|ENOSPC|write.*disk full/i.test(raw)) return "Out of disk space. Free space and try again, or run `docker image prune -a` to reclaim unused images.";
	if (/port is already allocated|bind.*address already in use|address already in use|failed to bind host port/i.test(raw)) return "Port 8880 is already in use on this host. Free it or change the host port (set OP_VOICE_PORT_HOST).";
	if (/unknown[^\n]*runtime[^\n]*nvidia|runtime\s+"nvidia"\s+not\s+found|nvidia.*runtime.*not[^a-z]+(found|registered)/i.test(raw)) return "The NVIDIA Docker runtime isn't registered on this machine. Try the CPU profile, or install nvidia-container-toolkit.";
	if (/invoking the NVIDIA Container Runtime Hook/i.test(raw)) return "Docker is in CDI mode but no CDI spec is registered. Try the CPU profile.";
	return raw.length > 300 ? raw.slice(0, 297) + "…" : raw;
}
//#endregion
//#region src/routes/admin/voice/+server.ts
/**
* GET /admin/voice  — Return current TTS/STT env vars from stack.env plus
*                     an `availability` block (best-effort reachability of
*                     the configured remote endpoints).
* PUT /admin/voice  — Write TTS/STT env vars to stack.env. Auto-enables
*                     the openpalm-voice addon, brings the chosen profile
*                     up, waits for /health, and translates Docker errors
*                     to operator-actionable copy.
*/
var VOICE_ADDON = "voice";
var VOICE_PROBE_TIMEOUT_MS = 18e4;
var VOICE_PROBE_INTERVAL_MS = 1e3;
var JOB_RETAIN_MS = 5 * 6e4;
var activeJobs = /* @__PURE__ */ new Map();
function setJob(addon, patch) {
	const existing = activeJobs.get(addon);
	const next = existing ? {
		...existing,
		...patch
	} : {
		state: "pulling",
		steps: [],
		startedAt: Date.now(),
		...patch
	};
	activeJobs.set(addon, next);
	return next;
}
function getActiveJob(addon) {
	const job = activeJobs.get(addon);
	if (!job) return void 0;
	if (Date.now() - (job.finishedAt ?? job.startedAt) > JOB_RETAIN_MS) {
		activeJobs.delete(addon);
		return;
	}
	return job;
}
var REACHABILITY_TIMEOUT_MS = 1500;
var PORT_PROBE_TIMEOUT_MS = 750;
async function probeReachable(baseURL) {
	if (!baseURL) return false;
	const url = baseURL.replace(/\/+$/, "") + "/v1/models";
	try {
		return (await fetch(url, {
			method: "GET",
			signal: AbortSignal.timeout(REACHABILITY_TIMEOUT_MS)
		})).status < 500;
	} catch {
		return false;
	}
}
/**
* Pick the best profile for this host. Prefers the first available GPU
* profile (anything that isn't the canonical CPU profile) so operators with NVIDIA/AMD hardware
* get the accelerated variant auto-selected. Falls back to the labelled
* default, then first available, then first profile.
*/
function resolveDefaultProfile(profiles) {
	if (profiles.length === 0) return null;
	const availableGpu = profiles.find((p) => p.id !== addonProfileId(VOICE_ADDON, "cpu") && p.available !== false);
	if (availableGpu) return availableGpu.id;
	const labelledDefault = profiles.find((p) => p.default);
	if (labelledDefault && labelledDefault.available !== false) return labelledDefault.id;
	const firstAvailable = profiles.find((p) => p.available !== false);
	if (firstAvailable) return firstAvailable.id;
	return profiles[0].id;
}
var GET = async (event) => {
	const requestId = getRequestId(event);
	const authError = requireAdmin(event, requestId);
	if (authError) return authError;
	const state = getState();
	const env = readStackEnv(state.stackDir);
	const ttsBaseURL = env["OP_TTS_BASE_URL"] ?? "";
	const sttBaseURL = env["OP_STT_BASE_URL"] ?? "";
	const profiles = await annotateAddonProfileAvailability(getAddonProfiles(state.homeDir, VOICE_ADDON));
	const selectedProfile = getAddonProfileSelection(state.stackDir, VOICE_ADDON) ?? resolveDefaultProfile(profiles);
	const [sttReachable, ttsReachable] = await Promise.all([probeReachable(sttBaseURL), probeReachable(ttsBaseURL)]);
	return jsonResponse(200, {
		tts: {
			enabled: true,
			engine: env["OP_TTS_ENGINE"] ?? "",
			provider: env["OP_TTS_PROVIDER"] ?? "",
			baseURL: ttsBaseURL,
			model: env["OP_TTS_MODEL"] ?? "",
			voice: env["OP_TTS_VOICE"] ?? ""
		},
		stt: {
			enabled: true,
			engine: env["OP_STT_ENGINE"] ?? "",
			provider: env["OP_STT_PROVIDER"] ?? "",
			baseURL: sttBaseURL,
			model: env["OP_STT_MODEL"] ?? "",
			language: env["OP_STT_LANGUAGE"] ?? ""
		},
		availability: {
			stt: {
				remoteConfigured: Boolean(sttBaseURL),
				remoteReachable: sttReachable
			},
			tts: {
				remoteConfigured: Boolean(ttsBaseURL),
				remoteReachable: ttsReachable
			}
		},
		addon: {
			profiles,
			selectedProfile,
			...getActiveJob(VOICE_ADDON) ? { activeJob: getActiveJob(VOICE_ADDON) } : {}
		}
	}, requestId);
};
function readSection(raw, kind) {
	if (!raw || typeof raw !== "object") return null;
	const section = {
		enabled: raw.enabled !== false,
		engine: typeof raw.engine === "string" ? raw.engine : void 0,
		provider: typeof raw.provider === "string" ? raw.provider : void 0,
		baseURL: typeof raw.baseURL === "string" ? raw.baseURL : void 0,
		model: typeof raw.model === "string" ? raw.model : void 0
	};
	if (kind === "tts" && typeof raw.voice === "string") section.voice = raw.voice;
	if (kind === "stt" && typeof raw.language === "string") section.language = raw.language;
	return section;
}
function voiceHostPort() {
	const raw = process.env.OP_VOICE_PORT_HOST?.trim();
	const n = raw ? Number(raw) : NaN;
	return Number.isFinite(n) && n > 0 ? n : 8880;
}
function openpalmVoiceBaseURL() {
	return `http://127.0.0.1:${voiceHostPort()}`;
}
var OPENPALM_VOICE_TTS_MODEL = "kokoro";
var OPENPALM_VOICE_STT_MODEL = "whisper-1";
var OPENPALM_VOICE_DEFAULT_VOICE = "bf_isabella";
/**
* For `engine === 'openpalm-voice'`, fill in baseURL/model with the addon's
* preset values when the user didn't provide them. This is the auto-config
* that makes "select OpenPalm Voice → Save" Just Work as long as the addon
* is enabled. The user can still override (e.g. point at a different
* voice host on the LAN).
*/
function applyOpenPalmVoicePreset(section, kind) {
	if (section.engine !== "openpalm-voice") return;
	if (!section.baseURL || !section.baseURL.trim()) section.baseURL = openpalmVoiceBaseURL();
	if (!section.model || !section.model.trim()) section.model = kind === "tts" ? OPENPALM_VOICE_TTS_MODEL : OPENPALM_VOICE_STT_MODEL;
	if (kind === "tts" && (!section.voice || !section.voice.trim())) section.voice = OPENPALM_VOICE_DEFAULT_VOICE;
}
function validateSection(section, kind) {
	if (!section || !section.engine) return null;
	if (section.engine === "browser" || section.engine === "browser-stt" || section.engine === "browser-tts") return null;
	if (section.engine.startsWith("skip-")) return null;
	if (!section.baseURL || !section.baseURL.trim()) return `Remote ${kind.toUpperCase()} requires an endpoint URL.`;
	return null;
}
function execFileNoThrow(cmd, args, timeoutMs) {
	return new Promise((resolve) => {
		execFile(cmd, args, { timeout: timeoutMs }, (error, stdout, stderr) => {
			let mergedStderr = stderr?.toString() ?? "";
			const code = error?.code;
			if (code && !mergedStderr) if (code === "ENOENT") mergedStderr = `spawn ${cmd} ENOENT: command not found`;
			else mergedStderr = `spawn ${cmd} ${code}`;
			resolve({
				ok: !error,
				stdout: stdout?.toString() ?? "",
				stderr: mergedStderr
			});
		});
	});
}
/**
* True when the local docker daemon already has the named image cached.
* `docker image inspect` exits 0 only when the image is present locally.
*/
async function dockerImagePresent(imageRef) {
	if (!imageRef) return true;
	return (await execFileNoThrow("docker", [
		"image",
		"inspect",
		imageRef
	], 5e3)).ok;
}
/**
* Heuristic: image tags that include `-cu121` / `-rocm6` / `-cpu` are the
* multi-GB voice images. Show the "this may take a few minutes" toast for
* first pulls so the operator knows the upcoming compose-up isn't stuck.
*/
function isLargeImageTag(imageRef) {
	return /(-cu\d+|-rocm\d+|-cpu)(\s|$|@|\b)/i.test(imageRef);
}
/**
* Read the resolved image for a service from the merged compose config.
* Best-effort — returns "" on any failure so callers can skip the pre-pull
* check rather than blocking save.
*/
async function resolveServiceImage(composeFiles, service) {
	const args = ["compose"];
	for (const f of composeFiles) args.push("-f", f);
	args.push("--project-name", resolveProjectName(), "config", "--format", "json");
	const res = await execFileNoThrow("docker", args, 15e3);
	if (!res.ok) return "";
	try {
		return JSON.parse(res.stdout).services?.[service]?.image ?? "";
	} catch {
		return "";
	}
}
function resolveProjectName() {
	return process.env.OP_PROJECT_NAME?.trim() || process.env.COMPOSE_PROJECT_NAME?.trim() || "openpalm";
}
/**
* Probe a TCP port on 127.0.0.1. Resolves true when the connect succeeds
* within PORT_PROBE_TIMEOUT_MS — meaning something is already listening.
*/
function isPortListening(port) {
	return new Promise((resolve) => {
		const socket = connect({
			host: "127.0.0.1",
			port
		});
		let done = false;
		const finish = (listening) => {
			if (done) return;
			done = true;
			try {
				socket.destroy();
			} catch {}
			resolve(listening);
		};
		socket.setTimeout(PORT_PROBE_TIMEOUT_MS, () => finish(false));
		socket.once("connect", () => finish(true));
		socket.once("error", () => finish(false));
	});
}
/**
* True when a docker container whose name matches openpalm-voice* is
* already running and presumably owns the host port. Used by the port
* pre-flight to avoid false positives when our own voice container is
* the listener.
*/
async function ourVoiceContainerRunning() {
	const res = await execFileNoThrow("docker", [
		"ps",
		"--filter",
		"name=openpalm-voice",
		"--format",
		"{{.Names}}"
	], 5e3);
	if (!res.ok) return false;
	return res.stdout.trim().length > 0;
}
/**
* Read the Docker healthcheck state of a container.
* Returns "starting" while compose's start_period grace window is in
* effect; "healthy" / "unhealthy" / "none" / "" otherwise.
*/
async function readContainerHealthStatus(containerNamePrefix) {
	const name = (await execFileNoThrow("docker", [
		"ps",
		"--filter",
		`name=${containerNamePrefix}`,
		"--format",
		"{{.Names}}"
	], 5e3)).stdout.split("\n").map((s) => s.trim()).find(Boolean);
	if (!name) return "";
	return (await execFileNoThrow("docker", [
		"inspect",
		name,
		"--format",
		"{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}"
	], 5e3)).stdout.trim();
}
/**
* Write a sibling compose overlay that switches `voice-cuda` from the
* legacy `runtime: nvidia` form to the CDI `driver: cdi` form. Caller
* includes it in the composeUp file list ONLY when the host probe
* indicates the runtime is missing but a CDI spec exists.
*
* The canonical compose.yml stays as the runtime-nvidia form (the case
* that needs no manual setup beyond installing nvidia-container-toolkit).
*
* Returns the absolute path of the overlay, or null when there is no
* voice compose overlay to patch.
*/
function writeCdiOverlayIfNeeded(homeDir) {
	const stackDir = join(homeDir, "config", "stack");
	if (!existsSync(join(stackDir, "services.compose.yml"))) return null;
	const overlayPath = join(stackDir, "voice.compose.cdi.yml");
	writeFileSync(overlayPath, [
		"# Generated overlay — switches voice-cuda from runtime:nvidia to CDI.",
		"# Applied only when the host probe shows the legacy NVIDIA runtime is",
		"# missing but /etc/cdi/nvidia.yaml is present.",
		"services:",
		"  voice-cuda:",
		"    runtime: \"\"",
		"    deploy:",
		"      resources:",
		"        reservations:",
		"          devices:",
		"            - driver: cdi",
		"              device_ids:",
		"                - nvidia.com/gpu=all",
		""
	].join("\n"));
	return overlayPath;
}
/**
* Detect rootless Docker. The compose `user: "${OP_UID:-1000}:${OP_GID:-1000}"`
* directive bakes the host UID into the container — but on a rootless
* daemon the bind-mount UID inside the container is subuid-remapped, so
* the resulting container UID has no write permission against
* `${OP_HOME}/data/voice/models`. Removing the `user:` directive lets
* Docker pick whatever UID the rootless mapping translates to inside the
* user namespace, which DOES have write access to the bind-mount.
*
* `docker info` is the authoritative source: rootless daemons advertise
* `SecurityOptions: ... name=rootless` and `CgroupDriver: ... rootless`.
* We accept either signal.
*/
async function detectRootlessDocker() {
	const res = await execFileNoThrow("docker", [
		"info",
		"--format",
		"{{json .}}"
	], 5e3);
	if (!res.ok || !res.stdout) return false;
	try {
		const parsed = JSON.parse(res.stdout);
		if ((Array.isArray(parsed.SecurityOptions) ? parsed.SecurityOptions.map((s) => String(s)) : []).some((s) => /name=rootless/i.test(s))) return true;
		if (typeof parsed.CgroupDriver === "string" && /rootless/i.test(parsed.CgroupDriver)) return true;
		return false;
	} catch {
		return /name=rootless|cgroup\s*driver:.*rootless/i.test(res.stdout);
	}
}
/**
* Write a sibling overlay that drops the `user:` directive from each
* voice service. Mirrors writeCdiOverlayIfNeeded: caller includes the
* returned path in composeUp's file list. Returns null when there is
* no voice compose overlay to patch (so the file list stays valid and Docker
* doesn't blow up on a missing -f arg).
*/
function writeRootlessOverlayIfNeeded(homeDir) {
	const stackDir = join(homeDir, "config", "stack");
	if (!existsSync(join(stackDir, "services.compose.yml"))) return null;
	const overlayPath = join(stackDir, "voice.compose.rootless.yml");
	writeFileSync(overlayPath, [
		"# Generated overlay — removes the `user:` directive from voice services.",
		"# Applied only when `docker info` reports a rootless daemon. On rootless",
		"# Docker the compose-baked UID has no write access to the bind-mounted",
		"# state directory; letting Docker pick the namespaced UID restores it.",
		"services:",
		"  voice:",
		"    user: null",
		"  voice-cuda:",
		"    user: null",
		"  voice-rocm:",
		"    user: null",
		""
	].join("\n"));
	return overlayPath;
}
var PUT = (event) => {
	return withSerialQueue("admin:voice:put", () => handlePut(event));
};
async function handlePut(event) {
	const requestId = getRequestId(event);
	const authError = requireAdmin(event, requestId);
	if (authError) return authError;
	const state = getState();
	let body;
	try {
		body = await event.request.json();
	} catch {
		return errorResponse(400, "Bad Request", "Invalid JSON body", {}, requestId);
	}
	if (!body || typeof body !== "object") return errorResponse(400, "Bad Request", "Body must be an object", {}, requestId);
	const b = body;
	const ttsSection = readSection(b.tts, "tts");
	const sttSection = readSection(b.stt, "stt");
	if (ttsSection) applyOpenPalmVoicePreset(ttsSection, "tts");
	if (sttSection) applyOpenPalmVoicePreset(sttSection, "stt");
	const ttsErr = validateSection(ttsSection, "tts");
	if (ttsErr) return errorResponse(400, "invalid_tts", ttsErr, {}, requestId);
	const sttErr = validateSection(sttSection, "stt");
	if (sttErr) return errorResponse(400, "invalid_stt", sttErr, {}, requestId);
	const config = {};
	if (ttsSection) config.tts = {
		enabled: ttsSection.enabled,
		engine: ttsSection.engine,
		provider: ttsSection.provider,
		baseURL: ttsSection.baseURL,
		model: ttsSection.model,
		voice: ttsSection.voice
	};
	if (sttSection) config.stt = {
		enabled: sttSection.enabled,
		engine: sttSection.engine,
		provider: sttSection.provider,
		baseURL: sttSection.baseURL,
		model: sttSection.model,
		language: sttSection.language
	};
	writeVoiceVars(config, state.stackDir);
	if (!(ttsSection?.engine === "openpalm-voice" || sttSection?.engine === "openpalm-voice")) {
		if (listEnabledAddonIds(state.homeDir).includes(VOICE_ADDON)) try {
			const voiceServiceNames = getAddonProfiles(state.homeDir, VOICE_ADDON).flatMap((p) => p.services);
			const unique = Array.from(new Set(voiceServiceNames));
			if (unique.length > 0) await composeStop(unique, buildComposeOptions(state));
		} catch (e) {
			console.warn("[voice] composeStop on disengage failed:", e);
		}
		return jsonResponse(200, { ok: true }, requestId);
	}
	const availableProfiles = await annotateAddonProfileAvailability(getAddonProfiles(state.homeDir, VOICE_ADDON));
	const requestedProfile = typeof b.profile === "string" ? b.profile.trim() : "";
	let activeProfile = null;
	if (requestedProfile) {
		if (!availableProfiles.some((p) => p.id === requestedProfile)) return errorResponse(400, "invalid_profile", `Unknown voice profile "${requestedProfile}". Available: ${availableProfiles.map((p) => p.id).join(", ") || "(none)"}`, {}, requestId);
		activeProfile = requestedProfile;
		setAddonProfileSelection(state.stackDir, VOICE_ADDON, activeProfile);
	} else activeProfile = getAddonProfileSelection(state.stackDir, VOICE_ADDON) ?? resolveDefaultProfile(availableProfiles);
	const wasAlreadyEnabled = listEnabledAddonIds(state.homeDir).includes(VOICE_ADDON);
	const steps = [];
	if (!wasAlreadyEnabled) try {
		setAddonEnabled(state.homeDir, state.stackDir, VOICE_ADDON, true, state);
		steps.push({
			step: "enable",
			ok: true
		});
	} catch (e) {
		const detail = e instanceof Error ? e.message : String(e);
		steps.push({
			step: "enable",
			ok: false,
			detail
		});
		return jsonResponse(502, {
			ok: false,
			voiceAddon: {
				wasAlreadyEnabled,
				steps,
				error: `Could not enable voice addon: ${detail}`
			}
		}, requestId);
	}
	else steps.push({
		step: "enable",
		ok: true,
		detail: "already enabled"
	});
	const hostPort = voiceHostPort();
	const inVitest = !!process.env.VITEST;
	if (inVitest ? false : await isPortListening(hostPort)) {
		if (!await ourVoiceContainerRunning()) {
			const msg = translateDockerError(`Bind for 127.0.0.1:${hostPort} failed: port is already allocated`);
			steps.push({
				step: "port-check",
				ok: false,
				detail: msg
			});
			return jsonResponse(502, {
				ok: false,
				voiceAddon: {
					wasAlreadyEnabled,
					steps,
					error: msg
				}
			}, requestId);
		}
		steps.push({
			step: "port-check",
			ok: true,
			detail: "our container is the listener"
		});
	} else steps.push({
		step: "port-check",
		ok: true
	});
	const profileServices = activeProfile ? availableProfiles.find((p) => p.id === activeProfile)?.services ?? [] : [];
	const services = profileServices.length > 0 ? profileServices : [VOICE_ADDON];
	const composeFilesBase = buildComposeOptions(state).files;
	const primaryService = services[0];
	let backgroundPull = false;
	if (primaryService && !inVitest) {
		const imageRef = await resolveServiceImage(composeFilesBase, primaryService);
		if (imageRef && isLargeImageTag(imageRef)) {
			if (!await dockerImagePresent(imageRef)) {
				backgroundPull = true;
				steps.push({
					step: "pulling",
					ok: true,
					detail: "first-time download — several minutes for several GB"
				});
			}
		}
	}
	const extraFiles = [];
	const cdiFallbackSupported = process.platform !== "win32";
	if (activeProfile === addonProfileId(VOICE_ADDON, "cuda") && !inVitest && cdiFallbackSupported) {
		const runtimeMissing = (await getAddonProfileAvailability({ id: addonProfileId(VOICE_ADDON, "cuda") })).available === false || !await dockerHasNvidiaRuntime();
		const cdiSpecPresent = existsSync("/etc/cdi/nvidia.yaml");
		if (runtimeMissing && cdiSpecPresent) {
			const overlay = writeCdiOverlayIfNeeded(state.homeDir);
			if (overlay) {
				extraFiles.push(overlay);
				steps.push({
					step: "cdi-fallback",
					ok: true,
					detail: "using CDI device reservation"
				});
			}
		}
	}
	if (!inVitest) try {
		if (await detectRootlessDocker()) {
			const overlay = writeRootlessOverlayIfNeeded(state.homeDir);
			if (overlay) {
				extraFiles.push(overlay);
				steps.push({
					step: "rootless-fallback",
					ok: true,
					detail: "dropping user: directive for rootless Docker"
				});
			}
		}
	} catch (e) {
		console.warn("[voice] rootless detection failed:", e);
	}
	if (backgroundPull) {
		setJob(VOICE_ADDON, {
			state: "pulling",
			steps: [...steps],
			startedAt: Date.now(),
			profile: activeProfile ?? void 0,
			finishedAt: void 0,
			error: void 0
		});
		runBringUpJob({
			state,
			services,
			activeProfile,
			extraFiles,
			availableProfiles,
			baseSteps: [...steps]
		});
		return jsonResponse(202, {
			ok: true,
			voiceAddon: {
				wasAlreadyEnabled,
				status: "pulling",
				steps,
				message: "Voice image is downloading in the background (~2–8 GB). Poll GET /admin/voice for progress; UI auto-refreshes."
			}
		}, requestId);
	}
	const outcome = await runBringUp({
		state,
		services,
		activeProfile,
		extraFiles,
		availableProfiles,
		steps
	});
	if (!outcome.composeOk) return jsonResponse(502, {
		ok: false,
		voiceAddon: {
			wasAlreadyEnabled,
			steps: outcome.steps,
			error: `Voice addon failed to start: ${outcome.composeErr ?? "unknown error"}`
		}
	}, requestId);
	return jsonResponse(outcome.healthy || outcome.warming ? 200 : 502, {
		ok: outcome.healthy || outcome.warming,
		voiceAddon: {
			wasAlreadyEnabled,
			steps: outcome.steps,
			...outcome.warming ? { warming: true } : {},
			...outcome.healthy || outcome.warming ? {} : { error: "Voice addon is starting but did not become healthy in time." }
		}
	}, requestId);
}
/**
* Inline composeStop-other-profiles + composeUp + /health poll. Returns
* the terminal state. Pushed `steps` get mutated in place so the caller
* (sync or background) can read progress as it happens.
*/
async function runBringUp(input) {
	const { state, services, activeProfile, extraFiles, availableProfiles, steps } = input;
	let composeOk = true;
	let composeErr;
	try {
		const otherProfileServices = availableProfiles.filter((p) => p.id !== activeProfile).flatMap((p) => p.services).filter((svc) => !services.includes(svc));
		if (otherProfileServices.length > 0) try {
			await composeStop(otherProfileServices, buildComposeOptions(state));
		} catch (e) {
			console.warn("[voice] composeStop other profiles failed:", e);
		}
		const baseOpts = buildComposeOptions(state);
		const result = await composeUp({
			...baseOpts,
			files: [...baseOpts.files, ...extraFiles],
			services,
			forceRecreate: true,
			...activeProfile ? { profiles: [activeProfile] } : {}
		});
		composeOk = result.ok;
		if (!result.ok) composeErr = translateDockerError(parseComposeStderr(result.stderr).find((f) => services.includes(f.service))?.reason ?? result.stderr ?? `compose up exited ${result.code}`);
	} catch (e) {
		composeOk = false;
		composeErr = translateDockerError(e instanceof Error ? e.message : String(e));
	}
	steps.push({
		step: "compose-up",
		ok: composeOk,
		...composeErr ? { detail: composeErr.slice(0, 500) } : {}
	});
	if (!composeOk) return {
		composeOk,
		composeErr,
		healthy: false,
		warming: false,
		steps
	};
	const probeUrl = `${openpalmVoiceBaseURL()}/health`;
	const deadline = Date.now() + VOICE_PROBE_TIMEOUT_MS;
	let healthy = false;
	while (Date.now() < deadline) {
		try {
			if ((await fetch(probeUrl, { signal: AbortSignal.timeout(1500) })).ok) {
				healthy = true;
				break;
			}
		} catch {}
		await new Promise((r) => setTimeout(r, VOICE_PROBE_INTERVAL_MS));
	}
	let warming = false;
	if (!healthy) try {
		if (await readContainerHealthStatus("openpalm-voice") === "starting") warming = true;
	} catch {}
	steps.push({
		step: "healthy",
		ok: healthy || warming,
		...healthy ? {} : warming ? { detail: "still warming up — refresh in a moment" } : { detail: `did not respond at ${probeUrl} within ${VOICE_PROBE_TIMEOUT_MS / 1e3}s` }
	});
	return {
		composeOk,
		healthy,
		warming,
		steps
	};
}
/**
* Background variant: runs runBringUp and persists state transitions
* into the activeJobs map. Returns nothing — the UI polls GET
* /admin/voice to observe completion.
*/
async function runBringUpJob(input) {
	const steps = [...input.baseSteps];
	try {
		setJob(VOICE_ADDON, {
			state: "starting",
			steps
		});
		const outcome = await runBringUp({
			...input,
			steps
		});
		if (!outcome.composeOk) {
			setJob(VOICE_ADDON, {
				state: "error",
				steps: outcome.steps,
				error: `Voice addon failed to start: ${outcome.composeErr ?? "unknown error"}`,
				finishedAt: Date.now()
			});
			return;
		}
		setJob(VOICE_ADDON, {
			state: outcome.healthy ? "healthy" : outcome.warming ? "starting" : "error",
			steps: outcome.steps,
			...outcome.healthy || outcome.warming ? { error: void 0 } : { error: "Voice addon is starting but did not become healthy in time." },
			finishedAt: Date.now()
		});
	} catch (e) {
		setJob(VOICE_ADDON, {
			state: "error",
			steps,
			error: e instanceof Error ? e.message : String(e),
			finishedAt: Date.now()
		});
	}
}
/**
* Lightweight wrapper around `docker info` to check whether the
* `nvidia` runtime is registered. Used as a second signal alongside the
* cached canonical CUDA profile availability result.
*/
async function dockerHasNvidiaRuntime() {
	const res = await execFileNoThrow("docker", [
		"info",
		"--format",
		"{{json .Runtimes}}"
	], 2e3);
	return res.ok && res.stdout.includes("\"nvidia\"");
}

export { GET, PUT };
//# sourceMappingURL=_server.ts-mz0P4bQg.js.map
