mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add feature install/uninstall API routes with SSE progress
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, ManifestBundle>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
// 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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user