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:
@@ -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", []);
|
||||
|
||||
Reference in New Issue
Block a user