fix: reliable, self-healing AI feature-bundle installs (#472)

Make on-demand AI feature-bundle installs reliable and self-healing, closing
the failure modes behind most "some tool doesn't work" reports.

Multi-bundle installs: tools needing more than one bundle (Passport Photo,
Enhance Faces) install every required bundle from one action and stay
not-installed until all are present. Verified across all 19 AI tools.

Downloads: self-heal the accelerated Hugging Face (Xet) client so an upgraded
venv no longer silently falls back to slow urllib; restart instead of
corrupting a resumed partial when a proxy ignores Range and returns 200;
verify the completed size; fail fast on disk-full and HTTP 4xx; retry
transient errors five times; add hf_transfer fallback and document Xet egress.

Install integrity: crash-atomic venv writes so a killed or out-of-space
install can no longer tear the shared venv and break other tools; a boot
breadcrumb reseeds a torn venv to a clean state automatically; a post-install
smoke import test refuses to record a bundle whose libraries cannot load; an
install watchdog stops a wedged installer that would otherwise hold the venv
writer lock forever.

Adds unit and end-to-end tests for every failure mode above.
This commit is contained in:
SnapOtter
2026-07-10 07:32:48 +00:00
committed by GitHub
parent ffeacd4b3c
commit a731c3d1fe
33 changed files with 1913 additions and 125 deletions
+15 -7
View File
@@ -1,5 +1,10 @@
import type { Tool } from "@snapotter/shared";
import { PYTHON_SIDECAR_TOOLS, SECTIONS, TOOL_BUNDLE_MAP, toolSection } from "@snapotter/shared";
import {
getRequiredBundlesForTool,
PYTHON_SIDECAR_TOOLS,
SECTIONS,
toolSection,
} from "@snapotter/shared";
import { Clock, Download, FileImage, Loader2, Pin } from "lucide-react";
import { useMemo } from "react";
import { Link } from "react-router-dom";
@@ -63,12 +68,15 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge, showPin
const queued = useFeaturesStore((s) => s.queued);
const aiStatus = useMemo(() => {
if (!isAiTool) return "installed";
const bundleId = TOOL_BUNDLE_MAP[tool.id];
if (!bundleId) return "installed";
if (queued.includes(bundleId)) return "queued";
if (installing[bundleId]) return "installing";
const bundle = bundles.find((b) => b.id === bundleId);
return bundle?.status === "installed" ? "installed" : "not_installed";
const requiredBundleIds = getRequiredBundlesForTool(tool.id);
if (requiredBundleIds.length === 0) return "installed";
if (requiredBundleIds.some((bundleId) => queued.includes(bundleId))) return "queued";
if (requiredBundleIds.some((bundleId) => installing[bundleId])) return "installing";
return requiredBundleIds.every(
(bundleId) => bundles.find((bundle) => bundle.id === bundleId)?.status === "installed",
)
? "installed"
: "not_installed";
}, [isAiTool, tool.id, bundles, installing, queued]);
const section = toolSection(tool);
@@ -1,4 +1,8 @@
import type { FeatureBundleState } from "@snapotter/shared";
import {
FEATURE_BUNDLES,
type FeatureBundleState,
getRequiredBundlesForTool,
} from "@snapotter/shared";
import { AlertCircle, Clock, Download, Loader2, RotateCcw } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
@@ -48,18 +52,45 @@ function formatTimeRemaining(ms: number): string {
interface FeatureInstallPromptProps {
bundle: FeatureBundleState;
isAdmin: boolean;
toolId?: string;
toolName?: string;
toolDescription?: string;
}
function fallbackBundleState(bundleId: string): FeatureBundleState | null {
const info = FEATURE_BUNDLES[bundleId];
if (!info) return null;
return {
id: info.id,
name: info.name,
description: info.description,
status: "not_installed",
installedVersion: null,
estimatedSize: info.estimatedSize,
enablesTools: info.enablesTools,
progress: null,
error: null,
};
}
export function FeatureInstallPrompt({
bundle,
isAdmin,
toolId,
toolName,
toolDescription,
}: FeatureInstallPromptProps) {
const { t } = useTranslation();
const { installBundle, clearError, installing, errors, startTimes, queued } = useFeaturesStore();
const {
bundles,
installBundle,
installTool,
clearError,
installing,
errors,
startTimes,
queued,
} = useFeaturesStore();
const progress = installing[bundle.id] ?? null;
const error = errors[bundle.id] ?? null;
const isInstalling = !!progress;
@@ -68,6 +99,37 @@ export function FeatureInstallPrompt({
const displayName = toolName || bundle.name;
const displayDescription = toolDescription || bundle.description;
const isRepair = bundle.status === "error";
const requiredBundleIds = toolId ? getRequiredBundlesForTool(toolId) : [bundle.id];
const requiredBundles = requiredBundleIds
.map(
(bundleId) =>
bundles.find((candidate) => candidate.id === bundleId) ??
(bundle.id === bundleId ? bundle : fallbackBundleState(bundleId)),
)
.filter((candidate): candidate is FeatureBundleState => candidate !== null);
const bundlesNeedingDownload = requiredBundles.filter(
(candidate) => candidate.status !== "installed",
);
const downloadSizeLabel =
bundlesNeedingDownload
.map((candidate) =>
candidate.downloadBytes ? formatFileSize(candidate.downloadBytes) : candidate.estimatedSize,
)
.join(" + ") ||
(bundle.downloadBytes ? formatFileSize(bundle.downloadBytes) : bundle.estimatedSize);
// Show the per-bundle breakdown for any multi-bundle tool, including the
// repair state: when one bundle of a multi-bundle tool (e.g. passport-photo)
// errors, the user still needs to see that the sibling bundle is installing,
// queued, or already done. Hiding it during repair is exactly when it hurts.
const showBundleBreakdown = toolId !== undefined && requiredBundles.length > 1;
function bundleStatusLabel(candidate: FeatureBundleState): string {
if (installing[candidate.id]) return t.settings.aiFeatures.installing;
if (queued.includes(candidate.id)) return t.settings.aiFeatures.queued;
if (candidate.status === "installed") return t.settings.aiFeatures.installed;
if (candidate.status === "error") return t.settings.aiFeatures.repair;
return t.settings.aiFeatures.notInstalled;
}
const [messageIndex, setMessageIndex] = useState(() =>
Math.floor(Math.random() * PROGRESS_MESSAGES.length),
@@ -94,7 +156,11 @@ export function FeatureInstallPrompt({
function handleInstall() {
clearError(bundle.id);
installBundle(bundle.id);
if (toolId) {
installTool(toolId);
} else {
installBundle(bundle.id);
}
}
// Defensive guard: if the bundle is already installed (status may have
@@ -126,14 +192,49 @@ export function FeatureInstallPrompt({
{!isRepair && (
<p className="text-sm text-muted-foreground">
{format(t.features.requiresDownload, {
size: bundle.downloadBytes
? formatFileSize(bundle.downloadBytes)
: bundle.estimatedSize,
size: downloadSizeLabel,
})}
</p>
)}
</div>
{showBundleBreakdown && (
<div className="w-full max-w-md rounded-lg border border-border bg-background text-start overflow-hidden">
{requiredBundles.map((candidate) => {
const isCandidateInstalling = !!installing[candidate.id];
const isCandidateQueued = queued.includes(candidate.id);
const isCandidateInstalled = candidate.status === "installed";
const statusClass = isCandidateInstalled
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: isCandidateInstalling || isCandidateQueued
? "bg-primary/10 text-primary"
: candidate.status === "error"
? "bg-destructive/10 text-destructive"
: "bg-muted text-muted-foreground";
return (
<div
key={candidate.id}
className="flex items-center justify-between gap-3 px-4 py-3 border-b border-border last:border-b-0"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">{candidate.name}</p>
<p className="text-xs text-muted-foreground">
{candidate.downloadBytes
? formatFileSize(candidate.downloadBytes)
: candidate.estimatedSize}
</p>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ${statusClass}`}
>
{bundleStatusLabel(candidate)}
</span>
</div>
);
})}
</div>
)}
{(error || (isRepair && bundle.error)) && (
<div className="flex items-center gap-2 bg-destructive/10 text-destructive rounded-lg px-4 py-3 max-w-md w-full">
<AlertCircle className="h-4 w-4 shrink-0" />
+1
View File
@@ -600,6 +600,7 @@ export function ToolPage() {
<FeatureInstallPrompt
bundle={featureBundle}
isAdmin={isAdmin}
toolId={toolId}
toolName={tool?.name}
toolDescription={tool?.description}
/>
+76
View File
@@ -20,6 +20,13 @@ interface BundleProgress {
stage: string;
}
interface ToolBundleInstallResult {
bundleId: string;
jobId?: string;
queued?: boolean;
skipped?: boolean;
}
interface FeaturesState {
bundles: FeatureBundleState[];
loaded: boolean;
@@ -35,6 +42,7 @@ interface FeaturesState {
isToolInstalled: (toolId: string) => boolean;
getBundleForTool: (toolId: string) => FeatureBundleState | null;
installBundle: (bundleId: string) => Promise<void>;
installTool: (toolId: string) => Promise<void>;
uninstallBundle: (bundleId: string) => Promise<void>;
reinstallBundle: (bundleId: string) => Promise<void>;
installAll: () => Promise<void>;
@@ -346,6 +354,74 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
}
},
installTool: async (toolId: string) => {
const required = requiredBundlesForTool(toolId);
const targets = required.filter(
(bundleId) => get().bundles.find((b) => b.id === bundleId)?.status !== "installed",
);
if (targets.length === 0) return;
const errors = { ...get().errors };
const installing = { ...get().installing };
const startTimes = { ...get().startTimes };
const now = Date.now();
for (const bundleId of targets) {
delete errors[bundleId];
if (!get().queued.includes(bundleId)) {
installing[bundleId] = installing[bundleId] ?? { percent: 5, stage: "Starting..." };
}
startTimes[bundleId] = startTimes[bundleId] ?? now;
}
set({ errors, installing, startTimes });
try {
const result = await apiPost<{ bundles: ToolBundleInstallResult[] }>(
`/v1/admin/tools/${toolId}/features/install`,
{},
);
for (const item of result.bundles) {
if (item.skipped) {
stopTracking(item.bundleId);
continue;
}
if (item.queued) {
const nextInstalling = { ...get().installing };
delete nextInstalling[item.bundleId];
set({
installing: nextInstalling,
queued: get().queued.includes(item.bundleId)
? get().queued
: [...get().queued, item.bundleId],
});
startPolling(item.bundleId);
continue;
}
if (item.jobId) {
listenToProgress(item.bundleId, item.jobId);
}
}
if (result.bundles.every((item) => item.skipped)) {
await refreshBundles();
}
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to start installation";
const nextErrors = { ...get().errors };
for (const bundleId of targets) {
stopTracking(bundleId);
nextErrors[bundleId] = message;
}
set({ errors: nextErrors });
maybeFinishInstallAll();
}
},
uninstallBundle: async (bundleId: string) => {
try {
await apiPost(`/v1/admin/features/${bundleId}/uninstall`, {});