mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(ai): add a Reset AI Environment admin feature for the upgrade gap (#459)
Uninstalling a bundle only deletes its downloaded model weights, never the
shared venv's site-packages, so self-hosters who already hit an AI bundle
conflict (e.g. the scipy ABI strand) have no clean self-service path via
uninstall+reinstall: reinstalling just overlays corrected files on top of
stale ones. Adds POST /api/v1/admin/features/reset, which wipes
/data/ai/{venv,models,pip-cache}, resets installed.json, and reseeds a real
working venv from the image's baked /opt/venv (extracted docker/reseed-ai-venv.sh,
now shared with entrypoint.sh's existing base-venv-upgrade bootstrap instead
of duplicating that logic) -- leaving an empty venv directory here would
make the very next install fail with "spawn .../python3 ENOENT", caught by
testing this live rather than assuming it. Ships with a matching Settings UI
section (inline confirm, same pattern as per-bundle uninstall) and strings
across all 21 locales.
Verified against a real snapotter/snapotter:1.17.2 image migrated to 2.0.0,
with real multi-GB bundles installed (background-removal + OCR): confirmed
the migrated instance's inherited python3.11 venv (2.0.0 itself uses 3.12)
still imports the fixed scipy/numpy/paddleocr correctly, then reset + real
reinstall + actual tool execution (remove-background, verified output image)
all worked end-to-end.
This commit is contained in:
@@ -252,6 +252,50 @@ export function getInstallingBundle(): {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the shared AI venv, downloaded models, and pip cache, then reset
|
||||
* installed.json to empty. For self-hosters whose venv already has stale or
|
||||
* conflicting package files from a previous bundle version (the class of bug
|
||||
* in project_ai_bundle_numpy_abi_strand): uninstalling a bundle only removes
|
||||
* its model weights, never the shared site-packages it wrote into, so a
|
||||
* reinstall just overlays corrected files on top of the old ones rather than
|
||||
* replacing them. This is the blunt, reliable alternative: everything AI
|
||||
* related is deleted and every bundle needs reinstalling (a fresh download
|
||||
* from the HuggingFace bundle repo), but there is no partial/stale state left
|
||||
* to reason about afterward.
|
||||
*/
|
||||
export function resetAiEnvironment(): void {
|
||||
if (!acquireInstallLock("__reset__")) {
|
||||
const installing = getInstallingBundle();
|
||||
throw new Error(
|
||||
`Cannot reset: a bundle install is already in progress (${installing?.bundleId ?? "unknown"})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(MODELS_DIR, { recursive: true, force: true });
|
||||
rmSync(join(AI_DIR, "pip-cache"), { recursive: true, force: true });
|
||||
writeInstalled({ bundles: {} });
|
||||
invalidateCache();
|
||||
|
||||
// Reseed the venv from the image's baked /opt/venv (base packages: numpy,
|
||||
// Pillow, opencv) via the same script the entrypoint uses on a base-venv
|
||||
// upgrade. A fresh install right after a reset needs a real, working venv
|
||||
// to install into -- leaving an empty directory (no python3 binary) would
|
||||
// make the very next install fail with "spawn .../python3 ENOENT".
|
||||
if (existsSync("/opt/venv")) {
|
||||
execFileSync("/usr/local/bin/reseed-ai-venv.sh", { stdio: "ignore", timeout: 120_000 });
|
||||
} else {
|
||||
// Not a Docker image build (local dev, or a test fixture): nothing to
|
||||
// reseed from, just leave an empty directory.
|
||||
rmSync(join(AI_DIR, "venv"), { recursive: true, force: true });
|
||||
}
|
||||
ensureAiDirs();
|
||||
} finally {
|
||||
releaseInstallLock();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress tracking (in-memory, for SSE) ──────────────────────────────
|
||||
|
||||
let currentProgress: {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 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
|
||||
* POST /api/v1/admin/features/reset - Wipe the AI venv/models, reset all bundles
|
||||
* GET /api/v1/admin/features/disk-usage - Get AI model disk usage
|
||||
* POST /api/v1/admin/features/import - Import an offline bundle archive
|
||||
*/
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
isFeatureInstalled,
|
||||
markUninstalled,
|
||||
releaseInstallLock,
|
||||
resetAiEnvironment,
|
||||
setInstallProgress,
|
||||
verifyBundleModels,
|
||||
} from "../lib/feature-status.js";
|
||||
@@ -449,6 +451,36 @@ export async function registerFeatureRoutes(app: FastifyInstance): Promise<void>
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/v1/admin/features/reset - Wipe the AI venv/models/pip-cache and
|
||||
// reset every bundle to not-installed. Existing installs can't self-heal a
|
||||
// stale/conflicting venv via uninstall+reinstall alone (uninstall only
|
||||
// removes model weights), so this is the reliable full reset.
|
||||
app.post(
|
||||
"/api/v1/admin/features/reset",
|
||||
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = await requirePermission("features:manage")(_request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
try {
|
||||
resetAiEnvironment();
|
||||
} catch (err) {
|
||||
return reply.status(409).send({
|
||||
error: err instanceof Error ? err.message : "Reset failed",
|
||||
});
|
||||
}
|
||||
shutdownDispatcher();
|
||||
|
||||
trackEvent(ANALYTICS_EVENTS.AI_BUNDLE_ACTION, {
|
||||
bundle_id: "all",
|
||||
action: "reset_environment",
|
||||
duration_ms: 0,
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { FeatureBundleState } from "@snapotter/shared";
|
||||
import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Download,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { apiGet, formatHeaders } from "@/lib/api";
|
||||
@@ -60,6 +69,8 @@ export function AiFeaturesSection() {
|
||||
uninstallBundle,
|
||||
reinstallBundle,
|
||||
installAll,
|
||||
resetEnvironment,
|
||||
resetError,
|
||||
} = useFeaturesStore();
|
||||
const [diskUsage, setDiskUsage] = useState<number | null>(null);
|
||||
|
||||
@@ -134,6 +145,98 @@ export function AiFeaturesSection() {
|
||||
loadDiskUsage();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResetEnvironmentSection
|
||||
onReset={resetEnvironment}
|
||||
error={resetError}
|
||||
onResetDone={loadDiskUsage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetEnvironmentSection({
|
||||
onReset,
|
||||
error,
|
||||
onResetDone,
|
||||
}: {
|
||||
onReset: () => Promise<void>;
|
||||
error: string | null;
|
||||
onResetDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
const handleReset = async () => {
|
||||
setConfirming(false);
|
||||
setResetting(true);
|
||||
try {
|
||||
await onReset();
|
||||
onResetDone();
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-4 border-t border-border space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{t.settings.aiFeatures.resetTitle}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t.settings.aiFeatures.resetDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!confirming && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={resetting}
|
||||
onClick={() => setConfirming(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-destructive/10 hover:text-destructive transition-colors disabled:opacity-50"
|
||||
>
|
||||
{resetting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t.settings.aiFeatures.resetButton}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{confirming && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-destructive">{t.settings.aiFeatures.resetConfirmMessage}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t.common.confirm}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
{format(t.settings.aiFeatures.resetFailed, { error })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ interface FeaturesState {
|
||||
reinstallBundle: (bundleId: string) => Promise<void>;
|
||||
installAll: () => Promise<void>;
|
||||
clearError: (bundleId: string) => void;
|
||||
resetEnvironment: () => Promise<void>;
|
||||
resetError: string | null;
|
||||
}
|
||||
|
||||
export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||
@@ -249,6 +251,7 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||
queued: [],
|
||||
installAllActive: false,
|
||||
startTimes: {},
|
||||
resetError: null,
|
||||
|
||||
fetch: async () => {
|
||||
if (get().loaded && !get().loadError) {
|
||||
@@ -397,5 +400,16 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
||||
delete errors[bundleId];
|
||||
set({ errors });
|
||||
},
|
||||
|
||||
resetEnvironment: async () => {
|
||||
set({ resetError: null });
|
||||
try {
|
||||
await apiPost("/v1/admin/features/reset", {});
|
||||
set({ installing: {}, errors: {}, queued: [], startTimes: {} });
|
||||
await refreshBundles();
|
||||
} catch (err) {
|
||||
set({ resetError: err instanceof Error ? err.message : "Reset failed" });
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -583,10 +583,12 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY docker/entrypoint-lib.sh /usr/local/bin/entrypoint-lib.sh
|
||||
COPY docker/embedded-lib.sh /usr/local/bin/embedded-lib.sh
|
||||
COPY docker/embedded/postgres-bootstrap.sh /usr/local/bin/embedded-postgres-bootstrap.sh
|
||||
COPY docker/reseed-ai-venv.sh /usr/local/bin/reseed-ai-venv.sh
|
||||
COPY docker/wait-for-postgres.mjs /app/docker/wait-for-postgres.mjs
|
||||
# s6-overlay reads its service tree from /etc/s6-overlay/s6-rc.d (embedded mode)
|
||||
COPY docker/s6/s6-rc.d /etc/s6-overlay/s6-rc.d
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/embedded-postgres-bootstrap.sh \
|
||||
/usr/local/bin/reseed-ai-venv.sh \
|
||||
&& chmod +x /etc/s6-overlay/s6-rc.d/postgres/run /etc/s6-overlay/s6-rc.d/redis/run \
|
||||
/etc/s6-overlay/s6-rc.d/snapotter/run \
|
||||
/etc/s6-overlay/s6-rc.d/postgres-init/up /etc/s6-overlay/s6-rc.d/postgres-ready/up \
|
||||
|
||||
@@ -119,11 +119,7 @@ if [ -d "/opt/venv" ]; then
|
||||
fi
|
||||
|
||||
if [ "$NEED_BOOTSTRAP" = true ]; then
|
||||
mkdir -p /data/ai/models /data/ai/pip-cache
|
||||
rm -rf "$AI_VENV"
|
||||
cp -r /opt/venv "$AI_VENV_TMP"
|
||||
mv "$AI_VENV_TMP" "$AI_VENV"
|
||||
rewrite_venv_paths "$AI_VENV" "/opt/venv" "$AI_VENV"
|
||||
/usr/local/bin/reseed-ai-venv.sh
|
||||
# Reset installed-bundle state: their packages lived in the old venv.
|
||||
# Models in /data/ai/models survive, so reinstalling a bundle only
|
||||
# reruns pip (model downloads are idempotent and skip existing files).
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Reseed /data/ai/venv from the image's baked /opt/venv (base packages only:
|
||||
# numpy, Pillow, opencv). A raw venv copy is not relocatable -- console
|
||||
# scripts and pyvenv.cfg keep the source path -- so rewrite_venv_paths (from
|
||||
# entrypoint-lib.sh) patches those after the copy.
|
||||
#
|
||||
# Used from two places: entrypoint.sh calls this on first boot / when the
|
||||
# base venv's stamp changed, and the "Reset AI Environment" admin route
|
||||
# (apps/api/src/lib/feature-status.ts resetAiEnvironment()) calls this after
|
||||
# wiping /data/ai/venv, so existing installs stuck with a stale/conflicting
|
||||
# venv (uninstall alone only removes model weights, never the shared
|
||||
# site-packages) have a real, working venv to reinstall bundles into
|
||||
# afterward instead of an empty directory with no python3 binary.
|
||||
. /usr/local/bin/entrypoint-lib.sh
|
||||
|
||||
AI_VENV="/data/ai/venv"
|
||||
AI_VENV_TMP="/data/ai/venv.bootstrapping"
|
||||
|
||||
if [ ! -d "/opt/venv" ]; then
|
||||
echo "reseed-ai-venv: no base venv at /opt/venv to seed from" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$AI_VENV_TMP" ]; then
|
||||
rm -rf "$AI_VENV_TMP"
|
||||
fi
|
||||
|
||||
mkdir -p /data/ai/models /data/ai/pip-cache
|
||||
rm -rf "$AI_VENV"
|
||||
cp -r /opt/venv "$AI_VENV_TMP"
|
||||
mv "$AI_VENV_TMP" "$AI_VENV"
|
||||
rewrite_venv_paths "$AI_VENV" "/opt/venv" "$AI_VENV"
|
||||
echo "AI venv seeded at $AI_VENV"
|
||||
@@ -3505,6 +3505,13 @@ export const ar: TranslationKeys = {
|
||||
importing: "جارٍ الاستيراد...",
|
||||
importSuccess: "تم استيراد الحزمة بنجاح.",
|
||||
importError: "فشل استيراد الحزمة: {error}",
|
||||
resetTitle: "إعادة تعيين بيئة AI",
|
||||
resetDescription:
|
||||
"يحذف كل ميزات AI المثبتة والنماذج التي تم تنزيلها، ثم يبدأ من جديد. استخدم هذا إذا كانت أدوات AI تتصرف بشكل غير متوقع بعد التحديث، وإعادة تثبيت الحزمة لا تحل المشكلة.",
|
||||
resetButton: "إعادة تعيين",
|
||||
resetConfirmMessage:
|
||||
"سيؤدي هذا إلى حذف جميع ميزات AI والنماذج المثبتة. ستحتاج إلى إعادة تثبيتها من Hugging Face. لا يمكن التراجع عن هذا الإجراء.",
|
||||
resetFailed: "فشلت إعادة التعيين: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "حول",
|
||||
|
||||
@@ -3546,6 +3546,13 @@ export const de: TranslationKeys = {
|
||||
importing: "Wird importiert...",
|
||||
importSuccess: "Paket erfolgreich importiert.",
|
||||
importError: "Paketimport fehlgeschlagen: {error}",
|
||||
resetTitle: "AI-Umgebung zurücksetzen",
|
||||
resetDescription:
|
||||
"Löscht alle installierten AI-Funktionen und heruntergeladenen Modelle und beginnt anschließend neu. Verwenden Sie dies, wenn sich AI-Tools nach einem Update unerwartet verhalten und die Neuinstallation eines Pakets das Problem nicht behebt.",
|
||||
resetButton: "Zurücksetzen",
|
||||
resetConfirmMessage:
|
||||
"Dadurch werden alle installierten AI-Funktionen und Modelle gelöscht. Sie müssen sie erneut von Hugging Face installieren. Dies kann nicht rückgängig gemacht werden.",
|
||||
resetFailed: "Zurücksetzen fehlgeschlagen: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Info",
|
||||
|
||||
@@ -3455,6 +3455,13 @@ export const en = {
|
||||
importing: "Importing...",
|
||||
importSuccess: "Bundle imported successfully.",
|
||||
importError: "Bundle import failed: {error}",
|
||||
resetTitle: "Reset AI Environment",
|
||||
resetDescription:
|
||||
"Deletes every installed AI feature and downloaded model, then starts fresh. Use this if AI tools are behaving unexpectedly after an update and reinstalling a bundle doesn't fix it.",
|
||||
resetButton: "Reset",
|
||||
resetConfirmMessage:
|
||||
"This deletes all installed AI features and models. You'll need to reinstall them from Hugging Face. This can't be undone.",
|
||||
resetFailed: "Reset failed: {error}",
|
||||
},
|
||||
fileManagement: {
|
||||
title: "File Management",
|
||||
|
||||
@@ -3524,6 +3524,13 @@ export const es: TranslationKeys = {
|
||||
importing: "Importando...",
|
||||
importSuccess: "Paquete importado correctamente.",
|
||||
importError: "Error al importar el paquete: {error}",
|
||||
resetTitle: "Restablecer entorno de AI",
|
||||
resetDescription:
|
||||
"Elimina todas las funciones de AI instaladas y los modelos descargados, y vuelve a empezar desde cero. Utiliza esto si las herramientas de AI se comportan de forma inesperada tras una actualización y reinstalar un paquete no soluciona el problema.",
|
||||
resetButton: "Restablecer",
|
||||
resetConfirmMessage:
|
||||
"Esto elimina todas las funciones y modelos de AI instalados. Tendrás que reinstalarlos desde Hugging Face. Esta acción no se puede deshacer.",
|
||||
resetFailed: "Error al restablecer: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Acerca de",
|
||||
|
||||
@@ -3549,6 +3549,13 @@ export const fr: TranslationKeys = {
|
||||
importing: "Importation...",
|
||||
importSuccess: "Bundle importé avec succès.",
|
||||
importError: "Échec de l'importation du bundle : {error}",
|
||||
resetTitle: "Réinitialiser l'environnement AI",
|
||||
resetDescription:
|
||||
"Supprime toutes les fonctionnalités AI installées et les modèles téléchargés, puis repart de zéro. Utilisez cette option si les outils AI se comportent de manière inattendue après une mise à jour et que la réinstallation d'un bundle ne résout pas le problème.",
|
||||
resetButton: "Réinitialiser",
|
||||
resetConfirmMessage:
|
||||
"Cette action supprime toutes les fonctionnalités et tous les modèles AI installés. Vous devrez les réinstaller depuis Hugging Face. Cette action est irréversible.",
|
||||
resetFailed: "Échec de la réinitialisation : {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "À propos",
|
||||
|
||||
@@ -3333,6 +3333,13 @@ export const hi: TranslationKeys = {
|
||||
importing: "आयात हो रहा है...",
|
||||
importSuccess: "बंडल सफलतापूर्वक आयात किया गया।",
|
||||
importError: "बंडल आयात विफल: {error}",
|
||||
resetTitle: "AI परिवेश रीसेट करें",
|
||||
resetDescription:
|
||||
"सभी इंस्टॉल की गई AI सुविधाएं और डाउनलोड किए गए मॉडल हटा देता है, फिर नए सिरे से शुरुआत करता है। इसका उपयोग तब करें जब अपडेट के बाद AI टूल असामान्य व्यवहार करें और बंडल को फिर से इंस्टॉल करने से समस्या ठीक न हो।",
|
||||
resetButton: "रीसेट करें",
|
||||
resetConfirmMessage:
|
||||
"इससे सभी इंस्टॉल की गई AI सुविधाएं और मॉडल हट जाएंगे। आपको उन्हें Hugging Face से दोबारा इंस्टॉल करना होगा। इसे पूर्ववत नहीं किया जा सकता।",
|
||||
resetFailed: "रीसेट विफल: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "जानकारी",
|
||||
|
||||
@@ -3526,6 +3526,13 @@ export const id: TranslationKeys = {
|
||||
importing: "Mengimpor...",
|
||||
importSuccess: "Bundel berhasil diimpor.",
|
||||
importError: "Impor bundel gagal: {error}",
|
||||
resetTitle: "Reset Lingkungan AI",
|
||||
resetDescription:
|
||||
"Menghapus semua fitur AI yang terinstal dan model yang telah diunduh, lalu memulai dari awal. Gunakan ini jika alat AI berperilaku tidak semestinya setelah pembaruan dan menginstal ulang bundel tidak menyelesaikan masalah.",
|
||||
resetButton: "Reset",
|
||||
resetConfirmMessage:
|
||||
"Ini akan menghapus semua fitur dan model AI yang terinstal. Anda perlu menginstalnya kembali dari Hugging Face. Tindakan ini tidak dapat dibatalkan.",
|
||||
resetFailed: "Reset gagal: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Tentang",
|
||||
|
||||
@@ -3538,6 +3538,13 @@ export const it: TranslationKeys = {
|
||||
importing: "Importazione in corso...",
|
||||
importSuccess: "Bundle importato correttamente.",
|
||||
importError: "Importazione del bundle non riuscita: {error}",
|
||||
resetTitle: "Ripristina ambiente IA",
|
||||
resetDescription:
|
||||
"Elimina tutte le funzionalità IA installate e i modelli scaricati, quindi riparte da zero. Usa questa opzione se gli strumenti IA si comportano in modo anomalo dopo un aggiornamento e la reinstallazione di un bundle non risolve il problema.",
|
||||
resetButton: "Ripristina",
|
||||
resetConfirmMessage:
|
||||
"Questa operazione elimina tutte le funzionalità e i modelli IA installati. Dovrai reinstallarli da Hugging Face. L'operazione non può essere annullata.",
|
||||
resetFailed: "Ripristino non riuscito: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Informazioni",
|
||||
|
||||
@@ -3479,6 +3479,13 @@ export const ja: TranslationKeys = {
|
||||
importing: "インポート中...",
|
||||
importSuccess: "バンドルのインポートに成功しました。",
|
||||
importError: "バンドルのインポートに失敗しました: {error}",
|
||||
resetTitle: "AI環境をリセット",
|
||||
resetDescription:
|
||||
"インストール済みのAI機能とダウンロード済みのモデルをすべて削除し、まっさらな状態からやり直します。アップデート後にAIツールの動作がおかしくなり、バンドルを再インストールしても直らない場合に使用してください。",
|
||||
resetButton: "リセット",
|
||||
resetConfirmMessage:
|
||||
"インストール済みのAI機能とモデルがすべて削除されます。Hugging Faceから再インストールする必要があります。この操作は取り消せません。",
|
||||
resetFailed: "リセットに失敗しました: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "SnapOtterについて",
|
||||
|
||||
@@ -3461,6 +3461,13 @@ export const ko: TranslationKeys = {
|
||||
importing: "가져오는 중...",
|
||||
importSuccess: "번들을 가져왔습니다.",
|
||||
importError: "번들 가져오기 실패: {error}",
|
||||
resetTitle: "AI 환경 재설정",
|
||||
resetDescription:
|
||||
"설치된 모든 AI 기능과 다운로드한 모델을 삭제한 후 처음부터 다시 시작합니다. 업데이트 후 AI 도구가 예상치 못하게 동작하고 번들을 다시 설치해도 문제가 해결되지 않을 때 사용하세요.",
|
||||
resetButton: "재설정",
|
||||
resetConfirmMessage:
|
||||
"설치된 모든 AI 기능과 모델이 삭제됩니다. Hugging Face에서 다시 설치해야 합니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
resetFailed: "재설정 실패: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "정보",
|
||||
|
||||
@@ -3535,6 +3535,13 @@ export const nl: TranslationKeys = {
|
||||
importing: "Importeren...",
|
||||
importSuccess: "Bundel succesvol geïmporteerd.",
|
||||
importError: "Bundel importeren mislukt: {error}",
|
||||
resetTitle: "AI-omgeving resetten",
|
||||
resetDescription:
|
||||
"Verwijdert alle geïnstalleerde AI-functies en gedownloade modellen, en begint daarna helemaal opnieuw. Gebruik dit als AI-tools zich na een update onverwacht gedragen en het opnieuw installeren van een bundel het probleem niet oplost.",
|
||||
resetButton: "Resetten",
|
||||
resetConfirmMessage:
|
||||
"Hiermee worden alle geïnstalleerde AI-functies en modellen verwijderd. Je moet ze opnieuw installeren vanaf Hugging Face. Dit kan niet ongedaan worden gemaakt.",
|
||||
resetFailed: "Resetten mislukt: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Over",
|
||||
|
||||
@@ -3538,6 +3538,13 @@ export const pl: TranslationKeys = {
|
||||
importing: "Importowanie...",
|
||||
importSuccess: "Pakiet zaimportowany pomyślnie.",
|
||||
importError: "Import pakietu nie powiódł się: {error}",
|
||||
resetTitle: "Zresetuj środowisko AI",
|
||||
resetDescription:
|
||||
"Usuwa wszystkie zainstalowane funkcje AI i pobrane modele, a następnie zaczyna od nowa. Użyj tej opcji, jeśli narzędzia AI zachowują się nieprawidłowo po aktualizacji, a ponowna instalacja pakietu nie rozwiązuje problemu.",
|
||||
resetButton: "Resetuj",
|
||||
resetConfirmMessage:
|
||||
"Spowoduje to usunięcie wszystkich zainstalowanych funkcji i modeli AI. Konieczna będzie ich ponowna instalacja z Hugging Face. Tej operacji nie można cofnąć.",
|
||||
resetFailed: "Resetowanie nie powiodło się: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Informacje",
|
||||
|
||||
@@ -3532,6 +3532,13 @@ export const ptBR: TranslationKeys = {
|
||||
importing: "Importando...",
|
||||
importSuccess: "Pacote importado com sucesso.",
|
||||
importError: "Falha ao importar pacote: {error}",
|
||||
resetTitle: "Redefinir ambiente de AI",
|
||||
resetDescription:
|
||||
"Exclui todos os recursos de AI instalados e os modelos baixados, e recomeça do zero. Use isso se as ferramentas de AI estiverem se comportando de forma inesperada após uma atualização e a reinstalação de um pacote não resolver o problema.",
|
||||
resetButton: "Redefinir",
|
||||
resetConfirmMessage:
|
||||
"Isso exclui todos os recursos e modelos de AI instalados. Você precisará reinstalá-los pelo Hugging Face. Essa ação não pode ser desfeita.",
|
||||
resetFailed: "Falha ao redefinir: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Sobre",
|
||||
|
||||
@@ -3529,6 +3529,13 @@ export const ru: TranslationKeys = {
|
||||
importing: "Импорт...",
|
||||
importSuccess: "Пакет успешно импортирован.",
|
||||
importError: "Ошибка импорта пакета: {error}",
|
||||
resetTitle: "Сбросить AI-среду",
|
||||
resetDescription:
|
||||
"Удаляет все установленные AI-функции и загруженные модели, а затем начинает заново. Используйте это, если AI-инструменты ведут себя непредсказуемо после обновления, а переустановка пакета не решает проблему.",
|
||||
resetButton: "Сбросить",
|
||||
resetConfirmMessage:
|
||||
"Это приведёт к удалению всех установленных AI-функций и моделей. Их нужно будет переустановить с Hugging Face. Это действие нельзя отменить.",
|
||||
resetFailed: "Ошибка сброса: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "О программе",
|
||||
|
||||
@@ -3524,6 +3524,13 @@ export const sv: TranslationKeys = {
|
||||
importing: "Importerar...",
|
||||
importSuccess: "Paket importerat.",
|
||||
importError: "Paketimport misslyckades: {error}",
|
||||
resetTitle: "Återställ AI-miljö",
|
||||
resetDescription:
|
||||
"Tar bort alla installerade AI-funktioner och nedladdade modeller, och börjar sedan om från grunden. Använd det här om AI-verktyg beter sig oväntat efter en uppdatering och det inte hjälper att installera om ett paket.",
|
||||
resetButton: "Återställ",
|
||||
resetConfirmMessage:
|
||||
"Detta tar bort alla installerade AI-funktioner och modeller. Du måste installera om dem från Hugging Face. Det går inte att ångra.",
|
||||
resetFailed: "Återställning misslyckades: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Om",
|
||||
|
||||
@@ -3486,6 +3486,13 @@ export const th: TranslationKeys = {
|
||||
importing: "กำลังนำเข้า...",
|
||||
importSuccess: "นำเข้าชุดข้อมูลสำเร็จ",
|
||||
importError: "นำเข้าชุดข้อมูลไม่สำเร็จ: {error}",
|
||||
resetTitle: "รีเซ็ตสภาพแวดล้อม AI",
|
||||
resetDescription:
|
||||
"ลบฟีเจอร์ AI ที่ติดตั้งไว้ทั้งหมดและโมเดลที่ดาวน์โหลดไว้ แล้วเริ่มต้นใหม่ทั้งหมด ใช้ตัวเลือกนี้หากเครื่องมือ AI ทำงานผิดปกติหลังการอัปเดต และการติดตั้งชุดข้อมูลใหม่ไม่ช่วยแก้ปัญหา",
|
||||
resetButton: "รีเซ็ต",
|
||||
resetConfirmMessage:
|
||||
"การดำเนินการนี้จะลบฟีเจอร์และโมเดล AI ที่ติดตั้งไว้ทั้งหมด คุณจะต้องติดตั้งใหม่จาก Hugging Face การกระทำนี้ไม่สามารถย้อนกลับได้",
|
||||
resetFailed: "รีเซ็ตล้มเหลว: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "เกี่ยวกับ",
|
||||
|
||||
@@ -3532,6 +3532,13 @@ export const tr: TranslationKeys = {
|
||||
importing: "İçe aktarılıyor...",
|
||||
importSuccess: "Paket başarıyla içe aktarıldı.",
|
||||
importError: "Paket içe aktarılamadı: {error}",
|
||||
resetTitle: "AI Ortamını Sıfırla",
|
||||
resetDescription:
|
||||
"Kurulu tüm AI özelliklerini ve indirilen modelleri siler, ardından sıfırdan başlar. Bir güncellemeden sonra AI araçları beklenmedik şekilde davranıyorsa ve paketi yeniden kurmak sorunu çözmüyorsa bunu kullanın.",
|
||||
resetButton: "Sıfırla",
|
||||
resetConfirmMessage:
|
||||
"Bu işlem, kurulu tüm AI özelliklerini ve modellerini siler. Bunları Hugging Face üzerinden yeniden kurmanız gerekir. Bu işlem geri alınamaz.",
|
||||
resetFailed: "Sıfırlama başarısız oldu: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Hakkında",
|
||||
|
||||
@@ -3529,6 +3529,13 @@ export const uk: TranslationKeys = {
|
||||
importing: "Імпортування...",
|
||||
importSuccess: "Пакет успішно імпортовано.",
|
||||
importError: "Не вдалося імпортувати пакет: {error}",
|
||||
resetTitle: "Скинути середовище AI",
|
||||
resetDescription:
|
||||
"Видаляє всі встановлені функції AI та завантажені моделі, а потім починає заново. Використовуйте це, якщо інструменти AI поводяться непередбачувано після оновлення, а перевстановлення пакета не вирішує проблему.",
|
||||
resetButton: "Скинути",
|
||||
resetConfirmMessage:
|
||||
"Це видалить усі встановлені функції та моделі AI. Вам потрібно буде перевстановити їх із Hugging Face. Цю дію не можна скасувати.",
|
||||
resetFailed: "Не вдалося скинути: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Про програму",
|
||||
|
||||
@@ -3525,6 +3525,13 @@ export const vi: TranslationKeys = {
|
||||
importing: "Đang nhập...",
|
||||
importSuccess: "Đã nhập gói thành công.",
|
||||
importError: "Nhập gói thất bại: {error}",
|
||||
resetTitle: "Đặt lại môi trường AI",
|
||||
resetDescription:
|
||||
"Xóa mọi tính năng AI đã cài đặt và các mô hình đã tải xuống, sau đó bắt đầu lại từ đầu. Hãy dùng tùy chọn này nếu công cụ AI hoạt động bất thường sau khi cập nhật và việc cài đặt lại gói không khắc phục được sự cố.",
|
||||
resetButton: "Đặt lại",
|
||||
resetConfirmMessage:
|
||||
"Thao tác này sẽ xóa mọi tính năng và mô hình AI đã cài đặt. Bạn sẽ cần cài đặt lại chúng từ Hugging Face. Hành động này không thể hoàn tác.",
|
||||
resetFailed: "Đặt lại thất bại: {error}",
|
||||
},
|
||||
about: {
|
||||
heading: "Giới thiệu",
|
||||
|
||||
@@ -3271,6 +3271,13 @@ export const zhCN: TranslationKeys = {
|
||||
importing: "导入中...",
|
||||
importSuccess: "包导入成功。",
|
||||
importError: "包导入失败:{error}",
|
||||
resetTitle: "重置 AI 环境",
|
||||
resetDescription:
|
||||
"删除所有已安装的 AI 功能和已下载的模型,然后重新开始。如果更新后 AI 工具出现异常,且重新安装包也无法解决问题,请使用此功能。",
|
||||
resetButton: "重置",
|
||||
resetConfirmMessage:
|
||||
"此操作将删除所有已安装的 AI 功能和模型,你需要从 Hugging Face 重新安装它们。此操作无法撤销。",
|
||||
resetFailed: "重置失败:{error}",
|
||||
},
|
||||
about: {
|
||||
heading: "关于",
|
||||
|
||||
@@ -3270,6 +3270,13 @@ export const zhTW: TranslationKeys = {
|
||||
importing: "匯入中...",
|
||||
importSuccess: "套件匯入成功。",
|
||||
importError: "套件匯入失敗:{error}",
|
||||
resetTitle: "重設 AI 環境",
|
||||
resetDescription:
|
||||
"刪除所有已安裝的 AI 功能與已下載的模型,然後重新開始。如果更新後 AI 工具出現異常,且重新安裝套件也無法解決問題,請使用此功能。",
|
||||
resetButton: "重設",
|
||||
resetConfirmMessage:
|
||||
"此操作將刪除所有已安裝的 AI 功能與模型,你需要從 Hugging Face 重新安裝。此操作無法復原。",
|
||||
resetFailed: "重設失敗:{error}",
|
||||
},
|
||||
about: {
|
||||
heading: "關於",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Integration tests for POST /api/v1/admin/features/reset at the HTTP route
|
||||
* level: wipes the AI venv/models/pip-cache and resets installed.json, so
|
||||
* existing installs stuck with a stale/conflicting venv (uninstall alone only
|
||||
* removes model weights, never the shared site-packages) have a reliable way
|
||||
* to get back to a clean slate rather than overlaying corrected files on top
|
||||
* of stale ones.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
shutdownDispatcherMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@snapotter/ai", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, shutdownDispatcher: hoisted.shutdownDispatcherMock };
|
||||
});
|
||||
|
||||
// ── Temp DATA_DIR before importing feature-status ────────────────
|
||||
const testRoot = join(tmpdir(), `snapotter-feature-reset-${randomUUID()}`);
|
||||
const aiDir = join(testRoot, "ai");
|
||||
const modelsDir = join(aiDir, "models");
|
||||
const venvDir = join(aiDir, "venv");
|
||||
const installedPath = join(aiDir, "installed.json");
|
||||
const lockPath = join(aiDir, "install.lock");
|
||||
|
||||
process.env.DATA_DIR = testRoot;
|
||||
// Point at the real manifest so isDockerEnvironment() is true and
|
||||
// ensureAiDirs() actually recreates the skeleton after a reset.
|
||||
process.env.FEATURE_MANIFEST_PATH = join(process.cwd(), "docker/feature-manifest.json");
|
||||
|
||||
mkdirSync(modelsDir, { recursive: true });
|
||||
writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8");
|
||||
|
||||
const { markInstalled, invalidateCache, releaseInstallLock, acquireInstallLock } = await import(
|
||||
"../../../apps/api/src/lib/feature-status.js"
|
||||
);
|
||||
const { loginAsAdmin } = await import("../test-server.js");
|
||||
|
||||
describe("POST /api/v1/admin/features/reset", () => {
|
||||
let app: Awaited<ReturnType<typeof import("fastify")>>["default"] extends (
|
||||
...args: infer _A
|
||||
) => infer R
|
||||
? R
|
||||
: never;
|
||||
let token: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const Fastify = (await import("fastify")).default;
|
||||
const multipartPlugin = (await import("@fastify/multipart")).default;
|
||||
const cookie = (await import("@fastify/cookie")).default;
|
||||
const cors = (await import("@fastify/cors")).default;
|
||||
|
||||
app = Fastify({ logger: false, bodyLimit: 100 * 1024 * 1024 });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(multipartPlugin, { limits: { fileSize: 100 * 1024 * 1024 } });
|
||||
await app.register(cookie, { secret: "test-cookie-secret", hook: "onRequest" });
|
||||
|
||||
const { authMiddleware, authRoutes, ensureBuiltinRoles, ensureDefaultAdmin } = await import(
|
||||
"../../../apps/api/src/plugins/auth.js"
|
||||
);
|
||||
await authMiddleware(app);
|
||||
await authRoutes(app);
|
||||
await ensureBuiltinRoles();
|
||||
await ensureDefaultAdmin();
|
||||
|
||||
const { db, schema } = await import("../../../apps/api/src/db/index.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ mustChangePassword: false })
|
||||
.where(eq(schema.users.username, "admin"));
|
||||
|
||||
const { registerFeatureRoutes } = await import("../../../apps/api/src/routes/features.js");
|
||||
await registerFeatureRoutes(app);
|
||||
|
||||
token = await loginAsAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
try {
|
||||
releaseInstallLock();
|
||||
} catch {
|
||||
// no lock held
|
||||
}
|
||||
writeFileSync(installedPath, JSON.stringify({ bundles: {} }), "utf-8");
|
||||
invalidateCache();
|
||||
hoisted.shutdownDispatcherMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
releaseInstallLock();
|
||||
} catch {
|
||||
// no lock held
|
||||
}
|
||||
});
|
||||
|
||||
const auth = () => ({ authorization: `Bearer ${token}` });
|
||||
|
||||
async function postReset() {
|
||||
return app.inject({ method: "POST", url: "/api/v1/admin/features/reset", headers: auth() });
|
||||
}
|
||||
|
||||
it("requires auth", async () => {
|
||||
const res = await app.inject({ method: "POST", url: "/api/v1/admin/features/reset" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("wipes the venv, models, and installed.json, and returns ok", async () => {
|
||||
markInstalled("ocr", "2.0.0", ["paddleocr-server-det"]);
|
||||
mkdirSync(join(venvDir, "lib", "python3.12", "site-packages"), { recursive: true });
|
||||
writeFileSync(join(modelsDir, "leftover.onnx"), "stale weights");
|
||||
|
||||
const res = await postReset();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true });
|
||||
expect(existsSync(join(venvDir, "lib"))).toBe(false);
|
||||
expect(existsSync(join(modelsDir, "leftover.onnx"))).toBe(false);
|
||||
const installed = JSON.parse(readFileSync(installedPath, "utf-8"));
|
||||
expect(installed.bundles).toEqual({});
|
||||
});
|
||||
|
||||
it("shuts down the dispatcher so the next AI request starts fresh", async () => {
|
||||
await postReset();
|
||||
expect(hoisted.shutdownDispatcherMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 409 instead of tearing anything down when a bundle install is in progress", async () => {
|
||||
markInstalled("ocr", "2.0.0", ["paddleocr-server-det"]);
|
||||
acquireInstallLock("ocr");
|
||||
|
||||
const res = await postReset();
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.error).toMatch(/install.*progress/i);
|
||||
// Nothing torn down: the bundle is still marked installed.
|
||||
const installed = JSON.parse(readFileSync(installedPath, "utf-8"));
|
||||
expect(installed.bundles).toHaveProperty("ocr");
|
||||
expect(existsSync(lockPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -276,6 +276,70 @@ describe("Install lock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetAiEnvironment", () => {
|
||||
function markDockerEnvironment() {
|
||||
// isDockerEnvironment() checks for the manifest path, which the
|
||||
// beforeEach already points at a file under tempDir; write something
|
||||
// there so ensureAiDirs() actually recreates the skeleton afterward.
|
||||
writeFileSync(process.env.FEATURE_MANIFEST_PATH ?? "", JSON.stringify({ bundles: {} }));
|
||||
}
|
||||
|
||||
it("removes venv, models, and pip-cache directories", () => {
|
||||
markDockerEnvironment();
|
||||
const venvDir = join(aiDir, "venv");
|
||||
const pipCacheDir = join(aiDir, "pip-cache");
|
||||
mkdirSync(join(venvDir, "lib", "python3.12", "site-packages", "scipy"), { recursive: true });
|
||||
writeFileSync(join(modelsDir, "some-model.onnx"), "fake weights");
|
||||
mkdirSync(pipCacheDir, { recursive: true });
|
||||
writeFileSync(join(pipCacheDir, "cached.whl"), "fake wheel");
|
||||
|
||||
mod.resetAiEnvironment();
|
||||
|
||||
expect(existsSync(join(venvDir, "lib"))).toBe(false);
|
||||
expect(existsSync(join(modelsDir, "some-model.onnx"))).toBe(false);
|
||||
expect(existsSync(join(pipCacheDir, "cached.whl"))).toBe(false);
|
||||
});
|
||||
|
||||
it("resets installed.json to empty", () => {
|
||||
markDockerEnvironment();
|
||||
mod.markInstalled("ocr", "2.0.0", ["paddleocr-server-det"]);
|
||||
mod.markInstalled("background-removal", "2.0.0", ["rembg-u2net"]);
|
||||
expect(mod.isFeatureInstalled("ocr")).toBe(true);
|
||||
|
||||
mod.resetAiEnvironment();
|
||||
|
||||
const data = JSON.parse(readFileSync(installedPath, "utf-8"));
|
||||
expect(data.bundles).toEqual({});
|
||||
expect(mod.isFeatureInstalled("ocr")).toBe(false);
|
||||
expect(mod.isFeatureInstalled("background-removal")).toBe(false);
|
||||
});
|
||||
|
||||
it("recreates an empty directory skeleton so a fresh install has somewhere to write", () => {
|
||||
markDockerEnvironment();
|
||||
mod.resetAiEnvironment();
|
||||
|
||||
expect(existsSync(join(aiDir, "venv"))).toBe(true);
|
||||
expect(existsSync(modelsDir)).toBe(true);
|
||||
expect(existsSync(join(aiDir, "pip-cache"))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to reset while a bundle install is in progress", () => {
|
||||
markDockerEnvironment();
|
||||
mod.acquireInstallLock("ocr");
|
||||
|
||||
expect(() => mod.resetAiEnvironment()).toThrow(/install.*progress/i);
|
||||
|
||||
// Nothing should have been torn down.
|
||||
expect(existsSync(lockPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("releases its own lock after completing", () => {
|
||||
markDockerEnvironment();
|
||||
mod.resetAiEnvironment();
|
||||
expect(existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feature status queries", () => {
|
||||
it("isFeatureInstalled returns true for installed bundle", () => {
|
||||
mod.markInstalled("background-removal", "1.0.0", []);
|
||||
|
||||
@@ -223,6 +223,49 @@ describe("useFeaturesStore (expanded)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetEnvironment", () => {
|
||||
it("posts to the reset endpoint and refreshes bundles on success", async () => {
|
||||
apiPostMock.mockResolvedValueOnce({});
|
||||
apiGetMock.mockResolvedValueOnce({ bundles: [] });
|
||||
|
||||
await useFeaturesStore.getState().resetEnvironment();
|
||||
|
||||
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/reset", {});
|
||||
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
|
||||
expect(useFeaturesStore.getState().resetError).toBeNull();
|
||||
});
|
||||
|
||||
it("clears stale installing/errors/queued state on success", async () => {
|
||||
useFeaturesStore.setState({
|
||||
installing: { ocr: { percent: 40, stage: "downloading" } },
|
||||
errors: { ocr: "some old error" },
|
||||
queued: ["ocr"],
|
||||
startTimes: { ocr: Date.now() },
|
||||
});
|
||||
apiPostMock.mockResolvedValueOnce({});
|
||||
apiGetMock.mockResolvedValueOnce({ bundles: [] });
|
||||
|
||||
await useFeaturesStore.getState().resetEnvironment();
|
||||
|
||||
const state = useFeaturesStore.getState();
|
||||
expect(state.installing).toEqual({});
|
||||
expect(state.errors).toEqual({});
|
||||
expect(state.queued).toEqual([]);
|
||||
expect(state.startTimes).toEqual({});
|
||||
});
|
||||
|
||||
it("sets resetError on failure and does not refresh bundles", async () => {
|
||||
apiPostMock.mockRejectedValueOnce(new Error("a bundle install is already in progress"));
|
||||
|
||||
await useFeaturesStore.getState().resetEnvironment();
|
||||
|
||||
expect(useFeaturesStore.getState().resetError).toBe(
|
||||
"a bundle install is already in progress",
|
||||
);
|
||||
expect(apiGetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearError", () => {
|
||||
it("does not affect other errors when clearing one", () => {
|
||||
useFeaturesStore.setState({
|
||||
|
||||
Reference in New Issue
Block a user