From cadb0f80719c69cfdb2b789c1e38b953c34c73cb Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Sat, 18 Apr 2026 02:34:21 +0800 Subject: [PATCH] feat: add feature install/uninstall API routes with SSE progress --- apps/api/src/index.ts | 9 ++ apps/api/src/routes/features.ts | 257 ++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 apps/api/src/routes/features.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8d860dde..a8bac475 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,6 +7,7 @@ import { env } from "./config.js"; import { db, schema } from "./db/index.js"; import { runMigrations } from "./db/migrate.js"; import { startCleanupCron } from "./lib/cleanup.js"; +import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js"; import { shutdownWorkerPool } from "./lib/worker-pool.js"; import { authMiddleware, authRoutes, ensureDefaultAdmin, requireAdmin } from "./plugins/auth.js"; import { registerStatic } from "./plugins/static.js"; @@ -15,6 +16,7 @@ import { apiKeyRoutes } from "./routes/api-keys.js"; import { registerBatchRoutes } from "./routes/batch.js"; import { brandingRoutes } from "./routes/branding.js"; import { docsRoutes } from "./routes/docs.js"; +import { registerFeatureRoutes } from "./routes/features.js"; import { fileRoutes } from "./routes/files.js"; import { registerPipelineRoutes } from "./routes/pipeline.js"; import { recoverStaleJobs, registerProgressRoutes } from "./routes/progress.js"; @@ -33,6 +35,10 @@ await ensureDefaultAdmin(); // Mark any jobs left in processing/queued from a previous unclean shutdown recoverStaleJobs(); +// Set up AI feature directories and recover from interrupted installs +ensureAiDirs(); +recoverInterruptedInstalls(); + const app = Fastify({ logger: { level: env.LOG_LEVEL }, bodyLimit: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024, @@ -113,6 +119,9 @@ await apiKeyRoutes(app); // Settings routes await settingsRoutes(app); +// Feature management routes (AI feature bundle install/uninstall) +await registerFeatureRoutes(app); + // Branding routes (logo upload/serve/delete) await brandingRoutes(app); diff --git a/apps/api/src/routes/features.ts b/apps/api/src/routes/features.ts new file mode 100644 index 00000000..6c7672fd --- /dev/null +++ b/apps/api/src/routes/features.ts @@ -0,0 +1,257 @@ +/** + * 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 + */ + +import { spawn } from "node:child_process"; +import crypto from "node:crypto"; +import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { shutdownDispatcher } from "@ashim/ai"; +import { FEATURE_BUNDLES } from "@ashim/shared"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { + acquireInstallLock, + getAiDir, + getFeatureStates, + getManifestPath, + getModelsDir, + invalidateCache, + isDockerEnvironment, + isFeatureInstalled, + markUninstalled, + releaseInstallLock, + setInstallProgress, +} from "../lib/feature-status.js"; +import { requireAdmin, 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; +} + +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; + const entries = readdirSync(dirPath, { withFileTypes: true }); + 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 = requireAdmin(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 already installed` }); + } + + if (!acquireInstallLock(bundleId)) { + return reply.status(409).send({ error: "Another install is already in progress" }); + } + + const jobId = crypto.randomUUID(); + const scriptPath = join(process.cwd(), "packages/ai/python/install_feature.py"); + + const child = spawn(pythonPath, [scriptPath, bundleId], { + stdio: ["ignore", "ignore", "pipe"], + env: { + ...process.env, + BUNDLE_ID: bundleId, + }, + }); + + let stderrBuffer = ""; + + child.stderr.on("data", (chunk: Buffer) => { + stderrBuffer += chunk.toString(); + + // Process complete lines + const lines = stderrBuffer.split("\n"); + // Keep the last incomplete line in the buffer + stderrBuffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + 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 — ignore non-progress stderr output + } + } + }); + + child.on("close", (code) => { + releaseInstallLock(); + + if (code === 0) { + invalidateCache(); + shutdownDispatcher(); + setInstallProgress(bundleId, { percent: 100, stage: "Complete" }, null); + updateSingleFileProgress({ jobId, phase: "complete", percent: 100, stage: "Complete" }); + } else { + const errorMsg = `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 = requireAdmin(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` }); + } + + // Read manifest to find model files to delete + const manifest = readManifest(); + if (manifest) { + const manifestBundle = manifest.bundles[bundleId]; + if (manifestBundle) { + const modelsDir = getModelsDir(); + for (const model of manifestBundle.models) { + if (!model.path) continue; + const modelPath = join(modelsDir, model.path); + try { + if (existsSync(modelPath)) { + unlinkSync(modelPath); + } + } catch { + // Best-effort deletion + } + } + } + } + + markUninstalled(bundleId); + shutdownDispatcher(); + + 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 = requireAdmin(request, reply); + if (!admin) return; + + const totalBytes = getDirSize(getAiDir()); + return reply.send({ totalBytes }); + }, + ); +}