+ );
+}
+
/** The take-back, where a state has something to take back and nothing to accept. */
function Discard({ onDiscard }: { readonly onDiscard: () => void }): JSX.Element {
return (
diff --git a/frontend/ui-core/src/annotator/suggestPanel.test.tsx b/frontend/ui-core/src/annotator/suggestPanel.test.tsx
index 00dd7621..cbf9a1ad 100644
--- a/frontend/ui-core/src/annotator/suggestPanel.test.tsx
+++ b/frontend/ui-core/src/annotator/suggestPanel.test.tsx
@@ -20,7 +20,11 @@ import type { Suggestion, SuggestionState } from "@visionset/annotator";
import { SuggestPanel } from "./SuggestPanel";
import type { Answer } from "@visionset/annotator";
import { usableConnection, type Connection } from "../data/inferenceQueries";
-import type { BrowserModelAcquisition, BrowserSuggestionTarget } from "../inference/browserPort.js";
+import type {
+ BrowserModelAcquisition,
+ BrowserModelCatalogEntry,
+ BrowserSuggestionTarget,
+} from "../inference/browserPort.js";
const A_BOX = { type: "bbox", x: 10, y: 20, width: 30, height: 40 } as const;
@@ -658,6 +662,104 @@ describe("this device, once a browser runtime is wired", () => {
};
}
+ function catalogEntry(overrides: Partial = {}): BrowserModelCatalogEntry {
+ return {
+ id: "efficient-sam-ti",
+ label: "EfficientSAM-Ti",
+ modelRef: "robomous/efficient-sam-ti@revision",
+ revision: "revision",
+ bytes: 41_301_678,
+ license: "Apache-2.0",
+ source: { label: "EfficientSAM", href: "https://github.com/yformer/EfficientSAM" },
+ state: "available",
+ storage: "none",
+ ...overrides,
+ };
+ }
+
+ it("shows admitted model identity and explicitly acquires an available model", async () => {
+ const acquire = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ render(mount({
+ browserTargets: [],
+ browserModels: [catalogEntry()],
+ activeTarget: { kind: "browser", targetId: "efficient-sam-ti" },
+ onChooseTarget: vi.fn(),
+ onAcquireBrowserModel: acquire,
+ }));
+
+ const section = screen.getByTestId("suggest-device-section");
+ expect(section.textContent).toContain("EfficientSAM-Ti");
+ expect(section.textContent).toContain("41 MB");
+ expect(section.textContent).toContain("Apache-2.0");
+ expect(screen.getByRole("link", { name: "EfficientSAM" }).getAttribute("href")).toBe(
+ "https://github.com/yformer/EfficientSAM",
+ );
+ await user.click(screen.getByTestId("suggest-device-acquire-efficient-sam-ti"));
+ expect(acquire).toHaveBeenCalledWith("efficient-sam-ti");
+ });
+
+ it.each([
+ ["downloading", "Downloading…"],
+ ["installed", "Installed"],
+ ["activating", "Loading…"],
+ ["ready", "Ready"],
+ ] as const)("renders the catalog %s state as %s", (state, label) => {
+ render(mount({
+ browserTargets: state === "ready" ? [READY] : [],
+ browserModels: [catalogEntry({ state, storage: state === "downloading" ? "none" : "persistent" })],
+ activeTarget: { kind: "browser", targetId: READY.id },
+ onChooseTarget: vi.fn(),
+ onAcquireBrowserModel: vi.fn(),
+ onRemoveBrowserModel: vi.fn(),
+ }));
+ expect(screen.getByTestId("suggest-device-section").textContent).toContain(label);
+ });
+
+ it("removes an installed model through an explicit packaged control", async () => {
+ const remove = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ render(mount({
+ browserTargets: [],
+ browserModels: [catalogEntry({ state: "installed", storage: "persistent" })],
+ activeTarget: { kind: "browser", targetId: READY.id },
+ onChooseTarget: vi.fn(),
+ onRemoveBrowserModel: remove,
+ }));
+ await user.click(screen.getByTestId("suggest-device-remove-efficient-sam-ti"));
+ expect(remove).toHaveBeenCalledWith("efficient-sam-ti");
+ });
+
+ it("reports session-only readiness without claiming the model is installed", () => {
+ render(mount({
+ browserTargets: [READY],
+ browserModels: [catalogEntry({ state: "ready", storage: "session" })],
+ activeTarget: { kind: "browser", targetId: READY.id },
+ onChooseTarget: vi.fn(),
+ onRemoveBrowserModel: vi.fn(),
+ }));
+ expect(screen.getByTestId("suggest-device-session-only").textContent).toMatch(
+ /ready for this session.*not saved/i,
+ );
+ expect(screen.getByTestId("suggest-device-section").textContent).not.toContain("Installed");
+ });
+
+ it("turns a cached integrity failure into useful prose and another explicit Download", () => {
+ render(mount({
+ browserTargets: [],
+ browserModels: [catalogEntry({
+ state: "failed",
+ storage: "none",
+ error: "cached encoder SHA-256 mismatch",
+ })],
+ activeTarget: { kind: "browser", targetId: READY.id },
+ onChooseTarget: vi.fn(),
+ onAcquireBrowserModel: vi.fn(),
+ }));
+ expect(screen.getByRole("alert").textContent).toMatch(/failed verification.*download/i);
+ expect(screen.getByTestId("suggest-device-acquire-efficient-sam-ti")).toBeTruthy();
+ });
+
it("renders no device section, and no tab chooser, when no runtime is wired at all", () => {
render(mount());
diff --git a/frontend/ui-core/src/inference/browserRuntime.test.tsx b/frontend/ui-core/src/inference/browserRuntime.test.tsx
index a439a789..bfea0d07 100644
--- a/frontend/ui-core/src/inference/browserRuntime.test.tsx
+++ b/frontend/ui-core/src/inference/browserRuntime.test.tsx
@@ -445,6 +445,12 @@ describe("staleStoredBrowserTarget", () => {
// fails must keep surfacing through `blocker`/`refusal`, not silently revert.
expect(staleStoredBrowserTarget({ kind: "browser", targetId: "gone" }, [], true)).toBe(false);
});
+
+ it("keeps a known admitted preference when the model is not installed yet", () => {
+ expect(staleStoredBrowserTarget({ kind: "browser", targetId: "efficient-sam-ti" }, [], false, true)).toBe(
+ false,
+ );
+ });
});
describe("BrowserSuggestionAssetSource", () => {
From 4ce489099c8173136ed9c9f7814c7e23da51f8ba Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Thu, 17 Sep 2026 11:58:46 -0700
Subject: [PATCH 05/15] test(inference): prove persistent browser model
lifecycle
---
frontend/app/e2e/browserSuggestion.spec.ts | 137 +++++++++++++++++-
.../BrowserModelCatalog.test.ts | 17 +++
.../browserInference/BrowserModelCatalog.ts | 13 +-
.../ui-core/src/annotator/AnnotationPage.tsx | 37 +++--
.../src/inference/browserRuntime.test.tsx | 35 ++++-
5 files changed, 219 insertions(+), 20 deletions(-)
diff --git a/frontend/app/e2e/browserSuggestion.spec.ts b/frontend/app/e2e/browserSuggestion.spec.ts
index 26fec77c..6c6726c1 100644
--- a/frontend/app/e2e/browserSuggestion.spec.ts
+++ b/frontend/app/e2e/browserSuggestion.spec.ts
@@ -64,20 +64,45 @@ const DECODER_BYTES = HAS_ARTIFACTS ? readFileSync(DECODER_PATH) : Buffer.alloc(
* transform and would throw under this suite's plain Node/tsx loader.
*/
const REAL_ENCODER_BYTE_LENGTH = 24_799_777;
+const REVISION = "b19782d049c0-843761ca46f4";
+const MODEL_REF = `robomous/efficient-sam-ti@${REVISION}`;
+const REGISTRY = {
+ schema_version: 1,
+ models: [
+ { id: "efficient-sam-ti", name: "EfficientSAM-Ti", revision: REVISION, model_ref: MODEL_REF, manifest: `/models/efficient-sam-ti/${REVISION}/manifest.json` },
+ { id: "mobile-sam", name: "MobileSAM", revision: "359e37f2b168-7983079ab060", model_ref: "robomous/mobile-sam@359e37f2b168-7983079ab060", manifest: "/models/mobile-sam/359e37f2b168-7983079ab060/manifest.json" },
+ { id: "efficientvit-sam-l0", name: "EfficientViT-SAM-L0", revision: "e48dd681ba4b-1d3ba86d781b", model_ref: "robomous/efficientvit-sam-l0@e48dd681ba4b-1d3ba86d781b", manifest: "/models/efficientvit-sam-l0/e48dd681ba4b-1d3ba86d781b/manifest.json" },
+ { id: "slimsam-77-uniform", name: "SlimSAM-77-uniform", revision: "7f2c646efd21-e6eb3c03cdbd", model_ref: "robomous/slimsam-77-uniform@7f2c646efd21-e6eb3c03cdbd", manifest: "/models/slimsam-77-uniform/7f2c646efd21-e6eb3c03cdbd/manifest.json" },
+ { id: "sam2.1-hiera-tiny", name: "SAM2.1-hiera-tiny", revision: "7f000e65546d-6dbe21e6e60e", model_ref: "robomous/sam2.1-hiera-tiny@7f000e65546d-6dbe21e6e60e", manifest: "/models/sam2.1-hiera-tiny/7f000e65546d-6dbe21e6e60e/manifest.json" },
+ ],
+};
+const MANIFEST = {
+ schema_version: 1,
+ id: "efficient-sam-ti",
+ name: "EfficientSAM-Ti",
+ revision: REVISION,
+ model_ref: MODEL_REF,
+ source: { repository: "https://github.com/yformer/EfficientSAM", revision: "d525f622e6f640acf5a0fc37c7ca1f243da5bde0" },
+ runtime: { format: "onnx", opset: 17, onnxruntime_web: "1.29.0" },
+ capabilities: { point_suggest: true, positive_points: true, negative_points: false, max_points: 6 },
+ artifacts: {
+ encoder: { path: "encoder.onnx", bytes: 24_799_777, sha256: "b19782d049c09a8f1cc36ccc6029264ca23c8ac35e6379fd9ef9f1bc6d81e7f2", content_type: "application/octet-stream" },
+ decoder: { path: "decoder.onnx", bytes: 16_501_901, sha256: "843761ca46f4aa00b09fdcf0c94271321f76eece092a744296c742d682a86172", content_type: "application/octet-stream" },
+ },
+};
/** Routes the CDN manifest + artifacts to the local fixture — no network call ever leaves the page. */
async function mockCdn(page: Page, encoderBytes = ENCODER_BYTES, decoderBytes = DECODER_BYTES): Promise {
// Registered first, so it is matched *last* (Playwright tries the most-recently-added
- // handler first): anything at this host the three specific routes below don't
+ // handler first): anything at this host the specific routes below don't
// recognise is hard-aborted rather than silently reaching the real CDN — including if
// `VITE_MODEL_CDN_BASE_URL` or the manifest layout ever drifts out from under this stub.
await page.route("**/models.robomous.ai/**", (route) => route.abort());
- // The real, live manifest shape (fixed in manifest.ts/acquireEfficientSam.ts after a
- // production incident): artifacts nest under `artifacts`, and each `path` is a bare
- // filename resolved relative to the manifest's own revision directory — never a
- // leading-slash, top-level path.
+ await page.route("**/models.robomous.ai/registry/v1.json", (route) => route.fulfill({ json: REGISTRY }));
+ // The complete deployed v1 shape: registry admission validates every field before
+ // acquisition, while the pinned build record remains the integrity anchor.
await page.route("**/models.robomous.ai/models/efficient-sam-ti/**/manifest.json", (route) =>
- route.fulfill({ json: { artifacts: { encoder: { path: "encoder.onnx" }, decoder: { path: "decoder.onnx" } } } }),
+ route.fulfill({ json: MANIFEST }),
);
await page.route("**/models.robomous.ai/models/efficient-sam-ti/**/encoder.onnx", (route) =>
route.fulfill({ body: encoderBytes, contentType: "application/octet-stream" }),
@@ -185,7 +210,7 @@ function suggestCallsOf(sent: Request[]): Request[] {
function countModelRequestsFromNow(page: Page): () => number {
let count = 0;
page.on("request", (request) => {
- if (request.url().includes("models.robomous.ai")) count += 1;
+ if (/models\.robomous\.ai\/.*\/(encoder|decoder)\.onnx$/.test(request.url())) count += 1;
});
return () => count;
}
@@ -211,7 +236,15 @@ async function armSuggestTool(page: Page): Promise {
async function acquireAndSelectBrowserTarget(page: Page): Promise {
await page.getByTestId("suggest-target-browser").click();
await page.getByTestId("suggest-device-acquire-efficient-sam-ti").click();
- await expect(page.getByTestId("suggest-device-section").getByText(/ready/i)).toBeVisible({ timeout: 60_000 });
+ await expect(page.getByTestId("suggest-device-section").getByText("Ready", { exact: true })).toBeVisible({
+ timeout: 60_000,
+ });
+}
+
+async function makeBrowserSuggestion(page: Page): Promise {
+ const picture = (await page.getByTestId("annotator-canvas").boundingBox())!;
+ await page.mouse.click(picture.x + picture.width / 2, picture.y + picture.height / 2);
+ await expect(page.getByTestId("suggestion-shape")).toBeVisible({ timeout: 30_000 });
}
test.describe("browser suggestion", () => {
@@ -239,6 +272,94 @@ test.describe("browser suggestion", () => {
expect(suggestCallsOf(sent)).toHaveLength(0);
});
+ test("an installed model survives reload and suggests again without an artifact GET", async ({ page }) => {
+ const sent: Request[] = [];
+ const artifactRequests = countModelRequestsFromNow(page);
+ await openJobWithBrowserRuntime(page, sent, true);
+ await armSuggestTool(page);
+ await acquireAndSelectBrowserTarget(page);
+ await makeBrowserSuggestion(page);
+ expect(artifactRequests()).toBe(2);
+
+ await page.reload();
+ await expect(page.getByTestId("annotation-page")).toBeVisible();
+ await armSuggestTool(page);
+ await expect(page.getByTestId("suggest-device-section").getByText(/ready/i)).toBeVisible({ timeout: 60_000 });
+ expect(artifactRequests()).toBe(2);
+ await makeBrowserSuggestion(page);
+ expect(suggestCallsOf(sent)).toHaveLength(0);
+ });
+
+ test("an installed model remains usable when registry and artifact routes fail", async ({ page }) => {
+ const sent: Request[] = [];
+ await openJobWithBrowserRuntime(page, sent, true);
+ await armSuggestTool(page);
+ await acquireAndSelectBrowserTarget(page);
+ await makeBrowserSuggestion(page);
+
+ await page.route("**/models.robomous.ai/registry/v1.json", (route) =>
+ route.fulfill({ status: 503, body: "offline fixture" }),
+ );
+ await page.route("**/models.robomous.ai/**/*.onnx", (route) =>
+ route.fulfill({ status: 503, body: "offline fixture" }),
+ );
+ const artifactRequests = countModelRequestsFromNow(page);
+ await page.reload();
+ await expect(page.getByTestId("annotation-page")).toBeVisible();
+ await armSuggestTool(page);
+ await expect(page.getByTestId("suggest-device-section").getByText(/ready/i)).toBeVisible({ timeout: 60_000 });
+ expect(artifactRequests()).toBe(0);
+ await makeBrowserSuggestion(page);
+ expect(suggestCallsOf(sent)).toHaveLength(0);
+ });
+
+ test("Remove from this browser clears artifacts, disposes readiness, and survives reload", async ({ page }) => {
+ const sent: Request[] = [];
+ await openJobWithBrowserRuntime(page, sent, true);
+ await armSuggestTool(page);
+ await acquireAndSelectBrowserTarget(page);
+
+ await page.getByTestId("suggest-device-remove-efficient-sam-ti").click();
+ await expect(page.getByTestId("suggest-device-acquire-efficient-sam-ti")).toBeVisible();
+ expect(await page.evaluate(async () => (await caches.open("visionset-browser-models-v1")).keys().then((keys) => keys.length))).toBe(0);
+
+ const artifactRequests = countModelRequestsFromNow(page);
+ await page.reload();
+ await expect(page.getByTestId("annotation-page")).toBeVisible();
+ await armSuggestTool(page);
+ await expect(page.getByTestId("suggest-device-acquire-efficient-sam-ti")).toBeVisible();
+ expect(artifactRequests()).toBe(0);
+ });
+
+ test("a persistent storage refusal is reported as session-only readiness", async ({ page }) => {
+ await page.addInitScript(() => {
+ const nativeOpen = caches.open.bind(caches);
+ caches.open = async (name: string): Promise => {
+ const cache = await nativeOpen(name);
+ return {
+ add: cache.add.bind(cache),
+ addAll: cache.addAll.bind(cache),
+ match: cache.match.bind(cache),
+ matchAll: cache.matchAll.bind(cache),
+ delete: cache.delete.bind(cache),
+ keys: cache.keys.bind(cache),
+ put: async () => { throw new DOMException("fixture quota", "QuotaExceededError"); },
+ };
+ };
+ });
+ const sent: Request[] = [];
+ await openJobWithBrowserRuntime(page, sent, true);
+ await armSuggestTool(page);
+ await acquireAndSelectBrowserTarget(page);
+ await expect(page.getByTestId("suggest-device-session-only")).toBeVisible();
+ await makeBrowserSuggestion(page);
+
+ await page.reload();
+ await expect(page.getByTestId("annotation-page")).toBeVisible();
+ await armSuggestTool(page);
+ await expect(page.getByTestId("suggest-device-acquire-efficient-sam-ti")).toBeVisible();
+ });
+
test("a ready browser target is never blocked by a server-connection blocker", async ({ page }) => {
const sent: Request[] = [];
await openJobWithBrowserRuntime(page, sent, false); // no server connections
diff --git a/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts b/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
index 6263cc83..4e84c271 100644
--- a/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
+++ b/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
@@ -171,6 +171,23 @@ describe("createBrowserModelCatalog", () => {
expect(catalog.snapshot()[0]).toMatchObject({ state: "available", storage: "none" });
});
+ it("does not claim cached artifacts were removed when persistent deletion fails", async () => {
+ const { catalog, dispose, store } = harness({ installed: true });
+ await settles(catalog);
+ await catalog.activate(ADMISSION.id);
+ vi.mocked(store.remove).mockRejectedValue(new Error("storage delete failed"));
+
+ await expect(catalog.remove(ADMISSION.id)).rejects.toThrow(/storage delete failed/i);
+
+ expect(dispose).toHaveBeenCalledTimes(1);
+ expect(catalog.listTargets()).toEqual([]);
+ expect(catalog.snapshot()[0]).toMatchObject({
+ state: "failed",
+ storage: "persistent",
+ error: "storage delete failed",
+ });
+ });
+
it("keeps a cached admitted model usable when registry discovery fails", async () => {
const { catalog, download } = harness({ installed: true, discover: async () => Promise.reject(new Error("503")) });
await settles(catalog);
diff --git a/frontend/app/src/data/browserInference/BrowserModelCatalog.ts b/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
index 44db9d1c..9eb6faf5 100644
--- a/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
+++ b/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
@@ -209,11 +209,22 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
return once(id, async () => {
const record = required(id);
const previous = active?.id === id ? active.value : null;
+ const previousStorage = record.storage;
if (previous !== null) active = null;
record.sessionArtifacts = undefined;
update(record, { visible: true, state: "available", storage: "none", error: undefined });
if (previous !== null) previous.runtime.dispose();
- await deps.store.remove(record.admission);
+ try {
+ await deps.store.remove(record.admission);
+ } catch (error) {
+ update(record, {
+ visible: true,
+ state: "failed",
+ storage: previousStorage === "persistent" ? "persistent" : "none",
+ error: message(error),
+ });
+ throw error;
+ }
});
},
listTargets() {
diff --git a/frontend/ui-core/src/annotator/AnnotationPage.tsx b/frontend/ui-core/src/annotator/AnnotationPage.tsx
index 779a1d40..8425a3ed 100644
--- a/frontend/ui-core/src/annotator/AnnotationPage.tsx
+++ b/frontend/ui-core/src/annotator/AnnotationPage.tsx
@@ -191,7 +191,12 @@ import type { SuggestionOut } from "../data/inferenceQueries";
import { useServerSuggestionExecutor } from "../inference/suggestionExecutor";
import type { SuggestionExecutor } from "../inference/suggestionExecutor";
import { useBrowserInferenceRuntime } from "../inference/VisionSetBrowserInferenceProvider.js";
-import type { ActiveSuggestionTarget, BrowserSuggestionAssetSource, BrowserSuggestionTarget } from "../inference/browserPort.js";
+import type {
+ ActiveSuggestionTarget,
+ BrowserModelCatalogEntry,
+ BrowserSuggestionAssetSource,
+ BrowserSuggestionTarget,
+} from "../inference/browserPort.js";
import { computeSuggestBlocker } from "../inference/targetBlocker.js";
import { readPref, writePref } from "../data/prefs";
@@ -231,12 +236,10 @@ function writeStoredSuggestTarget(projectId: string, target: ActiveSuggestionTar
* Whether a stored browser-target preference is stale enough to fall back to Server
* silently.
*
- * Acquired model bytes are never persisted across page loads, so "the stored browser
- * target isn't in `listTargets()`'s answer" is the ordinary shape of every reload for
- * someone who previously picked a browser target — not a rare failure, and it must not
- * surface as a `blocker`. `explicitlyChosen` is what keeps this from also catching a
- * target this *session* picked and which later drops out: that one is pinned, and stays
- * pinned to `not-ready`/`refusal` rather than silently reverting.
+ * A catalog-known target may be installed, activating, or awaiting an explicit download;
+ * none of those make its preference stale. Only an ID unknown to the build falls back.
+ * `explicitlyChosen` separately keeps a target picked in this session pinned if it later
+ * drops out, so failure stays visible instead of silently switching to Server.
*/
export function staleStoredBrowserTarget(
storedTarget: StoredSuggestTarget,
@@ -251,6 +254,20 @@ export function staleStoredBrowserTarget(
return !browserTargets.some((row) => row.id === storedTarget.targetId);
}
+/**
+ * Reconciles the asynchronously resolved Phase F target list with the catalog's synchronous
+ * lifecycle snapshot. Removal and corruption invalidate an executor before the next
+ * `listTargets()` promise settles, so a target is answerable only while both views say ready.
+ */
+export function readyBrowserTargets(
+ browserTargets: readonly BrowserSuggestionTarget[] | undefined,
+ browserModels: readonly BrowserModelCatalogEntry[],
+): readonly BrowserSuggestionTarget[] | undefined {
+ if (browserTargets === undefined) return browserTargets;
+ const readyIds = new Set(browserModels.filter((entry) => entry.state === "ready").map((entry) => entry.id));
+ return browserTargets.filter((target) => readyIds.has(target.id));
+}
+
const EMPTY_BROWSER_MODELS = Object.freeze([]);
const emptyBrowserModels = () => EMPTY_BROWSER_MODELS;
const subscribeToNothing = () => () => {};
@@ -1037,6 +1054,8 @@ function Workspace({
cancelled = true;
};
}, [browserRuntime, browserTargetsRefreshKey, browserModels]);
+ const answerableBrowserTargets =
+ browserCatalog === undefined ? browserTargets : readyBrowserTargets(browserTargets, browserModels);
const [storedTarget, setStoredTarget] = useState(() => readStoredSuggestTarget(projectId));
// Whether this session picked a browser target through `chooseTarget`, as opposed to one
@@ -1084,7 +1103,7 @@ function Workspace({
.catch(() => setBrowserTargetsRefreshKey((key) => key + 1));
}, [activeBrowserTargetId, browserCatalog, browserModels, suggestArmed]);
- const blocker = computeSuggestBlocker(activeTarget, serverBlocker, browserTargets);
+ const blocker = computeSuggestBlocker(activeTarget, serverBlocker, answerableBrowserTargets);
// `executorFor` throws for a target that isn't actually ready yet — which is exactly the
// state selecting "This device" starts in, before a download ever completes — so this must
// check readiness itself rather than trust `browserRuntime !== null` alone. Derived from
@@ -2929,7 +2948,7 @@ function Workspace({
// matching while parked — the one reading that has to name it.
heldClass={activeClass}
blocker={blocker}
- browserTargets={browserRuntime === null ? undefined : browserTargets}
+ browserTargets={browserRuntime === null ? undefined : answerableBrowserTargets}
browserAcquisitions={browserRuntime?.listAcquisitions?.()}
{...(browserCatalog === undefined
? {}
diff --git a/frontend/ui-core/src/inference/browserRuntime.test.tsx b/frontend/ui-core/src/inference/browserRuntime.test.tsx
index bfea0d07..02acaf0e 100644
--- a/frontend/ui-core/src/inference/browserRuntime.test.tsx
+++ b/frontend/ui-core/src/inference/browserRuntime.test.tsx
@@ -8,9 +8,17 @@ import {
useBrowserInferenceRuntime,
VisionSetBrowserInferenceProvider,
} from "./VisionSetBrowserInferenceProvider";
-import type { BrowserSuggestionAssetSource, VisionSetBrowserInferenceRuntime } from "./browserPort";
+import type {
+ BrowserModelCatalogEntry,
+ BrowserSuggestionAssetSource,
+ VisionSetBrowserInferenceRuntime,
+} from "./browserPort";
import { clearPrefs, writePref } from "../data/prefs";
-import { AnnotationPage, staleStoredBrowserTarget } from "../annotator/AnnotationPage";
+import {
+ AnnotationPage,
+ readyBrowserTargets,
+ staleStoredBrowserTarget,
+} from "../annotator/AnnotationPage";
import { TooltipProvider } from "@robomous/ui-core";
import { renderWithData } from "../testing/dataHarness";
import { stubResizeObserver } from "../testing/resizeObserver.js";
@@ -424,6 +432,29 @@ describe("executor selection never calls executorFor on an unready browser targe
});
});
+describe("readyBrowserTargets", () => {
+ const target = { id: "efficient-sam-ti", label: "EfficientSAM-Ti", modelRef: "model@revision" };
+ const model: BrowserModelCatalogEntry = {
+ id: target.id,
+ label: target.label,
+ modelRef: target.modelRef,
+ revision: "revision",
+ bytes: 41_301_678,
+ license: "Apache-2.0",
+ source: { label: "EfficientSAM", href: "https://example.test/upstream" },
+ state: "ready",
+ storage: "persistent",
+ };
+
+ it("removes a stale ready target synchronously when catalog removal publishes", () => {
+ expect(readyBrowserTargets([target], [{ ...model, state: "available", storage: "none" }])).toEqual([]);
+ });
+
+ it("keeps a target while its catalog entry is ready", () => {
+ expect(readyBrowserTargets([target], [model])).toEqual([target]);
+ });
+});
+
describe("staleStoredBrowserTarget", () => {
const listed = [{ id: "t1", label: "T1", modelRef: "m@rev" }];
From 7e37786068ae9e1dcecd105ba5a9a2ad79917a3a Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Thu, 17 Sep 2026 12:09:55 -0700
Subject: [PATCH 06/15] docs(inference): define persistent browser model
acquisition
---
docs/content/architecture/frontend/app.md | 31 ++++++++
.../frontend/browser-inference.md | 9 +--
docs/content/ui.md | 19 +++--
frontend/app/e2e/browserSuggestion.spec.ts | 71 ++++++++++++++++++-
frontend/app/src/data/OssSession.tsx | 4 +-
.../BrowserInferenceRuntime.test.ts | 14 +++-
.../BrowserInferenceRuntime.ts | 13 ++++
tests/packaging/test_wheel.py | 14 +++-
8 files changed, 161 insertions(+), 14 deletions(-)
diff --git a/docs/content/architecture/frontend/app.md b/docs/content/architecture/frontend/app.md
index 1b0348be..cbb0f419 100644
--- a/docs/content/architecture/frontend/app.md
+++ b/docs/content/architecture/frontend/app.md
@@ -40,6 +40,37 @@ managed host replaces only the `FrameSink`'s destination, the same way it replac
`ossClient.ts` today, which is what proves the boundary actually sits where
[ui-core.md](ui-core.md) says it does.
+The browser-model acquisition system is composed here for the same reason. The public
+registry and immutable manifests are discovery data, **not a trust anchor**. The app validates
+their schema and intersects them with a build-owned admission catalog that pins the model ID,
+revision, graph contract, source and license metadata, artifact roles, byte counts and SHA-256
+digests. Registry entries the build cannot execute are not offered. Registry metadata cannot
+name code, dynamic imports, preprocessing functions, or paths outside the configured public
+model base.
+
+An explicit Download action fetches admitted artifacts. Every complete download is checked for
+its pinned size and digest before any byte is written to the versioned Cache Storage namespace;
+unverified or partial models are never installed. Storage keys include the model revision and
+artifact digest, so mirrors of identical immutable content share an identity without confusing
+different revisions. Activation reads the whole admitted set back and repeats both checks. A
+missing or corrupt entry is removed and leaves the model unavailable; activation never turns
+that failure into a network download.
+
+The catalog and the execution port deliberately answer different questions. Catalog states
+describe known, downloading, installed, activating, ready, and failed models. `listTargets()`
+continues to list only models that can answer now. Startup inspects storage but creates no ONNX
+Runtime worker or session. A cached model activates lazily when the armed Suggest surface needs
+the selected target; runtime sessions and image embeddings remain memory-only. Removal first
+invalidates and disposes that runtime, then deletes only the admitted revision's model
+artifacts.
+
+Cache Storage is a browser-managed persistence layer, not a permanence guarantee. If a verified
+download cannot be written, the app may run it for that session while stating that it was not
+saved. An admitted cached model can be verified and activated when registry and artifact routes
+are unavailable, while Server inference remains independent of the catalog and public model
+source. No service worker, Python-side browser cache, or automatic revision update participates
+in this flow.
+
## What a route does
```mermaid
diff --git a/docs/content/architecture/frontend/browser-inference.md b/docs/content/architecture/frontend/browser-inference.md
index a8fe9d14..f9c67b5e 100644
--- a/docs/content/architecture/frontend/browser-inference.md
+++ b/docs/content/architecture/frontend/browser-inference.md
@@ -17,10 +17,11 @@ that is supposed to be *injected with* runtimes - the dependency arrow pointing
through the seam it exists to serve. A host that holds both adapts one to the other, which is
what [host composition](../decisions/browser-inference-is-host-injected.md) is for.
-Nothing in this repository composes that adapter yet, and nothing imports this package.
-**A package existing is not the product offering a browser target.** There is no "This device"
-control and no user-visible change - a caller who wants EfficientSAM-Ti running still has to
-supply the weights and wire the adapter itself.
+The OSS app composes that adapter at its host boundary. **A package existing is still not the
+product offering a browser target:** the app supplies admitted, verified graph bytes and adapts
+the resulting runtime to `ui-core`; this package neither discovers models nor decides which
+ones VisionSet trusts. Another host can omit browser inference or provide a different
+implementation of the same optional port.
## Core and adapter are two entrypoints, for the same reason as media
diff --git a/docs/content/ui.md b/docs/content/ui.md
index b25b44c4..bb64e4cb 100644
--- a/docs/content/ui.md
+++ b/docs/content/ui.md
@@ -627,11 +627,20 @@ and without a connection being configured at all - useful where the workspace ha
model that can answer a click, or where the server is slow to reach.
It has to be downloaded first, and the panel says so: about 41 MB, on an explicit
-press, never on its own. **The download does not survive a page reload.** It is held
-for the session only, so reopening the editor tomorrow - or reloading today - means
-downloading it again before "This device" can answer. Until then the tab shows the
-Download button rather than inviting a click that would do nothing, and the choice
-falls back to Server.
+press, never on its own. Before that press the panel names the model's upstream source,
+license and download size. Verified weights are stored in this browser and normally
+survive a reload; the browser can still evict its storage, and a removed or evicted
+model can be downloaded again. If storage is unavailable, a verified download may be
+ready for the current session without being described as installed.
+
+Opening the app, arming Suggest, or choosing **This device** never downloads missing
+weights. A stored choice for a known model remains selected and shows the Download
+action when that model is not installed. An installed model is loaded only when the
+suggestion surface needs it, and loading from browser storage does not fetch the model
+again. **Remove from this browser** releases the running model and removes its stored
+weights. The model source receives artifact requests during an explicit download, but
+the image and points being annotated remain local during browser inference. Self-hosted
+deployments can replace the public model source with `VITE_MODEL_CDN_BASE_URL`.
**Alt-click needs Server.** The model running here takes only points that are *on*
the object; a point marking something that is not part of it is refused rather than
diff --git a/frontend/app/e2e/browserSuggestion.spec.ts b/frontend/app/e2e/browserSuggestion.spec.ts
index 6c6726c1..af131064 100644
--- a/frontend/app/e2e/browserSuggestion.spec.ts
+++ b/frontend/app/e2e/browserSuggestion.spec.ts
@@ -186,8 +186,9 @@ async function openJobWithBrowserRuntime(
page: Page,
sent: Request[],
suggestible: boolean,
+ modelSource: "fixture" | "live" = "fixture",
): Promise {
- await mockCdn(page);
+ if (modelSource === "fixture") await mockCdn(page);
await serveApi(page, sent, undefined, undefined, undefined, undefined, suggestible);
await mockAssetImage(page);
await page.goto(`/jobs/${JOB}`);
@@ -290,6 +291,74 @@ test.describe("browser suggestion", () => {
expect(suggestCallsOf(sent)).toHaveLength(0);
});
+ test("live CDN smoke: admitted artifacts persist and reactivate without a second download", async ({ page }) => {
+ test.skip(process.env.VISIONSET_LIVE_MODEL_SMOKE !== "1", "manual smoke against models.robomous.ai");
+ test.setTimeout(120_000);
+ const sent: Request[] = [];
+ const responses: { url: string; bytes: number; milliseconds: number }[] = [];
+ page.on("response", async (response) => {
+ if (!response.url().startsWith("https://models.robomous.ai/")) return;
+ await response.finished();
+ const timing = response.request().timing();
+ const declaredBytes = Number(response.headers()["content-length"] ?? 0);
+ const bytes = declaredBytes > 0 ? declaredBytes : (await response.body()).byteLength;
+ responses.push({ url: response.url(), bytes, milliseconds: timing.responseEnd });
+ });
+ // Warm Chromium's connection to the public origin with the same registry document the
+ // application will validate. This keeps a one-off DNS/TLS stall from consuming the whole
+ // UI timeout while still exercising the app's own fetch, parser, and admission match below.
+ await page.goto("https://models.robomous.ai/registry/v1.json");
+ await expect(page.locator("body")).toContainText('"schema_version": 1');
+ await openJobWithBrowserRuntime(page, sent, true, "live");
+ await armSuggestTool(page);
+ const coldStarted = Date.now();
+ await acquireAndSelectBrowserTarget(page);
+ const coldMilliseconds = Date.now() - coldStarted;
+ await makeBrowserSuggestion(page);
+
+ const cacheMeasurements = await page.evaluate(async () => {
+ const cache = await caches.open("visionset-browser-models-v1");
+ const keys = await cache.keys();
+ let bytes = 0;
+ let readMilliseconds = 0;
+ let shaMilliseconds = 0;
+ const lookupStarted = performance.now();
+ await Promise.all(keys.map((key) => cache.match(key)));
+ const lookupMilliseconds = performance.now() - lookupStarted;
+ for (const key of keys) {
+ const response = await cache.match(key);
+ if (response === undefined) continue;
+ const readStarted = performance.now();
+ const body = await response.arrayBuffer();
+ readMilliseconds += performance.now() - readStarted;
+ bytes += body.byteLength;
+ const shaStarted = performance.now();
+ await crypto.subtle.digest("SHA-256", body);
+ shaMilliseconds += performance.now() - shaStarted;
+ }
+ return { entries: keys.length, bytes, lookupMilliseconds, readMilliseconds, shaMilliseconds };
+ });
+
+ const artifactRequests = countModelRequestsFromNow(page);
+ const reloadStarted = Date.now();
+ await page.reload();
+ await expect(page.getByTestId("annotation-page")).toBeVisible();
+ await armSuggestTool(page);
+ await expect(page.getByTestId("suggest-device-section").getByText("Ready", { exact: true })).toBeVisible({
+ timeout: 60_000,
+ });
+ const reloadActivationMilliseconds = Date.now() - reloadStarted;
+ expect(artifactRequests()).toBe(0);
+ await makeBrowserSuggestion(page);
+
+ console.info("VISIONSET_LIVE_MODEL_SMOKE", JSON.stringify({
+ responses,
+ coldMilliseconds,
+ reloadActivationMilliseconds,
+ ...cacheMeasurements,
+ }));
+ });
+
test("an installed model remains usable when registry and artifact routes fail", async ({ page }) => {
const sent: Request[] = [];
await openJobWithBrowserRuntime(page, sent, true);
diff --git a/frontend/app/src/data/OssSession.tsx b/frontend/app/src/data/OssSession.tsx
index 60c834d2..3c8c0d22 100644
--- a/frontend/app/src/data/OssSession.tsx
+++ b/frontend/app/src/data/OssSession.tsx
@@ -37,7 +37,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { VisionSetBrowserInferenceProvider, VisionSetDataProvider, VisionSetMediaProvider } from "@visionset/ui-core";
import { MediabunnyVideoMaterializer } from "@visionset/media/mediabunny";
-import { createOssBrowserInferenceRuntime } from "./browserInference/BrowserInferenceRuntime";
+import { getSharedOssBrowserInferenceRuntime } from "./browserInference/BrowserInferenceRuntime";
import { createOssDataClient, requestSession } from "./ossClient";
import { createLocalApiFrameSink } from "./frameSink";
import { clearToken, readToken, writeToken } from "./token";
@@ -164,7 +164,7 @@ export function OssSessionProvider({
* and SHA-256 verification. Built once per session — its acquisition state (unacquired
* vs. ready) must survive across asset navigation, not reset on every render.
*/
- const browserInferenceRuntime = useMemo(() => createOssBrowserInferenceRuntime(), []);
+ const browserInferenceRuntime = useMemo(() => getSharedOssBrowserInferenceRuntime(), []);
const signIn = useCallback((next: string) => {
writeToken(next);
diff --git a/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts b/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts
index d8f37d67..8feac0fe 100644
--- a/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts
+++ b/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { SuggestionRequest } from "@visionset/ui-core";
-import { createOssBrowserInferenceRuntime } from "./BrowserInferenceRuntime.js";
+import {
+ createOssBrowserInferenceRuntime,
+ getSharedOssBrowserInferenceRuntime,
+} from "./BrowserInferenceRuntime.js";
import { EFFICIENT_SAM_TI_REVISION } from "./manifest.js";
import type { VisionSetBrowserInferenceRuntime } from "@visionset/ui-core";
import type { BrowserModelArtifacts } from "./artifactStore.js";
@@ -30,6 +33,15 @@ async function acquisition(runtime: VisionSetBrowserInferenceRuntime) {
}
describe("createOssBrowserInferenceRuntime", () => {
+ it("shares the host runtime across repeated composition calls", () => {
+ const runtime = createOssBrowserInferenceRuntime(fakeDeps());
+ const factory = vi.fn(() => runtime);
+
+ expect(getSharedOssBrowserInferenceRuntime(factory)).toBe(runtime);
+ expect(getSharedOssBrowserInferenceRuntime(factory)).toBe(runtime);
+ expect(factory).toHaveBeenCalledTimes(1);
+ });
+
it("exposes the additive model catalog while preserving the Phase F runtime members", () => {
const runtime = createOssBrowserInferenceRuntime(fakeDeps());
expect(runtime.modelCatalog).toBeDefined();
diff --git a/frontend/app/src/data/browserInference/BrowserInferenceRuntime.ts b/frontend/app/src/data/browserInference/BrowserInferenceRuntime.ts
index b609201a..e3256c49 100644
--- a/frontend/app/src/data/browserInference/BrowserInferenceRuntime.ts
+++ b/frontend/app/src/data/browserInference/BrowserInferenceRuntime.ts
@@ -94,3 +94,16 @@ export function createOssBrowserInferenceRuntime(deps: Deps = REAL_DEPS): Vision
},
};
}
+
+let sharedRuntime: VisionSetBrowserInferenceRuntime | undefined;
+
+/**
+ * The browser model catalog belongs to the page, not to an authenticated server-data scope.
+ * Sharing it also keeps React development strict mounts from starting duplicate discovery.
+ */
+export function getSharedOssBrowserInferenceRuntime(
+ factory: () => VisionSetBrowserInferenceRuntime = createOssBrowserInferenceRuntime,
+): VisionSetBrowserInferenceRuntime {
+ sharedRuntime ??= factory();
+ return sharedRuntime;
+}
diff --git a/tests/packaging/test_wheel.py b/tests/packaging/test_wheel.py
index d7694e9e..634fef0d 100644
--- a/tests/packaging/test_wheel.py
+++ b/tests/packaging/test_wheel.py
@@ -100,7 +100,19 @@
#: Media suffixes. `_static/` legitimately holds none today — the app ships as
#: HTML, CSS, JavaScript and one WebAssembly runtime — so any of these is
#: something nobody meant to ship.
-FORBIDDEN_SUFFIXES = (".mp4", ".mov", ".avi", ".jpg", ".jpeg", ".tiff", ".bmp")
+FORBIDDEN_SUFFIXES = (
+ ".mp4",
+ ".mov",
+ ".avi",
+ ".jpg",
+ ".jpeg",
+ ".tiff",
+ ".bmp",
+ # Browser model weights belong to the explicit CDN/cache acquisition flow.
+ # ONNX Runtime's own `.wasm` payload remains the deliberately packaged exception.
+ ".onnx",
+ ".pt",
+)
#: How long the freshly installed server gets to bind a socket.
#:
From f23c6bf32bffe6be8abec2536c9ab5943f4b0d06 Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Thu, 17 Sep 2026 12:12:51 -0700
Subject: [PATCH 07/15] fix(inference): keep catalog storage state truthful
---
.../BrowserModelCatalog.test.ts | 13 +++++++++++++
.../browserInference/BrowserModelCatalog.ts | 11 ++++++++---
.../app/src/data/browserInference/manifest.ts | 16 ++++++++++------
.../ui-core/src/annotator/SuggestPanel.tsx | 19 ++++++++++++++++---
.../src/annotator/suggestPanel.test.tsx | 14 ++++++++++++++
5 files changed, 61 insertions(+), 12 deletions(-)
diff --git a/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts b/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
index 4e84c271..f511b004 100644
--- a/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
+++ b/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
@@ -148,6 +148,19 @@ describe("createBrowserModelCatalog", () => {
expect(catalog.listTargets()).toEqual([]);
});
+ it("keeps persistent storage truthful when runtime startup fails after verified cache read", async () => {
+ const { catalog, download } = harness({
+ installed: true,
+ ready: async () => Promise.reject(new Error("runtime startup failed")),
+ });
+ await settles(catalog);
+
+ await expect(catalog.activate(ADMISSION.id)).rejects.toThrow(/runtime startup failed/i);
+
+ expect(download).not.toHaveBeenCalled();
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "failed", storage: "persistent" });
+ });
+
it("does not treat a missing decoder as installed or ready", async () => {
const { catalog, download } = harness({ installed: false, discover: async () => true });
await settles(catalog);
diff --git a/frontend/app/src/data/browserInference/BrowserModelCatalog.ts b/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
index 9eb6faf5..da2afb36 100644
--- a/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
+++ b/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
@@ -193,16 +193,21 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
return once(id, async () => {
const record = required(id);
if (record.state === "ready") return;
+ let artifacts: BrowserModelArtifacts;
try {
- const artifacts = record.sessionArtifacts ?? (await deps.store.readVerified(record.admission));
- if (artifacts === null) throw new Error(`${record.admission.label} is not installed in this browser`);
- await startRuntime(record, artifacts);
+ const stored = record.sessionArtifacts ?? (await deps.store.readVerified(record.admission));
+ if (stored === null) throw new Error(`${record.admission.label} is not installed in this browser`);
+ artifacts = stored;
} catch (error) {
record.sessionArtifacts = undefined;
if (active?.id === id) active = null;
update(record, { visible: true, state: "failed", storage: "none", error: message(error) });
throw error;
}
+ // `startRuntime` owns its failure state. At this point bytes have already passed the
+ // cache integrity check, so a worker/session failure must not pretend persistent
+ // storage disappeared or was corrupt.
+ await startRuntime(record, artifacts);
});
},
remove(id) {
diff --git a/frontend/app/src/data/browserInference/manifest.ts b/frontend/app/src/data/browserInference/manifest.ts
index a2300584..a3834cb1 100644
--- a/frontend/app/src/data/browserInference/manifest.ts
+++ b/frontend/app/src/data/browserInference/manifest.ts
@@ -4,13 +4,15 @@
* Self-hosted deployments override VITE_MODEL_CDN_BASE_URL to point at their own mirror
* of this same manifest layout.
*/
+import { EFFICIENT_SAM_TI_ADMISSION } from "./admissionCatalog.js";
+
export const MODEL_CDN_BASE_URL: string = (
(import.meta.env["VITE_MODEL_CDN_BASE_URL"] as string | undefined) ?? "https://models.robomous.ai"
).replace(/\/+$/, "");
export const MODEL_REGISTRY_URL = `${MODEL_CDN_BASE_URL}/registry/v1.json`;
-export const EFFICIENT_SAM_TI_REVISION = "b19782d049c0-843761ca46f4";
+export const EFFICIENT_SAM_TI_REVISION = EFFICIENT_SAM_TI_ADMISSION.revision;
/**
* Shared by the manifest URL and every artifact URL, so the CDN's directory layout
@@ -21,11 +23,13 @@ export const EFFICIENT_SAM_TI_BASE_URL = `${MODEL_CDN_BASE_URL}/models/efficient
export const EFFICIENT_SAM_TI_MANIFEST_URL = `${EFFICIENT_SAM_TI_BASE_URL}/manifest.json`;
-/** Verified 2026-09-16 against the live models.robomous.ai release — see the design doc §5. */
-export const EFFICIENT_SAM_TI_EXPECTED = {
- encoder: { sha256: "b19782d049c09a8f1cc36ccc6029264ca23c8ac35e6379fd9ef9f1bc6d81e7f2", bytes: 24_799_777 },
- decoder: { sha256: "843761ca46f4aa00b09fdcf0c94271321f76eece092a744296c742d682a86172", bytes: 16_501_901 },
-} as const;
+/** Phase F compatibility view; the build admission record is the single trust anchor. */
+export const EFFICIENT_SAM_TI_EXPECTED = Object.fromEntries(
+ EFFICIENT_SAM_TI_ADMISSION.artifacts.map((artifact) => [
+ artifact.role,
+ { sha256: artifact.sha256, bytes: artifact.bytes },
+ ]),
+) as Record<"encoder" | "decoder", { readonly sha256: string; readonly bytes: number }>;
/**
* Mirrors the real, already-deployed manifest shape (nested under `artifacts`,
diff --git a/frontend/ui-core/src/annotator/SuggestPanel.tsx b/frontend/ui-core/src/annotator/SuggestPanel.tsx
index cbdc6662..1d8be971 100644
--- a/frontend/ui-core/src/annotator/SuggestPanel.tsx
+++ b/frontend/ui-core/src/annotator/SuggestPanel.tsx
@@ -443,6 +443,14 @@ export function SuggestPanel({
// thing that is actually available: the Download control the tab below already draws.
const browserTabUnacquired =
runtimeWired && activeTarget?.kind === "browser" && blocker === "not-ready";
+ const activeBrowserModel =
+ activeTarget?.kind === "browser"
+ ? browserModels?.find((model) => model.id === activeTarget.targetId)
+ : undefined;
+ const browserModelBusy =
+ activeBrowserModel?.state === "downloading" ||
+ activeBrowserModel?.state === "installed" ||
+ activeBrowserModel?.state === "activating";
return (
}>
@@ -450,11 +458,16 @@ export function SuggestPanel({
(browserTabUnacquired ? (
<>
- Download the model first
+ {activeBrowserModel?.state === "downloading"
+ ? "Downloading the model…"
+ : browserModelBusy
+ ? "Loading the model…"
+ : "Download the model first"}
- “This device” has nothing to answer with yet, so a click does nothing. Download
- it below, or switch back to Server.
+ {browserModelBusy
+ ? "“This device” is getting the selected model ready. A click will work once loading finishes."
+ : "“This device” has nothing to answer with yet, so a click does nothing. Download it below, or switch back to Server."}
>
) : (
diff --git a/frontend/ui-core/src/annotator/suggestPanel.test.tsx b/frontend/ui-core/src/annotator/suggestPanel.test.tsx
index cbf9a1ad..658f5e9d 100644
--- a/frontend/ui-core/src/annotator/suggestPanel.test.tsx
+++ b/frontend/ui-core/src/annotator/suggestPanel.test.tsx
@@ -716,6 +716,20 @@ describe("this device, once a browser runtime is wired", () => {
expect(screen.getByTestId("suggest-device-section").textContent).toContain(label);
});
+ it("describes a cached model as loading instead of asking for another download", () => {
+ render(mount({
+ blocker: "not-ready",
+ browserTargets: [],
+ browserModels: [catalogEntry({ state: "installed", storage: "persistent" })],
+ activeTarget: { kind: "browser", targetId: READY.id },
+ onChooseTarget: vi.fn(),
+ onRemoveBrowserModel: vi.fn(),
+ }));
+
+ expect(screen.getByTestId("suggest-idle-unacquired").textContent).toMatch(/loading/i);
+ expect(screen.getByTestId("suggest-panel").textContent).not.toContain("Download the model first");
+ });
+
it("removes an installed model through an explicit packaged control", async () => {
const remove = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
From 8d70f297ca01138166e24a7df558746a2c254043 Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Thu, 17 Sep 2026 12:14:52 -0700
Subject: [PATCH 08/15] test(inference): keep runtime fixture type safe
---
.../src/data/browserInference/BrowserInferenceRuntime.test.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts b/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts
index 8feac0fe..3f910d2a 100644
--- a/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts
+++ b/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts
@@ -29,7 +29,9 @@ async function acquisition(runtime: VisionSetBrowserInferenceRuntime) {
// Registry/cache initialization is asynchronous in Phase G. `listTargets()` waits for that
// initial pass, after which the synchronous Phase F compatibility view is populated.
await runtime.listTargets();
- return runtime.listAcquisitions?.()[0]!;
+ const available = runtime.listAcquisitions?.()[0];
+ if (available === undefined) throw new Error("expected an available browser model fixture");
+ return available;
}
describe("createOssBrowserInferenceRuntime", () => {
From 88e228f2a9322dad387b3c118fef3bda174b6e59 Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Thu, 17 Sep 2026 12:21:49 -0700
Subject: [PATCH 09/15] test(inference): require complete artifact removal
---
frontend/app/src/data/browserInference/artifactStore.test.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/frontend/app/src/data/browserInference/artifactStore.test.ts b/frontend/app/src/data/browserInference/artifactStore.test.ts
index 88b4be14..6f5b8a59 100644
--- a/frontend/app/src/data/browserInference/artifactStore.test.ts
+++ b/frontend/app/src/data/browserInference/artifactStore.test.ts
@@ -159,6 +159,9 @@ describe("createCacheArtifactStore", () => {
await store.remove(first.admission);
+ for (const artifact of first.admission.artifacts) {
+ expect(cache.entries.has(cacheKeyFor(first.admission, artifact))).toBe(false);
+ }
expect(await store.inspect(first.admission)).toBe(false);
expect(await store.inspect(second.admission)).toBe(true);
});
From 5eeec0520fd857acdf8569ebe24d5c4f91fe5c68 Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Thu, 17 Sep 2026 12:30:05 -0700
Subject: [PATCH 10/15] test(inference): isolate real-model functional timing
---
frontend/app/e2e/browserSuggestion.spec.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/frontend/app/e2e/browserSuggestion.spec.ts b/frontend/app/e2e/browserSuggestion.spec.ts
index af131064..976ddd76 100644
--- a/frontend/app/e2e/browserSuggestion.spec.ts
+++ b/frontend/app/e2e/browserSuggestion.spec.ts
@@ -249,6 +249,10 @@ async function makeBrowserSuggestion(page: Page): Promise {
}
test.describe("browser suggestion", () => {
+ // Real ONNX work competes with the rest of the fully-parallel app suite on local machines.
+ // This is a functional ceiling, not a performance assertion; measured timings are reported
+ // by the opt-in live smoke instead of turning shared-runner wall clock into a gate.
+ test.setTimeout(60_000);
test.skip(!HAS_ARTIFACTS, MISSING_MESSAGE);
test("Server target: a click issues exactly one /inference/suggest HTTP request", async ({ page }) => {
From 74b817912cd3b5a87b547471e2ea718113d7786f Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Thu, 17 Sep 2026 12:42:48 -0700
Subject: [PATCH 11/15] fix(inference): serialize browser model lifecycle
operations
---
.../BrowserModelCatalog.test.ts | 31 +++++++++++++++++
.../browserInference/BrowserModelCatalog.ts | 34 +++++++++++++++----
.../acquireEfficientSam.test.ts | 26 +++++++-------
.../browserInference/acquireEfficientSam.ts | 22 +++++++-----
.../app/src/data/browserInference/manifest.ts | 5 +++
5 files changed, 90 insertions(+), 28 deletions(-)
diff --git a/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts b/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
index f511b004..e942e1fd 100644
--- a/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
+++ b/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
@@ -184,6 +184,25 @@ describe("createBrowserModelCatalog", () => {
expect(catalog.snapshot()[0]).toMatchObject({ state: "available", storage: "none" });
});
+ it("queues removal behind activation instead of mistaking activation for removal", async () => {
+ const pendingReady = deferred();
+ const { catalog, dispose, store } = harness({ installed: true, ready: () => pendingReady.promise });
+ await settles(catalog);
+
+ const activating = catalog.activate(ADMISSION.id);
+ await vi.waitFor(() => expect(catalog.snapshot()[0]?.state).toBe("activating"));
+ const removing = catalog.remove(ADMISSION.id);
+ expect(store.remove).not.toHaveBeenCalled();
+
+ pendingReady.resolve([]);
+ await Promise.all([activating, removing]);
+
+ expect(dispose).toHaveBeenCalledTimes(1);
+ expect(store.remove).toHaveBeenCalledTimes(1);
+ expect(catalog.listTargets()).toEqual([]);
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "available", storage: "none" });
+ });
+
it("does not claim cached artifacts were removed when persistent deletion fails", async () => {
const { catalog, dispose, store } = harness({ installed: true });
await settles(catalog);
@@ -209,6 +228,18 @@ describe("createBrowserModelCatalog", () => {
expect(download).not.toHaveBeenCalled();
});
+ it("keeps an admitted uninstalled model visible and retryable when registry discovery fails", async () => {
+ const { catalog } = harness({ discover: async () => Promise.reject(new Error("registry unavailable")) });
+ await settles(catalog);
+
+ expect(catalog.snapshot()[0]).toMatchObject({
+ state: "failed",
+ storage: "none",
+ error: "registry unavailable",
+ });
+ expect(catalog.isKnown(ADMISSION.id)).toBe(true);
+ });
+
it("knows an admitted but uninstalled preference without fabricating a ready target", async () => {
const { catalog } = harness();
expect(catalog.isKnown(ADMISSION.id)).toBe(true);
diff --git a/frontend/app/src/data/browserInference/BrowserModelCatalog.ts b/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
index da2afb36..ebff08a2 100644
--- a/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
+++ b/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
@@ -82,7 +82,10 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
]),
);
const listeners = new Set<() => void>();
- const operations = new Map>();
+ const operations = new Map<
+ string,
+ { readonly kind: "acquire" | "activate" | "remove"; readonly promise: Promise }
+ >();
let active: { readonly id: string; readonly value: ActiveModel } | null = null;
let snapshot: readonly BrowserModelCatalogEntry[] = [];
@@ -118,6 +121,13 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
update(record, { visible: true, state: "installed", storage: "persistent", error: undefined });
} else if (discovered) {
update(record, { visible: true, state: "available", storage: "none", error: undefined });
+ } else if (registryResult.status === "rejected") {
+ update(record, {
+ visible: true,
+ state: "failed",
+ storage: "none",
+ error: message(registryResult.reason),
+ });
}
}
@@ -143,11 +153,21 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
}
}
- function once(id: string, operation: () => Promise): Promise {
+ function once(
+ id: string,
+ kind: "acquire" | "activate" | "remove",
+ operation: () => Promise,
+ ): Promise {
const current = operations.get(id);
- if (current !== undefined) return current;
+ if (current !== undefined) {
+ if (current.kind === kind) return current.promise;
+ return current.promise.then(
+ () => once(id, kind, operation),
+ () => once(id, kind, operation),
+ );
+ }
const promise = operation().finally(() => operations.delete(id));
- operations.set(id, promise);
+ operations.set(id, { kind, promise });
return promise;
}
@@ -162,7 +182,7 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
},
isKnown: (id) => records.has(id),
acquire(id, options) {
- return once(id, async () => {
+ return once(id, "acquire", async () => {
const record = required(id);
if (record.state === "ready") return;
if (!record.discovered) {
@@ -190,7 +210,7 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
});
},
activate(id) {
- return once(id, async () => {
+ return once(id, "activate", async () => {
const record = required(id);
if (record.state === "ready") return;
let artifacts: BrowserModelArtifacts;
@@ -211,7 +231,7 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
});
},
remove(id) {
- return once(id, async () => {
+ return once(id, "remove", async () => {
const record = required(id);
const previous = active?.id === id ? active.value : null;
const previousStorage = record.storage;
diff --git a/frontend/app/src/data/browserInference/acquireEfficientSam.test.ts b/frontend/app/src/data/browserInference/acquireEfficientSam.test.ts
index d0fecc57..aea18f2a 100644
--- a/frontend/app/src/data/browserInference/acquireEfficientSam.test.ts
+++ b/frontend/app/src/data/browserInference/acquireEfficientSam.test.ts
@@ -150,23 +150,25 @@ describe("acquireEfficientSam", () => {
expect(result.decoder).toEqual(decoderBytes);
});
- it("fails closed on a manifest artifact path shaped like a traversal, rather than building whatever URL it names", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(async (url: string) => {
+ it.each(["../secrets.onnx", "..", "%2e%2e", "\\..\\private"])(
+ "fails closed on manifest artifact path %s before any artifact request",
+ async (path) => {
+ const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("manifest.json")) {
return new Response(
- JSON.stringify({ artifacts: { encoder: { path: "../secrets.onnx" }, decoder: { path: "decoder.onnx" } } }),
+ JSON.stringify({ artifacts: { encoder: { path }, decoder: { path: "decoder.onnx" } } }),
);
}
throw new Error(`unexpected url ${url}`);
- }),
- );
- vi.resetModules();
- const { acquireEfficientSam } = await import("./acquireEfficientSam.js");
-
- await expect(acquireEfficientSam()).rejects.toThrow(/unexpected manifest artifact path/i);
- });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ vi.resetModules();
+ const { acquireEfficientSam } = await import("./acquireEfficientSam.js");
+
+ await expect(acquireEfficientSam()).rejects.toThrow(/unexpected manifest artifact path/i);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ },
+ );
});
describe("fetchEfficientSamManifest", () => {
diff --git a/frontend/app/src/data/browserInference/acquireEfficientSam.ts b/frontend/app/src/data/browserInference/acquireEfficientSam.ts
index 59979fc8..6396711d 100644
--- a/frontend/app/src/data/browserInference/acquireEfficientSam.ts
+++ b/frontend/app/src/data/browserInference/acquireEfficientSam.ts
@@ -1,14 +1,18 @@
-import { EFFICIENT_SAM_TI_BASE_URL, EFFICIENT_SAM_TI_EXPECTED, fetchEfficientSamManifest } from "./manifest.js";
+import {
+ EFFICIENT_SAM_TI_ARTIFACT_PATHS,
+ EFFICIENT_SAM_TI_BASE_URL,
+ EFFICIENT_SAM_TI_EXPECTED,
+ fetchEfficientSamManifest,
+} from "./manifest.js";
// The manifest's `artifacts.*.path` is a bare filename, resolved relative to the
// manifest's own directory (`EFFICIENT_SAM_TI_BASE_URL`, the same prefix
// `EFFICIENT_SAM_TI_MANIFEST_URL` is built from) — not an absolute path safe to
-// append directly to `MODEL_CDN_BASE_URL`. A `path` containing a slash is rejected
-// outright: it should always be a bare filename, and a mutated manifest asking to
-// climb out of its own directory fails closed here rather than silently building
-// whatever URL it names.
-function artifactUrl(path: string): string {
- if (path.includes("/")) throw new Error(`unexpected manifest artifact path: ${path}`);
+// append directly to `MODEL_CDN_BASE_URL`. The mutable value must equal the
+// build-admitted filename, so normalized or encoded traversal syntax fails before
+// an artifact request rather than silently building whatever URL it names.
+function artifactUrl(path: string, expectedPath: string): string {
+ if (path !== expectedPath) throw new Error(`unexpected manifest artifact path: ${path}`);
return `${EFFICIENT_SAM_TI_BASE_URL}/${path}`;
}
@@ -58,12 +62,12 @@ export async function acquireEfficientSam(
}> {
const manifest = await fetchEfficientSamManifest(signal);
const encoder = await fetchVerified(
- artifactUrl(manifest.artifacts.encoder.path),
+ artifactUrl(manifest.artifacts.encoder.path, EFFICIENT_SAM_TI_ARTIFACT_PATHS.encoder),
EFFICIENT_SAM_TI_EXPECTED.encoder,
signal,
);
const decoder = await fetchVerified(
- artifactUrl(manifest.artifacts.decoder.path),
+ artifactUrl(manifest.artifacts.decoder.path, EFFICIENT_SAM_TI_ARTIFACT_PATHS.decoder),
EFFICIENT_SAM_TI_EXPECTED.decoder,
signal,
);
diff --git a/frontend/app/src/data/browserInference/manifest.ts b/frontend/app/src/data/browserInference/manifest.ts
index a3834cb1..2ea274f2 100644
--- a/frontend/app/src/data/browserInference/manifest.ts
+++ b/frontend/app/src/data/browserInference/manifest.ts
@@ -31,6 +31,11 @@ export const EFFICIENT_SAM_TI_EXPECTED = Object.fromEntries(
]),
) as Record<"encoder" | "decoder", { readonly sha256: string; readonly bytes: number }>;
+/** Admitted filenames used to bind the actual artifact request to the build's trust record. */
+export const EFFICIENT_SAM_TI_ARTIFACT_PATHS = Object.fromEntries(
+ EFFICIENT_SAM_TI_ADMISSION.artifacts.map((artifact) => [artifact.role, artifact.path]),
+) as Record<"encoder" | "decoder", string>;
+
/**
* Mirrors the real, already-deployed manifest shape (nested under `artifacts`,
* with each `path` a bare filename relative to the manifest's own directory) — not
From 749642f848d49a0f799949f4d76a689bec48a9be Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Thu, 17 Sep 2026 12:48:02 -0700
Subject: [PATCH 12/15] fix(inference): expose unavailable admitted models
---
.../browserInference/BrowserModelCatalog.test.ts | 12 ++++++++++++
.../src/data/browserInference/BrowserModelCatalog.ts | 7 +++++--
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts b/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
index e942e1fd..a5002e45 100644
--- a/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
+++ b/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
@@ -240,6 +240,18 @@ describe("createBrowserModelCatalog", () => {
expect(catalog.isKnown(ADMISSION.id)).toBe(true);
});
+ it("does not strand an admitted preference when a valid registry omits its release", async () => {
+ const { catalog } = harness({ discover: async () => false });
+ await settles(catalog);
+
+ expect(catalog.snapshot()[0]).toMatchObject({
+ state: "failed",
+ storage: "none",
+ error: expect.stringMatching(/not available.*registry/i),
+ });
+ expect(catalog.isKnown(ADMISSION.id)).toBe(true);
+ });
+
it("knows an admitted but uninstalled preference without fabricating a ready target", async () => {
const { catalog } = harness();
expect(catalog.isKnown(ADMISSION.id)).toBe(true);
diff --git a/frontend/app/src/data/browserInference/BrowserModelCatalog.ts b/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
index ebff08a2..d5c9ffff 100644
--- a/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
+++ b/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
@@ -121,12 +121,15 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
update(record, { visible: true, state: "installed", storage: "persistent", error: undefined });
} else if (discovered) {
update(record, { visible: true, state: "available", storage: "none", error: undefined });
- } else if (registryResult.status === "rejected") {
+ } else {
update(record, {
visible: true,
state: "failed",
storage: "none",
- error: message(registryResult.reason),
+ error:
+ registryResult.status === "rejected"
+ ? message(registryResult.reason)
+ : `${record.admission.label} is not available from the configured registry`,
});
}
}
From b5ed3fa8b797bf3c7cfb823810e0e8cf73aff0cc Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Thu, 17 Sep 2026 12:57:38 -0700
Subject: [PATCH 13/15] test(inference): await live registry admission
---
frontend/app/e2e/browserSuggestion.spec.ts | 21 ++++++++++++++++-----
1 file changed, 16 insertions(+), 5 deletions(-)
diff --git a/frontend/app/e2e/browserSuggestion.spec.ts b/frontend/app/e2e/browserSuggestion.spec.ts
index 976ddd76..ddb7c4d1 100644
--- a/frontend/app/e2e/browserSuggestion.spec.ts
+++ b/frontend/app/e2e/browserSuggestion.spec.ts
@@ -300,21 +300,32 @@ test.describe("browser suggestion", () => {
test.setTimeout(120_000);
const sent: Request[] = [];
const responses: { url: string; bytes: number; milliseconds: number }[] = [];
+ page.on("request", (request) => {
+ if (request.url().startsWith("https://models.robomous.ai/")) {
+ console.info("VISIONSET_LIVE_MODEL_REQUEST", request.url());
+ }
+ });
+ page.on("requestfailed", (request) => {
+ if (request.url().startsWith("https://models.robomous.ai/")) {
+ console.info("VISIONSET_LIVE_MODEL_REQUEST_FAILED", request.url(), request.failure()?.errorText);
+ }
+ });
page.on("response", async (response) => {
if (!response.url().startsWith("https://models.robomous.ai/")) return;
+ console.info("VISIONSET_LIVE_MODEL_RESPONSE", response.status(), response.url());
await response.finished();
+ console.info("VISIONSET_LIVE_MODEL_RESPONSE_FINISHED", response.url());
const timing = response.request().timing();
const declaredBytes = Number(response.headers()["content-length"] ?? 0);
const bytes = declaredBytes > 0 ? declaredBytes : (await response.body()).byteLength;
responses.push({ url: response.url(), bytes, milliseconds: timing.responseEnd });
});
- // Warm Chromium's connection to the public origin with the same registry document the
- // application will validate. This keeps a one-off DNS/TLS stall from consuming the whole
- // UI timeout while still exercising the app's own fetch, parser, and admission match below.
- await page.goto("https://models.robomous.ai/registry/v1.json");
- await expect(page.locator("body")).toContainText('"schema_version": 1');
await openJobWithBrowserRuntime(page, sent, true, "live");
await armSuggestTool(page);
+ // Registry discovery is deliberately asynchronous and does not block the editor. Wait for
+ // its immutable manifest validation before selecting the controlled This device tab; a
+ // machine-speed click before the catalog has any target is intentionally a no-op.
+ await expect.poll(() => responses.some(({ url }) => url.endsWith("/manifest.json"))).toBe(true);
const coldStarted = Date.now();
await acquireAndSelectBrowserTarget(page);
const coldMilliseconds = Date.now() - coldStarted;
From 98c6e58a0589b34b899c4edf834d0c52b8b0d4f6 Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Thu, 17 Sep 2026 13:41:19 -0700
Subject: [PATCH 14/15] fix(inference): harden browser model lifecycle
---
.../BrowserInferenceRuntime.test.ts | 34 ++++-
.../BrowserModelCatalog.test.ts | 92 +++++++++++-
.../browserInference/BrowserModelCatalog.ts | 114 ++++++++++----
.../acquireEfficientSam.test.ts | 142 ++++++++++++------
.../browserInference/artifactStore.test.ts | 46 ++++++
.../data/browserInference/artifactStore.ts | 50 +++++-
.../app/src/data/browserInference/manifest.ts | 7 +-
.../browserInference/registryClient.test.ts | 50 +++++-
.../data/browserInference/registryClient.ts | 78 +++++++---
.../ui-core/src/annotator/SuggestPanel.tsx | 10 ++
.../src/annotator/suggestPanel.test.tsx | 30 ++++
frontend/ui-core/src/inference/browserPort.ts | 10 +-
12 files changed, 551 insertions(+), 112 deletions(-)
diff --git a/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts b/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts
index 3f910d2a..9e3f74cf 100644
--- a/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts
+++ b/frontend/app/src/data/browserInference/BrowserInferenceRuntime.test.ts
@@ -6,12 +6,14 @@ import {
} from "./BrowserInferenceRuntime.js";
import { EFFICIENT_SAM_TI_REVISION } from "./manifest.js";
import type { VisionSetBrowserInferenceRuntime } from "@visionset/ui-core";
-import type { BrowserModelArtifacts } from "./artifactStore.js";
+import type { BrowserArtifactStore, BrowserModelArtifacts } from "./artifactStore.js";
function fakeDeps(overrides?: {
acquire?: () => Promise;
ready?: () => Promise;
supported?: () => boolean;
+ store?: BrowserArtifactStore;
+ discover?: () => Promise;
}) {
const prepareImage = vi.fn(async () => ({ width: 4, height: 4 }));
const dispose = vi.fn();
@@ -22,7 +24,15 @@ function fakeDeps(overrides?: {
dispose,
}));
const acquire = vi.fn(overrides?.acquire ?? (async () => ({ encoder: new Uint8Array(1), decoder: new Uint8Array(1) })));
- return { acquire, createRuntime, supported: overrides?.supported ?? ((): boolean => true), prepareImage, dispose };
+ return {
+ acquire,
+ createRuntime,
+ supported: overrides?.supported ?? ((): boolean => true),
+ ...(overrides?.store === undefined ? {} : { store: overrides.store }),
+ ...(overrides?.discover === undefined ? {} : { discover: overrides.discover }),
+ prepareImage,
+ dispose,
+ };
}
async function acquisition(runtime: VisionSetBrowserInferenceRuntime) {
@@ -60,6 +70,26 @@ describe("createOssBrowserInferenceRuntime", () => {
expect(runtime.listAcquisitions?.()).toHaveLength(1);
});
+ it("does not wait for a hanging registry before exposing and activating an installed model", async () => {
+ const artifacts = { encoder: new Uint8Array(1), decoder: new Uint8Array(1) };
+ const store: BrowserArtifactStore = {
+ inspect: vi.fn(async () => true),
+ readVerified: vi.fn(async () => artifacts),
+ writeVerified: vi.fn(async () => undefined),
+ remove: vi.fn(async () => undefined),
+ };
+ const runtime = createOssBrowserInferenceRuntime(fakeDeps({
+ store,
+ discover: () => new Promise(() => undefined),
+ }));
+
+ // This awaits cache inspection only. A mutable registry must not become a prerequisite for
+ // an already-admitted local model, including after an offline reload.
+ await expect(runtime.listTargets()).resolves.toEqual([]);
+ await runtime.modelCatalog!.activate("efficient-sam-ti");
+ await expect(runtime.listTargets()).resolves.toHaveLength(1);
+ });
+
it("lists the target and no acquisitions after acquiring", async () => {
const deps = fakeDeps();
const runtime = createOssBrowserInferenceRuntime(deps);
diff --git a/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts b/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
index a5002e45..92f919ae 100644
--- a/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
+++ b/frontend/app/src/data/browserInference/BrowserModelCatalog.test.ts
@@ -4,7 +4,12 @@ import type { PromptableSegmentationRuntime } from "@visionset/browser-inference
import type { BrowserSuggestionTarget, SuggestionExecutor } from "@visionset/ui-core";
import type { BrowserModelAdmission } from "./admissionCatalog.js";
-import type { BrowserArtifactStore, BrowserModelArtifacts } from "./artifactStore.js";
+import {
+ BrowserArtifactRollbackError,
+ BrowserArtifactStorageIndeterminateError,
+ type BrowserArtifactStore,
+ type BrowserModelArtifacts,
+} from "./artifactStore.js";
import { createBrowserModelCatalog } from "./BrowserModelCatalog.js";
const ADMISSION: BrowserModelAdmission = {
@@ -36,6 +41,7 @@ function deferred() {
function harness(overrides: {
installed?: boolean;
+ inspect?: () => Promise;
discover?: () => Promise;
read?: () => Promise;
write?: () => Promise;
@@ -44,7 +50,7 @@ function harness(overrides: {
} = {}) {
let installed = overrides.installed ?? false;
const store: BrowserArtifactStore = {
- inspect: vi.fn(async () => installed),
+ inspect: vi.fn(overrides.inspect ?? (async () => installed)),
readVerified: vi.fn(overrides.read ?? (async () => (installed ? ARTIFACTS : null))),
writeVerified: vi.fn(overrides.write ?? (async () => { installed = true; })),
remove: vi.fn(async () => { installed = false; }),
@@ -63,14 +69,15 @@ function harness(overrides: {
modelRef: ADMISSION.modelRef,
};
const download = vi.fn(overrides.download ?? (async () => ARTIFACTS));
+ const activate = vi.fn(async () => ({ runtime, executor, target }));
const catalog = createBrowserModelCatalog({
admissions: [ADMISSION],
store,
discover: overrides.discover ?? (async () => true),
download,
- activate: vi.fn(async () => ({ runtime, executor, target })),
+ activate,
});
- return { catalog, store, download, runtime, executor, dispose };
+ return { catalog, store, download, runtime, executor, dispose, activate };
}
async function settles(catalog: ReturnType): Promise {
@@ -94,6 +101,36 @@ describe("createBrowserModelCatalog", () => {
expect(runtime.ready).not.toHaveBeenCalled();
});
+ it("publishes cached installation and permits activation while registry discovery never settles", async () => {
+ const never = new Promise(() => undefined);
+ const { catalog, download, runtime } = harness({ installed: true, discover: async () => never });
+
+ await settles(catalog);
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "installed", storage: "persistent" });
+
+ await catalog.activate(ADMISSION.id);
+ expect(catalog.listTargets()).toHaveLength(1);
+ expect(runtime.ready).toHaveBeenCalledTimes(1);
+ expect(download).not.toHaveBeenCalled();
+ });
+
+ it("serializes activation requested before cache initialization and creates one runtime", async () => {
+ const inspection = deferred();
+ const { catalog, activate, dispose, runtime } = harness({ installed: true, inspect: () => inspection.promise });
+
+ const first = catalog.activate(ADMISSION.id);
+ const second = catalog.activate(ADMISSION.id);
+ expect(activate).not.toHaveBeenCalled();
+ inspection.resolve(true);
+ await Promise.all([first, second]);
+
+ expect(activate).toHaveBeenCalledTimes(1);
+ expect(runtime.ready).toHaveBeenCalledTimes(1);
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "ready", storage: "persistent" });
+ await catalog.remove(ADMISSION.id);
+ expect(dispose).toHaveBeenCalledTimes(1);
+ });
+
it("deduplicates an explicit acquisition and exposes every lifecycle transition", async () => {
const pending = deferred();
const { catalog, download } = harness({ download: () => pending.promise });
@@ -122,6 +159,38 @@ describe("createBrowserModelCatalog", () => {
expect(catalog.listTargets()).toHaveLength(1);
});
+ it("keeps removal available when a failed cache write could not be rolled back", async () => {
+ const rollbackFailure = new BrowserArtifactRollbackError(
+ new DOMException("quota", "QuotaExceededError"),
+ new Error("cache delete failed"),
+ );
+ const { catalog, store } = harness({ write: async () => Promise.reject(rollbackFailure) });
+ await settles(catalog);
+
+ await catalog.acquire(ADMISSION.id);
+
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "ready", storage: "unknown" });
+ await catalog.remove(ADMISSION.id);
+ expect(store.remove).toHaveBeenCalledTimes(1);
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "available", storage: "none" });
+ });
+
+ it("keeps removal available when Cache Storage could not open", async () => {
+ const openFailure = new BrowserArtifactStorageIndeterminateError(
+ "Browser model cache could not be opened (cache namespace unavailable).",
+ new Error("cache namespace unavailable"),
+ );
+ const { catalog, store } = harness({ write: async () => Promise.reject(openFailure) });
+ await settles(catalog);
+
+ await catalog.acquire(ADMISSION.id);
+
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "ready", storage: "unknown" });
+ await catalog.remove(ADMISSION.id);
+ expect(store.remove).toHaveBeenCalledTimes(1);
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "available", storage: "none" });
+ });
+
it("activates installed bytes from cache without any artifact download", async () => {
const { catalog, download, runtime, store } = harness({ installed: true });
await settles(catalog);
@@ -144,7 +213,7 @@ describe("createBrowserModelCatalog", () => {
await expect(catalog.activate(ADMISSION.id)).rejects.toThrow(/sha-256 mismatch/i);
expect(download).not.toHaveBeenCalled();
- expect(catalog.snapshot()[0]).toMatchObject({ state: "failed", storage: "none" });
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "failed", storage: "unknown" });
expect(catalog.listTargets()).toEqual([]);
});
@@ -215,14 +284,25 @@ describe("createBrowserModelCatalog", () => {
expect(catalog.listTargets()).toEqual([]);
expect(catalog.snapshot()[0]).toMatchObject({
state: "failed",
- storage: "persistent",
+ storage: "unknown",
error: "storage delete failed",
});
});
+ it("keeps removal available when inspection cannot determine whether artifacts remain", async () => {
+ const { catalog, store } = harness({ inspect: async () => Promise.reject(new Error("cache match failed")) });
+ await settles(catalog);
+
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "failed", storage: "unknown" });
+ await catalog.remove(ADMISSION.id);
+ expect(store.remove).toHaveBeenCalledTimes(1);
+ expect(catalog.snapshot()[0]).toMatchObject({ state: "available", storage: "none" });
+ });
+
it("keeps a cached admitted model usable when registry discovery fails", async () => {
const { catalog, download } = harness({ installed: true, discover: async () => Promise.reject(new Error("503")) });
await settles(catalog);
+ await vi.waitFor(() => expect(catalog.snapshot()[0]).toMatchObject({ warning: "503" }));
await catalog.activate(ADMISSION.id);
expect(catalog.listTargets()).toHaveLength(1);
expect(download).not.toHaveBeenCalled();
diff --git a/frontend/app/src/data/browserInference/BrowserModelCatalog.ts b/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
index d5c9ffff..504f7bdd 100644
--- a/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
+++ b/frontend/app/src/data/browserInference/BrowserModelCatalog.ts
@@ -7,7 +7,11 @@ import type {
} from "@visionset/ui-core";
import type { BrowserModelAdmission } from "./admissionCatalog.js";
-import type { BrowserArtifactStore, BrowserModelArtifacts } from "./artifactStore.js";
+import {
+ BrowserArtifactStorageIndeterminateError,
+ type BrowserArtifactStore,
+ type BrowserModelArtifacts,
+} from "./artifactStore.js";
interface ActiveModel {
readonly runtime: PromptableSegmentationRuntime;
@@ -38,6 +42,7 @@ interface ModelRecord {
state: BrowserModelCatalogEntry["state"];
storage: BrowserModelCatalogEntry["storage"];
error?: string;
+ registryFailure?: string;
sessionArtifacts?: BrowserModelArtifacts;
}
@@ -65,6 +70,7 @@ function entryOf(record: ModelRecord): BrowserModelCatalogEntry {
state: record.state,
storage: record.storage,
...(record.error === undefined ? {} : { error: record.error }),
+ ...(record.registryFailure === undefined ? {} : { warning: record.registryFailure }),
};
}
@@ -84,10 +90,14 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
const listeners = new Set<() => void>();
const operations = new Map<
string,
- { readonly kind: "acquire" | "activate" | "remove"; readonly promise: Promise }
+ { readonly kind: "initialize" | "acquire" | "activate" | "remove"; readonly promise: Promise }
>();
let active: { readonly id: string; readonly value: ActiveModel } | null = null;
let snapshot: readonly BrowserModelCatalogEntry[] = [];
+ // One lifecycle lane makes the active runtime a real singleton, not merely a best-effort
+ // per-model convention. It also means an initial cache inspection cannot publish over a
+ // caller that has already started activation.
+ let lifecycleTail: Promise | null = null;
function publish(): void {
snapshot = [...records.values()].filter((record) => record.visible).map(entryOf);
@@ -110,27 +120,47 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
}
async function initializeRecord(record: ModelRecord): Promise {
- const [cacheResult, registryResult] = await Promise.allSettled([
- deps.store.inspect(record.admission),
- deps.discover(record.admission),
- ]);
- const installed = cacheResult.status === "fulfilled" && cacheResult.value;
- const discovered = registryResult.status === "fulfilled" && registryResult.value;
- record.discovered = discovered;
- if (installed) {
- update(record, { visible: true, state: "installed", storage: "persistent", error: undefined });
- } else if (discovered) {
- update(record, { visible: true, state: "available", storage: "none", error: undefined });
- } else {
- update(record, {
- visible: true,
+ try {
+ const installed = await deps.store.inspect(record.admission);
+ if (installed) {
+ update(record, { visible: true, state: "installed", storage: "persistent", error: undefined });
+ } else if (record.registryFailure !== undefined) {
+ update(record, { visible: true, state: "failed", storage: "none", error: record.registryFailure });
+ } else {
+ update(record, { visible: true, state: "available", storage: "none", error: undefined });
+ }
+ } catch (error) {
+ // An inspection may fail while opening storage, matching an entry, or cleaning a partial
+ // model. In each case we cannot honestly say that no persistent bytes remain.
+ update(record, { visible: true, state: "failed", storage: "unknown", error: message(error) });
+ }
+ }
+
+ async function discoverRecord(record: ModelRecord): Promise {
+ try {
+ const discovered = await deps.discover(record.admission);
+ record.discovered = discovered;
+ record.registryFailure = discovered
+ ? undefined
+ : `${record.admission.label} is not available from the configured registry`;
+ // Discovery is advisory for a locally verified admission. Never let a late registry
+ // answer replace installed, activating, or ready local state.
+ if (discovered || record.state !== "available" || record.storage !== "none") {
+ publish();
+ return;
+ }
+ update(record, record.registryFailure === undefined ? { error: undefined } : {
state: "failed",
- storage: "none",
- error:
- registryResult.status === "rejected"
- ? message(registryResult.reason)
- : `${record.admission.label} is not available from the configured registry`,
+ error: record.registryFailure,
});
+ } catch (error) {
+ record.discovered = false;
+ record.registryFailure = message(error);
+ if (record.state === "available" && record.storage === "none") {
+ update(record, { state: "failed", error: record.registryFailure });
+ } else {
+ publish();
+ }
}
}
@@ -158,7 +188,7 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
function once(
id: string,
- kind: "acquire" | "activate" | "remove",
+ kind: "initialize" | "acquire" | "activate" | "remove",
operation: () => Promise,
): Promise {
const current = operations.get(id);
@@ -169,12 +199,30 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
() => once(id, kind, operation),
);
}
- const promise = operation().finally(() => operations.delete(id));
+ const promise = lifecycleTail === null ? operation() : lifecycleTail.then(operation);
+ const settled = promise.catch(() => undefined);
+ lifecycleTail = settled;
operations.set(id, { kind, promise });
+ void promise.then(
+ () => {
+ if (operations.get(id)?.promise === promise) operations.delete(id);
+ if (lifecycleTail === settled) lifecycleTail = null;
+ },
+ () => {
+ if (operations.get(id)?.promise === promise) operations.delete(id);
+ if (lifecycleTail === settled) lifecycleTail = null;
+ },
+ );
return promise;
}
- const initialized = Promise.all([...records.values()].map(initializeRecord)).then(() => undefined);
+ // Cache inspection must settle the public initialization boundary. Registry discovery is
+ // deliberately background metadata: an offline/hanging registry cannot strand an admitted
+ // cached model or delay server-independent browser activation.
+ const initialized = Promise.all(
+ [...records.values()].map((record) => once(record.admission.id, "initialize", () => initializeRecord(record))),
+ ).then(() => undefined);
+ for (const record of records.values()) void discoverRecord(record);
const catalog: OssBrowserModelCatalog = {
initialized,
@@ -191,6 +239,7 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
if (!record.discovered) {
record.discovered = await deps.discover(record.admission, options?.signal);
if (!record.discovered) throw new Error(`browser model "${id}" is not available from this registry`);
+ record.registryFailure = undefined;
}
update(record, { visible: true, state: "downloading", storage: "none", error: undefined });
try {
@@ -198,8 +247,11 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
let storage: BrowserModelCatalogEntry["storage"] = "persistent";
try {
await deps.store.writeVerified(record.admission, artifacts);
- } catch {
- storage = "session";
+ } catch (error) {
+ // Verified bytes remain usable in memory even when persistence is refused. The
+ // artifact store rolls back partial writes before rejecting. If rollback itself
+ // failed, keep removal available because any subset of the revision may remain.
+ storage = error instanceof BrowserArtifactStorageIndeterminateError ? "unknown" : "session";
record.sessionArtifacts = artifacts;
}
update(record, { state: "installed", storage, error: undefined });
@@ -224,7 +276,9 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
} catch (error) {
record.sessionArtifacts = undefined;
if (active?.id === id) active = null;
- update(record, { visible: true, state: "failed", storage: "none", error: message(error) });
+ // `readVerified` may itself fail while removing a corrupt or partial entry. Preserve
+ // uncertainty so the UI still offers explicit removal instead of hiding remnants.
+ update(record, { visible: true, state: "failed", storage: "unknown", error: message(error) });
throw error;
}
// `startRuntime` owns its failure state. At this point bytes have already passed the
@@ -237,18 +291,18 @@ export function createBrowserModelCatalog(deps: CatalogDeps): OssBrowserModelCat
return once(id, "remove", async () => {
const record = required(id);
const previous = active?.id === id ? active.value : null;
- const previousStorage = record.storage;
if (previous !== null) active = null;
record.sessionArtifacts = undefined;
- update(record, { visible: true, state: "available", storage: "none", error: undefined });
if (previous !== null) previous.runtime.dispose();
try {
await deps.store.remove(record.admission);
+ update(record, { visible: true, state: "available", storage: "none", error: undefined });
} catch (error) {
update(record, {
visible: true,
state: "failed",
- storage: previousStorage === "persistent" ? "persistent" : "none",
+ // Cache deletion is multi-artifact. A failure can leave any subset behind.
+ storage: "unknown",
error: message(error),
});
throw error;
diff --git a/frontend/app/src/data/browserInference/acquireEfficientSam.test.ts b/frontend/app/src/data/browserInference/acquireEfficientSam.test.ts
index aea18f2a..6b965b36 100644
--- a/frontend/app/src/data/browserInference/acquireEfficientSam.test.ts
+++ b/frontend/app/src/data/browserInference/acquireEfficientSam.test.ts
@@ -10,8 +10,11 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { EFFICIENT_SAM_TI_MANIFEST_V1 } from "./fixtures/manifests-v1.js";
+import registryV1 from "./fixtures/registry-v1.json";
import { fetchVerified } from "./acquireEfficientSam.js";
import { fetchEfficientSamManifest } from "./manifest.js";
+import { fetchAdmittedBrowserModels } from "./registryClient.js";
// `Uint8Array`, not the bare `Uint8Array` — see the same note in
// acquireEfficientSam.ts: TypeScript 6's `lib.dom.d.ts` requires the concrete
@@ -25,8 +28,33 @@ async function sha256Of(bytes: Uint8Array): Promise {
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
}
+interface ManifestArtifactOverride {
+ readonly path?: string;
+ readonly bytes?: number;
+ readonly sha256?: string;
+ readonly content_type?: string;
+}
+
+function manifestWithArtifactOverrides(
+ encoder: ManifestArtifactOverride = {},
+ decoder: ManifestArtifactOverride = {},
+) {
+ return {
+ ...EFFICIENT_SAM_TI_MANIFEST_V1,
+ artifacts: {
+ encoder: { ...EFFICIENT_SAM_TI_MANIFEST_V1.artifacts.encoder, ...encoder },
+ decoder: { ...EFFICIENT_SAM_TI_MANIFEST_V1.artifacts.decoder, ...decoder },
+ },
+ };
+}
+
describe("fetchVerified", () => {
- beforeEach(() => vi.restoreAllMocks());
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ vi.doUnmock("./admissionCatalog.js");
+ vi.doUnmock("./manifest.js");
+ });
it("returns the bytes when size and SHA-256 both match", async () => {
const bytes = bytesOf("hello world");
@@ -55,17 +83,50 @@ describe("fetchVerified", () => {
});
describe("acquireEfficientSam", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ vi.doUnmock("./admissionCatalog.js");
+ vi.doUnmock("./manifest.js");
+ });
+
+ it("rejects a mutated acquisition-time manifest even after catalog discovery admitted an earlier copy", async () => {
+ let manifestRequests = 0;
+ const mutatedManifest = {
+ ...structuredClone(EFFICIENT_SAM_TI_MANIFEST_V1),
+ runtime: { ...EFFICIENT_SAM_TI_MANIFEST_V1.runtime, opset: 18 },
+ };
+ const fetchMock = vi.fn(async (url: string | URL | Request) => {
+ const href = String(url);
+ if (href.endsWith("registry/v1.json")) return new Response(JSON.stringify(registryV1));
+ if (href.endsWith("manifest.json")) {
+ manifestRequests += 1;
+ return new Response(JSON.stringify(manifestRequests === 1 ? EFFICIENT_SAM_TI_MANIFEST_V1 : mutatedManifest));
+ }
+ throw new Error(`artifact must not be fetched after a manifest mismatch: ${href}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ await expect(fetchAdmittedBrowserModels("https://models.robomous.ai")).resolves.toHaveLength(1);
+ await expect((await import("./acquireEfficientSam.js")).acquireEfficientSam()).rejects.toThrow(
+ /admission mismatch at runtime\.opset/i,
+ );
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ });
+
it("fetches the manifest, then each artifact — never before the manifest resolves", async () => {
const encoderBytes = bytesOf("encoder-fixture");
const decoderBytes = bytesOf("decoder-fixture");
+ const manifest = manifestWithArtifactOverrides(
+ { bytes: encoderBytes.byteLength, sha256: await sha256Of(encoderBytes) },
+ { bytes: decoderBytes.byteLength, sha256: await sha256Of(decoderBytes) },
+ );
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("manifest.json")) {
// The real, already-deployed manifest shape: artifacts nested under
// `artifacts`, each `path` a bare filename relative to the manifest's own
// directory — not `{ encoder: { path: "/encoder.onnx" } }` at the top level.
- return new Response(
- JSON.stringify({ artifacts: { encoder: { path: "encoder.onnx" }, decoder: { path: "decoder.onnx" } } }),
- );
+ return new Response(JSON.stringify(manifest));
}
if (url.endsWith("encoder.onnx")) return new Response(encoderBytes);
if (url.endsWith("decoder.onnx")) return new Response(decoderBytes);
@@ -79,15 +140,24 @@ describe("acquireEfficientSam", () => {
// the cache first, forcing the dynamic `import()` below to re-evaluate both
// modules fresh, this time picking up the mock.
vi.resetModules();
- vi.doMock("./manifest.js", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- EFFICIENT_SAM_TI_EXPECTED: {
- encoder: { sha256: await sha256Of(encoderBytes), bytes: encoderBytes.byteLength },
- decoder: { sha256: await sha256Of(decoderBytes), bytes: decoderBytes.byteLength },
- },
+ vi.doMock("./admissionCatalog.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ const admitted = {
+ ...actual.EFFICIENT_SAM_TI_ADMISSION,
+ artifacts: [
+ {
+ ...actual.EFFICIENT_SAM_TI_ADMISSION.artifacts[0],
+ bytes: encoderBytes.byteLength,
+ sha256: await sha256Of(encoderBytes),
+ },
+ {
+ ...actual.EFFICIENT_SAM_TI_ADMISSION.artifacts[1],
+ bytes: decoderBytes.byteLength,
+ sha256: await sha256Of(decoderBytes),
+ },
+ ] as const,
};
+ return { ...actual, EFFICIENT_SAM_TI_ADMISSION: admitted, ADMITTED_BROWSER_MODELS: [admitted] };
});
const { acquireEfficientSam } = await import("./acquireEfficientSam.js");
@@ -108,46 +178,21 @@ describe("acquireEfficientSam", () => {
);
});
- it("never trusts the manifest's own bytes/sha256 — a manifest that lies about both still verifies against the pinned constants", async () => {
- const encoderBytes = bytesOf("encoder-fixture");
- const decoderBytes = bytesOf("decoder-fixture");
+ it("rejects a manifest that lies about an admitted artifact hash before fetching artifact bytes", async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("manifest.json")) {
return new Response(
- JSON.stringify({
- artifacts: {
- // Deliberately wrong `bytes`/`sha256` alongside the real `path` — a
- // manifest that lies about its own artifacts' hashes. If acquisition
- // ever read these instead of `EFFICIENT_SAM_TI_EXPECTED`, this fixture
- // would either reject the correct fixture bytes or accept forged ones.
- encoder: { path: "encoder.onnx", bytes: 1, sha256: "0".repeat(64) },
- decoder: { path: "decoder.onnx", bytes: 1, sha256: "0".repeat(64) },
- },
- }),
+ JSON.stringify(manifestWithArtifactOverrides({ sha256: "0".repeat(64) })),
);
}
- if (url.endsWith("encoder.onnx")) return new Response(encoderBytes);
- if (url.endsWith("decoder.onnx")) return new Response(decoderBytes);
- throw new Error(`unexpected url ${url}`);
+ throw new Error(`artifact must not be fetched: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
vi.resetModules();
- vi.doMock("./manifest.js", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- EFFICIENT_SAM_TI_EXPECTED: {
- encoder: { sha256: await sha256Of(encoderBytes), bytes: encoderBytes.byteLength },
- decoder: { sha256: await sha256Of(decoderBytes), bytes: decoderBytes.byteLength },
- },
- };
- });
const { acquireEfficientSam } = await import("./acquireEfficientSam.js");
- const result = await acquireEfficientSam();
-
- expect(result.encoder).toEqual(encoderBytes);
- expect(result.decoder).toEqual(decoderBytes);
+ await expect(acquireEfficientSam()).rejects.toThrow(/admission mismatch at artifacts\.encoder\.sha256/i);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
});
it.each(["../secrets.onnx", "..", "%2e%2e", "\\..\\private"])(
@@ -156,7 +201,7 @@ describe("acquireEfficientSam", () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("manifest.json")) {
return new Response(
- JSON.stringify({ artifacts: { encoder: { path }, decoder: { path: "decoder.onnx" } } }),
+ JSON.stringify(manifestWithArtifactOverrides({ path })),
);
}
throw new Error(`unexpected url ${url}`);
@@ -165,14 +210,19 @@ describe("acquireEfficientSam", () => {
vi.resetModules();
const { acquireEfficientSam } = await import("./acquireEfficientSam.js");
- await expect(acquireEfficientSam()).rejects.toThrow(/unexpected manifest artifact path/i);
+ await expect(acquireEfficientSam()).rejects.toThrow(/admission mismatch at artifacts\.encoder\.path/i);
expect(fetchMock).toHaveBeenCalledTimes(1);
},
);
});
describe("fetchEfficientSamManifest", () => {
- beforeEach(() => vi.restoreAllMocks());
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.resetModules();
+ vi.doUnmock("./admissionCatalog.js");
+ vi.doUnmock("./manifest.js");
+ });
it("throws a diagnosable error, not a bare TypeError, when the CDN's manifest schema has moved", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ artifacts: { encoder: {} } }))));
@@ -184,7 +234,7 @@ describe("fetchEfficientSamManifest", () => {
"fetch",
vi.fn().mockResolvedValue(
new Response(
- JSON.stringify({ artifacts: { encoder: { path: "encoder.onnx" }, decoder: { path: "decoder.onnx" } } }),
+ JSON.stringify(EFFICIENT_SAM_TI_MANIFEST_V1),
),
),
);
diff --git a/frontend/app/src/data/browserInference/artifactStore.test.ts b/frontend/app/src/data/browserInference/artifactStore.test.ts
index 6f5b8a59..34139a9d 100644
--- a/frontend/app/src/data/browserInference/artifactStore.test.ts
+++ b/frontend/app/src/data/browserInference/artifactStore.test.ts
@@ -4,6 +4,8 @@ import { describe, expect, it, vi } from "vitest";
import type { BrowserModelAdmission } from "./admissionCatalog.js";
import {
CACHE_NAMESPACE,
+ BrowserArtifactRollbackError,
+ BrowserArtifactStorageIndeterminateError,
cacheKeyFor,
createCacheArtifactStore,
type ArtifactCache,
@@ -113,6 +115,50 @@ describe("createCacheArtifactStore", () => {
expect(cache.entries.size).toBe(0);
});
+ it("distinguishes a failed write whose rollback also fails and preserves the write cause", async () => {
+ const cache = new MemoryCache();
+ const quota = new DOMException("quota", "QuotaExceededError");
+ const cleanup = new Error("cache delete failed");
+ cache.put.mockImplementationOnce(async (request, response) => {
+ cache.entries.set(String(request), response.clone());
+ });
+ cache.put.mockRejectedValueOnce(quota);
+ cache.delete.mockRejectedValue(cleanup);
+ const { admission, artifacts } = await fixture();
+ const store = createCacheArtifactStore(storage(cache));
+
+ let thrown: unknown;
+ try {
+ await store.writeVerified(admission, artifacts);
+ } catch (error) {
+ thrown = error;
+ }
+
+ expect(thrown).toBeInstanceOf(BrowserArtifactRollbackError);
+ expect(thrown).toMatchObject({ cause: quota, cleanupCause: cleanup });
+ expect((thrown as Error).message).toMatch(/quota.*cleanup failed/i);
+ // The failed cleanup leaves the written encoder potentially resident; callers must offer
+ // explicit removal rather than claiming a clean session-only fallback.
+ expect(cache.entries.size).toBe(1);
+ });
+
+ it("marks a rejected Cache Storage open as indeterminate while retaining the original cause", async () => {
+ const openFailure = new Error("cache namespace unavailable");
+ const cacheStorage: ArtifactCacheStorage = { open: vi.fn(async () => Promise.reject(openFailure)) };
+ const { admission, artifacts } = await fixture();
+
+ let thrown: unknown;
+ try {
+ await createCacheArtifactStore(cacheStorage).writeVerified(admission, artifacts);
+ } catch (error) {
+ thrown = error;
+ }
+
+ expect(thrown).toBeInstanceOf(BrowserArtifactStorageIndeterminateError);
+ expect(thrown).toMatchObject({ cause: openFailure });
+ expect((thrown as Error).message).toMatch(/could not be opened.*namespace unavailable/i);
+ });
+
it("cleans up a partial cache instead of treating it as installed", async () => {
const cache = new MemoryCache();
const { admission, artifacts } = await fixture();
diff --git a/frontend/app/src/data/browserInference/artifactStore.ts b/frontend/app/src/data/browserInference/artifactStore.ts
index e69612ac..8728fddc 100644
--- a/frontend/app/src/data/browserInference/artifactStore.ts
+++ b/frontend/app/src/data/browserInference/artifactStore.ts
@@ -26,6 +26,40 @@ export interface BrowserArtifactStore {
remove(model: BrowserModelAdmission): Promise;
}
+/**
+ * Cache Storage could not establish a trustworthy persistent state. Callers must retain a
+ * removal affordance: pre-existing entries may still be resident even when this operation could
+ * not inspect or clean them.
+ */
+export class BrowserArtifactStorageIndeterminateError extends Error {
+ constructor(message: string, cause: unknown) {
+ super(message, { cause });
+ this.name = "BrowserArtifactStorageIndeterminateError";
+ }
+}
+
+/**
+ * A failed cache write normally leaves no model bytes behind because the store rolls its keys
+ * back. This error is deliberately distinct: the write failed *and* that rollback failed, so a
+ * caller must not describe the model as merely session-only or hide its removal affordance.
+ */
+export class BrowserArtifactRollbackError extends BrowserArtifactStorageIndeterminateError {
+ readonly cleanupCause: unknown;
+
+ constructor(writeCause: unknown, cleanupCause: unknown) {
+ super(
+ `Browser model cache write failed (${messageFor(writeCause)}) and cleanup failed (${messageFor(cleanupCause)}).`,
+ writeCause,
+ );
+ this.name = "BrowserArtifactRollbackError";
+ this.cleanupCause = cleanupCause;
+ }
+}
+
+function messageFor(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
export function cacheKeyFor(model: BrowserModelAdmission, artifact: ArtifactAdmission): string {
return (
`${CACHE_ORIGIN}/__visionset_model_cache__/` +
@@ -100,7 +134,15 @@ export function createCacheArtifactStore(
verifyArtifact(bytesFor(artifacts, artifact.role), artifact, `downloaded ${artifact.role}`),
),
);
- const opened = await cache();
+ let opened: ArtifactCache | null;
+ try {
+ opened = await cache();
+ } catch (error) {
+ throw new BrowserArtifactStorageIndeterminateError(
+ `Browser model cache could not be opened (${messageFor(error)}).`,
+ error,
+ );
+ }
if (opened === null) throw new Error("browser model storage is unavailable");
try {
for (const artifact of model.artifacts) {
@@ -111,7 +153,11 @@ export function createCacheArtifactStore(
);
}
} catch (error) {
- await remove(model);
+ try {
+ await remove(model);
+ } catch (cleanupError) {
+ throw new BrowserArtifactRollbackError(error, cleanupError);
+ }
throw error;
}
},
diff --git a/frontend/app/src/data/browserInference/manifest.ts b/frontend/app/src/data/browserInference/manifest.ts
index 2ea274f2..05296afe 100644
--- a/frontend/app/src/data/browserInference/manifest.ts
+++ b/frontend/app/src/data/browserInference/manifest.ts
@@ -5,6 +5,7 @@
* of this same manifest layout.
*/
import { EFFICIENT_SAM_TI_ADMISSION } from "./admissionCatalog.js";
+import { validateManifestAgainstAdmission } from "./registryClient.js";
export const MODEL_CDN_BASE_URL: string = (
(import.meta.env["VITE_MODEL_CDN_BASE_URL"] as string | undefined) ?? "https://models.robomous.ai"
@@ -39,8 +40,9 @@ export const EFFICIENT_SAM_TI_ARTIFACT_PATHS = Object.fromEntries(
/**
* Mirrors the real, already-deployed manifest shape (nested under `artifacts`,
* with each `path` a bare filename relative to the manifest's own directory) — not
- * an assumed flat shape. Only `path` is read from this; `bytes`/`sha256` are never
- * trusted from the manifest itself (see `EFFICIENT_SAM_TI_EXPECTED`).
+ * an assumed flat shape. Before an acquisition may use it, every supported field
+ * is validated against the build admission record. The admission record remains
+ * the integrity anchor for the artifact verification that follows.
*/
export interface EfficientSamManifest {
readonly artifacts: {
@@ -66,5 +68,6 @@ export async function fetchEfficientSamManifest(signal?: AbortSignal): Promise {
expect(fetch).toHaveBeenCalledTimes(2);
});
+ it("accepts an equivalent relative admitted manifest path and resolves it below a mirror base prefix", async () => {
+ const row = admittedRow();
+ row.manifest = "models/efficient-sam-ti/b19782d049c0-843761ca46f4/manifest.json";
+ const fetch = fetchFixture(registryWith(row));
+
+ const result = await fetchAdmittedBrowserModels("https://models.example/mirror", { fetch });
+
+ expect(result).toHaveLength(1);
+ expect(result[0]!.manifestUrl.href).toBe(
+ "https://models.example/mirror/models/efficient-sam-ti/b19782d049c0-843761ca46f4/manifest.json",
+ );
+ expect(fetch.mock.calls.map(([input]) => String(input))).toEqual([
+ "https://models.example/mirror/registry/v1.json",
+ "https://models.example/mirror/models/efficient-sam-ti/b19782d049c0-843761ca46f4/manifest.json",
+ ]);
+ });
+
+ it("validates and preserves a registry license when the deployed registry supplies one", async () => {
+ const row = { ...admittedRow(), license: "Apache-2.0" };
+
+ const [model] = await fetchAdmittedBrowserModels(BASE, {
+ fetch: fetchFixture(registryWith(row)),
+ });
+
+ expect(model?.license).toBe("Apache-2.0");
+ expect(model?.registryLicense).toBe("Apache-2.0");
+ });
+
+ it("rejects a registry license mismatch against the admission", async () => {
+ await expect(
+ fetchAdmittedBrowserModels(BASE, {
+ fetch: fetchFixture(registryWith({ ...admittedRow(), license: "MIT" })),
+ }),
+ ).rejects.toThrow(/admission mismatch at registry\.license/i);
+ });
+
it.each([
null,
{},
@@ -59,6 +95,14 @@ describe("fetchAdmittedBrowserModels", () => {
);
});
+ it("rejects a malformed optional registry license", async () => {
+ await expect(
+ fetchAdmittedBrowserModels(BASE, {
+ fetch: fetchFixture(registryWith({ ...admittedRow(), license: 42 })),
+ }),
+ ).rejects.toThrow(/registry schema/i);
+ });
+
it("rejects duplicate IDs even when their revisions differ", async () => {
const first = admittedRow();
const second = { ...admittedRow(), revision: "another-immutable-revision" };
@@ -122,6 +166,8 @@ describe("fetchAdmittedBrowserModels", () => {
"//evil.example/manifest.json",
"/models/%2e%2e/escape/manifest.json",
"/models/model/manifest.json?mutable=1",
+ "/models/model/manifest.json%3Fmutable%3D1",
+ "https%3A%2F%2Fevil.example/manifest.json",
])("rejects an unsafe manifest path %s", async (manifestPath) => {
await expect(
fetchAdmittedBrowserModels(BASE, {
@@ -145,8 +191,8 @@ describe("fetchAdmittedBrowserModels", () => {
});
describe("resolveModelPath", () => {
- it("resolves a root-relative public path below a configured base prefix", () => {
- expect(resolveModelPath("https://models.example/base", "/base/models/a/manifest.json").href).toBe(
+ it("resolves a root-relative logical path below a configured base prefix", () => {
+ expect(resolveModelPath("https://models.example/base", "/models/a/manifest.json").href).toBe(
"https://models.example/base/models/a/manifest.json",
);
});
diff --git a/frontend/app/src/data/browserInference/registryClient.ts b/frontend/app/src/data/browserInference/registryClient.ts
index be72f231..de30e604 100644
--- a/frontend/app/src/data/browserInference/registryClient.ts
+++ b/frontend/app/src/data/browserInference/registryClient.ts
@@ -10,6 +10,8 @@ interface RegistryRow {
readonly revision: string;
readonly modelRef: string;
readonly manifest: string;
+ /** Optional in the measured v1 registry schema; preserve it when supplied. */
+ readonly license?: string;
}
export interface AdmittedRegistryModel {
@@ -19,6 +21,8 @@ export interface AdmittedRegistryModel {
readonly revision: string;
readonly modelRef: string;
readonly license: string;
+ /** The registry's matching license declaration, when its schema supplied one. */
+ readonly registryLicense?: string;
readonly source: BrowserModelAdmission["source"];
readonly manifestUrl: URL;
readonly artifactUrls: Readonly>;
@@ -43,6 +47,11 @@ function text(value: unknown, at: string): string {
return value;
}
+function optionalText(value: unknown, at: string): string | undefined {
+ if (value === undefined) return undefined;
+ return text(value, at);
+}
+
function number(value: unknown, at: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`unexpected registry schema at ${at}`);
@@ -72,6 +81,7 @@ function parseRegistry(value: unknown): readonly RegistryRow[] {
revision: text(row["revision"], `models[${index}].revision`),
modelRef: text(row["model_ref"], `models[${index}].model_ref`),
manifest: text(row["manifest"], `models[${index}].manifest`),
+ license: optionalText(row["license"], `models[${index}].license`),
};
});
}
@@ -84,17 +94,14 @@ function normalizedBase(baseUrl: string): URL {
return base;
}
-export function resolveModelPath(baseUrl: string, path: string): URL {
- const base = normalizedBase(baseUrl);
- if (
- path.length === 0 ||
- path.includes("\\") ||
- path.includes("?") ||
- path.includes("#") ||
- path.startsWith("//") ||
- /^[a-z][a-z\d+.-]*:/i.test(path) ||
- /%2f|%5c/i.test(path)
- ) {
+/**
+ * Registry paths identify objects inside a configured model source, rather than
+ * origin-root URLs. Canonicalize the accepted spelling so `/models/x` and
+ * `models/x` compare as the same admitted logical path, then resolve below the
+ * base's (possibly non-root) path prefix.
+ */
+export function normalizeModelPath(path: string): string {
+ if (path.length === 0 || /%2f|%5c/i.test(path)) {
throw new Error(`unsafe model path: ${path}`);
}
let decoded: string;
@@ -103,14 +110,27 @@ export function resolveModelPath(baseUrl: string, path: string): URL {
} catch {
throw new Error(`unsafe model path: ${path}`);
}
- if (decoded.split("/").some((segment) => segment === "." || segment === "..")) {
+ if (
+ decoded.includes("\\") ||
+ decoded.includes("?") ||
+ decoded.includes("#") ||
+ decoded.startsWith("//") ||
+ /^[a-z][a-z\d+.-]*:/i.test(decoded)
+ ) {
throw new Error(`unsafe model path: ${path}`);
}
- const resolved = new URL(path, base);
- if (resolved.origin !== base.origin || !resolved.pathname.startsWith(base.pathname)) {
- throw new Error(`unsafe model path outside configured base: ${path}`);
+ if (decoded.split("/").some((segment) => segment === "." || segment === "..")) {
+ throw new Error(`unsafe model path: ${path}`);
}
- return resolved;
+ const relative = decoded.replace(/^\/+/, "");
+ if (relative.length === 0) throw new Error(`unsafe model path: ${path}`);
+ return `/${relative}`;
+}
+
+export function resolveModelPath(baseUrl: string, path: string): URL {
+ const base = normalizedBase(baseUrl);
+ const logicalPath = normalizeModelPath(path);
+ return new URL(logicalPath.slice(1), base);
}
function assertEqual(actual: unknown, expected: unknown, field: string): void {
@@ -136,7 +156,15 @@ function validateArtifact(
return path;
}
-function validateManifest(value: unknown, admission: BrowserModelAdmission): Record {
+/**
+ * Validates remote metadata against this build's admission record. The manifest
+ * describes a release but never becomes its trust anchor: artifact size and hash
+ * must still exactly equal the build-pinned admission values.
+ */
+export function validateManifestAgainstAdmission(
+ value: unknown,
+ admission: BrowserModelAdmission,
+): Record {
const manifest = record(value, "manifest");
assertEqual(number(manifest["schema_version"], "manifest.schema_version"), 1, "schema_version");
assertEqual(text(manifest["id"], "manifest.id"), admission.id, "id");
@@ -162,6 +190,14 @@ function validateManifest(value: unknown, admission: BrowserModelAdmission): Rec
assertEqual(boolean(capabilities["negative_points"], "manifest.capabilities.negative_points"), admission.capabilities.negativePoints, "capabilities.negative_points");
assertEqual(number(capabilities["max_points"], "manifest.capabilities.max_points"), admission.capabilities.maxPoints, "capabilities.max_points");
+ const artifacts = record(manifest["artifacts"], "manifest.artifacts");
+ const expectedRoles = new Set(admission.artifacts.map((artifact) => artifact.role));
+ for (const role of Object.keys(artifacts)) {
+ if (!expectedRoles.has(role as ArtifactAdmission["role"])) {
+ throw new Error(`model admission mismatch at artifacts.${role}`);
+ }
+ }
+
return Object.fromEntries(
admission.artifacts.map((artifact) => [artifact.role, validateArtifact(manifest, artifact)]),
) as Record;
@@ -191,9 +227,10 @@ export async function fetchAdmittedBrowserModels(
}
assertEqual(row.name, admission.label, "registry.name");
assertEqual(row.modelRef, admission.modelRef, "registry.model_ref");
- assertEqual(row.manifest, admission.manifestPath, "registry.manifest");
+ assertEqual(normalizeModelPath(row.manifest), normalizeModelPath(admission.manifestPath), "registry.manifest");
+ if (row.license !== undefined) assertEqual(row.license, admission.license, "registry.license");
const manifestUrl = resolveModelPath(baseUrl, row.manifest);
- const artifactPaths = validateManifest(
+ const artifactPaths = validateManifestAgainstAdmission(
await json(await fetcher(manifestUrl, { signal: options.signal }), "manifest"),
admission,
);
@@ -207,7 +244,8 @@ export async function fetchAdmittedBrowserModels(
label: admission.label,
revision: admission.revision,
modelRef: admission.modelRef,
- license: admission.license,
+ license: row.license ?? admission.license,
+ registryLicense: row.license,
source: admission.source,
manifestUrl,
artifactUrls,
diff --git a/frontend/ui-core/src/annotator/SuggestPanel.tsx b/frontend/ui-core/src/annotator/SuggestPanel.tsx
index 1d8be971..77c1c4ee 100644
--- a/frontend/ui-core/src/annotator/SuggestPanel.tsx
+++ b/frontend/ui-core/src/annotator/SuggestPanel.tsx
@@ -911,6 +911,16 @@ function CatalogModel({
Ready for this session, but it was not saved in this browser.
)}
+ {model.storage === "unknown" && (
+
+ Browser storage could not be checked. Remove this model to clear any saved files.
+