feat(jobs)!: SnapOtter 2.0 phase 2 job spine: async queues, worker pools, object storage, admin dashboard (#217)

This commit is contained in:
SnapOtter
2026-06-13 10:17:13 +08:00
parent 1c724d5d21
commit c451b939c7
130 changed files with 10438 additions and 3592 deletions
+57 -10
View File
@@ -1,10 +1,11 @@
/**
* 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
* 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";
@@ -30,6 +31,9 @@ import {
getInstallScriptPath,
getManifestPath,
getModelsDir,
ImportLockError,
ImportValidationError,
importBundleArchive,
invalidateCache,
isDockerEnvironment,
isFeatureInstalled,
@@ -101,7 +105,7 @@ function getDirSize(dirPath: string): number {
}
export async function registerFeatureRoutes(app: FastifyInstance): Promise<void> {
// GET /api/v1/features List feature bundles and their statuses
// 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;
@@ -125,7 +129,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
return reply.send({ bundles: getFeatureStates() });
});
// POST /api/v1/admin/features/:bundleId/install Install a feature bundle
// 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) => {
@@ -204,7 +208,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
});
}
} catch {
// Not JSON progress rembg/pip output noise, keep in lastStderrLines for error reporting
// Not JSON progress - rembg/pip output noise, keep in lastStderrLines for error reporting
}
}
});
@@ -224,7 +228,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
});
} else {
// Extract the structured error from Python's fail() function first.
// fail() writes {"error": "..."} to stderr prefer this over raw lines.
// 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];
@@ -276,7 +280,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
},
);
// POST /api/v1/admin/features/:bundleId/uninstall Uninstall a feature bundle
// 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) => {
@@ -351,7 +355,7 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
},
);
// GET /api/v1/admin/features/disk-usage Get AI model disk usage
// 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) => {
@@ -362,4 +366,47 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
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<ReturnType<FastifyRequest["file"]>>;
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;
}
},
);
}