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

+31
View File
@@ -19,6 +19,7 @@ import {
parseDfOutput,
} from "../src/lib/services/disk-usage";
import { publishDueArticles } from "../src/lib/services/news-scheduler";
import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup";
import { rcon } from "../src/lib/services/rcon";
function captureWorkerError(err: unknown, context: string): void {
@@ -353,6 +354,27 @@ async function publishScheduledArticles(): Promise<void> {
}
}
/** Nightly self-cleaning of old fake .nitro leftovers (skips when a scan is
* running in the studio). Requires the `items_base` truth list, so a DB outage
* shows up here as a captured error instead of deleting anything. */
async function autoCleanNitroFakes(): Promise<void> {
try {
const result = await scheduledAutoCleanFakeNitros(30);
if (result.skipped) {
logger.info("Skipped nightly nitro auto-clean: scan in progress", {
module: "jobs",
});
} else {
logger.info("Nightly nitro auto-clean finished", {
module: "jobs",
deleted: result.deleted,
});
}
} catch (err) {
captureWorkerError(err, "Nitro auto-clean failed");
}
}
async function reportWorkerHeartbeat(): Promise<void> {
if (!redis) return;
await redis
@@ -433,6 +455,15 @@ async function main() {
void reportWorkerHeartbeat();
});
await reportWorkerHeartbeat();
new Cron("0 2 * * *", () => {
autoCleanNitroFakes().catch((e) =>
captureWorkerError(e, "Nitro auto-clean error"),
);
});
logger.info("Scheduled: nitro auto-clean (daily 02:00)", {
module: "jobs",
});
await Promise.all([
backupEmulatorJar(),
cleanupOldLogs(),
@@ -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 });
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+72
View File
@@ -6497,6 +6497,78 @@
},
"admin": {
"studio": {
"nitroCleanup": {
"alreadyRunning": "A scan is already running — attached to it.",
"ageUnknown": "age unknown",
"ageBelow": "< 1d",
"scanButton": "Scan furniture assets",
"scanningAssets": "Scanning assets…",
"cancelButton": "Cancel",
"deleteSelected": "Delete selected ({count})",
"deleting": "Deleting…",
"redownloadBroken": "Re-download broken",
"redownloading": "Re-downloading…",
"autoClean": "Auto-clean old fakes",
"cleaning": "Cleaning…",
"exportManifest": "Export manifest",
"exporting": "Exporting…",
"phaseStems": "Loading furniture stems…",
"phaseReaddir": "Reading asset directories…",
"phaseValidate": "Validating .nitro bundles…",
"phaseScanning": "Scanning…",
"scanProgressFiles": "{count} files",
"repairProgress": "ok {ok}, failed {failed}",
"attachedNotice": "Scan continues in the background — results will appear when it finishes.",
"sessionCancelled": "Scan cancelled.",
"onlyOldLabel": "Only show files older than",
"onlyOldAgeLabel": "Minimum age in days to display",
"onlyOldToggle": "Hide recent files",
"daysLabel": "days",
"autoAgeLabel": "Minimum age in days for auto-clean",
"loadingResult": "Loading scan result…",
"scannedStat": "asset files scanned",
"cachedNotice": "(cached — directories unchanged)",
"fakeStat": "Fake nitros (no matching item)",
"brokenStat": "Broken nitros (unparseable)",
"groupEmpty": "None found",
"selectedCount": "{count} selected",
"copiesBadge": "{count} copies",
"groupFakeLabel": "Fake nitros",
"groupFakeDescription": "These files match no item in items_base — leftovers from deleted or renamed furniture.",
"groupBrokenLabel": "Broken nitros",
"groupBrokenDescription": "These files belong to an item but cannot be parsed as a valid Nitro bundle.",
"groupSwfLabel": "Orphaned SWFs",
"groupSwfDescription": ".swf files that match no item in items_base.",
"groupIconLabel": "Orphaned icons",
"groupIconDescription": "Icon files that match no item in items_base.",
"warningText": "Deleting a broken .nitro leaves its item in the database without a bundle — prefer re-downloading it, or let the catalog audit re-obtain it later. Database rows are never removed by this tool.",
"onlyAssetsTouched": "Only asset files are touched — FurnitureData.json and database rows stay intact.",
"emptyHint": "Scan every furni asset directory to find fake and broken nitro bundles plus orphaned SWFs and icons. Delete them, re-download the broken ones, or auto-clean old leftovers.",
"noResult": "No scan result yet — run a furniture asset scan first.",
"scanFailed": "The scan failed.",
"confirmDeleteTitle": "Delete {count} asset file(s)?",
"confirmDeleteDescription": "The files will be removed from every directory (public + mirrored gamedata). Database rows are never touched.",
"confirmDeleteLabel": "Delete",
"confirmRepairTitle": "Re-download {count} broken .nitro bundle(s)?",
"confirmRepairDescription": "The bundles will be fetched again from the configured nitro sources and written over every directory (public + mirrored gamedata).",
"confirmRepairLabel": "Re-download",
"confirmAutoCleanTitle": "Auto-clean fake nitros older than {days} day(s)?",
"confirmAutoCleanDescription": "Only fake .nitro files (no matching item) older than the threshold are removed. Broken bundles and recent files are never touched.",
"confirmAutoCleanLabel": "Auto-clean",
"toastDeletePartial": "Deleted {copies} copy/copies, but some files could not be removed",
"toastDeleteDone": "Deleted {copies} file copy/copies ({deleted} file(s))",
"toastRepairDone": "Repaired {count} .nitro bundle(s)",
"toastRepairFailed": "Could not re-obtain any bundle ({failed} failed)",
"toastRepairCancelled": "Repair cancelled",
"toastAutoCleanDone": "Auto-cleaned {deleted} old fake .nitro file(s) ({copies} copies). Skipped {recent} recent + {broken} broken.",
"toastAutoCleanNothing": "Nothing old enough to remove (skipped {recent} recent, {broken} broken)",
"toastManifestDownload": "Scan manifest downloaded",
"historyTitle": "Auto-clean history",
"historyScheduled": "scheduled",
"historyManual": "manual",
"historyEntry": "{deleted} removed ({copies} copies); {recent} recent and {broken} broken skipped",
"historyErrors": "{count} errors"
},
"importDiagnostics": {
"attempts": "Source file diagnostics",
"preliminary": "Preliminary check: these addresses were checked without downloading the files. The import will validate the original files.",
+72
View File
@@ -6503,6 +6503,78 @@
},
"admin": {
"studio": {
"nitroCleanup": {
"alreadyRunning": "Er draait al een scan — de resultaten volgen als die klaar is.",
"ageUnknown": "leeftijd onbekend",
"ageBelow": "< 1d",
"scanButton": "Furniture assets scannen",
"scanningAssets": "Assets scannen…",
"cancelButton": "Annuleren",
"deleteSelected": "{count} geselecteerd verwijderen",
"deleting": "Verwijderen…",
"redownloadBroken": "Opnieuw downloaden",
"redownloading": "Opnieuw downloaden…",
"autoClean": "Oude fakes opschonen",
"cleaning": "Opschonen…",
"exportManifest": "Manifest exporteren",
"exporting": "Exporteren…",
"phaseStems": "Furniture stems laden…",
"phaseReaddir": "Asset-mappen lezen…",
"phaseValidate": ".nitro-bundles valideren…",
"phaseScanning": "Scannen…",
"scanProgressFiles": "{count} bestanden",
"repairProgress": "ok {ok}, mislukt {failed}",
"attachedNotice": "De scan draait op de achtergrond verder — resultaten verschijnen als die klaar is.",
"sessionCancelled": "Scan geannuleerd.",
"onlyOldLabel": "Alleen bestanden ouder dan",
"onlyOldAgeLabel": "Minimumleeftijd in dagen om te tonen",
"onlyOldToggle": "Recente bestanden verbergen",
"daysLabel": "dagen",
"autoAgeLabel": "Minimumleeftijd in dagen voor opruimen",
"loadingResult": "Scanresultaat laden…",
"scannedStat": "asset-bestanden gescand",
"cachedNotice": "(in cache — mappen ongewijzigd)",
"fakeStat": "Fake-nitros (geen overeenkomend item)",
"brokenStat": "Kapotte nitros (niet parseerbaar)",
"groupEmpty": "Niets gevonden",
"selectedCount": "{count} geselecteerd",
"copiesBadge": "{count} kopieën",
"groupFakeLabel": "Fake-nitros",
"groupFakeDescription": "Deze bestanden matchen geen enkel item in items_base — overblijfselen van verwijderde of hernoemde furniture.",
"groupBrokenLabel": "Kapotte nitros",
"groupBrokenDescription": "Deze bestanden horen bij een item maar kunnen niet als geldige Nitro-bundle worden gelezen.",
"groupSwfLabel": "Verweesde SWFs",
"groupSwfDescription": ".swf-bestanden die geen enkel item in items_base matchen.",
"groupIconLabel": "Verweesde iconen",
"groupIconDescription": "Icoonbestanden die geen enkel item in items_base matchen.",
"warningText": "Een kapotte .nitro verwijderen laat het item in de database achter zonder bundle — download het liever opnieuw, of laat de catalogaudit het later opnieuw ophalen. Database-rijen worden door deze tool nooit verwijderd.",
"onlyAssetsTouched": "Alleen asset-bestanden worden aangeraakt — FurnitureData.json en database-rijen blijven intact.",
"emptyHint": "Scan elke furni-assetmap om fake- en kapotte nitro-bundles plus verweesde SWFs en iconen te vinden. Verwijder ze, download de kapotte opnieuw of ruim oude overblijfselen automatisch op.",
"noResult": "Nog geen scanresultaat — start eerst een furniture-assetscan.",
"scanFailed": "De scan is mislukt.",
"confirmDeleteTitle": "{count} assetbestand(en) verwijderen?",
"confirmDeleteDescription": "De bestanden worden uit elke map verwijderd (publiek + gespiegelde gamedata). Database-rijen worden nooit aangeraakt.",
"confirmDeleteLabel": "Verwijderen",
"confirmRepairTitle": "{count} kapotte .nitro-bundles opnieuw downloaden?",
"confirmRepairDescription": "De bundles worden opnieuw opgehaald van de geconfigureerde nitro-bronnen en over elke map geschreven (publiek + gespiegelde gamedata).",
"confirmRepairLabel": "Opnieuw downloaden",
"confirmAutoCleanTitle": "Fake-nitros ouder dan {days} dag(en) automatisch opruimen?",
"confirmAutoCleanDescription": "Alleen fake .nitro-bestanden (geen overeenkomend item) ouder dan de drempel worden verwijderd. Kapotte bundles en recente bestanden worden nooit aangeraakt.",
"confirmAutoCleanLabel": "Opschonen",
"toastDeletePartial": "{copies} kopie(ën) verwijderd, maar sommige bestanden konden niet worden verwijderd",
"toastDeleteDone": "{copies} bestandskopie(ën) verwijderd ({deleted} bestand(en))",
"toastRepairDone": "{count} .nitro-bundle(s) gerepareerd",
"toastRepairFailed": "Geen enkele bundle kon worden opgehaald ({failed} mislukt)",
"toastRepairCancelled": "Herstel geannuleerd",
"toastAutoCleanDone": "{deleted} oude fake .nitro-bestand(en) opgeruimd ({copies} kopieën). {recent} recente en {broken} kapotte overgeslagen.",
"toastAutoCleanNothing": "Niets oud genoeg om te verwijderen ({recent} recente en {broken} kapotte overgeslagen)",
"toastManifestDownload": "Scanmanifest gedownload",
"historyTitle": "Opruimgeschiedenis",
"historyScheduled": "gepland",
"historyManual": "handmatig",
"historyEntry": "{deleted} verwijderd ({copies} kopieën); {recent} recente en {broken} kapotte overgeslagen",
"historyErrors": "{count} fouten"
},
"importDiagnostics": {
"attempts": "Bestandsdiagnostiek",
"preliminary": "Voorafgaande controle van adressen; inhoud wordt tijdens import gevalideerd.",