feat(studio): run nitro scans in the background with cancel-re-attach and nightly auto-clean

This commit is contained in:
openhands committed 2026-09-19 13:41:14 +02:00
1 parent 9d571e0c29
commit 6c53f4680c
14 files changed
+1547 -303

No files matched your search

@@ -3,7 +3,10 @@ import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { clearFurniImportCache } from "@/lib/services/furni-import-cache";
import { autoCleanFakeNitros } from "@/lib/services/nitro-cleanup";
import {
autoCleanFakeNitros,
finishAutoClean,
} from "@/lib/services/nitro-cleanup";
/** Automatically remove fake .nitro leftovers older than `maxAgeDays`. */
export const POST = withAdmin(
@@ -21,6 +24,11 @@ export const POST = withAdmin(
: 30;
try {
const result = await autoCleanFakeNitros(maxAgeDays);
await finishAutoClean({
result,
maxAgeDays,
triggeredBy: "manual",
});
clearFurniImportCache();
if (result.deleted > 0) {
logAudit({
@@ -0,0 +1,13 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { cancelScanSession } from "@/lib/services/nitro-scan-session";
/**
* Abort the running background scan (in-process, or by marking the session
* cancelled when the process that owns it is not the one serving the request).
*/
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
const session = await cancelScanSession();
return apiOk({ session });
});
@@ -0,0 +1,10 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { readAutoCleanHistory } from "@/lib/services/nitro-cleanup";
/** Auto-clean history (manual + nightly scheduled runs), newest first. */
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
const history = await readAutoCleanHistory();
return apiOk({ history });
});
@@ -5,6 +5,7 @@ import { logAudit } from "@/lib/services/audit";
import { clearFurniImportCache } from "@/lib/services/furni-import-cache";
import {
type NitroRepairProgress,
refreshScanCacheAfterMutation,
repairBrokenNitros,
} from "@/lib/services/nitro-cleanup";
@@ -54,7 +55,15 @@ export const POST = withAdmin(
repaired: number;
failed: number;
errors: string[];
files: Array<{ fileName: string; ok: boolean }>;
}) => {
// Repaired bundles are no longer broken — drop them from the cached
// scan (names/digests unchanged, so the cache otherwise stays valid).
await refreshScanCacheAfterMutation(
"nitro",
result.files.filter((file) => file.ok).map((file) => file.fileName),
0,
);
clearFurniImportCache();
if (result.repaired > 0) {
logAudit({
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,49 @@
import { apiError, apiJson, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import {
NitroScanConflictError,
runScanInBackground,
} from "@/lib/services/nitro-scan-session";
/**
* Start (or re-attach to) the furniture asset scan in the background. The scan
* keeps running detached in the server process once this returns, so closing
* the tab does not lose it. Clients poll GET `/session` for progress and fetch
* the result through GET `/data`.
*
* @returns 200 with the started session, or 409 with `conflict: true` and the
* in-flight session when a fresh scan is already running (<= the 60s stale
* window) — the client should attach to that instead of starting a new one.
*/
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
let body: { force?: unknown };
try {
body = await request.json();
} catch {
body = {};
}
const force = body?.force === true;
try {
const session = await runScanInBackground({ force });
logAudit({
userId: ctx.session.user.id,
action: "nitro_cleanup_scan",
target: "AssetFiles",
after: { force, sessionId: session.id },
});
return apiOk({ session });
} catch (err) {
if (err instanceof NitroScanConflictError) {
return apiJson(
{ ok: true, session: err.session, conflict: true },
{ status: 409 },
);
}
return apiError(`Failed to start scan: ${(err as Error).message}`, 500);
}
},
);
@@ -0,0 +1,14 @@
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { readScanSession } from "@/lib/services/nitro-scan-session";
/**
* Current scan session (or null when no scan ran/stored yet). Polled by the
* Studio panel while a background scan is running so a closed-tab scan can be
* re-attached after a refresh.
*/
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
const session = await readScanSession();
return apiOk({ session });
});