/** * Feature bundle management routes. * * GET /api/v1/features - List feature bundles and their statuses * POST /api/v1/admin/features/:bundleId/install - Install a feature bundle (async) * POST /api/v1/admin/features/:bundleId/uninstall - Uninstall a feature bundle * GET /api/v1/admin/features/disk-usage - Get AI model disk usage * POST /api/v1/admin/features/import - Import an offline bundle archive */ import { spawn } from "node:child_process"; import crypto from "node:crypto"; import { type Dirent, existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, } from "node:fs"; import { join } from "node:path"; import { shutdownDispatcher } from "@snapotter/ai"; import { ANALYTICS_EVENTS, FEATURE_BUNDLES } from "@snapotter/shared"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { trackEvent } from "../lib/analytics.js"; import { acquireInstallLock, getAiDir, getFeatureStates, getInstallScriptPath, getManifestPath, getModelsDir, ImportLockError, ImportValidationError, importBundleArchive, invalidateCache, isDockerEnvironment, isFeatureInstalled, markUninstalled, releaseInstallLock, setInstallProgress, verifyBundleModels, } from "../lib/feature-status.js"; import { requirePermission } from "../permissions.js"; import { requireAuth } from "../plugins/auth.js"; import { updateSingleFileProgress } from "./progress.js"; const venvPath = process.env.PYTHON_VENV_PATH || "/opt/venv"; const pythonPath = `${venvPath}/bin/python3`; interface BundleIdParams { bundleId: string; } interface ManifestModel { id: string; path?: string; downloadFn?: string; args?: string[]; } interface ManifestBundle { models: ManifestModel[]; } interface Manifest { bundles: Record; } function readManifest(): Manifest | null { const manifestPath = getManifestPath(); if (!existsSync(manifestPath)) return null; try { return JSON.parse(readFileSync(manifestPath, "utf-8")) as Manifest; } catch { return null; } } /** Recursively calculate total size of a directory in bytes. */ function getDirSize(dirPath: string): number { if (!existsSync(dirPath)) return 0; let total = 0; let entries: Dirent[]; try { entries = readdirSync(dirPath, { withFileTypes: true }); } catch { return 0; } for (const entry of entries) { const fullPath = join(dirPath, entry.name); if (entry.isDirectory()) { total += getDirSize(fullPath); } else if (entry.isFile()) { try { total += statSync(fullPath).size; } catch { // File may have been deleted between readdir and stat } } } return total; } export async function registerFeatureRoutes(app: FastifyInstance): Promise { // GET /api/v1/features - List feature bundles and their statuses app.get("/api/v1/features", async (request: FastifyRequest, reply: FastifyReply) => { const user = requireAuth(request, reply); if (!user) return; // In non-Docker environments, all bundles are available natively if (!isDockerEnvironment()) { const bundles = Object.values(FEATURE_BUNDLES).map((bundle) => ({ id: bundle.id, name: bundle.name, description: bundle.description, status: "installed" as const, installedVersion: null, estimatedSize: bundle.estimatedSize, enablesTools: bundle.enablesTools, progress: null, error: null, })); return reply.send({ bundles }); } return reply.send({ bundles: getFeatureStates() }); }); // POST /api/v1/admin/features/:bundleId/install - Install a feature bundle app.post( "/api/v1/admin/features/:bundleId/install", async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => { const admin = await requirePermission("features:manage")(request, reply); if (!admin) return; const { bundleId } = request.params; if (!FEATURE_BUNDLES[bundleId]) { return reply.status(404).send({ error: `Unknown bundle: ${bundleId}` }); } if (isFeatureInstalled(bundleId)) { const modelError = verifyBundleModels(bundleId); if (!modelError) { return reply.status(409).send({ error: `Bundle "${bundleId}" is already installed` }); } markUninstalled(bundleId); } if (!acquireInstallLock(bundleId)) { return reply.status(409).send({ error: "Another install is already in progress" }); } const jobId = crypto.randomUUID(); const scriptPath = getInstallScriptPath(); const manifestPath = getManifestPath(); const modelsDir = getModelsDir(); const installStartTime = Date.now(); const reqRef = request; const child = spawn(pythonPath, [scriptPath, bundleId, manifestPath, modelsDir], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, BUNDLE_ID: bundleId, PIP_CACHE_DIR: join(getAiDir(), "pip-cache"), }, }); let stderrBuffer = ""; let stdoutBuffer = ""; const lastStderrLines: string[] = []; child.stdout.on("data", (chunk: Buffer) => { stdoutBuffer += chunk.toString(); }); child.stderr.on("data", (chunk: Buffer) => { stderrBuffer += chunk.toString(); const lines = stderrBuffer.split("\n"); stderrBuffer = lines.pop() ?? ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; lastStderrLines.push(trimmed); if (lastStderrLines.length > 20) lastStderrLines.shift(); try { const parsed = JSON.parse(trimmed) as { progress?: number; stage?: string }; if (typeof parsed.progress === "number") { setInstallProgress( bundleId, { percent: parsed.progress, stage: parsed.stage ?? "" }, null, ); updateSingleFileProgress({ jobId, phase: "processing", percent: parsed.progress, stage: parsed.stage, }); } } catch { // Not JSON progress - rembg/pip output noise, keep in lastStderrLines for error reporting } } }); child.on("close", (code) => { releaseInstallLock(); if (code === 0) { invalidateCache(); shutdownDispatcher(); setInstallProgress(null, null, null); updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" }); trackEvent(reqRef, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { bundle_id: bundleId, action: "installed", duration_ms: Date.now() - installStartTime, }); } else { // Extract the structured error from Python's fail() function first. // fail() writes {"error": "..."} to stderr - prefer this over raw lines. let errorMsg: string | undefined; for (let i = lastStderrLines.length - 1; i >= 0; i--) { const line = lastStderrLines[i]; if (line.startsWith("{")) { try { const parsed = JSON.parse(line) as Record; if (typeof parsed.error === "string") { errorMsg = parsed.error; break; } } catch { // Not valid JSON } } } if (!errorMsg) { if (code === 137) { errorMsg = "Installation was killed due to insufficient memory. " + "Try increasing the container's memory limit (e.g. mem_limit: 6g in docker-compose.yml) and retry."; } else { const meaningful = lastStderrLines.filter( (l) => !l.startsWith("{") && !l.includes("pthread_setaffinity_np") && !l.includes("\x1b[") && !l.includes("━") && !/^\s*\d+%\|/.test(l), ); errorMsg = meaningful.join("\n") || stdoutBuffer.trim() || `Install failed with exit code ${code}`; } } setInstallProgress(bundleId, null, errorMsg); updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg }); } }); child.on("error", (err) => { releaseInstallLock(); const errorMsg = `Failed to spawn install process: ${err.message}`; setInstallProgress(bundleId, null, errorMsg); updateSingleFileProgress({ jobId, phase: "failed", percent: 0, error: errorMsg }); }); return reply.status(202).send({ jobId }); }, ); // POST /api/v1/admin/features/:bundleId/uninstall - Uninstall a feature bundle app.post( "/api/v1/admin/features/:bundleId/uninstall", async (request: FastifyRequest<{ Params: BundleIdParams }>, reply: FastifyReply) => { const admin = await requirePermission("features:manage")(request, reply); if (!admin) return; const { bundleId } = request.params; if (!FEATURE_BUNDLES[bundleId]) { return reply.status(404).send({ error: `Unknown bundle: ${bundleId}` }); } if (!isFeatureInstalled(bundleId)) { return reply.status(409).send({ error: `Bundle "${bundleId}" is not installed` }); } const manifest = readManifest(); if (manifest) { const manifestBundle = manifest.bundles[bundleId]; if (manifestBundle) { const protectedFiles = new Set(); const protectedDirs = new Set(); for (const [otherId, otherBundle] of Object.entries(manifest.bundles)) { if (otherId === bundleId || !isFeatureInstalled(otherId)) continue; for (const m of otherBundle.models ?? []) { if (m.path) protectedFiles.add(m.path); if (m.downloadFn === "rembg_session" && m.args?.[0]) { protectedFiles.add(`rembg/${m.args[0]}.onnx`); } if (m.downloadFn === "hf_snapshot" && m.args?.[1]) { protectedDirs.add(m.args[1]); } } } const modelsDir = getModelsDir(); for (const model of manifestBundle.models) { try { if (model.path && !protectedFiles.has(model.path)) { const modelPath = join(modelsDir, model.path); if (existsSync(modelPath)) unlinkSync(modelPath); } else if (model.downloadFn === "rembg_session" && model.args?.[0]) { const relPath = `rembg/${model.args[0]}.onnx`; if (!protectedFiles.has(relPath)) { const filePath = join(modelsDir, relPath); if (existsSync(filePath)) unlinkSync(filePath); } } else if (!model.path && model.downloadFn === "hf_snapshot" && model.args?.[1]) { const subdir = model.args[1]; if (!protectedDirs.has(subdir)) { const dirPath = join(modelsDir, subdir); if (existsSync(dirPath)) rmSync(dirPath, { recursive: true, force: true }); } } } catch { // Best-effort deletion } } } } markUninstalled(bundleId); shutdownDispatcher(); trackEvent(request, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { bundle_id: bundleId, action: "uninstalled", duration_ms: 0, }); return reply.send({ ok: true }); }, ); // GET /api/v1/admin/features/disk-usage - Get AI model disk usage app.get( "/api/v1/admin/features/disk-usage", async (request: FastifyRequest, reply: FastifyReply) => { const admin = await requirePermission("features:manage")(request, reply); if (!admin) return; const totalBytes = getDirSize(getAiDir()); return reply.send({ totalBytes }); }, ); // POST /api/v1/admin/features/import - Import an offline bundle archive app.post( "/api/v1/admin/features/import", async (request: FastifyRequest, reply: FastifyReply) => { const admin = await requirePermission("features:manage")(request, reply); if (!admin) return; let part: Awaited>; try { part = await request.file(); } catch { return reply.status(400).send({ error: "Expected a multipart file upload" }); } if (!part?.file) { return reply.status(400).send({ error: "No file provided" }); } try { const result = await importBundleArchive(part.file); invalidateCache(); shutdownDispatcher(); trackEvent(request, ANALYTICS_EVENTS.AI_BUNDLE_ACTION, { bundle_id: result.bundleId, action: "imported", duration_ms: 0, }); return reply.send({ bundleId: result.bundleId, version: result.version, }); } catch (err) { if (err instanceof ImportLockError) { return reply.status(409).send({ error: err.message }); } if (err instanceof ImportValidationError) { return reply.status(400).send({ error: err.message }); } throw err; } }, ); }