perf(studio): stream nitro repair progress over SSE and allow cancelling
CI / check (push) Successful in 4m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m43s

This commit is contained in:
openhands committed 2026-09-19 13:12:45 +02:00
1 parent ba81d16f00
commit 9d571e0c29
4 files changed
+249 -26

No files matched your search

@@ -3,9 +3,25 @@ import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { clearFurniImportCache } from "@/lib/services/furni-import-cache"; import { clearFurniImportCache } from "@/lib/services/furni-import-cache";
import { repairBrokenNitros } from "@/lib/services/nitro-cleanup"; import {
type NitroRepairProgress,
repairBrokenNitros,
} from "@/lib/services/nitro-cleanup";
/** Re-download the given broken .nitro bundles from the configured sources. */ type RepairSummary = {
repaired: number;
failed: number;
errors?: string[];
};
/**
* Re-download the given broken .nitro bundles from the configured nitro
* sources. Bundles are pulled in parallel server-side; when the client asks
* for `text/event-stream` a `progress` event is streamed after every bundle
* settles plus a final `result` event, mirroring the scan endpoint so a large
* repair batch cannot be cut off by a reverse-proxy timeout and can be
* cancelled client-side.
*/
export const POST = withAdmin( export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT }, { permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => { async (request, ctx) => {
@@ -23,8 +39,22 @@ export const POST = withAdmin(
if (fileNames.length === 0) { if (fileNames.length === 0) {
return apiError("fileNames[] is required", 400); return apiError("fileNames[] is required", 400);
} }
try {
const result = await repairBrokenNitros(fileNames); const summarize = (result: {
repaired: number;
failed: number;
errors: string[];
}): RepairSummary => ({
repaired: result.repaired,
failed: result.failed,
errors: result.errors.length > 0 ? result.errors : undefined,
});
const finish = async (result: {
repaired: number;
failed: number;
errors: string[];
}) => {
clearFurniImportCache(); clearFurniImportCache();
if (result.repaired > 0) { if (result.repaired > 0) {
logAudit({ logAudit({
@@ -39,10 +69,54 @@ export const POST = withAdmin(
}, },
}); });
} }
return apiOk({ return summarize(result);
repaired: result.repaired, };
failed: result.failed,
errors: result.errors.length > 0 ? result.errors : undefined, const wantsSse = request.headers
.get("accept")
?.includes("text/event-stream");
try {
if (!wantsSse) {
return apiOk(await finish(await repairBrokenNitros(fileNames)));
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
);
} catch {
/* stream closed by client */
}
};
try {
const result = await repairBrokenNitros(fileNames, {
onProgress: (progress: NitroRepairProgress) =>
send({ type: "progress", ...progress }),
});
send({ type: "result", ...(await finish(result)) });
} catch (err) {
send({
type: "error",
message: `Nitro repair failed: ${(err as Error).message}`,
});
}
try {
controller.close();
} catch {
/* ignore */
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}); });
} catch (err) { } catch (err) {
return apiError(`Nitro repair failed: ${(err as Error).message}`, 500); return apiError(`Nitro repair failed: ${(err as Error).message}`, 500);
@@ -258,6 +258,12 @@ export function NitroCleanupPanel() {
phase: string; phase: string;
scanned: number; scanned: number;
} | null>(null); } | null>(null);
const [repairProgress, setRepairProgress] = useState<{
completed: number;
total: number;
ok: number;
failed: number;
} | null>(null);
const [scan, setScan] = useState<NitroCleanupScan | null>(null); const [scan, setScan] = useState<NitroCleanupScan | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [selectedSwf, setSelectedSwf] = useState<Set<string>>(new Set()); const [selectedSwf, setSelectedSwf] = useState<Set<string>>(new Set());
@@ -486,32 +492,85 @@ export function NitroCleanupPanel() {
variant: "default", variant: "default",
}); });
if (!confirmed) return; if (!confirmed) return;
abortRef.current?.abort();
setRepairing(true); setRepairing(true);
setRepairProgress(null);
const abort = new AbortController();
abortRef.current = abort;
const holder: { result: RepairResponse | null } = { result: null };
let lastError: string | null = null;
try { try {
const res = await adminFetch("/api/admin/studio/nitro-cleanup/rebuild", { const res = await adminFetch("/api/admin/studio/nitro-cleanup/rebuild", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({ fileNames }), body: JSON.stringify({ fileNames }),
signal: abort.signal,
}); });
const data = (await res.json()) as RepairResponse & { if (!res.ok) {
error?: string; const body = (await res.json().catch(() => null)) as {
}; error?: string;
if (!res.ok) throw new Error(data.error || "Repair failed"); } | null;
if (data.repaired > 0) { throw new Error(
typeof body?.error === "string"
? body.error
: `Repair failed (HTTP ${res.status})`,
);
}
if (!res.body) throw new Error("Repair returned no response body");
await readSseStream(
res.body,
(evt) => {
if (evt.type === "progress") {
setRepairProgress({
completed: Number(evt.completed ?? 0),
total: Number(evt.total ?? fileNames.length),
ok: Number(evt.ok ?? 0),
failed: Number(evt.failed ?? 0),
});
} else if (evt.type === "result") {
holder.result = {
repaired: Number(evt.repaired ?? 0),
failed: Number(evt.failed ?? 0),
errors: Array.isArray(evt.errors)
? evt.errors.map(String)
: undefined,
};
} else if (evt.type === "error") {
lastError = String(evt.message ?? "Repair failed");
}
},
abort.signal,
SCAN_IDLE_TIMEOUT_MS,
);
if (lastError) throw new Error(lastError);
const data = holder.result;
if (data && data.repaired > 0) {
toast.success(`Repaired ${data.repaired} .nitro bundle(s)`); toast.success(`Repaired ${data.repaired} .nitro bundle(s)`);
} else { } else if (data) {
toast.warning(`Could not re-obtain any bundle (${data.failed} failed)`); toast.warning(`Could not re-obtain any bundle (${data.failed} failed)`);
} }
// Only drop the attempted bundles when every one succeeded; partial // Only drop the attempted bundles when every one succeeded; partial
// failures stay visible so they can be retried. // failures stay visible so they can be retried.
if (data.repaired >= fileNames.length) { if (data && data.repaired >= fileNames.length) {
dropFromScan("nitro", fileNames); dropFromScan("nitro", fileNames);
} }
setSelected(new Set()); setSelected(new Set());
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "Repair failed"); if ((err as Error).name === "AbortError") {
toast.info("Repair cancelled");
} else {
toast.error(err instanceof Error ? err.message : "Repair failed");
}
} finally { } finally {
setRepairing(false); setRepairing(false);
setRepairProgress(null);
if (abortRef.current === abort) abortRef.current = null;
} }
}; };
@@ -635,7 +694,7 @@ export function NitroCleanupPanel() {
)} )}
{scanning ? "Scanning assets…" : "Scan furniture assets"} {scanning ? "Scanning assets…" : "Scan furniture assets"}
</Button> </Button>
{scanning && ( {scanning || repairing ? (
<Button <Button
variant="outline" variant="outline"
size="lg" size="lg"
@@ -644,7 +703,7 @@ export function NitroCleanupPanel() {
<X className="w-4 h-4 mr-2" /> <X className="w-4 h-4 mr-2" />
Cancel Cancel
</Button> </Button>
)} ) : null}
{scan && ( {scan && (
<Button <Button
variant="destructive" variant="destructive"
@@ -734,6 +793,17 @@ export function NitroCleanupPanel() {
</div> </div>
)} )}
{repairing && repairProgress && (
<div className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 text-sm text-[var(--admin-text-muted)]">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
Re-downloading bundles…
<span className="font-mono text-xs">
{repairProgress.completed}/{repairProgress.total} — ok{" "}
{repairProgress.ok}, failed {repairProgress.failed}
</span>
</div>
)}
{scan && ( {scan && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"> <div className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
+56
View File
@@ -88,6 +88,7 @@ vi.mock("node:fs", async (orig) => {
import { import {
autoCleanFakeNitros, autoCleanFakeNitros,
deleteNitroCleanupFiles, deleteNitroCleanupFiles,
type NitroRepairProgress,
repairBrokenNitros, repairBrokenNitros,
scanFakeBrokenNitros, scanFakeBrokenNitros,
} from "@/lib/services/nitro-cleanup"; } from "@/lib/services/nitro-cleanup";
@@ -471,4 +472,59 @@ describe("repairBrokenNitros", () => {
expect(result.failed).toBe(1); expect(result.failed).toBe(1);
expect(result.errors[0]).toMatch(/could not re-obtain/); expect(result.errors[0]).toMatch(/could not re-obtain/);
}); });
it("reports per-bundle progress while re-downloading", async () => {
getTargetsFn.mockResolvedValue({
nitroDir: "/a/nitro",
swfDir: "/a/swf",
iconDir: "/a/icons",
mirrorDirs: [],
});
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
parseNitroBundle.mockReturnValue({});
downloadFileFn.mockResolvedValue({ ok: true, size: 100 });
const events: NitroRepairProgress[] = [];
const result = await repairBrokenNitros(["a.nitro", "b.nitro"], {
onProgress: (progress) => events.push(progress),
});
expect(result.repaired).toBe(2);
expect(events).toHaveLength(2);
expect(events.map((e) => e.completed).sort()).toEqual([1, 2]);
expect(events[events.length - 1]).toMatchObject({
total: 2,
ok: 2,
failed: 0,
});
expect(events[0].total).toBe(2);
});
it("counts successes and failures in repair progress", async () => {
getTargetsFn.mockResolvedValue({
nitroDir: "/a/nitro",
swfDir: "/a/swf",
iconDir: "/a/icons",
mirrorDirs: [],
});
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
parseNitroBundle.mockReturnValue({});
downloadFileFn.mockImplementation(async (url: string) => ({
ok: url.includes("a.nitro"),
size: 100,
error: url.includes("b.nitro") ? "HTTP 404" : undefined,
}));
const events: NitroRepairProgress[] = [];
await repairBrokenNitros(["a.nitro", "b.nitro"], {
onProgress: (progress) => events.push(progress),
});
expect(events.map((e) => e.completed).sort()).toEqual([1, 2]);
expect(events[events.length - 1]).toMatchObject({
total: 2,
ok: 1,
failed: 1,
});
});
}); });
+30 -7
View File
@@ -584,15 +584,28 @@ async function copyFileToDirs(
return errors; return errors;
} }
export interface NitroRepairProgress {
completed: number;
total: number;
ok: number;
failed: number;
fileName?: string;
error?: string;
}
/** /**
* Re-download broken .nitro bundles from the configured nitro sources. The * Re-download broken .nitro bundles from the configured nitro sources. The
* file stem matched an item in `items_base`, so the same classname candidates * file stem matched an item in `items_base`, so the same classname candidates
* used by repair-nitros.ts are tried; every downloaded bundle is validated * used by repair-nitros.ts are tried; every downloaded bundle is validated
* before it replaces the broken copies across all nitro directories. * before it replaces the broken copies across all nitro directories. Bundles
* are repaired in parallel (pool of 6) and `onProgress` is called after each
* one settles, so a huge batch can be streamed to the client and cancelled.
*/ */
export async function repairBrokenNitros( export async function repairBrokenNitros(
fileNames: string[], fileNames: string[],
options: { onProgress?: (progress: NitroRepairProgress) => void } = {},
): Promise<NitroRepairResult> { ): Promise<NitroRepairResult> {
const { onProgress } = options;
const targets = await getFurniAssetWriteTargets(); const targets = await getFurniAssetWriteTargets();
const nitroDirs = dirsForKind(targets, "nitro"); const nitroDirs = dirsForKind(targets, "nitro");
let sources = (await listSources().catch(() => [] as never)).filter( let sources = (await listSources().catch(() => [] as never)).filter(
@@ -604,14 +617,23 @@ export async function repairBrokenNitros(
let failed = 0; let failed = 0;
const errors: string[] = []; const errors: string[] = [];
const files: NitroRepairResult["files"] = []; const files: NitroRepairResult["files"] = [];
const uniqueFileNames = [...new Set(fileNames)];
const total = uniqueFileNames.length;
let completed = 0;
const finish = (fileName: string, ok: boolean, error?: string) => {
completed++;
if (ok) repaired++;
else failed++;
onProgress?.({ completed, total, ok: repaired, failed, fileName, error });
};
await runPool([...new Set(fileNames)], 6, async (rawName) => { await runPool(uniqueFileNames, 6, async (rawName) => {
const fileName = String(rawName ?? ""); const fileName = String(rawName ?? "");
if (!NITRO_FILE_RE.test(fileName) || !fileName.endsWith(".nitro")) { if (!NITRO_FILE_RE.test(fileName) || !fileName.endsWith(".nitro")) {
failed++;
const message = `Invalid nitro file name: ${fileName}`; const message = `Invalid nitro file name: ${fileName}`;
errors.push(message); errors.push(message);
files.push({ fileName, ok: false, error: message }); files.push({ fileName, ok: false, error: message });
finish(fileName, false, message);
return; return;
} }
const name = fileName.slice(0, -".nitro".length); const name = fileName.slice(0, -".nitro".length);
@@ -648,16 +670,16 @@ export async function repairBrokenNitros(
nitroDirs, nitroDirs,
); );
if (writeErrors.length === 0) { if (writeErrors.length === 0) {
repaired++;
files.push({ fileName, ok: true }); files.push({ fileName, ok: true });
finish(fileName, true);
} else { } else {
failed++;
errors.push(...writeErrors); errors.push(...writeErrors);
files.push({ files.push({
fileName, fileName,
ok: false, ok: false,
error: writeErrors[0], error: writeErrors[0],
}); });
finish(fileName, false, writeErrors[0]);
} }
return; return;
} }
@@ -670,13 +692,14 @@ export async function repairBrokenNitros(
} }
} }
} }
failed++; const message = "could not re-obtain bundle from any nitro source";
errors.push(`could not re-obtain ${fileName} from any nitro source`); errors.push(`could not re-obtain ${fileName} from any nitro source`);
files.push({ files.push({
fileName, fileName,
ok: false, ok: false,
error: "could not re-obtain bundle from any nitro source", error: message,
}); });
finish(fileName, false, message);
}); });
return { repaired, failed, errors, files }; return { repaired, failed, errors, files };