From da099820c707ed5714aa8f8f8d86038f00af0ede Mon Sep 17 00:00:00 2001 From: AngusBell97 <124716116+AngusBell97@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:12:25 +0100 Subject: [PATCH] Improve model selection and downloads in Galaxy --- starpilot/assets/model_manager.py | 4 +- starpilot/assets/model_sizes.py | 81 ++++++++++++ .../tests/test_download_failure_recovery.py | 53 ++++++++ starpilot/assets/tests/test_model_sizes.py | 99 +++++++++++++++ .../assets/components/tools/model_hardware.js | 52 ++++++++ .../assets/components/tools/model_manager.js | 115 +++++++++++++----- .../the_galaxy/assets/mobile/css/material.css | 4 + .../mobile/js/components/FeatureHelp.js | 95 +++++++++++++++ .../assets/mobile/js/views/ModelManager.js | 112 ++++++++++++----- .../tests/test_model_catalog_sizes_api.py | 23 ++++ .../the_galaxy/tests/test_model_hardware.mjs | 17 +++ .../tests/test_model_manager_legacy_state.mjs | 17 +++ .../test_model_manager_lifecycle_browser.mjs | 104 ++++++++++++++++ .../test_model_manager_selection_browser.mjs | 65 ++++++++++ .../tests/test_model_manager_state.mjs | 44 +++++++ .../tests/test_model_profile_payloads.py | 23 ++++ .../tests/test_model_selection_contract.py | 16 +++ starpilot/system/the_galaxy/the_galaxy.py | 11 +- 18 files changed, 871 insertions(+), 64 deletions(-) create mode 100644 starpilot/assets/model_sizes.py create mode 100644 starpilot/assets/tests/test_download_failure_recovery.py create mode 100644 starpilot/assets/tests/test_model_sizes.py create mode 100644 starpilot/system/the_galaxy/assets/components/tools/model_hardware.js create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/FeatureHelp.js create mode 100644 starpilot/system/the_galaxy/tests/test_model_catalog_sizes_api.py create mode 100644 starpilot/system/the_galaxy/tests/test_model_hardware.mjs create mode 100644 starpilot/system/the_galaxy/tests/test_model_manager_legacy_state.mjs create mode 100644 starpilot/system/the_galaxy/tests/test_model_manager_lifecycle_browser.mjs create mode 100644 starpilot/system/the_galaxy/tests/test_model_manager_selection_browser.mjs create mode 100644 starpilot/system/the_galaxy/tests/test_model_manager_state.mjs create mode 100644 starpilot/system/the_galaxy/tests/test_model_profile_payloads.py create mode 100644 starpilot/system/the_galaxy/tests/test_model_selection_contract.py diff --git a/starpilot/assets/model_manager.py b/starpilot/assets/model_manager.py index f174af12c0..32d1ae5307 100644 --- a/starpilot/assets/model_manager.py +++ b/starpilot/assets/model_manager.py @@ -937,6 +937,7 @@ class ModelManager: try: self._download_model(model_to_download, allow_gpu_without_gpu) finally: + self.downloading_model = False self.params_memory.remove(ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM) def _download_artifact_to_path(self, model_key: str, file_path: Path, remote_filename: str, @@ -1057,8 +1058,8 @@ class ModelManager: self.params_memory.put(DOWNLOAD_PROGRESS_PARAM, "eGPU variant downloaded!") return True finally: - self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM) self.downloading_model = False + self.params_memory.remove(MODEL_LAB_DOWNLOAD_PARAM) def _download_model(self, model_to_download: str, allow_gpu_without_gpu: bool): self.downloading_model = True @@ -1129,6 +1130,7 @@ class ModelManager: try: self._download_all_models(allow_gpu_without_gpu) finally: + self.downloading_model = False self.params_memory.remove(ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM) def _download_all_models(self, allow_gpu_without_gpu: bool): diff --git a/starpilot/assets/model_sizes.py b/starpilot/assets/model_sizes.py new file mode 100644 index 0000000000..d7595fc73d --- /dev/null +++ b/starpilot/assets/model_sizes.py @@ -0,0 +1,81 @@ +"""Local logical payload sizes. No HEAD requests, hashing, or model mutation.""" +from pathlib import Path +import re +import threading +import time + +MAX_DECLARED_BYTES = 1 << 50 + + +def positive_size(value): + return value if type(value) is int and 0 < value <= MAX_DECLARED_BYTES else None + + +def artifact_size(path, declared=None): + path = Path(path) + declared = positive_size(declared) + result = {'fileSizeBytes': None, 'declaredSizeBytes': declared, 'downloadedBytes': None, + 'sizeSource': 'metadata' if declared else 'unknown', + 'sizeStatus': 'declared' if declared else 'unknown'} + try: + if path.is_file(): + size = path.stat().st_size + result.update(fileSizeBytes=size if size > 0 else None, downloadedBytes=size, + sizeSource='installed', sizeStatus='complete' if size > 0 else 'partial') + else: + manifest = Path(str(path) + '.chunkmanifest') + chunks = [p for p in path.parent.glob(path.name + '.chunk*') + if re.fullmatch(re.escape(path.name) + r'\.chunk\d+of\d+', p.name) and p.is_file()] + if not manifest.is_file() and not chunks: + return result + count = None + if manifest.is_file(): + try: + text = manifest.read_text().strip() + count = int(text) if text.isdecimal() and len(text) <= 5 else None + except (ValueError, UnicodeError): + pass + expected = [Path(f'{path}.chunk{i + 1:02d}of{count:02d}') for i in range(count)] if count and count <= 10000 else [] + complete = bool(expected) and set(chunks) == set(expected) + downloaded = sum(p.stat().st_size for p in chunks) + result.update(downloadedBytes=downloaded, fileSizeBytes=downloaded if complete and downloaded > 0 else None, + sizeSource='installed' if complete else 'partial', + sizeStatus='complete' if complete and downloaded > 0 else 'partial') + if result['fileSizeBytes'] is not None and declared and declared != result['fileSizeBytes']: + result['sizeStatus'] = 'mismatch' + except OSError: + result.update(fileSizeBytes=None, downloadedBytes=None, sizeSource='unknown', sizeStatus='unknown') + return result + + +class ModelSizes: + """Bounded cache for local catalogue size reads, independent of statistics.""" + def __init__(self): + self.lock = threading.Lock() + self.sizes = {} + + def size(self, path, declared): + # Short TTL avoids repeated directory scans on status polls, not stale sizes + # across completed downloads. Bad metadata is treated as unknown by helper. + key = (str(path), repr(declared)) + with self.lock: + cached = self.sizes.get(key) + if cached is None or time.monotonic() >= cached[0]: + cached = (time.monotonic() + 2, artifact_size(path, declared)) + if len(self.sizes) > 1000: + self.sizes.clear() + self.sizes[key] = cached + return dict(cached[1]) + + def annotate(self, models, models_path, builtin_path, metadata, accelerator_filename): + for model in models: + key = model['value'] + entry = metadata.get(key, {}) + entry = entry if isinstance(entry, dict) else {} + path = builtin_path if model['builtin'] else Path(models_path) / f'{key}_driving_tinygrad.pkl' + model.update(self.size(path, entry.get('artifact_size'))) + variants = entry.get('accelerator_artifacts', {}) + variant = variants.get('chestnut') if isinstance(variants, dict) else None + if isinstance(variant, dict): + model['modelLabFileSize'] = self.size(Path(models_path) / accelerator_filename(key), variant.get('artifact_size')) + return models diff --git a/starpilot/assets/tests/test_download_failure_recovery.py b/starpilot/assets/tests/test_download_failure_recovery.py new file mode 100644 index 0000000000..ee12b69586 --- /dev/null +++ b/starpilot/assets/tests/test_download_failure_recovery.py @@ -0,0 +1,53 @@ +"""Source-class fault seam; native imports and real Params are not exercised.""" +import ast +from pathlib import Path +import pytest + + +def manager_class(): + tree = ast.parse((Path(__file__).parents[1] / 'model_manager.py').read_text()) + names = {'MODEL_LAB_ACCELERATOR', 'ALLOW_GPU_DOWNLOAD_WITHOUT_GPU_PARAM', + 'DOWNLOAD_PROGRESS_PARAM', 'MODEL_DOWNLOAD_PARAM', 'MODEL_LAB_DOWNLOAD_PARAM', + 'DEFAULT_MODEL_KEY', 'MODEL_KEY_CANONICAL_MAP'} + nodes = [n for n in tree.body if + isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id in names for t in n.targets) or + isinstance(n, ast.FunctionDef) and n.name in {'canonical_model_key', 'is_builtin_model_key'} or + isinstance(n, ast.ClassDef) and n.name == 'ModelManager'] + ns = {'Path': Path, 'model_accelerator_artifact_metadata': lambda *args: {}} + exec(compile(ast.Module(body=nodes, type_ignores=[]), 'model_manager.py', 'exec'), ns) + return ns['ModelManager'] + + +class FaultParams: + def __init__(self, cleanup_fault=False): + self.cleanup_fault = cleanup_fault + def get_bool(self, key): + return False + def put(self, *args): + raise OSError('synthetic Params write failure') + def remove(self, *args): + if self.cleanup_fault: + raise OSError('synthetic Params removal failure') + + +@pytest.mark.parametrize('cleanup_fault', [False, True]) +@pytest.mark.parametrize('operation', ['single', 'all', 'accelerator']) +def test_failed_download_releases_refresh_gate(cleanup_fault, operation): + cls = manager_class() + manager = cls.__new__(cls) + manager.downloading_model = False + manager.params_memory = FaultParams(cleanup_fault) + if operation == 'all': + manager._download_all_models = lambda allow: manager._download_model('rdf43', allow) + call = manager.download_all_models + elif operation == 'accelerator': + # Failure at upstream artifact metadata lookup; cleanup must release the gate. + def fail(): + raise OSError('synthetic hardware lookup failure') + cls.download_model_accelerator.__globals__['model_accelerator_artifact_metadata'] = lambda *args: fail() + call = lambda: manager.download_model_accelerator('gpu') + else: + call = lambda: manager.download_model('rdf43') + with pytest.raises(OSError): + call() + assert manager.downloading_model is False diff --git a/starpilot/assets/tests/test_model_sizes.py b/starpilot/assets/tests/test_model_sizes.py new file mode 100644 index 0000000000..e3bbc8ec79 --- /dev/null +++ b/starpilot/assets/tests/test_model_sizes.py @@ -0,0 +1,99 @@ +from pathlib import Path + +import pytest + +from openpilot.starpilot.assets.model_sizes import artifact_size, positive_size + + +@pytest.mark.parametrize('value', [None, True, False, 0, -1, '123', 'abc', 1.2, 2**60]) +def test_invalid_declared_size(value): + assert positive_size(value) is None + + +def test_monolithic_actual_size_precedes_chunks_and_reports_mismatch(tmp_path): + path = tmp_path / 'model.pkl' + path.write_bytes(b'actual model') + Path(str(path) + '.chunkmanifest').write_text('1') + Path(str(path) + '.chunk01of01').write_bytes(b'old chunk') + result = artifact_size(path, 2) + assert result['fileSizeBytes'] == len(b'actual model') + assert result['downloadedBytes'] == len(b'actual model') + assert result['sizeStatus'] == 'mismatch' + + +def test_complete_chunks_exclude_manifest_and_missing_chunk_is_partial(tmp_path): + path = tmp_path / 'model.pkl' + Path(str(path) + '.chunkmanifest').write_text('2') + first, second = Path(str(path) + '.chunk01of02'), Path(str(path) + '.chunk02of02') + first.write_bytes(b'abc') + second.write_bytes(b'defg') + assert artifact_size(path, 7)['fileSizeBytes'] == 7 + assert artifact_size(path, 7)['sizeStatus'] == 'complete' + second.unlink() + result = artifact_size(path, 7) + assert result['sizeStatus'] == 'partial' + assert result['fileSizeBytes'] is None + assert result['downloadedBytes'] == 3 and result['declaredSizeBytes'] == 7 + + +@pytest.mark.parametrize('manifest', ['0', '-1', 'bad', '100000000000000', '', '2.0']) +def test_malformed_chunk_manifest_is_not_complete(tmp_path, manifest): + path = tmp_path / 'model.pkl' + Path(str(path) + '.chunkmanifest').write_text(manifest) + assert artifact_size(path)['fileSizeBytes'] is None + assert artifact_size(path)['sizeStatus'] == 'partial' + + +def test_missing_manifest_and_missing_file(tmp_path): + path = tmp_path / 'model.pkl' + assert artifact_size(path, 20)['sizeStatus'] == 'declared' + assert artifact_size(path)['sizeStatus'] == 'unknown' + Path(str(path) + '.chunk01of02').write_bytes(b'partial') + assert artifact_size(path)['sizeStatus'] == 'partial' + assert artifact_size(path)['downloadedBytes'] == 7 + + +def test_stat_failure_is_unknown(tmp_path, monkeypatch): + path = tmp_path / 'model.pkl' + path.write_bytes(b'x') + def failed(*args, **kwargs): + raise PermissionError('test denied') + monkeypatch.setattr(Path, 'stat', failed) + assert artifact_size(path, 10)['sizeStatus'] == 'unknown' + + +def test_zero_byte_artifact_is_not_valid_size(tmp_path): + path = tmp_path / 'model.pkl' + path.touch() + assert artifact_size(path)['fileSizeBytes'] is None + assert artifact_size(path)['sizeStatus'] == 'partial' + + +def test_catalog_annotation_is_independent_of_statistics(tmp_path): + from openpilot.starpilot.assets.model_sizes import ModelSizes + (tmp_path / 'builtin.pkl').write_bytes(b'123') + (tmp_path / 'gpu_driving_tinygrad.pkl').write_bytes(b'12345') + (tmp_path / 'gpu_accel.pkl').write_bytes(b'1234567') + models = [{'value': 'stock', 'builtin': True}, {'value': 'gpu', 'builtin': False}] + result = ModelSizes().annotate(models, tmp_path, tmp_path / 'builtin.pkl', + {'gpu': {'artifact_size': 5, 'accelerator_artifacts': {'chestnut': {'artifact_size': 7}}}}, lambda key: key + '_accel.pkl') + assert result[0]['fileSizeBytes'] == 3 + assert result[1]['fileSizeBytes'] == 5 + assert result[1]['modelLabFileSize']['fileSizeBytes'] == 7 + assert all('stats' not in model for model in result) + + +def test_size_cache_refresh_and_bound(tmp_path, monkeypatch): + from openpilot.starpilot.assets import model_sizes + now = [0] + monkeypatch.setattr(model_sizes.time, 'monotonic', lambda: now[0]) + service = model_sizes.ModelSizes() + path = tmp_path / 'model.pkl' + assert service.size(path, None)['fileSizeBytes'] is None + path.write_bytes(b'123') + assert service.size(path, None)['fileSizeBytes'] is None + now[0] = 3 + assert service.size(path, None)['fileSizeBytes'] == 3 + for index in range(1002): + service.size(tmp_path / str(index), None) + assert len(service.sizes) <= 1001 diff --git a/starpilot/system/the_galaxy/assets/components/tools/model_hardware.js b/starpilot/system/the_galaxy/assets/components/tools/model_hardware.js new file mode 100644 index 0000000000..e10131aa01 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/tools/model_hardware.js @@ -0,0 +1,52 @@ +// Presentation only. No Params writes, telemetry collection or inferred mileage. +const FILTER_KEY = "galaxy.modelManager.hardwareFilter"; +const FILTERS = new Set(["both", "gpu", "comma"]); + +export function readHardwareFilter() { + try { + const value = localStorage.getItem(FILTER_KEY); + return FILTERS.has(value) ? value : "both"; + } catch { + return "both"; + } +} + +export function saveHardwareFilter(value) { + const filter = FILTERS.has(value) ? value : "both"; + try { localStorage.setItem(FILTER_KEY, filter); } catch { /* View still works without storage. */ } + return filter; +} + +export function matchesHardware(model, filter) { + if (filter === "gpu") return model?.requiresGpu === true; + if (filter === "comma") return model?.requiresGpu === false; + return true; +} + +export function hardwareLabel(model) { + if (model?.requiresGpu === true) return "GPU · External"; + if (model?.requiresGpu === false) return "Comma · On-device"; + return "Hardware unknown"; +} + +function bytes(value) { + return Number.isSafeInteger(value) && value > 0 ? value : null; +} + +function formatBytes(value) { + return value >= 1e9 ? `${(value / 1e9).toFixed(2)} GB` : `${(value / 1e6).toFixed(1)} MB`; +} + +export function fileSizeText(model) { + // modelSize is an architecture class, never a byte count. + const installed = bytes(model?.fileSizeBytes); + const declared = bytes(model?.declaredSizeBytes); + const downloaded = bytes(model?.downloadedBytes); + if (model?.partial || model?.sizeStatus === "partial") { + return `Partial: ${downloaded ? formatBytes(downloaded) : "unknown"} / ${declared ? formatBytes(declared) : "unknown"}`; + } + if (installed && declared && installed !== declared) return `${formatBytes(installed)} · size mismatch`; + if (installed) return formatBytes(installed); + if (declared) return `${formatBytes(declared)} · declared`; + return "Unavailable"; +} diff --git a/starpilot/system/the_galaxy/assets/components/tools/model_manager.js b/starpilot/system/the_galaxy/assets/components/tools/model_manager.js index 690110771a..683b60c671 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/model_manager.js +++ b/starpilot/system/the_galaxy/assets/components/tools/model_manager.js @@ -5,6 +5,7 @@ const state = reactive({ refreshing: false, error: "", actionBusy: false, + selectionUncertain: true, sortMode: "release_date", communityFavoriteFilter: "all", userFavoriteFilter: "all", @@ -27,7 +28,10 @@ const state = reactive({ let initialized = false; let pollingHandle = null; -let statusInFlight = false; +let statusInFlight = null; +let statusGeneration = 0; +let viewGeneration = 0; +let selectionWrite = null; let lastStatusSignature = ""; const REQUEST_TIMEOUT_MS = 20000; @@ -216,11 +220,16 @@ async function fetchJson(url, options = {}) { } async function fetchStatus() { - if (statusInFlight) return; - statusInFlight = true; + const generation = statusGeneration; + if (statusInFlight === generation) return; + statusInFlight = generation; try { + // Remount readback must follow settlement of an already sent selection. + if (selectionWrite) await selectionWrite.catch(() => {}); + if (generation !== statusGeneration || !isModelRouteActive()) return; const payload = await fetchJson("/api/models/status"); + if (generation !== statusGeneration || !isModelRouteActive()) return; const models = Array.isArray(payload.models) ? payload.models.filter(model => model && typeof model === "object") @@ -249,6 +258,15 @@ async function fetchStatus() { }; state.error = ""; + state.selectionUncertain = false; + // selected attributes cannot reset a select's dirty native value after a user edit. + queueMicrotask(() => { + if (generation !== statusGeneration || !isModelRouteActive()) return; + for (const profile of ["small", "big"]) { + const select = document.getElementById(`mm-active-${profile}-model-select`); + if (select) select.value = profile === "big" ? state.activeBigModel : state.activeSmallModel; + } + }); const signature = [ state.models.length, @@ -271,12 +289,15 @@ async function fetchStatus() { }); } } catch (error) { + if (generation !== statusGeneration || !isModelRouteActive()) return; state.error = error?.message || String(error); logDebug("Status fetch failed", state.error); } finally { - statusInFlight = false; - state.loading = false; - state.refreshing = false; + if (statusInFlight === generation) statusInFlight = null; + if (generation === statusGeneration && isModelRouteActive()) { + state.loading = false; + state.refreshing = false; + } } } @@ -296,8 +317,9 @@ async function refreshAll(showToast = false) { function ensurePolling() { if (pollingHandle) return; + const generation = viewGeneration; const poll = async () => { - if (!isModelRouteActive()) { + if (generation !== viewGeneration || !isModelRouteActive()) { pollingHandle = null; return; } @@ -307,7 +329,7 @@ function ensurePolling() { await fetchStatus(); nextDelay = state.status.downloading ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; } finally { - pollingHandle = setTimeout(poll, nextDelay); + if (generation === viewGeneration) pollingHandle = setTimeout(poll, nextDelay); } }; @@ -317,13 +339,13 @@ function ensurePolling() { async function setActiveModel(modelKey, profile = "") { const model = state.models.find(entry => safeText(entry?.value, "") === safeText(modelKey, "")); const resolvedProfile = profile || (model?.requiresGpu ? "big" : "small"); - const payload = await fetchJson("/api/models/active", { + selectionWrite = fetchJson("/api/models/active", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ profile: resolvedProfile, model: modelKey }), }); - notify(payload.message || `Selected "${modelKey}".`); + try { return await selectionWrite; } finally { selectionWrite = null; } } async function startDownload(modelKey) { @@ -391,6 +413,9 @@ async function refreshManifest() { } async function runAction(action, modelKey = "") { + const selecting = ["select", "select-small", "select-big"].includes(action); + if (!isModelRouteActive() || (selecting && state.selectionUncertain)) return; + const generation = viewGeneration; if (state.actionBusy) { notify("Please wait for the current action to finish.", "error"); return; @@ -411,9 +436,14 @@ async function runAction(action, modelKey = "") { } if (action === "select" || action === "select-small" || action === "select-big") { - if (!modelKey) return; + // Empty Active Big explicitly disables that profile; other selections require a model. + if (!modelKey && action !== "select-big") return; const profile = action === "select-small" ? "small" : action === "select-big" ? "big" : ""; - await setActiveModel(modelKey, profile); + state.selectionUncertain = true; + ++statusGeneration; // Invalidate pre-write polls, including their finalisers. + const payload = await setActiveModel(modelKey, profile); + if (generation !== viewGeneration || !isModelRouteActive()) return; + notify(payload.message || `Selected "${modelKey}".`); } else if (action === "download") { if (!modelKey) return; await startDownload(modelKey); @@ -434,18 +464,21 @@ async function runAction(action, modelKey = "") { await setUserFavorite(modelKey, false); } + if (generation !== viewGeneration || !isModelRouteActive()) return; await fetchStatus(); } catch (error) { + if (generation !== viewGeneration || !isModelRouteActive()) return; notify(error?.message || String(error), "error"); + // A failed response does not establish whether the server accepted the write. + await fetchStatus(); } finally { - state.actionBusy = false; + if (generation === viewGeneration && isModelRouteActive()) state.actionBusy = false; } } function bindDomHandlers() { if (window.__modelManagerHandlersBound) return; window.__modelManagerHandlersBound = true; - document.addEventListener("click", event => { if (!isModelRouteActive()) return; @@ -475,6 +508,8 @@ function bindDomHandlers() { const modelKey = safeText(target.value, ""); const profile = target.id === "mm-active-big-model-select" ? "big" : "small"; if (!modelKey && profile !== "big") return; + // Native selects change before their event; display only verified state. + target.value = profile === "big" ? state.activeBigModel : state.activeSmallModel; runAction(`select-${profile}`, modelKey).catch(() => {}); return; } @@ -532,7 +567,7 @@ function renderActions(model) { if (model.installed) { return html` - + ${model.builtin ? "" : html``} @@ -599,19 +634,41 @@ function renderSeriesSection(seriesName, models) { `; } +function ensureModelView() { + if (document.querySelector(".mm-wrapper")) return; + const generation = ++viewGeneration; + ++statusGeneration; + clearTimeout(pollingHandle); + pollingHandle = null; + // Arrow has no unmount hook. Observe this mount's removal, not just pathname: + // a quick leave-and-return must not revive an old write/readback continuation. + queueMicrotask(() => { + if (generation !== viewGeneration) return; + const observer = new MutationObserver(() => { + if (generation !== viewGeneration) { observer.disconnect(); return; } + // Arrow may replace the wrapper during an ordinary reactive render. + if (document.querySelector(".mm-wrapper") && isModelRouteActive()) return; + observer.disconnect(); + if (generation !== viewGeneration) return; + ++viewGeneration; + ++statusGeneration; + clearTimeout(pollingHandle); + pollingHandle = null; + }); + observer.observe(document.body, { childList: true, subtree: true }); + state.actionBusy = false; + state.selectionUncertain = true; + refreshAll(); + ensurePolling(); + }); +} + export function ModelManager() { if (!initialized) { initialized = true; bindDomHandlers(); - logDebug("Initializing component"); - refreshAll().catch(error => { - state.error = error?.message || String(error); - state.loading = false; - state.refreshing = false; - logDebug("Initial refresh failed", state.error); - }); } - ensurePolling(); + ensureModelView(); return html`
@@ -643,14 +700,14 @@ export function ModelManager() { Loaded: ${() => getCurrentModelName()} Active Small: ${() => getModelName(state.activeSmallModel)} Active Big: ${() => getModelName(state.activeBigModel)} - Progress: ${safeText(state.status.progress, "Idle")} + Progress: ${() => safeText(state.status.progress, "Idle")} ${() => getUserFavoriteModels(false).length} personal favorites ${() => state.status.isOnroad ? html`Onroad: actions disabled` : ""}
- ${() => { const orderedInstalled = getInstalledModels("small").sort((a, b) => { const aCurrent = safeText(a.value) === state.activeSmallModel ? 0 : 1; @@ -669,7 +726,7 @@ export function ModelManager() { - ${() => { const orderedInstalled = getInstalledModels("big").sort((a, b) => { const aCurrent = safeText(a.value) === state.activeBigModel ? 0 : 1; @@ -692,7 +749,7 @@ export function ModelManager() { - ${(() => { const favorites = getUserFavoriteModels(true); return favorites.length > 0 @@ -746,7 +803,7 @@ export function ModelManager() { ${() => !state.loading ? html`
- ${(() => { + ${() => { if (state.sortMode === "release_date") { const models = getReleaseOrderedModels(); return models.length === 0 @@ -758,7 +815,7 @@ export function ModelManager() { return seriesNames.length === 0 ? html`
No models available.
` : seriesNames.map(seriesName => renderSeriesSection(seriesName, grouped[seriesName])); - })()} + }}
` : ""}
diff --git a/starpilot/system/the_galaxy/assets/mobile/css/material.css b/starpilot/system/the_galaxy/assets/mobile/css/material.css index 9b987df931..1084480262 100644 --- a/starpilot/system/the_galaxy/assets/mobile/css/material.css +++ b/starpilot/system/the_galaxy/assets/mobile/css/material.css @@ -1,3 +1,7 @@ +/* Download status changes must not move the viewport to another card. */ +.gx-model-manager { overflow-anchor: none; } +.gx-model-manager .gx-row .gx-field { min-width: 0; } + .gx-personalities { overflow-anchor: none; } #gx-personality-settings { padding: 0 var(--sp-4) var(--sp-4); } .gx-personalities__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/FeatureHelp.js b/starpilot/system/the_galaxy/assets/mobile/js/components/FeatureHelp.js new file mode 100644 index 0000000000..680e897c66 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/mobile/js/components/FeatureHelp.js @@ -0,0 +1,95 @@ +// Shared confirmation dialog for model downloads. +let activeDialog = null +let dialogId = 0 +function installStyle() { + if (document.getElementById("gx-feature-help-style")) return + const style = document.createElement("style") + style.id = "gx-feature-help-style" + style.textContent = ` + .gx-help-dialog {box-sizing:border-box;width:min(640px,calc(100% - 24px));max-width:calc(100% - 24px);max-height:var(--gx-help-height,85dvh);margin:auto;padding:0;border:1px solid var(--outline-variant,#667085);border-radius:var(--radius-lg,18px);background:var(--background,#20252e);color:var(--on-surface,#edf0f5);box-shadow:0 16px 64px #0008;overflow:auto;overscroll-behavior:contain;overflow-wrap:anywhere;font-family:inherit} + .gx-help-dialog::backdrop {background:#0009} + .gx-help-dialog__heading {margin:0;padding:20px 20px 8px;font-size:1.25rem;line-height:1.35} + .gx-help-dialog__body {padding:0 20px;line-height:1.6} + .gx-help-dialog__body p {white-space:pre-line;margin:12px 0} + .gx-help-dialog__body h4 {font-size:1rem;margin:20px 0 8px} + .gx-help-dialog__actions {display:flex;justify-content:flex-end;flex-wrap:wrap;gap:12px;padding:16px 20px;position:sticky;bottom:0;background:var(--background,#20252e);border-top:1px solid var(--outline-variant,#667085)} + .gx-help-dialog__actions button {min-height:44px} + ` + document.head.appendChild(style) +} + +// Native modal provides inert background, Escape and focus restoration. No settings writes. +export function openGalaxyHelpDialog({ title, paragraphs = [], troubleshooting = [], confirmLabel = "Close", cancelLabel = "" }) { + installStyle() + activeDialog?.() + return new Promise(resolve => { + const opener = document.activeElement + const dialog = document.createElement("dialog") + dialog.className = "gx-help-dialog" + dialog.setAttribute("aria-modal", "true") + const titleId = `gx-help-title-${++dialogId}` + dialog.setAttribute("aria-labelledby", titleId) + const heading = document.createElement("h3") + heading.id = titleId + heading.className = "gx-help-dialog__heading" + heading.textContent = title + const body = document.createElement("div") + body.className = "gx-help-dialog__body" + const paragraph = text => { const p = document.createElement("p"); p.textContent = text; body.appendChild(p) } + paragraphs.forEach(paragraph) + if (troubleshooting.length) { + const h = document.createElement("h4"); h.textContent = "Troubleshooting"; body.appendChild(h) + troubleshooting.forEach(paragraph) + } + const actions = document.createElement("div") + actions.className = "gx-help-dialog__actions" + let settled = false + const finish = result => { + if (settled) return + settled = true + window.removeEventListener("hashchange", cancel) + window.removeEventListener("resize", resize) + window.visualViewport?.removeEventListener("resize", resize) + if (activeDialog === cancel) activeDialog = null + dialog.close() + dialog.remove() + if (opener?.isConnected) opener.focus({ preventScroll: true }) + resolve(result) + } + const cancel = () => finish(false) + const resize = () => { + // CSS zoom changes available CSS-pixel space independently of device scale. + let zoom = 1 + for (let node = document.body; node; node = node.parentElement) zoom *= parseFloat(getComputedStyle(node).zoom) || 1 + dialog.style.setProperty("--gx-help-height", `${Math.max(100, ((window.visualViewport?.height || innerHeight) - 24) / zoom)}px`) + } + const button = (label, result, secondary) => { + const b = document.createElement("button"); b.type = "button"; b.textContent = label + b.className = `gx-btn${secondary ? " gx-btn--tonal" : ""}` + b.addEventListener("click", () => finish(result)); actions.appendChild(b) + } + if (cancelLabel) button(cancelLabel, false, true) + button(confirmLabel, true, false) + dialog.append(heading, body, actions) + dialog.addEventListener("cancel", event => { event.preventDefault(); cancel() }) + dialog.addEventListener("click", event => { + const box = dialog.getBoundingClientRect() + if (event.target === dialog && (event.clientX < box.left || event.clientX > box.right || event.clientY < box.top || event.clientY > box.bottom)) cancel() + }) + dialog.addEventListener("keydown", event => { + if (event.key !== "Tab") return + const buttons = [...actions.querySelectorAll("button")] + const first = buttons[0], last = buttons.at(-1) + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() } + if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() } + }) + window.addEventListener("hashchange", cancel) + window.addEventListener("resize", resize) + window.visualViewport?.addEventListener("resize", resize) + document.body.appendChild(dialog) + activeDialog = cancel + resize() + dialog.showModal() + actions.querySelector("button").focus({ preventScroll: true }) + }) +} diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/ModelManager.js b/starpilot/system/the_galaxy/assets/mobile/js/views/ModelManager.js index f9570ec3aa..a4fa5af797 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/views/ModelManager.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/ModelManager.js @@ -1,6 +1,8 @@ import { api, showSnackbar } from "../api.js" import { usePolling } from "../composables.js" import { GalaxyConfirm } from "../components/GalaxyModal.js" +import { openGalaxyHelpDialog } from "../components/FeatureHelp.js" +import { readHardwareFilter, saveHardwareFilter, matchesHardware, hardwareLabel, fileSizeText } from "/assets/components/tools/model_hardware.js" function text(value, fallback = "") { return value === null || value === undefined ? fallback : String(value) @@ -11,6 +13,9 @@ function releasedTs(value) { return Number.isNaN(n) ? 0 : n } +// Survives SPA unmount: a new instance must read after a sent write settles. +let selectionWrite = null + export const ModelManager = { name: "ModelManager", data() { @@ -18,10 +23,13 @@ export const ModelManager = { loading: true, error: "", busy: "", + selectionUncertain: true, + statusGeneration: 0, + disposed: false, sortMode: "release_date", userFilter: "all", communityFilter: "all", - allowGpu: false, + hardwareFilter: readHardwareFilter(), models: [], currentModel: "", activeSmallModel: "", @@ -53,6 +61,7 @@ export const ModelManager = { const mode = this.sortMode const rows = (this.models || []) .filter((m) => m && typeof m === "object") + .filter((m) => matchesHardware(m, this.hardwareFilter)) .filter((m) => this.userFilter === "all" ? true : !!m.userFavorite === (this.userFilter === "yes")) .filter((m) => this.communityFilter === "all" ? true : !!m.communityFavorite === (this.communityFilter === "yes")) rows.sort((a, b) => { @@ -64,9 +73,6 @@ export const ModelManager = { }) return rows }, - anyGpuBlocked() { - return (this.models || []).some((m) => !!m.requiresGpu && !m.gpuAvailable) - }, downloadTargetLabel() { if (this.status.downloadAll) return "all missing models" const key = text(this.status.modelToDownload, "") @@ -78,10 +84,16 @@ export const ModelManager = { this.poll = usePolling(() => this.refresh(), { interval: 2000 }) this.poll.start() }, - beforeUnmount() { this.poll?.destroy() }, + beforeUnmount() { + this.disposed = true + ++this.statusGeneration + this.poll?.destroy() + }, methods: { - gpuBlocked(model) { - return !!model.requiresGpu && !model.gpuAvailable && !this.allowGpu + hardwareLabel, + fileSizeText, + setHardwareFilter(value) { + this.hardwareFilter = saveHardwareFilter(value) }, rowState(model) { const key = text(model.value, "") @@ -99,8 +111,12 @@ export const ModelManager = { return !this.status.isOnroad }, async refresh() { + const generation = ++this.statusGeneration try { + if (selectionWrite) await selectionWrite.catch(() => {}) + if (this.disposed || generation !== this.statusGeneration) return const p = await api.getModelStatus() + if (this.disposed || generation !== this.statusGeneration) return this.models = Array.isArray(p.models) ? p.models.filter((m) => m && typeof m === "object") : [] this.currentModel = text(p.currentModel, "") this.activeSmallModel = text(p.activeSmallModel, "") @@ -119,13 +135,17 @@ export const ModelManager = { isOnroad: !!p.isOnroad, } this.error = "" + this.selectionUncertain = false this.loading = false } catch (e) { + if (this.disposed || generation !== this.statusGeneration) return this.error = e?.message || String(e) this.loading = false } }, async runAction(action, model = null) { + const selecting = action === "select-small" || action === "select-big" + if (this.disposed || (selecting && this.selectionUncertain)) return if (this.busy) { showSnackbar("Please wait for the current action to finish.", "error") return @@ -138,16 +158,41 @@ export const ModelManager = { const label = text(model && model.label, key || "model") this.busy = `${action}:${key}` try { + let allowGpu = false + const needsGpuPrompt = action === "download" + ? model?.requiresGpu && !model.gpuAvailable + : action === "downloadAll" && this.models.some(m => !m.installed && m.requiresGpu && !m.gpuAvailable) + if (needsGpuPrompt) { + allowGpu = await openGalaxyHelpDialog({ + title: "No external GPU detected", + paragraphs: [action === "downloadAll" + ? "This download includes models that require an external GPU. You can download them now, but they cannot run until a compatible GPU is connected and detected." + : `“${label}” requires an external GPU. You can download it now, but it cannot run until a compatible GPU is connected and detected.`, + "GPU model files can be large. Downloading does not activate a model or change the model currently in use."], + confirmLabel: "Download anyway", cancelLabel: "Cancel", + }) + if (!allowGpu || this.disposed) return + // The car can change state while a confirmation is open. + await this.refresh() + if (this.disposed || this.error || !this.actionAllowedOnroad(action) || this.status.downloading) { + if (!this.disposed) showSnackbar("Download not started. Check the device status and try again while parked.", "error") + return + } + } let msg = "" if (action === "select-small" || action === "select-big") { const profile = action === "select-big" ? "big" : "small" - const p = await api.setActiveModel(profile, key) + this.selectionUncertain = true + ++this.statusGeneration // Pre-write polls cannot reconcile this selection. + selectionWrite = api.setActiveModel(profile, key) + let p + try { p = await selectionWrite } finally { selectionWrite = null } msg = p?.message || `Selected "${label}".` } else if (action === "download") { - const p = await api.startModelDownload(key, this.allowGpu) + const p = await api.startModelDownload(key, allowGpu) msg = p?.message || `Downloading "${label}"...` } else if (action === "downloadAll") { - const p = await api.downloadAllModels(this.allowGpu) + const p = await api.downloadAllModels(allowGpu) msg = p?.message || "Started downloading all models." } else if (action === "cancel") { const p = await api.postAction("/api/models/cancel") @@ -175,17 +220,21 @@ export const ModelManager = { const p = await api.postAction("/api/models/refresh_manifest") msg = p?.message || "Model manifest refreshed." } + if (this.disposed) return if (msg) showSnackbar(msg) await this.refresh() } catch (e) { + if (this.disposed) return showSnackbar(e?.message || String(e), "error") + // Reconcile a possibly accepted request before releasing the action lock. + await this.refresh() } finally { - this.busy = "" + if (!this.disposed) this.busy = "" } }, }, template: ` -
+
Loading models...
@@ -200,7 +249,7 @@ export const ModelManager = { {{ summary.installed }} installed {{ summary.missing }} missing {{ summary.total }} total - Active: {{ currentLabel }} + Selected: {{ currentLabel }}
@@ -226,23 +275,23 @@ export const ModelManager = { Controls
-
+
- +
Active Small -
Active Big - @@ -256,6 +305,15 @@ export const ModelManager = {
+
+ + +
+
-
-
- Download GPU models without GPU - GPU models are very large and will not run without an external GPU. -
- -
diff --git a/starpilot/system/the_galaxy/tests/test_model_catalog_sizes_api.py b/starpilot/system/the_galaxy/tests/test_model_catalog_sizes_api.py new file mode 100644 index 0000000000..7178cd9a1b --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_catalog_sizes_api.py @@ -0,0 +1,23 @@ +"""Catalogue sizes stay available without importing model statistics.""" +from test_dashboard_stats import _load_server_module, FakeParams + + +def test_catalog_status_has_independent_sizes(monkeypatch, tmp_path): + server = _load_server_module() + assert server._import_galaxy_web_symbols() + app = server.Flask('model_sizes_test') + server.setup(app) + class CatalogParams(FakeParams): + def get_default_value(self, key): + return {'Model': 'fixture-default', 'ModelName': 'Fixture default'}.get(key) + monkeypatch.setattr(server, 'params', CatalogParams({'IsOnroad': False})) + monkeypatch.setattr(server, 'params_memory', FakeParams({})) + monkeypatch.setattr(server, 'MODELS_PATH', tmp_path) + response = app.test_client().get('/api/models/status') + assert response.status_code == 200, response.get_json() + result = response.get_json() + assert result['models'] + assert 'statistics' not in result + for model in result['models']: + assert {'fileSizeBytes', 'declaredSizeBytes', 'downloadedBytes', 'sizeSource', 'sizeStatus'} <= model.keys() + assert 'stats' not in model diff --git a/starpilot/system/the_galaxy/tests/test_model_hardware.mjs b/starpilot/system/the_galaxy/tests/test_model_hardware.mjs new file mode 100644 index 0000000000..c3e14374b0 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_hardware.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import {readHardwareFilter,saveHardwareFilter,matchesHardware,hardwareLabel,fileSizeText} from '../assets/components/tools/model_hardware.js'; +assert.equal(readHardwareFilter(),'both'); +const stored=new Map();globalThis.localStorage={getItem:k=>stored.get(k),setItem:(k,v)=>stored.set(k,v)}; +for (const value of ['both','gpu','comma']) {assert.equal(saveHardwareFilter(value),value);assert.equal(readHardwareFilter(),value);} +assert.equal(saveHardwareFilter('invalid'),'both'); +assert.equal(matchesHardware({},'gpu'),false);assert.equal(matchesHardware({},'comma'),false);assert.equal(matchesHardware({},'both'),true); +assert.equal(matchesHardware({requiresGpu:true},'gpu'),true);assert.equal(matchesHardware({requiresGpu:false},'comma'),true); +assert.equal(hardwareLabel({}),'Hardware unknown'); +assert.equal(fileSizeText({modelSize:'big'}),'Unavailable'); +assert.equal(fileSizeText({fileSizeBytes:1500000000}),'1.50 GB'); +assert.equal(fileSizeText({declaredSizeBytes:1200000}),'1.2 MB · declared'); +assert.equal(fileSizeText({fileSizeBytes:1500000000,declaredSizeBytes:1000000000}),'1.50 GB · size mismatch'); +assert.equal(fileSizeText({partial:true,downloadedBytes:1200000,declaredSizeBytes:3000000}),'Partial: 1.2 MB / 3.0 MB'); +for(const size of [-1,0,NaN,Infinity,'123',true])assert.equal(fileSizeText({fileSizeBytes:size}),'Unavailable'); +globalThis.localStorage={getItem:()=>{throw Error('blocked')},setItem:()=>{throw Error('blocked')}};assert.equal(readHardwareFilter(),'both');assert.equal(saveHardwareFilter('gpu'),'gpu'); +console.log('PASS: hardware filtering, persistence, unknown/mismatch/partial/declared sizes'); diff --git a/starpilot/system/the_galaxy/tests/test_model_manager_legacy_state.mjs b/starpilot/system/the_galaxy/tests/test_model_manager_legacy_state.mjs new file mode 100644 index 0000000000..a442ee82d7 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_manager_legacy_state.mjs @@ -0,0 +1,17 @@ +// Existing classic UI: verify only selection/lifecycle compatibility fixes. +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +const source=readFileSync(new URL('../assets/components/tools/model_manager.js',import.meta.url),'utf8'); +const notices=[];globalThis.window={location:{pathname:'/manage_models'},showSnackbar:(...a)=>notices.push(a)}; +const selects={};globalThis.document={getElementById:id=>selects[id]??=({value:''})}; +const api=new Function('reactive','html',source.replace(/^import .*$/gm,'').replace('export function ModelManager','function ModelManager')+';return {state,fetchStatus,runAction};')(x=>x,()=>{}); +const payload=(active='gpu')=>({models:[{value:'gpu',installed:true,requiresGpu:true}],activeBigModel:active,activeSmallModel:'small',currentModel:'small',summary:{},isOnroad:false}); +const response=(data,ok=true)=>({ok,status:ok?200:503,json:async()=>data}); +let active='gpu',failWrite=false,failRead=false,hold=null,writes=[]; +globalThis.fetch=async(url,opts)=>{if(opts.method==='PUT'){writes.push(JSON.parse(opts.body));if(failWrite)return response({error:'uncertain'},false);active=writes.at(-1).model;return response({message:'selected'});}if(url==='/api/models/cancel'){writes.push('cancel');return response({});}if(hold){const value=hold;hold=null;return value;}return response(failRead?{error:'read failed'}:payload(active),!failRead);}; +await api.fetchStatus();assert.equal(api.state.selectionUncertain,false);assert.equal(selects['mm-active-big-model-select'].value,'gpu'); +let release;hold=new Promise(resolve=>release=resolve);const old=api.fetchStatus();await api.runAction('select-big','');release(response(payload('gpu')));await old;assert.equal(api.state.activeBigModel,'');assert.equal(selects['mm-active-big-model-select'].value,'');assert.deepEqual(writes,[{profile:'big',model:''}]); +failWrite=true;failRead=true;await api.runAction('select-big','gpu');assert.equal(api.state.selectionUncertain,true);assert.equal(api.state.actionBusy,false);await api.runAction('select-big','gpu');assert.equal(writes.length,2);await api.runAction('cancel');assert.equal(writes.length,3); +failRead=false;await api.fetchStatus();assert.equal(api.state.selectionUncertain,false);assert.equal(api.state.activeBigModel,''); +window.location.pathname='/elsewhere';await api.runAction('select-big','gpu');assert.equal(writes.length,3); +console.log('PASS: legacy explicit None, stale poll, readback failure lock, retry, cancellation and route guard'); diff --git a/starpilot/system/the_galaxy/tests/test_model_manager_lifecycle_browser.mjs b/starpilot/system/the_galaxy/tests/test_model_manager_lifecycle_browser.mjs new file mode 100644 index 0000000000..9ac81f492c --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_manager_lifecycle_browser.mjs @@ -0,0 +1,104 @@ +// Real Chromium, shipped components, synthetic API only. No device/network fall-through. +import assert from 'node:assert/strict'; +import {readFileSync, writeFileSync, mkdirSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +const {chromium} = await import(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const root = (process.env.REPO || fileURLToPath(new URL('../../../../', import.meta.url))) + '/starpilot/system/the_galaxy'; +const out = process.env.EVIDENCE; +if (out) mkdirSync(out, {recursive:true}); +const browser = await chromium.launch({executablePath:process.env.BROWSER_EXECUTABLE, args:['--no-sandbox']}); +const results=[]; +const tick=()=>new Promise(r=>setTimeout(r,100)); +try { +for (const surface of ['classic','mobile']) for (const scenario of ['stale-poll','reject-read-failure','abort-read-failure','leave-write','leave-readback']) { + const context=await browser.newContext({viewport:{width:1200,height:850}}); + const writes=[], unexpected=[], errors=[]; let active='gpu-a', heldGet=null, heldPut=null, holdNext=false, failReads=false, gets=0; + const models=['gpu-a','gpu-b','small'].map(value=>({value,label:value,installed:true,requiresGpu:value!=='small',gpuAvailable:true})); + const payload=()=>({models,activeBigModel:active,activeSmallModel:'small',currentModel:'small',summary:{installed:3,total:3},isOnroad:false,downloading:true,modelToDownload:'other'}); + const mobile=surface==='mobile'; + const mount=mobile ? `import {createApp} from 'vue';import {ModelManager} from '/assets/mobile/js/views/ModelManager.js';let app;window.mount=()=>{app=createApp(ModelManager);window.vm=app.mount('#app')};window.leave=()=>app.unmount();window.poll=()=>window.vm.refresh();window.act=(a)=>window.vm.runAction(a);` : `import {html} from '/assets/vendor/arrow-core.js';import {ModelManager} from '/assets/components/tools/model_manager.js';window.mount=()=>html\`\${()=>ModelManager()}\`(document.querySelector('#app'));window.leave=()=>document.querySelector('#app').replaceChildren();`; + await context.route('**/*',async route=>{ + const req=route.request(),u=new URL(req.url()); + if(u.origin!=='http://galaxy.invalid') {unexpected.push(req.url());return route.abort();} + if(u.pathname==='/api/models/status' && req.method()==='GET') { + gets++; const json=payload(); + if(holdNext) {holdNext=false;heldGet={route,json};return;} + return route.fulfill(failReads?{status:503,json:{error:'Synthetic read failure'}}:{json}); + } + if(req.method()!=='GET') { + writes.push({method:req.method(),path:u.pathname,body:req.postData()?req.postDataJSON():null}); + if(u.pathname==='/api/models/cancel') return route.fulfill({json:{message:'cancelled'}}); + if(u.pathname!=='/api/models/active') {unexpected.push(req.url());return route.abort();} + if(scenario==='leave-write') {heldPut=route;return;} + if(scenario!=='reject-read-failure') active=writes.at(-1).body.model; + if(scenario.includes('read-failure')) {failReads=true;return scenario.startsWith('abort')?route.abort('connectionreset'):route.fulfill({status:503,json:{error:'Synthetic rejection'}});} + if(scenario==='leave-readback') holdNext=true; + return route.fulfill({json:{message:'selected'}}); + } + if(u.pathname.startsWith('/assets/')) { + try { + let body=readFileSync(root+u.pathname); + if(u.pathname.endsWith('/tools/model_manager.js')) body=body.toString()+ '\nwindow.poll=()=>fetchStatus();window.act=(a)=>runAction(a);'; + return route.fulfill({body,contentType:u.pathname.endsWith('.css')?'text/css':'text/javascript'}); + } catch(e) {unexpected.push(u.pathname);return route.abort();} + } + if(u.pathname!=='/manage_models') {unexpected.push(u.pathname);return route.abort();} + return route.fulfill({contentType:'text/html',body:`
`}); + }); + const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message)); + // Capture snackbar effects without changing the shipped API module. + await page.addInitScript(()=>{ + // Hold periodic polling; every race uses explicitly released real requests. + const timeout=window.setTimeout;window.setTimeout=(fn,ms,...args)=>timeout(fn,[1000,2000,4000].includes(ms)?60000:ms,...args); + window.notices=[];window.showSnackbar=(...a)=>window.notices.push(a); + new MutationObserver(records=>{for(const r of records) if(r.target.id==='snackbar_wrapper') for(const n of r.addedNodes) window.notices.push(n.textContent);}).observe(document,{childList:true,subtree:true}); + }); + const select=mobile?page.locator('.gx-row').filter({has:page.getByText('Active Big',{exact:true})}).locator('select'):page.locator('#mm-active-big-model-select'); + const until=async(fn,msg)=>{for(let i=0;i<80;i++){if(await fn())return;await tick();}throw new Error(msg);}; + let failure=null; + try { + await page.goto('http://galaxy.invalid/manage_models'); + await until(async()=>await select.count() && await select.inputValue()==='gpu-a','initial selection'); + if(scenario==='stale-poll') {holdNext=true;await page.evaluate(()=>{window.poll()});await until(()=>heldGet,'held prewrite poll');} + await select.selectOption(''); + await until(()=>writes.length===1,'selection PUT'); + assert.deepEqual(writes,[{method:'PUT',path:'/api/models/active',body:{profile:'big',model:''}}]); + if(scenario==='stale-poll') { + await tick(); await heldGet.route.fulfill({json:heldGet.json});heldGet=null;await tick(); + assert.equal(await select.inputValue(),'','old poll must not overwrite accepted selection'); + assert.equal(await select.isDisabled(),false,'post-write readback unlocks'); + } else if(scenario.includes('read-failure')) { + await until(()=>gets>=2,'failed readback requested');await tick(); + assert.equal(await select.isDisabled(),true,'uncertain selection stays locked'); + await page.evaluate(()=>window.act('select-big'));await tick();assert.equal(writes.length,1,'programmatic second selection blocked'); + await page.evaluate(()=>window.act('cancel'));await until(()=>writes.length===2,'cancel remains usable'); + assert.deepEqual(writes[1],{method:'POST',path:'/api/models/cancel',body:null}); + failReads=false;await page.evaluate(()=>window.poll()); + await until(async()=>!(await select.isDisabled()),'authoritative retry unlocks'); + assert.equal(await select.inputValue(),scenario.startsWith('abort')?'':'gpu-a'); + } else { + await until(()=>scenario==='leave-write'?heldPut:heldGet,'held write/readback'); + await page.evaluate(()=>{history.pushState({},'', '/elsewhere');window.leave();});await tick(); + const before=await page.evaluate(()=>window.notices.length); + if(scenario==='leave-readback') active='gpu-b'; + await page.evaluate(()=>{history.pushState({},'', '/manage_models');window.mount();});await tick(); + if(heldPut) { + // A remount GET before this acceptance cannot establish the final selection. + active='';await heldPut.fulfill({json:{message:'selected'}});heldPut=null; + } else {active='gpu-b';await heldGet.route.fulfill({json:heldGet.json});heldGet=null;} + await tick();await tick(); + assert.equal(await page.evaluate(()=>window.notices.length),before,'old generation must not notify'); + await until(async()=>await select.count() && await select.inputValue()===active && !(await select.isDisabled()),'remount must read current state after pending write'); + assert.equal(writes.length,1,'navigation must not duplicate writes'); + } + assert.deepEqual(errors,[]);assert.deepEqual(unexpected,[]); + } catch(e) {failure=e.stack;} + results.push({surface,scenario,passed:!failure,failure,gets,writes,unexpected,errors}); + if(heldGet) await heldGet.route.abort().catch(()=>{}); + if(heldPut) await heldPut.abort().catch(()=>{}); + await context.close(); +} +} finally {await browser.close();} +if(out)writeFileSync(out+'/results.json',JSON.stringify(results,null,2)); +console.log(JSON.stringify(results,null,2)); +assert.equal(results.length,10);assert.equal(results.filter(r=>!r.passed).length,0); diff --git a/starpilot/system/the_galaxy/tests/test_model_manager_selection_browser.mjs b/starpilot/system/the_galaxy/tests/test_model_manager_selection_browser.mjs new file mode 100644 index 0000000000..6af90932ff --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_manager_selection_browser.mjs @@ -0,0 +1,65 @@ +const {chromium} = await import(process.env.PLAYWRIGHT_MODULE || 'playwright'); +import {fileURLToPath} from 'node:url'; +import {mkdtempSync, mkdirSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import { readFileSync, writeFileSync } from 'node:fs'; +import assert from 'node:assert/strict'; +const root = (process.env.REPO || fileURLToPath(new URL('../../../../', import.meta.url))) + '/starpilot/system/the_galaxy'; +const out = process.env.EVIDENCE || mkdtempSync(tmpdir() + '/model-manager-browser-'); +mkdirSync(out, {recursive:true}); +const browser = await chromium.launch({executablePath:process.env.BROWSER_EXECUTABLE || undefined, args:['--no-sandbox']}); +const errors = [], results = []; +const baseline = !!process.env.BASELINE; +try { + for (const surface of (process.env.MODEL_SURFACE ? [process.env.MODEL_SURFACE] : ['classic', 'mobile'])) for (const width of [1200,390]) { + const mobile=surface==='mobile'; + const context=await browser.newContext({viewport:{width,height:850}}); + let downloading=false, target='', polls=0, activeBig='fixture-0'; + const writes=[]; + const models=Array.from({length:36},(_,i)=>({value:`fixture-${i}`,label:`UI fixture model ${String(i).padStart(2,'0')}`,series:`Series ${i%3}`,released:`2026-08-${String(28-i%28).padStart(2,'0')}`,requiresGpu:i%2===0,gpuAvailable:true,installed:i===0 || i===2,userFavorite:i%3===0})); + const mount = mobile ? `import {createApp} from 'vue';import {ModelManager} from '/assets/mobile/js/views/ModelManager.js';createApp(ModelManager).mount('#app');` : `import {html} from '/assets/vendor/arrow-core.js';import {ModelManager} from '/assets/components/tools/model_manager.js';window.showSnackbar=()=>{};html\`\${()=>ModelManager()}\`(document.querySelector('#app'));`; + const preview=`

UI DEVELOPMENT PREVIEW — SYNTHETIC CATALOGUE, NO DEVICE CONNECTION

`; + await context.route('**/*',async route=>{ + const url=new URL(route.request().url()); + if(url.origin!=='http://galaxy.invalid') return route.abort(); + if(url.pathname.startsWith('/api/')) { + if(route.request().method()!=='GET') { + writes.push({path:url.pathname,body:route.request().postDataJSON()}); + if(url.pathname==='/api/models/active') { assert.equal(writes.at(-1).body.profile,'big'); if (!(process.env.MODEL_FAULT === 'reject' && writes.length === 1)) activeBig=writes.at(-1).body.model; + if (process.env.MODEL_FAULT && writes.length === 1) return route.fulfill({status:503,json:{error:'Synthetic uncertain response'}}); + } + else throw new Error('Unexpected write '+url.pathname); + return route.fulfill({json:{message:'Synthetic UI fixture action'}}); + } + assert.equal(url.pathname,'/api/models/status');polls++; + return route.fulfill({json:{models,currentModel:'fixture-0',activeBigModel:activeBig,activeSmallModel:'',summary:{installed:1,missing:35,total:36},downloading,modelToDownload:target,progress:downloading?`${polls}%`:'',isOnroad:false}}); + } + if(url.pathname.startsWith('/assets/')) { + try {return route.fulfill({body:readFileSync(root+url.pathname),contentType:url.pathname.endsWith('.css')?'text/css':'text/javascript'});} catch(e) {errors.push(String(e));return route.abort();} + } + return route.fulfill({contentType:'text/html',body:preview}); + }); + const page=await context.newPage();page.on('pageerror',e=>{errors.push(e.message); console.error('PAGEERROR',e.message);}); + await page.goto('http://galaxy.invalid/manage_models'); + const rowSelector=mobile?'.gx-card-grid > section':'.mm-row'; + const rows=page.locator(rowSelector); + await rows.first().waitFor(); + const select=mobile?page.locator('.gx-row').filter({has:page.getByText('Active Big',{exact:true})}).locator('select'):page.locator('#mm-active-big-model-select'); + assert.equal(await select.inputValue(),'fixture-0'); + await select.selectOption(''); + await new Promise(r=>setTimeout(r,350)); + assert.deepEqual(writes,[{path:'/api/models/active',body:{profile:'big',model:''}}],`${surface}: None must disable Active Big through API`); + assert.equal(await select.inputValue(),process.env.MODEL_FAULT === 'reject' ? 'fixture-0' : '', 'Selection must match authoritative status after uncertain response'); + if (process.env.MODEL_FAULT) assert.ok(polls >= 2, 'Uncertain write requires immediate authoritative readback'); + await select.selectOption('fixture-2'); + await new Promise(r=>setTimeout(r,350)); + assert.equal(writes.length,2); + assert.deepEqual(writes[1],{path:'/api/models/active',body:{profile:'big',model:'fixture-2'}}); + await page.reload();await rows.first().waitFor(); + assert.equal(await select.inputValue(),'fixture-2'); + results.push({surface,width,writes}); + await context.close(); + } + assert.deepEqual(errors,[]); + console.log(JSON.stringify({results,errors},null,2)); +} finally {await browser.close();} diff --git a/starpilot/system/the_galaxy/tests/test_model_manager_state.mjs b/starpilot/system/the_galaxy/tests/test_model_manager_state.mjs new file mode 100644 index 0000000000..c3bc7b54bd --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_manager_state.mjs @@ -0,0 +1,44 @@ +// Exercise shipped component methods with deferred requests; no device I/O. +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +const source = readFileSync(new URL('../assets/mobile/js/views/ModelManager.js', import.meta.url), 'utf8'); +const hardware = await import('../assets/components/tools/model_hardware.js'); +let status, write, prompt, requests, notices; +const deferred = () => { let resolve, reject; const promise = new Promise((a,b)=>{resolve=a;reject=b}); return {promise,resolve,reject}; }; +const payload = (overrides={}) => ({models:[{value:'gpu',requiresGpu:true,gpuAvailable:false,installed:false},{value:'small',requiresGpu:false,installed:true}],activeBigModel:'gpu',activeSmallModel:'small',currentModel:'small',summary:{},...overrides}); +const api = { + getModelStatus: () => status(), + setActiveModel: (profile,model) => { requests.push({profile,model}); return write(); }, + startModelDownload: (model,allow) => { requests.push({model,allow});return write(); }, + downloadAllModels: allow => { requests.push({all:true,allow});return write(); }, + postAction: action => { requests.push({action});return write(); }, +}; +const component = new Function('api','showSnackbar','usePolling','GalaxyConfirm','openGalaxyHelpDialog',...Object.keys(hardware), source.replace(/^import .*$/gm,'').replace('export const ModelManager', 'const ModelManager')+';return ModelManager;')(api,(...a)=>notices.push(a),()=>{},()=>{},()=>prompt(),...Object.values(hardware)); +function instance() { const vm=component.data();for(const [key,value] of Object.entries(component.methods))vm[key]=value.bind(vm);return vm; } +async function reset() {requests=[];notices=[];status=async()=>payload();write=async()=>({});prompt=async()=>true;const vm=instance();await vm.refresh();return vm;} +// Filters combine without altering the catalogue or selection. +let vm=await reset();vm.hardwareFilter='comma';vm.userFilter='all';vm.communityFilter='all';assert.deepEqual(component.computed.sorted.call(vm).map(m=>m.value),['small']);assert.equal(vm.activeBigModel,'gpu');assert.deepEqual(requests,[]); +// GPU approval is per action; cancellation sends no write and releases busy. +prompt=async()=>false;await vm.runAction('download',vm.models[0]);assert.deepEqual(requests,[]);assert.equal(vm.busy,''); +prompt=async()=>true;await vm.runAction('download',vm.models[0]);assert.deepEqual(requests,[{model:'gpu',allow:true}]); +// Approval must recheck road state, competing downloads and status failures. +for(const changed of [{isOnroad:true},{downloading:true},null]) { vm=await reset();status=changed?async()=>payload(changed):async()=>{throw Error('read failed')};await vm.runAction('download',vm.models[0]);assert.deepEqual(requests,[]);assert.equal(vm.busy,''); } +// Ordinary downloads never reuse approval, and failed writes release busy. +vm=await reset();write=async()=>{throw Error('download failed')};await vm.runAction('download',vm.models[1]);assert.deepEqual(requests,[{model:'small',allow:false}]);assert.equal(vm.busy,''); +vm=await reset();await vm.runAction('downloadAll');assert.deepEqual(requests,[{all:true,allow:true}]); +// A pre-write poll cannot overwrite authoritative post-write selection. +vm=await reset();let old=deferred();status=()=>old.promise;let poll=vm.refresh();write=async()=>({});status=async()=>payload({activeBigModel:''});await vm.runAction('select-big');old.resolve(payload());await poll;assert.equal(vm.activeBigModel,'');assert.equal(vm.selectionUncertain,false); +// An uncertain write plus failed readback locks only selection until retry. +vm=await reset();write=async()=>{throw Error('uncertain write')};status=async()=>{throw Error('read failure')};await vm.runAction('select-big');assert.equal(vm.selectionUncertain,true);await vm.runAction('select-big');assert.equal(requests.length,1);await vm.runAction('cancel');assert.equal(requests.length,2);status=async()=>payload({activeBigModel:''});await vm.refresh();assert.equal(vm.selectionUncertain,false);assert.equal(vm.activeBigModel,''); +// A remount must wait for an already sent write and ignore old notifications. +vm=await reset();const pending=deferred();write=()=>pending.promise;const action=vm.runAction('select-big');component.beforeUnmount.call(vm);const next=instance();let reads=0;status=async()=>{reads++;return payload({activeBigModel:''})};const refreshed=next.refresh();await Promise.resolve();assert.equal(reads,0);pending.resolve({message:'old success'});await action;await refreshed;assert.equal(next.activeBigModel,'');assert.equal(next.selectionUncertain,false);assert.deepEqual(notices,[]); +// Unmounted confirmation and overlapping action cannot submit another request. +vm=await reset();const confirmation=deferred();prompt=()=>confirmation.promise;const downloading=vm.runAction('download',vm.models[0]);await vm.runAction('downloadAll');assert.deepEqual(requests,[]);component.beforeUnmount.call(vm);confirmation.resolve(true);await downloading;assert.deepEqual(requests,[]); +// Two concurrent polls resolve newest-first. +vm=await reset();const a=deferred(),b=deferred();status=()=>a.promise;const first=vm.refresh();status=()=>b.promise;const second=vm.refresh();b.resolve(payload({activeBigModel:'new'}));await second;a.resolve(payload({activeBigModel:'old'}));await first;assert.equal(vm.activeBigModel,'new'); +assert.ok(!source.includes('GalaxySelect'));assert.ok(!source.includes('model_metrics'));assert.ok(!source.includes('model_stats'));assert.ok(source.includes('selectionUncertain || status.isOnroad')); +console.log('PASS: 15 hardware/download/selection/lifecycle scenarios against shipped methods'); + +const {compile} = await import('../assets/vendor/vue/vue.esm-browser.js'); +assert.equal(typeof compile(component.template, {onError(error) {throw error;}}), 'function'); +console.log('PASS: shipped Vue compiler accepts the native-select Model Manager template'); diff --git a/starpilot/system/the_galaxy/tests/test_model_profile_payloads.py b/starpilot/system/the_galaxy/tests/test_model_profile_payloads.py new file mode 100644 index 0000000000..2d5177c8b4 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_profile_payloads.py @@ -0,0 +1,23 @@ +"""Exercise shipped Flask handler with the existing isolated Params harness.""" +import pytest +from test_dashboard_stats import _load_server_module, FakeParams + + +@pytest.mark.parametrize('payload', [ + {'profile': 'big'}, {'profile': 'big', 'model': None}, + {'profile': 'big', 'model': False}, {'profile': 'big', 'model': 0}, + {'profile': 'big', 'model': []}, {'profile': 'big', 'model': {}}, + [], ['big'], True, 1, +]) +def test_malformed_selection_never_disables_big(monkeypatch, payload): + server = _load_server_module() + assert server._import_galaxy_web_symbols() + app = server.Flask('model_payload_test') + server.setup(app) + params = FakeParams({'IsOnroad': False, 'ActiveBigModel': 'installed-big', + 'ActiveBigModelName': 'Installed Big', 'ActiveBigModelVersion': 'v16'}) + monkeypatch.setattr(server, 'params', params) + before = params.values.copy() + response = app.test_client().put('/api/models/active', json=payload) + assert response.status_code == 400 + assert params.values == before diff --git a/starpilot/system/the_galaxy/tests/test_model_selection_contract.py b/starpilot/system/the_galaxy/tests/test_model_selection_contract.py new file mode 100644 index 0000000000..88ae9099d0 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_model_selection_contract.py @@ -0,0 +1,16 @@ +"""Actual Flask Active Big contract with in-memory Params, no native/device I/O.""" +import pytest +from test_personality_profiles_api import _client, the_galaxy + +@pytest.mark.parametrize('profile,onroad,expected', [('big',False,200),('small',False,400),('big',True,403),('invalid',False,400)]) +def test_active_big_empty_contract(monkeypatch, profile, onroad, expected): + client, params = _client(monkeypatch, {'IsOnroad':onroad}) + calls=[] + monkeypatch.setattr(the_galaxy,'disable_big_model_profile',lambda p:calls.append(p)) + monkeypatch.setattr(the_galaxy,'normalize_model_lab_config',lambda raw:{'enabled':False}) + response=client.put('/api/models/active',json={'profile':profile,'model':''}) + assert response.status_code == expected, response.get_json() + assert len(calls) == (expected == 200) + if expected == 200: + assert response.get_json()['model'] == '' + assert response.get_json()['profile'] == 'big' diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 9f2176aa0d..eecd23f4cd 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -5171,6 +5171,8 @@ class GalaxySlugMiddleware: def setup(app): + from openpilot.starpilot.assets.model_sizes import ModelSizes + model_sizes = ModelSizes() if not isinstance(app.wsgi_app, GalaxySlugMiddleware): app.wsgi_app = GalaxySlugMiddleware(app.wsgi_app) @@ -6887,7 +6889,10 @@ def setup(app): if params.get_bool("IsOnroad"): return jsonify({"error": "Cannot change active models while driving."}), 403 - data = request.get_json(silent=True) or {} + data = request.get_json(silent=True) + # An explicit empty model disables Active Big; malformed values must not. + if not isinstance(data, dict) or not isinstance(data.get("model"), str): + return jsonify({"error": "An explicit model string is required."}), 400 profile = str(data.get("profile") or "").strip().lower() if profile not in ("small", "big"): return jsonify({"error": "Model profile must be 'small' or 'big'."}), 400 @@ -7529,7 +7534,9 @@ def setup(app): }) models.sort(key=lambda model: (model["series"].lower(), model["label"].lower())) - return models + return model_sizes.annotate(models, MODELS_PATH, + Path(__file__).resolve().parents[3] / "selfdrive/modeld/models/driving_tinygrad.pkl", + artifact_metadata, model_accelerator_artifact_filename) @app.route("/api/routes", methods=["GET"]) def list_routes():