From 9ea43e1956d6756346ada6c8b667325573e93ee9 Mon Sep 17 00:00:00 2001 From: MrScripty Date: Sat, 26 Sep 2026 08:34:40 -0700 Subject: [PATCH 01/19] fix: repair managed Torch install feedback and flow --- .../execution-ledger.md | 27 ++++ .../plan.md | 70 ++++---- .../README.md | 37 +++++ .../acceptance.json | 59 +++++++ .../upstream-version-manager-progress.md | 39 +++-- frontend/src/App.tsx | 2 + frontend/src/components/AppShellState.ts | 6 + frontend/src/components/Header.test.tsx | 61 +++++++ frontend/src/components/Header.tsx | 6 + frontend/src/components/HeaderControls.tsx | 2 +- frontend/src/components/HeaderStatus.ts | 39 ++++- .../src/components/InstallDialog.test.tsx | 46 +++++- .../src/components/InstallDialogContent.tsx | 10 ++ .../components/ProgressDetailsView.test.tsx | 25 +++ .../src/components/ProgressDetailsView.tsx | 20 ++- .../components/TorchInstallPreview.test.tsx | 65 +++++++- .../src/components/TorchInstallPreview.tsx | 66 ++++++-- .../src/components/VersionListItem.test.tsx | 30 ++++ frontend/src/components/VersionListItem.tsx | 1 + .../src/components/VersionListItemButton.tsx | 17 +- .../src/components/VersionListItemState.ts | 33 ++-- .../src/components/VersionSelector.test.tsx | 27 ++++ frontend/src/components/VersionSelector.tsx | 4 + .../src/components/VersionSelectorState.ts | 8 +- .../components/app-panels/TorchPanel.test.tsx | 2 +- .../src/components/app-panels/TorchPanel.tsx | 4 +- .../app-panels/VersionManagementPanel.tsx | 1 + .../src/utils/installActivityPresentation.ts | 39 +++++ .../src/version_manager/installer/torch.rs | 136 ++++++++++++++++ .../src/version_manager/mod.rs | 1 + .../src/version_manager/torch_alternatives.rs | 5 +- .../src/version_manager/torch_preview.rs | 17 +- .../src/version_manager/torch_workspace.rs | 149 ++++++++++++++++++ rust/crates/pumas-rpc/src/contract.rs | 126 ++++++++++++--- torch-server/resolve_runtime.py | 2 + torch-server/tests/test_resolve_runtime.py | 6 +- 36 files changed, 1057 insertions(+), 131 deletions(-) create mode 100644 docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md create mode 100644 docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/acceptance.json create mode 100644 frontend/src/utils/installActivityPresentation.ts create mode 100644 rust/crates/pumas-app-manager/src/version_manager/torch_workspace.rs diff --git a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md index 34a48641..7f863597 100644 --- a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md +++ b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md @@ -1,5 +1,32 @@ # Execution Ledger: Cross-Platform Torch Runtime Management +## 2026-09-26 — Packaged Linux AppImage progress UX acceptance + +- Rebuilt the local Linux v0.7.0 AppImage and deb after fixing the Torch + install-progress presentation. The artifact/resource checks passed, and both + extracted backend packages passed `/health`. +- Through the rebuilt AppImage at 800×1000, removed the previous test install, + reviewed the v2.14.0 cu132/Core preset with automatic Python selection, and + installed 44 exact hashed artifacts. At the first sampled install state + (9 seconds elapsed), the header named the Torch install and current phase; the + dialog showed `Working…`, indeterminate setup bars, and accessible Cancel. + Neither bar exposed `aria-valuenow`, and the old false 95% was absent. The row + returned to Ready after completion. Focused source tests verify the pending + state before the first progress poll. +- Managed CPython 3.14.7 reported Torch `2.14.0+cu132`; a CPU tensor sum + returned 5. The host pip cache matched its pre-test snapshot and resolver + scratch was cleaned. The package cache was warm, so this run does not claim + cold-cache transfer progress. CUDA/device and Tuldok generation remain + untested. +- See + `reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md`. AppImage + SHA-256 is + `a28f302822ce99a9d687797606574c93a5afb9c184f293526b16b972294a670b`; deb + SHA-256 is + `d8effc33ba37466171c5fae0178e2555064850e40544c93249966fedbf4bce88`. + These are local candidates and do not update the public toolbar-linked + package; packaged Windows/macOS acceptance remains open. + ## 2026-09-26 — Current-source native Torch acceptance - Manual workflow diff --git a/docs/plans/torch-cross-platform-runtime-management/plan.md b/docs/plans/torch-cross-platform-runtime-management/plan.md index 1b17bff6..885c3f40 100644 --- a/docs/plans/torch-cross-platform-runtime-management/plan.md +++ b/docs/plans/torch-cross-platform-runtime-management/plan.md @@ -24,30 +24,35 @@ All active jobs in that manual workflow passed. The prior run [36223106097](https://github.com/MrScripty/Pumas-Library/actions/runs/36223106097) also exercised macOS's 913-second Retry-After behavior, but predates the metadata lock follow-up. Acceptance remains limited to CPU/Core RPC installation and -restart. CUDA/device execution, Electron UI-driven installation, packaged -Windows/macOS installation, and v2.14.0 Tuldok image generation remain -unverified; existing Tuldok image evidence remains scoped to its recorded -Torch 2.10 tuple. +restart plus the Linux AppImage UI install recorded below. CUDA/device +execution, packaged Windows/macOS installation, and v2.14.0 Tuldok image +generation remain unverified; existing Tuldok image evidence remains scoped to +its recorded Torch 2.10 tuple. Current-branch Linux v0.7.0 AppImage and deb candidates were rebuilt with the -generated CPython notices. Artifact naming and extracted resource hashes pass; -both extracted backends start and pass `/health`. The packaged Electron archive -contains the `get_torch_release_options` bridge method. AppImage SHA-256: -`1398ef0a9da1c0aab90681d3c91674ef88c6229a84984047938e7bd6eb350acd`; deb -SHA-256: `468b6f7c2af00ff8785f80e5486cd5133979e875dd351b4b9f5284ddfd195043`. -These are local build outputs: they do not exercise Electron UI-driven Torch -installation or replace the toolbar-linked public v0.7.0 assets (published -2026-09-17). The generated attribution inventory includes all three CPython -full-archive notice supersets and verifies 371 package entries, 78 hashed inputs, and 57 retained -legal texts against target-specific evidence. - -The bundled Linux backend from this candidate then passed the full Torch 2.14.0 -CPU/Core install/restart acceptance with backend `PATH` cleared. It provisioned -managed CPython 3.14.7, installed 25 hashed official artifacts, performed a CPU -operation returning 14 after restart, and passed probe plus protocol 3 sidecar -start/stop. See the [packaged Linux acceptance](reports/v2.14.0-linux-packaged-cpu-acceptance/README.md). -This exercised the packaged Linux backend; Electron UI interaction and -packaged Windows/macOS install acceptance remain unverified. +generated CPython notices and progress-feedback repair. Artifact naming and +extracted resource hashes pass; both extracted backends start and pass +`/health`. The packaged Electron archive contains the +`get_torch_release_options` bridge method. The latest local AppImage SHA-256 is +`a28f302822ce99a9d687797606574c93a5afb9c184f293526b16b972294a670b`; the deb +SHA-256 is +`d8effc33ba37466171c5fae0178e2555064850e40544c93249966fedbf4bce88`. +The AppImage passed the Linux v2.14.0 cu132 UI install recorded in the +[packaged UI acceptance report](reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md). +These are local build outputs; they do not replace the toolbar-linked public +v0.7.0 assets (published 2026-09-17). The generated attribution inventory +includes all three CPython full-archive notice supersets and verifies 371 +package entries, 78 hashed inputs, and 57 retained legal texts against +target-specific evidence. + +The earlier local Linux package candidate recorded in the +[packaged Linux backend acceptance](reports/v2.14.0-linux-packaged-cpu-acceptance/README.md) +passed the full Torch 2.14.0 CPU/Core install/restart path with backend `PATH` +cleared. It provisioned managed CPython 3.14.7, installed 25 hashed official +artifacts, performed a CPU operation returning 14 after restart, and passed the +probe plus protocol 3 sidecar start/stop. The latest AppImage's separate +UI-driven installation is recorded above; packaged Windows/macOS install +acceptance remains unverified. **Current phase:** The three-target CPU/Core RPC install and two-session sidecar lifecycle pass on current runtime source `21041697`. That run verifies @@ -81,13 +86,12 @@ also passed the three-target RPC install/restart and exercised the macOS long Retry-After path. Windows GNU compilation remains compile-only evidence but is supplemented by native Windows MSVC acceptance. -**Remaining gates:** Packaged Electron UI interaction, packaged Windows/macOS -Torch installation, and provider cancellation/tamper/retry cases remain open. -CUDA/device acceleration and v2.14.0 image/Tuldok generation are untested. The -local AppImage/deb candidates do not update the public toolbar-linked package. +**Remaining gates:** Packaged Windows/macOS Torch installation and provider +cancellation/tamper/retry cases remain open. CUDA/device acceleration and +v2.14.0 image/Tuldok generation are untested. The local AppImage/deb candidates +do not update the public toolbar-linked package. -**Next slice:** Exercise the Torch discovery/install controls through the Linux -Electron UI and run packaged install acceptance on Windows and macOS; then close +**Next slice:** Run packaged install acceptance on Windows and macOS, then close provider cancellation/tamper gates. Keep Tuldok qualification limited to its recorded exact image-generation tuple; publish a new toolbar-linked release only after the PR is merged and a new version is tagged. @@ -299,7 +303,7 @@ resolver, manager, desktop, and platform runtime owners. | X4 | Windows x64 and macOS arm64 discovery returns only exact official wheels whose tags match the provisioned native CPython; CPU/MPS/CUDA choices follow this contract | Native wheel fixtures and wrong-target rejection passed; manual native RPC run `36223106097` resolved exact official CPU wheel tags on Linux, Windows, and macOS arm64. CUDA hardware execution and macOS MPS acceleration remain untested | partial | | X5 | Preview retains the exact distribution version, release/build, official wheel URL/hash, Python provider artifact identity, interpreter fingerprint, and complete dependencies; install stages and verifies that exact identity before publication | Run `36223106097` resolved 25 hashed CPU/Core artifacts and passed v2.14.0 install on all three shipped targets; each report retains the exact Torch wheel URL/hash, dependency set, and managed interpreter identity (see X2 reports) | partial | | X6 | Installed versions can be inspected, explicitly selected, started with health/protocol checks, and stopped by their owned generation; cancellation, timeout, RPC shutdown, and failed cleanup do not leak a resolver, installer, sidecar, interpreter provisioner, or unregistered runtime | Run `36223106097` passed second-session restart, fresh CPU operation, probe revalidation, protocol 3 sidecar trial/stop, and graceful shutdown on Linux, Windows, and macOS (see X2 reports); broader cancellation/timeout/failure cleanup remains | partial | -| X7 | The same manager choices and lifecycle are reachable through packaged desktop controls and the existing RPC API; the user never has to choose Python, and the desktop offers no unsupported adapter | Packaged Linux backend install/restart passes with backend `PATH` cleared; the generated `app.asar` includes `get_torch_release_options`. Electron UI interaction and packaged Windows/macOS installation remain unverified | partial | +| X7 | The same manager choices and lifecycle are reachable through packaged desktop controls and the existing RPC API; the user never has to choose Python, and the desktop offers no unsupported adapter | Linux v0.7.0 AppImage UI installed v2.14.0 cu132 through the normal review flow at 800×1000. The first sampled install state showed the current phase in the header, a visible Cancel control, and indeterminate setup bars without a false percentage; the runtime reached Ready. See the [UI acceptance report](reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md). Packaged Windows/macOS installation remains unverified | partial | Plan acceptance is `pending` until every required row is satisfied on its declared native environment. Cross-compilation, Linux simulation, packaging, @@ -400,10 +404,10 @@ and unit fixtures do not substitute for the Windows/macOS runtime claims. repair. - **State:** Active; native RPC CPU/Core acceptance is retained for all three targets and provider-license integration is complete. Current-branch Linux - packages were rebuilt, passed resource/hash and backend-health checks, and - passed Torch CPU/Core install/restart through the packaged Linux backend. - Electron UI interaction and packaged Windows/macOS install acceptance remain - pending. + packages passed resource/hash and backend-health checks, the packaged Linux + backend passed Torch CPU/Core install/restart, and the v0.7.0 AppImage passed + the Torch UI install-progress flow. Packaged Windows/macOS install acceptance + remains pending. ## Constraints and re-plan triggers diff --git a/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md b/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md new file mode 100644 index 00000000..d4b08964 --- /dev/null +++ b/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md @@ -0,0 +1,37 @@ +# Torch 2.14.0 Linux AppImage UI progress acceptance + +**Status:** Pass for the local Linux x86_64 v0.7.0 AppImage install flow. +This evidence covers the packaged Electron UI and its bundled backend. It does +not qualify GPU execution, image generation, or packaged Windows/macOS use. + +The acceptance used the normal Torch release browser and review controls in the +rebuilt AppImage, with an isolated Pumas library root and separate XDG profile. +The window viewport was 800×1000. The previous v2.14.0 test runtime was removed +through the UI before repeating the install. The reviewed preset was cu132, +Python selected automatically, and Core runtime only; the preview reported 44 +exact official artifacts with hashes. + +| Claim | Result | +| --- | --- | +| Packaged artifact | Local `Pumas.Library-0.7.0.AppImage`; SHA-256 `a28f302822ce99a9d687797606574c93a5afb9c184f293526b16b972294a670b` | +| Companion package | Local `pumas-library-electron_0.7.0_amd64.deb`; SHA-256 `d8effc33ba37466171c5fae0178e2555064850e40544c93249966fedbf4bce88` | +| Target and runtime selection | Linux x86_64; Torch `v2.14.0`, cu132; Python selected automatically; 44 exact hashed artifacts resolved | +| First sampled install state | At 9 seconds elapsed, header read `Installing Torch v2.14.0 · Installing resolved wheel artifacts`; dialog showed `Working…`, `Final Setup · In progress`, and `Cancel` | +| Progress semantics | Overall and setup bars had no `aria-valuenow`; no 95% value was shown while setup progress was unmeasured | +| Cancel affordance | Visible with accessible name `Cancel current installation`; cancellation was not triggered in this completion run | +| Terminal state | `1 installed`; v2.14.0 row showed `Installed cu132 · python3.14 · none · unverified` and `Ready` | +| Installed interpreter and CPU operation | Managed CPython `3.14.7`; Torch `2.14.0+cu132`; CPU tensor sum returned `5`; CUDA unavailable on this host | +| Package smoke | Artifact naming/resource checks passed; both extracted package backends passed `/health` | +| Isolation | Test used a workspace-owned launcher root; resolver scratch was removed; the pre-test host pip-cache snapshot was unchanged | + +The install used the isolated library's existing package cache. This run +records activity feedback at the first sampled UI state (9 seconds elapsed), +indeterminate setup presentation, and the terminal UI state without claiming a +cold-cache download-speed sample. Focused source tests separately verify the +pending-install state before the first progress poll. The full frontend test +suite and typecheck passed for the reviewed progress behavior. + +The exact machine-readable result is [acceptance.json](acceptance.json). This is +a local candidate only and was not uploaded to the toolbar-linked public +release. The successful CPU tensor operation does not qualify CUDA device use, +Tuldok image generation, or Windows/macOS packaged installation. diff --git a/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/acceptance.json b/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/acceptance.json new file mode 100644 index 00000000..7d75e095 --- /dev/null +++ b/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/acceptance.json @@ -0,0 +1,59 @@ +{ + "success": true, + "evidence_kind": "user-workflow", + "environment": "representative-linux-x86_64", + "execution_mode": "manual-real-ui", + "application_version": "0.7.0", + "application_artifacts": { + "appimage": { + "name": "Pumas.Library-0.7.0.AppImage", + "sha256": "a28f302822ce99a9d687797606574c93a5afb9c184f293526b16b972294a670b" + }, + "deb": { + "name": "pumas-library-electron_0.7.0_amd64.deb", + "sha256": "d8effc33ba37466171c5fae0178e2555064850e40544c93249966fedbf4bce88" + } + }, + "viewport": { + "width": 800, + "height": 1000 + }, + "runtime": { + "release": "v2.14.0", + "build": "cu132", + "python_selection": "automatic", + "managed_cpython": "3.14.7", + "torch_version": "2.14.0+cu132", + "resolved_hashed_artifact_count": 44, + "terminal_state": "Ready", + "cpu_tensor_sum": 5, + "cuda_available": false + }, + "progress_observation": { + "elapsed_seconds": 9, + "header": "Installing Torch v2.14.0 · Installing resolved wheel artifacts", + "phase": "Installing resolved wheel artifacts", + "setup_state": "indeterminate", + "cancel_visible": true, + "cancel_accessible_name": "Cancel current installation", + "cancel_triggered": false, + "aria_valuenow_present": false, + "false_95_percent_displayed": false + }, + "isolation": { + "launcher_root": "task-owned isolated test root", + "host_pip_cache_unchanged": true, + "resolver_scratch_removed": true, + "package_cache_state": "warm" + }, + "package_smoke": { + "artifact_names_and_resource_hashes": "passed", + "appimage_rpc_health": "passed", + "deb_rpc_health": "passed" + }, + "limits": [ + "This local AppImage result does not update the public toolbar-linked release.", + "CUDA/device execution, Windows/macOS packaged installation, and v2.14.0 Tuldok image generation were not tested.", + "The runtime cache was warm, so the report makes no cold-cache download-progress claim." + ] +} diff --git a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md index 6c1dd25f..c77004fd 100644 --- a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md +++ b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md @@ -24,9 +24,10 @@ The current-source Torch 2.14.0 CPU/Core RPC install/restart and native QA pass on Linux x86_64, Windows x86_64, and macOS arm64 in manual run [36229508586](https://github.com/MrScripty/Pumas-Library/actions/runs/36229508586) on runtime commit `21041697`; each target provisioned Pumas-managed CPython -3.14.7 and installed 25 hashed official artifacts. The broader support claim -remains bounded by Electron UI-driven and packaged Windows/macOS installation, -provider cancellation/tamper, and non-CPU runtime gates. CPython notices are +3.14.7 and installed 25 hashed official artifacts. The Linux v0.7.0 AppImage +UI install-progress flow has since passed locally; the broader support claim +remains bounded by packaged Windows/macOS installation, provider +cancellation/tamper, and non-CPU runtime gates. CPython notices are generated for all three desktop targets, with fail-closed provider-pin, archive-mapping, and exact legal-file checks. Pumas does not compile Torch from source; XPU remains outside this @@ -643,17 +644,21 @@ come from Electron's `api:call` allowlist before Rust dispatch. The public `v0.7.0` tag lacks that method in `electron/src/rpc-method-registry.ts`; the current branch contains it and the regression test passes. The toolbar-linked public release is still v0.7.0, so it has not received the branch's updated -bridge. Current-branch local Linux AppImage/deb candidates include the updated -bridge and generated CPython notices, pass extracted-resource hash checks, and -start their packaged backends through `/health`. Their SHA-256 values are -`1398ef0a9da1c0aab90681d3c91674ef88c6229a84984047938e7bd6eb350acd` (AppImage) -and `468b6f7c2af00ff8785f80e5486cd5133979e875dd351b4b9f5284ddfd195043` (deb). -The packaged Linux RPC backend then passed Torch 2.14.0 CPU/Core install and -restart using managed CPython 3.14.7, the 25-hash official artifact resolution, -CPU operation, probe, protocol 3 sidecar start/stop, and graceful cleanup; see -the [packaged acceptance report](../../torch-cross-platform-runtime-management/reports/v2.14.0-linux-packaged-cpu-acceptance/README.md). -This is backend acceptance rather than GUI-driven installation. These are local -candidates and have not been uploaded to the toolbar-linked release. Packaged -Electron UI interaction, Windows/macOS packaged installation, provider -cancellation/tamper/retry, CUDA/MPS execution, and v2.14.0 Tuldok image -generation remain open. +bridge. The latest current-branch local Linux AppImage/deb candidates include +the updated bridge and generated CPython notices, pass extracted-resource hash +checks, and start their packaged backends through `/health`. Their SHA-256 +values are +`a28f302822ce99a9d687797606574c93a5afb9c184f293526b16b972294a670b` (AppImage) +and `d8effc33ba37466171c5fae0178e2555064850e40544c93249966fedbf4bce88` (deb). +The packaged Linux RPC backend passed Torch 2.14.0 CPU/Core install and restart +using managed CPython 3.14.7 and 25 hashed official artifacts; see the +[packaged backend acceptance](../../torch-cross-platform-runtime-management/reports/v2.14.0-linux-packaged-cpu-acceptance/README.md). +The latest AppImage also passed the real UI install flow for Torch v2.14.0 +cu132: at the first sampled install state, the header named the current phase, +the dialog showed Cancel, setup bars were indeterminate with no `aria-valuenow`, +and the runtime reached Ready. The exact report records the 44-artifact review, +managed CPython 3.14.7, CPU tensor result 5, and the warm-cache limit: +[packaged UI acceptance](../../torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md). +These are local candidates and have not been uploaded to the toolbar-linked +release. Packaged Windows/macOS installation, provider cancellation/tamper/retry, +CUDA/MPS execution, and v2.14.0 Tuldok image generation remain open. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 20bf4714..f07e5832 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -211,9 +211,11 @@ export default function InferencePluginsApp() { onImportDialogClose={handleImportDialogClose} onPathsDropped={handlePathsDropped} header={buildAppShellHeader({ + appId: appVersions.appId, activeModelDownload: activeDownload, activeModelDownloadCount: activeDownloadCount, installationProgress, + installingTag: appVersions.installingTag, isCheckingLauncherUpdates, launcherLatestVersion: getLauncherLatestVersion(launcherUpdateState), launcherUpdateAvailable, diff --git a/frontend/src/components/AppShellState.ts b/frontend/src/components/AppShellState.ts index 7346a2e6..601d82f8 100644 --- a/frontend/src/components/AppShellState.ts +++ b/frontend/src/components/AppShellState.ts @@ -61,9 +61,11 @@ interface BuildModelManagerPropsOptions { } interface BuildAppShellHeaderOptions { + appId?: AppHeaderProps['appId']; activeModelDownload: AppHeaderProps['activeModelDownload']; activeModelDownloadCount: number; installationProgress: AppHeaderProps['installationProgress']; + installingTag?: AppHeaderProps['installingTag']; isCheckingLauncherUpdates: boolean; launcherLatestVersion: string | null; launcherUpdateAvailable: boolean; @@ -181,9 +183,11 @@ export function buildModelManagerProps({ } export function buildAppShellHeader({ + appId, activeModelDownload, activeModelDownloadCount, installationProgress, + installingTag, isCheckingLauncherUpdates, launcherLatestVersion, launcherUpdateAvailable, @@ -197,6 +201,7 @@ export function buildAppShellHeader({ onMinimize, }: BuildAppShellHeaderOptions): AppHeaderProps { return { + appId, systemResources, appResources: status?.app_resources?.ollama, launcherUpdateAvailable, @@ -213,6 +218,7 @@ export function buildAppShellHeader({ networkAvailable, modelLibraryLoaded, installationProgress, + installingTag, activeModelDownload, activeModelDownloadCount, }; diff --git a/frontend/src/components/Header.test.tsx b/frontend/src/components/Header.test.tsx index 1ac6fd15..6bac49d9 100644 --- a/frontend/src/components/Header.test.tsx +++ b/frontend/src/components/Header.test.tsx @@ -144,6 +144,67 @@ describe('Header Component', () => { expect(screen.queryByText(/25% complete/)).not.toBeInTheDocument(); }); + it('shows the pending and current Torch installation phase in the header', () => { + const { rerender } = render(
); + expect(screen.getByText(/Starting installation…/)).toBeInTheDocument(); + rerender(
); + expect(screen.getByText(/Creating managed Python environment/)).toHaveAttribute( + 'title', 'Installing Torch v2.14.0 · Creating managed Python environment' + ); + expect(screen.queryByText(/95%/)).not.toBeInTheDocument(); + }); + + it('keeps concurrent model and Torch runtime counts and speed alongside the Torch phase', () => { + render(
); + expect(screen.getByText(/Downloading 1 model & 1 runtime/)).toHaveTextContent('4.0 MB/s'); + expect(screen.getByText(/Downloading 1 model & 1 runtime/)).toHaveTextContent('Creating managed Python environment'); + }); + + it('keeps model download status when another runtime installation has not reported progress yet', () => { + render(
); + expect(screen.getByText(/Downloading 1 model & 1 runtime/)).toHaveTextContent('4.0 MB/s'); + expect(screen.getByText(/Downloading 1 model & 1 runtime/)).toHaveTextContent('Starting installation…'); + }); + + it.each([ + ['queued', 'Queued model download'], + ['pausing', 'Pausing model download'], + ['cancelling', 'Cancelling model download'], + ] as const)('keeps the %s model state while Torch installation is pending', (status, label) => { + render(
); + expect(screen.getByText(new RegExp(label))).toHaveTextContent('model'); + expect(screen.getByText(new RegExp(label))).toHaveTextContent('Torch runtime: Starting installation…'); + }); + it('combines model and runtime download counts and speed in the header', () => { const activeModelDownload = { downloadId: 'dl-1', diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index d1c53e0d..07f268f4 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -6,6 +6,7 @@ import { HeaderResourceStrip } from './HeaderResourceStrip'; import { getHeaderStatusInfo, type InstallationProgress } from './HeaderStatus'; interface HeaderProps { + appId?: string | null; systemResources?: SystemResources; appResources?: { gpu_memory?: number; @@ -21,11 +22,13 @@ interface HeaderProps { networkAvailable?: boolean | null; modelLibraryLoaded?: boolean | null; installationProgress?: InstallationProgress | null; + installingTag?: string | null; activeModelDownload?: ActiveModelDownload | null; activeModelDownloadCount?: number; } export const Header: React.FC = ({ + appId, systemResources, appResources, launcherUpdateAvailable, @@ -38,13 +41,16 @@ export const Header: React.FC = ({ networkAvailable, modelLibraryLoaded, installationProgress, + installingTag, activeModelDownload, activeModelDownloadCount = 0, }) => { const status = getHeaderStatusInfo({ + appId, activeModelDownload, activeModelDownloadCount, installationProgress, + installingTag, modelLibraryLoaded, networkAvailable, }); diff --git a/frontend/src/components/HeaderControls.tsx b/frontend/src/components/HeaderControls.tsx index 21fe373a..657b2632 100644 --- a/frontend/src/components/HeaderControls.tsx +++ b/frontend/src/components/HeaderControls.tsx @@ -48,7 +48,7 @@ export function HeaderStatusBadge({ status }: { status: HeaderStatusInfo }) {
- {status.text} + {status.text}
); diff --git a/frontend/src/components/HeaderStatus.ts b/frontend/src/components/HeaderStatus.ts index e36d438a..2714d2f4 100644 --- a/frontend/src/components/HeaderStatus.ts +++ b/frontend/src/components/HeaderStatus.ts @@ -1,6 +1,7 @@ import { Clock, Database, Download, RefreshCw, WifiOff, type LucideIcon } from 'lucide-react'; import type { ActiveModelDownload } from '../hooks/useActiveModelDownload'; import { formatBytes, formatSpeed } from '../utils/formatters'; +import { getInstallActivityPresentation } from '../utils/installActivityPresentation'; export interface InstallationProgress { tag: string; @@ -49,12 +50,14 @@ function getCombinedDownloadStatus({ activeModelDownload, activeModelDownloadCount, installationProgress, + runtimePending = false, }: { activeModelDownload?: ActiveModelDownload | null; activeModelDownloadCount: number; installationProgress?: InstallationProgress | null; + runtimePending?: boolean; }): HeaderStatusInfo | null { - const runtimeActive = Boolean(installationProgress && !installationProgress.completed_at); + const runtimeActive = runtimePending || Boolean(installationProgress && !installationProgress.completed_at); const modelCount = activeModelDownload ? Math.max(activeModelDownloadCount, 1) : 0; const runtimeCount = runtimeActive ? 1 : 0; @@ -159,23 +162,57 @@ function getActiveDownloadStatus( } export function getHeaderStatusInfo({ + appId, activeModelDownload, activeModelDownloadCount, installationProgress, + installingTag, modelLibraryLoaded, networkAvailable, }: { + appId?: string | null; activeModelDownload?: ActiveModelDownload | null; activeModelDownloadCount: number; installationProgress?: InstallationProgress | null; + installingTag?: string | null; modelLibraryLoaded?: boolean | null; networkAvailable?: boolean | null; }): HeaderStatusInfo { + const activity = getInstallActivityPresentation({ appId, installingTag, progress: installationProgress }); const combinedDownloadStatus = getCombinedDownloadStatus({ activeModelDownload, activeModelDownloadCount, installationProgress, + runtimePending: activity.active, }); + if (activity.active && activeModelDownload && activeModelDownload.status !== 'downloading') { + const modelStatus = getActiveDownloadStatus(activeModelDownload, activeModelDownloadCount); + return { + ...modelStatus, + text: `${modelStatus.text} · ${appId === 'torch' ? 'Torch ' : ''}runtime: ${activity.phase}`, + }; + } + if (appId === 'torch' && activity.active && activeModelDownload && combinedDownloadStatus) { + return { + ...combinedDownloadStatus, + text: `${combinedDownloadStatus.text} · Torch: ${activity.phase}`, + }; + } + if (activity.active && !installationProgress && activeModelDownload && combinedDownloadStatus) { + return { + ...combinedDownloadStatus, + text: `${combinedDownloadStatus.text} · ${activity.phase}`, + }; + } + if (activity.active && (appId === 'torch' || !installationProgress)) { + return { + icon: activity.indeterminate ? RefreshCw : Download, + spinning: activity.indeterminate, + text: appId === 'torch' + ? `Installing Torch ${installingTag ?? installationProgress?.tag} · ${activity.phase}` + : activity.phase, + }; + } if (combinedDownloadStatus) { return combinedDownloadStatus; } diff --git a/frontend/src/components/InstallDialog.test.tsx b/frontend/src/components/InstallDialog.test.tsx index 42543fdb..7a20e690 100644 --- a/frontend/src/components/InstallDialog.test.tsx +++ b/frontend/src/components/InstallDialog.test.tsx @@ -31,6 +31,11 @@ const ollamaPatchReleases: VersionRelease[] = [ }, ]; +const torchRelease: VersionRelease = { + tagName: 'v2.14.0', name: 'Torch v2.14.0', + publishedAt: '2026-04-12T00:00:00Z', prerelease: false, +}; + const activeProgress: InstallationProgress = { tag: 'v0.22.1', started_at: '2026-04-12T00:00:00Z', @@ -49,6 +54,43 @@ const activeProgress: InstallationProgress = { }; describe('InstallDialog', () => { + it('shows pending Torch installation immediately and offers cancellation before the first poll', async () => { + const onCancelInstallation = vi.fn().mockResolvedValue(true); + render(); + + expect(screen.getByRole('status')).toHaveTextContent('Starting installation…'); + expect(screen.queryByText('Downloading...')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel current installation' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel installation' })); + await waitFor(() => expect(onCancelInstallation).toHaveBeenCalledOnce()); + }); + + it('shows the Torch setup phase without a weighted percent and keeps Cancel visible', async () => { + const onCancelInstallation = vi.fn().mockResolvedValue(true); + render(); + expect(screen.getByRole('status')).toHaveTextContent('Creating managed Python environment'); + expect(screen.queryByText('95%')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel current installation' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel installation' })); + await waitFor(() => expect(onCancelInstallation).toHaveBeenCalledOnce()); + }); + it('keeps installed runtimes manageable when remote releases are unavailable', () => { render( { ); fireEvent.click(screen.getByRole('button', { name: 'Back' })); - fireEvent.click(screen.getByRole('button', { name: '25%' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel current version installation' })); fireEvent.click(screen.getByRole('button', { name: 'Cancel installation' })); await waitFor(() => expect(onCancelInstallation).toHaveBeenCalledTimes(1)); @@ -224,7 +266,7 @@ describe('InstallDialog', () => { ); fireEvent.click(screen.getByRole('button', { name: 'Back' })); - fireEvent.click(screen.getByRole('button', { name: '25%' })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel current version installation' })); fireEvent.click(screen.getByRole('button', { name: 'Cancel installation' })); expect(await screen.findByText('Cancellation unavailable')).toBeInTheDocument(); diff --git a/frontend/src/components/InstallDialogContent.tsx b/frontend/src/components/InstallDialogContent.tsx index ce5f291d..6459ac84 100644 --- a/frontend/src/components/InstallDialogContent.tsx +++ b/frontend/src/components/InstallDialogContent.tsx @@ -4,6 +4,7 @@ import type { InstallationProgress, VersionRelease } from '../hooks/useVersions' import { ProgressDetailsView } from './ProgressDetailsView'; import { VersionListItem } from './VersionListItem'; import type { TorchInstalledConfig } from '../types/torch-install'; +import { getInstallActivityPresentation } from '../utils/installActivityPresentation'; interface InstallDialogContentProps { appId?: string; @@ -70,6 +71,7 @@ export function InstallDialogContent({ onInspectTorchProbe, onReportRemoveError, }: InstallDialogContentProps) { + const activity = getInstallActivityPresentation({ appId, installingTag: installingVersion, progress }); return (
@@ -88,6 +90,13 @@ export function InstallDialogContent({ )} + {activity.active && (appId === 'torch' || !progress) && ( +
+ {activity.phase} + +
+ )} + {isRateLimited && ( { + it('treats Torch setup as indeterminate and shows the backend phase', () => { + render(); + expect(screen.getByText('Creating managed Python environment')).toBeInTheDocument(); + expect(screen.queryByText('95%')).not.toBeInTheDocument(); + expect(screen.queryByText('0%')).not.toBeInTheDocument(); + expect(screen.getByRole('progressbar', { name: 'Overall installation progress' })).not.toHaveAttribute('aria-valuenow'); + expect(screen.getByRole('progressbar', { name: 'Final Setup progress' })).not.toHaveAttribute('aria-valuenow'); + }); + + it('shows measured Torch setup progress when the stage reports real movement', () => { + render(); + expect(screen.getByRole('progressbar', { name: 'Overall installation progress' })).toHaveAttribute('aria-valuenow', '95'); + expect(screen.getByRole('progressbar', { name: 'Final Setup progress' })).toHaveAttribute('aria-valuenow', '40'); + }); + it('renders dependency progress details and routes detail actions', () => { const onBackToList = vi.fn(); const onToggleCompletedItems = vi.fn(); diff --git a/frontend/src/components/ProgressDetailsView.tsx b/frontend/src/components/ProgressDetailsView.tsx index 639f3b2d..e7dbbaf8 100644 --- a/frontend/src/components/ProgressDetailsView.tsx +++ b/frontend/src/components/ProgressDetailsView.tsx @@ -23,6 +23,7 @@ import type { InstallationProgress } from '../hooks/useVersions'; import { formatBytes, formatSpeed } from '../utils/formatters'; import { formatElapsedTime } from '../utils/installationFormatters'; import { IconButton } from './ui'; +import { getInstallActivityPresentation } from '../utils/installActivityPresentation'; const STAGE_LABELS = { download: 'Downloading', @@ -89,6 +90,7 @@ function getInstallationOutcome( } interface ProgressDetailsViewProps { + appId?: string; progress: InstallationProgress; installingVersion: string | null; showCompletedItems: boolean; @@ -98,6 +100,7 @@ interface ProgressDetailsViewProps { } export function ProgressDetailsView({ + appId, progress, installingVersion, showCompletedItems, @@ -108,6 +111,7 @@ export function ProgressDetailsView({ const CurrentStageIcon = STAGE_ICONS[progress.stage]; const overallProgress = clampProgress(progress.overall_progress); const stageProgress = clampProgress(progress.stage_progress); + const activity = getInstallActivityPresentation({ appId, installingTag: installingVersion, progress }); const outcome = getInstallationOutcome(progress, installingVersion); const OutcomeIcon = outcome?.kind === 'succeeded' ? CheckCircle2 : AlertCircle; const outcomeContainerClass = outcome?.kind === 'succeeded' @@ -140,22 +144,22 @@ export function ProgressDetailsView({
Overall Progress - {overallProgress}% + {activity.indeterminate ? 'Working…' : `${overallProgress}%`}
- : + />}
@@ -171,7 +175,7 @@ export function ProgressDetailsView({ {STAGE_LABELS[progress.stage]} - {stageProgress}% + {activity.indeterminate ? 'In progress' : `${stageProgress}%`}
{progress.current_item && ( @@ -184,15 +188,15 @@ export function ProgressDetailsView({ aria-label={`${STAGE_LABELS[progress.stage]} progress`} aria-valuemin={0} aria-valuemax={100} - aria-valuenow={stageProgress} + aria-valuenow={activity.indeterminate ? undefined : stageProgress} className="w-full h-1.5 bg-[hsl(var(--surface-lowest))] rounded-full overflow-hidden mt-2" > - : + />} diff --git a/frontend/src/components/TorchInstallPreview.test.tsx b/frontend/src/components/TorchInstallPreview.test.tsx index 3e65281d..62abaf0b 100644 --- a/frontend/src/components/TorchInstallPreview.test.tsx +++ b/frontend/src/components/TorchInstallPreview.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TorchInstallPreview } from './TorchInstallPreview'; import type { @@ -172,6 +172,10 @@ describe('TorchInstallPreview', () => { expect(qualified).toHaveAttribute('aria-pressed', 'true'); fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); await waitFor(() => expect(getPreview).toHaveBeenLastCalledWith({ tag: 'v2.9.1', build: 'cu130', python: 'python3.12', adapter: 'bundled' })); + expect(screen.getByText(/1 fixed preset wheel highlight/)).toBeInTheDocument(); + expect(screen.getByText('Additional bundled dependencies and their URLs are selected during installation.')).toBeInTheDocument(); + expect(screen.getByRole('list', { name: 'Preset wheel highlight URLs and SHA-256 hashes' })).toBeInTheDocument(); + expect(screen.queryByText(/1 exact artifacts resolved/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Install fixed preset' })); expect(onInstall).toHaveBeenCalledWith('review-1'); @@ -357,25 +361,72 @@ describe('TorchInstallPreview', () => { expect(getPreview).toHaveBeenLastCalledWith({ tag: 'v2.14.0', build: 'cpu', python: 'auto', adapter: 'none' }); }); - it('keeps a long artifact list and its install action inside the scrollable preview region', async () => { + it('keeps a 62-artifact audit available without pushing the install action below the review', async () => { getPreview.mockImplementation(async (request) => ({ status: 'resolved', preview: { ...request, previewId: 'review-many', expiresInSeconds: 300, qualification: 'unverified', python: 'python3.12', - artifacts: Array.from({ length: 30 }, (_, index) => ({ + artifacts: Array.from({ length: 62 }, (_, index) => ({ name: `wheel-${index}`, version: '1.0', url: `https://download.pytorch.org/whl/cu130/wheel-${index}.whl`, sha256: 'a'.repeat(64), })), }, })); - render(); + const onInstall = vi.fn(); + render(); fireEvent.click(await screen.findByRole('button', { name: 'Check selected combination' })); const previewRegion = screen.getByRole('region', { name: 'Torch installation preview for v2.14.0' }); - await waitFor(() => expect(screen.getByText('wheel-29 1.0')).toBeInTheDocument()); - expect(previewRegion).toHaveClass('flex-1', 'min-h-0', 'overflow-y-auto'); - expect(previewRegion).toContainElement(screen.getByRole('button', { name: 'Install reviewed artifacts' })); + await waitFor(() => expect(screen.getByText(/62 exact artifacts resolved/)).toBeInTheDocument()); + expect(previewRegion).toHaveClass('max-h-[calc(80vh-5rem)]', 'overflow-hidden'); + const audit = screen.getByRole('list', { name: 'Exact artifact URLs and SHA-256 hashes' }); + expect(screen.getByRole('status')).toHaveTextContent('62 exact artifacts resolved'); + expect(screen.getByRole('status')).not.toContainElement(audit); + expect(screen.getByRole('region', { name: 'Artifact URL and hash review' })).toContainElement(audit); + expect(audit).toHaveClass('max-h-72', 'overflow-y-auto'); + expect(audit.closest('details')).toBeNull(); + expect(within(audit).getAllByRole('listitem')).toHaveLength(62); + expect(audit).toContainElement(screen.getByText('wheel-61 1.0')); + const install = screen.getByRole('button', { name: 'Install reviewed artifacts' }); + expect(install).toBeEnabled(); + expect(previewRegion.firstElementChild).toHaveClass('min-h-0', 'overflow-y-auto'); + expect(previewRegion.firstElementChild).toContainElement(audit); + expect(previewRegion.firstElementChild).not.toContainElement(install); + expect(install.parentElement).toHaveClass('shrink-0'); + fireEvent.click(install); + expect(onInstall).toHaveBeenCalledWith('review-many'); + }); + + it('expires a resolved preview and requires a new check before installation', async () => { + getPreview.mockImplementation(async (request) => ({ + status: 'resolved', + preview: { + ...request, previewId: 'review-expiring', expiresInSeconds: 5, + python: 'python3.12', qualification: 'unverified', artifacts: [], + }, + })); + const onInstall = vi.fn(); + render(); + const check = await screen.findByRole('button', { name: 'Check selected combination' }); + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + try { + await act(async () => { fireEvent.click(check); }); + const install = screen.getByRole('button', { name: 'Install reviewed artifacts' }); + expect(install).toBeEnabled(); + act(() => { vi.advanceTimersByTime(5_000); }); + expect(screen.getByRole('alert')).toHaveTextContent('Preview expired. Check selected combination again before installing.'); + expect(screen.getByRole('button', { name: 'Preview expired — check again' })).toBeDisabled(); + expect(onInstall).not.toHaveBeenCalled(); + + await act(async () => { fireEvent.click(check); }); + expect(screen.getByRole('button', { name: 'Install reviewed artifacts' })).toBeEnabled(); + expect(screen.queryByText('Preview expired. Check selected combination again before installing.')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Install reviewed artifacts' })); + expect(onInstall).toHaveBeenCalledWith('review-expiring'); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/frontend/src/components/TorchInstallPreview.tsx b/frontend/src/components/TorchInstallPreview.tsx index bc4534a1..39df4867 100644 --- a/frontend/src/components/TorchInstallPreview.tsx +++ b/frontend/src/components/TorchInstallPreview.tsx @@ -43,6 +43,8 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev const [adapter, setAdapter] = useState(''); const [selectionMode, setSelectionMode] = useState<'preset' | 'upstream'>('upstream'); const [preview, setPreview] = useState(null); + const [previewExpiresAt, setPreviewExpiresAt] = useState(null); + const [previewExpired, setPreviewExpired] = useState(false); const [loadingRuntimeOptions, setLoadingRuntimeOptions] = useState(true); const [loadingReleaseOptions, setLoadingReleaseOptions] = useState(true); const [probing, setProbing] = useState(false); @@ -63,6 +65,8 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev setReleaseOptions(null); setReleaseError(null); setPreview(null); + setPreviewExpiresAt(null); + setPreviewExpired(false); setError(null); setRejection(null); setChecks([]); @@ -101,9 +105,26 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev }; }, [tag, releaseAttempt]); + useEffect(() => { + if (previewExpiresAt === null) return; + let timeout: number; + const expireWhenDue = () => { + const remaining = previewExpiresAt - Date.now(); + if (remaining <= 0) { + setPreviewExpired(true); + } else { + timeout = window.setTimeout(expireWhenDue, Math.min(remaining, 2_147_483_647)); + } + }; + expireWhenDue(); + return () => window.clearTimeout(timeout); + }, [previewExpiresAt]); + const invalidatePreview = () => { requestNumber.current += 1; setPreview(null); + setPreviewExpiresAt(null); + setPreviewExpired(false); setProbing(false); setError(null); setRejection(null); @@ -122,6 +143,8 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev setChecks((previous) => [...previous.filter((item) => item.key !== key), { key, label, status }]); }; setPreview(null); + setPreviewExpiresAt(null); + setPreviewExpired(false); setError(null); setRejection(null); setProbing(true); @@ -130,6 +153,8 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev if (requestNumber.current === currentRequest) { if (outcome.status === 'resolved') { setPreview(outcome.preview); + setPreviewExpiresAt(Date.now() + outcome.preview.expiresInSeconds * 1000); + setPreviewExpired(false); recordCheck('resolved'); } else { setRejection(outcome); @@ -189,13 +214,15 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev const eligibleCheckedBuilds = alternatives?.checkedBuilds.filter((choice) => availableBuilds.includes(choice)) ?? []; const loadingOptions = loadingRuntimeOptions || (!isPresetRelease && loadingReleaseOptions); const canInstall = preview !== null && Boolean(preview.previewId) + && previewExpiresAt !== null && !previewExpired && Date.now() < previewExpiresAt && preview.tag === tag && preview.build === build && Boolean(preview.python) && preview.python !== 'auto' && (!fixedPreset || preview.python === runtimeOptions.preset.python) && preview.adapter === adapter; return ( -
+
+
@@ -299,22 +326,35 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev
} } {preview && ( -
-

{fixedPreset ? 'Fixed preset highlights' : 'Exact artifacts resolved'}

+
+
+

{fixedPreset ? 'Fixed preset highlights' : 'Exact artifacts resolved'}

+

{fixedPreset + ? `${preview.artifacts.length} fixed preset wheel highlight${preview.artifacts.length === 1 ? '' : 's'} · ${preview.build} · ${preview.python}` + : `${preview.artifacts.length} exact artifacts resolved · ${preview.build} · ${preview.python} · ${preview.adapter === 'none' ? 'Core runtime only' : preview.adapter}`}

+

Selected Python: {preview.python}

+ {fixedPreset &&

Additional bundled dependencies and their URLs are selected during installation.

} {preview.qualification === 'unverified' &&

Artifact resolution is unverified; trial this installation before selecting it as default.

} - {preview.artifacts.length > 0 &&
    {preview.artifacts.map((artifact) => ( -
  • - {artifact.name} {artifact.version}
    - URL: {artifact.url}
    - SHA-256: {artifact.sha256} -
  • - ))}
} + {previewExpired &&

Preview expired. Check selected combination again before installing.

} + {preview.artifacts.length > 0 &&
+

{fixedPreset ? 'Preset wheel highlight URLs and SHA-256 hashes' : 'Exact artifact URLs and SHA-256 hashes'}

+
    {preview.artifacts.map((artifact) => ( +
  • + {artifact.name} {artifact.version}
    + URL: {artifact.url}
    + SHA-256: {artifact.sha256} +
  • + ))}
+
}
)} - +
+
+ +
); } diff --git a/frontend/src/components/VersionListItem.test.tsx b/frontend/src/components/VersionListItem.test.tsx index 3b557b73..8b7875dd 100644 --- a/frontend/src/components/VersionListItem.test.tsx +++ b/frontend/src/components/VersionListItem.test.tsx @@ -143,6 +143,7 @@ describe('VersionListItem', () => { }); expect(screen.getByText('2/4')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel current version installation' })).toBeInTheDocument(); const progressButton = getClosestButton('2/4'); fireEvent.pointerEnter(progressButton); @@ -174,4 +175,33 @@ describe('VersionListItem', () => { expect(screen.getByText('50%')).toBeInTheDocument(); }); + + it('shows an indeterminate Torch setup phase and keeps the row cancel action', () => { + const { props, rerender } = renderVersionListItem({ + appId: 'torch', isInstalling: true, + progress: { ...dependencyProgress, stage: 'setup', stage_progress: 0, overall_progress: 95, current_item: 'Creating managed Python environment', error: null }, + }); + const button = getClosestButton('Creating managed Python environment'); + expect(screen.queryByText('95%')).not.toBeInTheDocument(); + expect(button.querySelector('.download-progress-ring.is-waiting')).toBeInTheDocument(); + fireEvent.click(button); + expect(props.onCancel).toHaveBeenCalledOnce(); + + rerender(); + expect(screen.getByText('Qualifying runtime')).toBeInTheDocument(); + expect(button.querySelector('.download-progress-ring.is-waiting')).not.toBeInTheDocument(); + }); + + it('labels a measured non-download phase instead of calling it a download', () => { + const { container } = renderVersionListItem({ + isInstalling: true, + progress: { ...dependencyProgress, stage: 'extract', stage_progress: 40, overall_progress: 35, current_item: 'Unpacking runtime' }, + }); + expect(screen.getByText('Unpacking runtime')).toBeInTheDocument(); + expect(screen.queryByText('Downloading...')).not.toBeInTheDocument(); + expect(container.querySelector('.download-progress-ring.is-waiting')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/VersionListItem.tsx b/frontend/src/components/VersionListItem.tsx index d15045df..e76e99dd 100644 --- a/frontend/src/components/VersionListItem.tsx +++ b/frontend/src/components/VersionListItem.tsx @@ -64,6 +64,7 @@ export function VersionListItem({ onCancelMouseLeave, }: VersionListItemProps) { const displayState = getVersionInstallDisplayState({ + appId, installNetworkStatus, isHovered, isInstalled, diff --git a/frontend/src/components/VersionListItemButton.tsx b/frontend/src/components/VersionListItemButton.tsx index d0ba99a2..c9ca4acd 100644 --- a/frontend/src/components/VersionListItemButton.tsx +++ b/frontend/src/components/VersionListItemButton.tsx @@ -185,8 +185,9 @@ export function VersionListItemButton({ onInstall, onRemove, }: VersionListItemButtonProps) { + const canCancel = isInstalling && !isInstalled; const handleButtonClick = () => { - if (isInstalling) { + if (canCancel) { onCancel(); return; } @@ -199,23 +200,25 @@ export function VersionListItemButton({ return ( {getButtonContent({ displayState, isCancelHovered, isInstalled, - isInstalling, + isInstalling: canCancel, })} ); diff --git a/frontend/src/components/VersionListItemState.ts b/frontend/src/components/VersionListItemState.ts index 0bf175f7..aae58e28 100644 --- a/frontend/src/components/VersionListItemState.ts +++ b/frontend/src/components/VersionListItemState.ts @@ -1,5 +1,6 @@ import type { CSSProperties } from 'react'; import type { InstallationProgress, InstallNetworkStatus, VersionRelease } from '../hooks/useVersions'; +import { getInstallActivityPresentation, type InstallActivityPresentation } from '../utils/installActivityPresentation'; export interface VersionInstallDisplayState { displayTag: string; @@ -22,7 +23,8 @@ function getDownloadPercent(progress: InstallationProgress | null): number | nul return null; } -function getRingPercent(progress: InstallationProgress | null): number | null { +function getRingPercent(progress: InstallationProgress | null, indeterminate: boolean): number | null { + if (indeterminate) return null; const overallPercent = progress ? Math.round(progress.overall_progress || 0) : null; const downloadPercent = getDownloadPercent(progress); const stagePercent = progress ? progress.stage_progress : null; @@ -33,8 +35,9 @@ function getRingPercent(progress: InstallationProgress | null): number | null { return overallPercent ?? stagePercent; } -function getPackageLabel(progress: InstallationProgress | null): string { - if (progress?.stage === 'download') { +function getPackageLabel(progress: InstallationProgress | null, activity: InstallActivityPresentation): string { + if (!progress || activity.indeterminate && progress.stage === 'setup') return activity.phase; + if (progress.stage === 'download') { const downloadPercent = getDownloadPercent(progress); if (downloadPercent !== null && (progress.downloaded_bytes > 0 || downloadPercent > 0)) { return `${downloadPercent}%`; @@ -43,24 +46,25 @@ function getPackageLabel(progress: InstallationProgress | null): string { return `${Math.round(progress.stage_progress)}%`; } } - if (progress && progress.dependency_count !== null) { + if (progress.stage === 'dependencies' && progress.dependency_count !== null) { return `${progress.completed_dependencies}/${progress.dependency_count}`; } - if (progress?.stage === 'dependencies') { + if (progress.stage === 'dependencies') { return 'Installing...'; } - return 'Downloading...'; + return activity.phase; } function isPendingDownload( isInstalling: boolean, isInstallFailed: boolean, - progress: InstallationProgress | null + progress: InstallationProgress | null, + indeterminate: boolean ): boolean { if (!isInstalling || isInstallFailed) { return false; } - if (!progress) { + if (!progress || indeterminate) { return true; } return ( @@ -103,6 +107,7 @@ function getDisplayTag(release: VersionRelease): string { } export function getVersionInstallDisplayState({ + appId, installNetworkStatus, isHovered, isInstalled, @@ -110,6 +115,7 @@ export function getVersionInstallDisplayState({ progress, release, }: { + appId?: string; installNetworkStatus: InstallNetworkStatus; isHovered: boolean; isInstalled: boolean; @@ -118,17 +124,22 @@ export function getVersionInstallDisplayState({ release: VersionRelease; }): VersionInstallDisplayState { const isInstallFailed = installNetworkStatus === 'failed' || Boolean(progress?.error); + const activity = getInstallActivityPresentation({ + appId, + installingTag: isInstalling ? release.tagName : null, + progress, + }); return { displayTag: getDisplayTag(release), downloadIconClass: getDownloadIconClass(installNetworkStatus), downloadIconStyle: getDownloadIconStyle(installNetworkStatus), isComplete: isInstalled || (isInstalling && Boolean(progress?.success) && Boolean(progress?.completed_at)), - isDownloadPending: isPendingDownload(isInstalling, isInstallFailed, progress), + isDownloadPending: isPendingDownload(isInstalling, isInstallFailed, progress, activity.indeterminate), isInstallFailed, - packageLabel: getPackageLabel(progress), + packageLabel: getPackageLabel(progress, activity), ringColor: isInstallFailed ? 'hsl(var(--accent-error))' : 'hsl(var(--accent-success))', - ringPercent: getRingPercent(progress), + ringPercent: getRingPercent(progress, activity.indeterminate), showUninstall: isInstalled && !isInstalling && isHovered, totalBytes: (progress ? progress.total_size : null) ?? release.totalSize ?? release.archiveSize ?? null, }; diff --git a/frontend/src/components/VersionSelector.test.tsx b/frontend/src/components/VersionSelector.test.tsx index 23a32509..8600802a 100644 --- a/frontend/src/components/VersionSelector.test.tsx +++ b/frontend/src/components/VersionSelector.test.tsx @@ -1,8 +1,35 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { VersionSelector } from './VersionSelector'; +import type { InstallationProgress } from '../types/versions'; + +const torchSetupProgress: InstallationProgress = { + tag: 'v2.14.0', started_at: '2026-04-12T00:00:00Z', stage: 'setup', + stage_progress: 0, overall_progress: 95, current_item: 'Creating managed Python environment', + download_speed: null, eta_seconds: null, total_size: null, downloaded_bytes: 0, + dependency_count: null, completed_dependencies: 0, completed_items: [], error: null, +}; describe('VersionSelector popup', () => { + it('keeps the Torch install indicator indeterminate during setup', () => { + const { container, rerender } = render(); + const ring = container.querySelector('.download-progress-ring.is-waiting'); + expect(ring).toBeInTheDocument(); + expect(ring).toHaveStyle({ '--progress': '60deg' }); + + rerender(); + expect(container.querySelector('.download-progress-ring.is-waiting')).not.toBeInTheDocument(); + }); it('shows a failed switch and keeps the active version selected', async () => { render( getVersionSelectorDisplayState({ + appId, activeVersion, diskSpacePercent, hasNewVersion, @@ -123,6 +126,7 @@ export function VersionSelector({ isLoading, latestVersion, }), [ + appId, activeVersion, diskSpacePercent, hasNewVersion, diff --git a/frontend/src/components/VersionSelectorState.ts b/frontend/src/components/VersionSelectorState.ts index e23a6e01..96dd868a 100644 --- a/frontend/src/components/VersionSelectorState.ts +++ b/frontend/src/components/VersionSelectorState.ts @@ -1,10 +1,12 @@ import { APIError } from '../errors'; import type { InstallationProgress } from '../hooks/useVersions'; import { getLogger } from '../utils/logger'; +import { getInstallActivityPresentation } from '../utils/installActivityPresentation'; const logger = getLogger('VersionSelector'); interface VersionSelectorStateInput { + appId?: string | null; activeVersion: string | null; diskSpacePercent: number; hasNewVersion: boolean; @@ -66,6 +68,7 @@ function getProgressDegrees(progressPercent: number): number { } export function getVersionSelectorDisplayState({ + appId, activeVersion, diskSpacePercent, installedVersions, @@ -80,7 +83,10 @@ export function getVersionSelectorDisplayState({ const hasInstallActivity = Boolean(installingVersion) && !isInstallComplete; const isInstallFailed = installNetworkStatus === 'failed'; const isInstallPending = - hasInstallActivity && !isInstallFailed && isPendingInstallProgress(installationProgress); + hasInstallActivity && !isInstallFailed && ( + getInstallActivityPresentation({ appId, installingTag: installingVersion, progress: installationProgress }).indeterminate + || isPendingInstallProgress(installationProgress) + ); const progressPercent = installationProgress?.overall_progress ?? 0; return { diff --git a/frontend/src/components/app-panels/TorchPanel.test.tsx b/frontend/src/components/app-panels/TorchPanel.test.tsx index d4ce6db5..ee296c87 100644 --- a/frontend/src/components/app-panels/TorchPanel.test.tsx +++ b/frontend/src/components/app-panels/TorchPanel.test.tsx @@ -242,7 +242,7 @@ describe('TorchPanel shared version controls', () => { }); const oldRow = getVersionRow(oldTag); fireEvent.pointerEnter(oldRow); - const uninstallButton = await within(oldRow).findByRole('button', { name: 'Uninstall' }); + const uninstallButton = await within(oldRow).findByRole('button', { name: 'Uninstall version' }); fireEvent.click(uninstallButton); await waitFor(() => { diff --git a/frontend/src/components/app-panels/TorchPanel.tsx b/frontend/src/components/app-panels/TorchPanel.tsx index df632815..68d35b58 100644 --- a/frontend/src/components/app-panels/TorchPanel.tsx +++ b/frontend/src/components/app-panels/TorchPanel.tsx @@ -30,8 +30,8 @@ export function TorchPanel({ const isManagerOpen = versions.isSupported && showVersionManager; return ( -
-
+
+
= { + download: 'Downloading', + extract: 'Extracting', + venv: 'Creating Environment', + dependencies: 'Installing Dependencies', + setup: 'Final Setup', +}; + +export interface InstallActivityPresentation { + active: boolean; + indeterminate: boolean; + phase: string; +} + +export function getInstallActivityPresentation({ + appId, + installingTag, + progress, +}: { + appId?: string | null; + installingTag?: string | null; + progress?: InstallationProgress | null; +}): InstallActivityPresentation { + const currentProgress = progress && (!installingTag || progress.tag === installingTag) ? progress : null; + const terminal = Boolean(currentProgress?.completed_at || currentProgress?.error); + const active = Boolean((installingTag || currentProgress?.tag) && !terminal); + const indeterminate = active && (!currentProgress || ( + appId === 'torch' && currentProgress.stage === 'setup' && currentProgress.stage_progress <= 0 + )); + const stageLabel = currentProgress ? STAGE_LABELS[currentProgress.stage] : null; + + return { + active, + indeterminate, + phase: currentProgress?.current_item?.trim() || stageLabel || 'Starting installation…', + }; +} diff --git a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs index 72218e46..fcab903b 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs @@ -210,6 +210,26 @@ impl TorchPendingStage { fn path(&self) -> &Path { self.directory.path() } + + fn scratch_path(&self) -> Result { + let scratch = self.path().join("temp"); + match std::fs::create_dir(&scratch) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(PumasError::from(error)), + } + let stage_root = std::fs::canonicalize(self.path()).map_err(PumasError::from)?; + let scratch = std::fs::canonicalize(&scratch).map_err(PumasError::from)?; + if !scratch.starts_with(&stage_root) || scratch == stage_root { + return Err(failed( + "Torch installer scratch directory escaped its stage", + )); + } + if !scratch.is_dir() { + return Err(failed("Torch installer scratch path is not a directory")); + } + Ok(scratch) + } } impl Drop for TorchPendingStage { @@ -508,6 +528,115 @@ mod cross_process_recovery_tests { assert!(!stage_path.exists() && !marker.exists()); } + #[cfg(unix)] + #[tokio::test] + async fn runtime_command_uses_stage_scratch_and_preserves_other_environment() { + let root = tempfile::tempdir().unwrap(); + let launcher_root = root.path().join("launcher with spaces"); + let metadata = Arc::new(MetadataManager::new(&launcher_root)); + metadata.ensure_directories().unwrap(); + let tracker = Arc::new(RwLock::new(InstallationProgressTracker::new( + launcher_root.join("launcher-data/cache"), + ))); + let installer = VersionInstaller::new( + launcher_root, + AppId::Torch, + metadata, + tracker, + Arc::new(AtomicBool::new(false)), + ); + let versions = installer.versions_dir(); + std::fs::create_dir_all(&versions).unwrap(); + let stage = Arc::new( + TorchPendingStage::new( + &versions, + "v2.9.1", + TorchVersionsLock::try_acquire(&versions).unwrap(), + ) + .unwrap(), + ); + let expected_scratch = std::fs::canonicalize(stage.path()).unwrap().join("temp"); + let log = root.path().join("child.log"); + let mut command = Command::new("/bin/sh"); + command + .args([ + "-c", + "printf '%s\\n' \"$TMPDIR\" \"$TMP\" \"$TEMP\" \"$TORCH_UNRELATED_ENV\"", + ]) + .env("TORCH_UNRELATED_ENV", "retained value"); + let (progress_tx, _progress_rx) = mpsc::channel(4); + + installer + .run_runtime_command( + command, + &log, + "Testing scratch", + &progress_tx, + Some(stage.clone()), + ) + .await + .unwrap(); + + assert_eq!( + std::fs::read_to_string(&log) + .unwrap() + .lines() + .collect::>(), + vec![ + expected_scratch.to_str().unwrap(), + expected_scratch.to_str().unwrap(), + expected_scratch.to_str().unwrap(), + "retained value", + ] + ); + assert!(expected_scratch.is_dir()); + drop(stage); + assert!(!expected_scratch.exists()); + + let stage = Arc::new( + TorchPendingStage::new( + &versions, + "v2.9.1", + TorchVersionsLock::try_acquire(&versions).unwrap(), + ) + .unwrap(), + ); + std::fs::write(stage.path().join("temp"), b"not a directory").unwrap(); + let sentinel = root.path().join("child-ran"); + let mut command = Command::new("/bin/sh"); + command.args(["-c", "touch \"$1\"", "sh"]).arg(&sentinel); + assert!(installer + .run_runtime_command( + command, + &log, + "Testing invalid scratch", + &progress_tx, + Some(stage) + ) + .await + .is_err()); + assert!(!sentinel.exists()); + } + + #[cfg(unix)] + #[test] + fn stage_scratch_rejects_a_link_outside_the_stage() { + let root = tempfile::tempdir().unwrap(); + let owner = TorchVersionsLock::try_acquire(root.path()).unwrap(); + let stage = TorchPendingStage::new(root.path(), "v2.9.1", owner).unwrap(); + let outside = root.path().join("outside"); + std::fs::create_dir(&outside).unwrap(); + std::os::unix::fs::symlink(&outside, stage.path().join("temp")).unwrap(); + + assert!(stage.scratch_path().is_err()); + + std::fs::remove_file(stage.path().join("temp")).unwrap(); + let missing_outside = root.path().join("missing-outside"); + std::os::unix::fs::symlink(&missing_outside, stage.path().join("temp")).unwrap(); + assert!(stage.scratch_path().is_err()); + assert!(!missing_outside.exists()); + } + #[tokio::test] async fn cancelled_staging_waiter_cannot_release_a_running_blocking_stage_task() { let root = tempfile::tempdir().unwrap(); @@ -1630,6 +1759,13 @@ impl VersionInstaller { stage_lease: Option>, ) -> Result<()> { self.check_cancelled()?; + if let Some(stage_lease) = &stage_lease { + let scratch = stage_lease.scratch_path()?; + command + .env("TMPDIR", &scratch) + .env("TMP", &scratch) + .env("TEMP", &scratch); + } self.progress_tracker.write().await.update_stage( InstallationStage::Setup, 0.0, diff --git a/rust/crates/pumas-app-manager/src/version_manager/mod.rs b/rust/crates/pumas-app-manager/src/version_manager/mod.rs index 994656f0..09365bea 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/mod.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/mod.rs @@ -53,6 +53,7 @@ pub mod size_calculator; mod state; mod torch_alternatives; mod torch_preview; +mod torch_workspace; pub use constraints::ConstraintsManager; pub use dependencies::DependencyManager; diff --git a/rust/crates/pumas-app-manager/src/version_manager/torch_alternatives.rs b/rust/crates/pumas-app-manager/src/version_manager/torch_alternatives.rs index f522418a..0473ab78 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/torch_alternatives.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/torch_alternatives.rs @@ -793,7 +793,7 @@ impl VersionManager { )); } }; - let workspace = Arc::new(tempfile::tempdir().map_err(PumasError::from)?); + let workspace = super::torch_workspace::create_launcher_workspace(&self.launcher_root)?; let resolver = workspace.path().join("resolve_runtime.py"); std::fs::write( &resolver, @@ -1011,7 +1011,8 @@ impl VersionManager { ensure_managed_torch_interpreter(&managed_root, &self.torch_cleanup, &candidates[0]) .await { - let workspace = Arc::new(tempfile::tempdir().map_err(PumasError::from)?); + let workspace = + super::torch_workspace::create_launcher_workspace(&self.launcher_root)?; let resolver = workspace.path().join("resolve_runtime.py"); std::fs::write( &resolver, diff --git a/rust/crates/pumas-app-manager/src/version_manager/torch_preview.rs b/rust/crates/pumas-app-manager/src/version_manager/torch_preview.rs index 80ce3080..eaaf5999 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/torch_preview.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/torch_preview.rs @@ -875,7 +875,9 @@ mod tests { #[tokio::test] async fn cancelled_resolver_waiter_keeps_workspace_until_owned_drain() { - let workspace = std::sync::Arc::new(tempfile::tempdir().unwrap()); + let launcher_root = tempfile::tempdir().unwrap(); + let workspace = + super::torch_workspace::create_launcher_workspace(launcher_root.path()).unwrap(); let path = workspace.path().to_path_buf(); let ready = path.join("ready"); let cleanup = std::sync::Arc::new(super::installer::TorchCleanupTasks::default()); @@ -885,7 +887,7 @@ mod tests { let mut command = Command::new("sh"); command .arg("-c") - .arg("touch \"$1\"; sleep 30") + .arg("printf '%s\\n' \"$TMPDIR\" \"$TMP\" \"$TEMP\" > \"$1.tmp\"; mv \"$1.tmp\" \"$1\"; sleep 30") .arg("sh") .arg(&ready); run_preview_resolver( @@ -903,6 +905,14 @@ mod tests { }) .await .unwrap(); + let environment = std::fs::read_to_string(path.join("ready")).unwrap(); + let expected_tmp = path.join("tmp"); + let canonical_launcher_root = std::fs::canonicalize(launcher_root.path()).unwrap(); + assert!(path.starts_with(canonical_launcher_root.join("launcher-data/tmp/torch-preview"))); + assert_eq!( + environment.lines().collect::>(), + vec![expected_tmp.to_str().unwrap(); 3] + ); task.abort(); assert!(task.await.unwrap_err().is_cancelled()); drop(workspace); @@ -1333,6 +1343,7 @@ pub(super) async fn run_preview_resolver( deadline: Duration, cleanup: &super::installer::TorchCleanupTasks, ) -> Result { + super::torch_workspace::configure_resolver_command(&mut command, workspace.path())?; let stdout = std::fs::File::create(workspace.path().join("resolver.stdout")) .map_err(PumasError::from)?; let stderr = std::fs::File::create(workspace.path().join("resolver.stderr")) @@ -1806,7 +1817,7 @@ impl VersionManager { } // This temporary directory is only a resolver workspace. Retained preview // data is held in memory, so restart invalidates every outstanding ID. - let workspace = std::sync::Arc::new(tempfile::tempdir().map_err(PumasError::from)?); + let workspace = super::torch_workspace::create_launcher_workspace(&self.launcher_root)?; let resolver = workspace.path().join("resolve_runtime.py"); fs::write( &resolver, diff --git a/rust/crates/pumas-app-manager/src/version_manager/torch_workspace.rs b/rust/crates/pumas-app-manager/src/version_manager/torch_workspace.rs new file mode 100644 index 00000000..f505f8f3 --- /dev/null +++ b/rust/crates/pumas-app-manager/src/version_manager/torch_workspace.rs @@ -0,0 +1,149 @@ +//! Operation-scoped scratch space for Torch resolver processes. + +use pumas_library::{PumasError, Result}; +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::process::Command; + +/// Create a unique workspace under the launcher's managed temporary directory. +pub(super) fn create_launcher_workspace(launcher_root: &Path) -> Result> { + let root = fs::canonicalize(launcher_root) + .map_err(|err| PumasError::io_with_path(err, launcher_root))?; + let mut parent = root.clone(); + for component in ["launcher-data", "tmp", "torch-preview"] { + parent = checked_child_directory(&parent, component, &root)?; + } + + tempfile::Builder::new() + .prefix("resolver-") + .tempdir_in(&parent) + .map(Arc::new) + .map_err(|err| PumasError::io_with_path(err, &parent)) +} + +/// Give one resolver process its own temporary directory without changing our environment. +pub(super) fn configure_resolver_command(command: &mut Command, workspace: &Path) -> Result<()> { + let workspace = + fs::canonicalize(workspace).map_err(|err| PumasError::io_with_path(err, workspace))?; + let temporary = checked_child_directory(&workspace, "tmp", &workspace)?; + for name in ["TMPDIR", "TMP", "TEMP"] { + command.env(name, &temporary); + } + Ok(()) +} + +fn checked_child_directory(parent: &Path, name: &str, root: &Path) -> Result { + let child = parent.join(name); + match fs::create_dir(&child) { + Ok(()) => {} + Err(err) if err.kind() == ErrorKind::AlreadyExists => {} + Err(err) => return Err(PumasError::io_with_path(err, &child)), + } + let resolved = fs::canonicalize(&child).map_err(|err| PumasError::io_with_path(err, &child))?; + if !resolved.starts_with(root) || resolved == root { + return Err(PumasError::Config { + message: format!( + "Torch resolver temporary directory escapes its root: {}", + child.display() + ), + }); + } + if !resolved.is_dir() { + return Err(PumasError::Config { + message: format!( + "Torch resolver temporary path is not a directory: {}", + child.display() + ), + }); + } + Ok(resolved) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspaces_are_unique_and_managed() { + let root = tempfile::tempdir().unwrap(); + let first = create_launcher_workspace(root.path()).unwrap(); + let second = create_launcher_workspace(root.path()).unwrap(); + let parent = fs::canonicalize(root.path()) + .unwrap() + .join("launcher-data/tmp/torch-preview"); + + assert_ne!(first.path(), second.path()); + assert_eq!(first.path().parent(), Some(parent.as_path())); + assert_eq!(second.path().parent(), Some(parent.as_path())); + assert!(first.path().is_dir()); + assert!(second.path().is_dir()); + } + + #[cfg(unix)] + #[test] + fn escaping_preview_symlink_is_rejected_without_touching_target() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let managed_tmp = root.path().join("launcher-data/tmp"); + fs::create_dir_all(&managed_tmp).unwrap(); + let sentinel = outside.path().join("keep"); + fs::write(&sentinel, b"untouched").unwrap(); + symlink(outside.path(), managed_tmp.join("torch-preview")).unwrap(); + + assert!(create_launcher_workspace(root.path()).is_err()); + assert_eq!(fs::read(&sentinel).unwrap(), b"untouched"); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 1); + assert!(managed_tmp.join("torch-preview").is_symlink()); + } + + #[test] + fn command_uses_workspace_tmp_and_keeps_other_environment() { + let root = tempfile::tempdir().unwrap(); + let workspace = create_launcher_workspace(root.path()).unwrap(); + let mut command = Command::new("resolver"); + command.env("RESOLVER_SENTINEL", "preserved"); + + configure_resolver_command(&mut command, workspace.path()).unwrap(); + + let environment: std::collections::HashMap<_, _> = command + .as_std() + .get_envs() + .map(|(name, value)| (name.to_owned(), value.map(ToOwned::to_owned))) + .collect(); + let temporary = workspace.path().join("tmp"); + assert!(temporary.is_dir()); + for name in ["TMPDIR", "TMP", "TEMP"] { + assert_eq!( + environment.get(std::ffi::OsStr::new(name)), + Some(&Some(temporary.as_os_str().to_owned())) + ); + } + assert_eq!( + environment.get(std::ffi::OsStr::new("RESOLVER_SENTINEL")), + Some(&Some("preserved".into())) + ); + } + + #[test] + fn paths_with_spaces_work() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("launcher with spaces"); + fs::create_dir(&root).unwrap(); + let workspace = create_launcher_workspace(&root).unwrap(); + let mut command = Command::new("resolver"); + + configure_resolver_command(&mut command, workspace.path()).unwrap(); + + let temporary = workspace.path().join("tmp"); + assert!(temporary.is_dir()); + assert!(temporary.starts_with(fs::canonicalize(&root).unwrap())); + assert!(command + .as_std() + .get_envs() + .any(|(name, value)| { name == "TMPDIR" && value == Some(temporary.as_os_str()) })); + } +} diff --git a/rust/crates/pumas-rpc/src/contract.rs b/rust/crates/pumas-rpc/src/contract.rs index 49d63ce3..518ef4e7 100644 --- a/rust/crates/pumas-rpc/src/contract.rs +++ b/rust/crates/pumas-rpc/src/contract.rs @@ -4124,7 +4124,7 @@ impl TryFrom }, } } - TorchPreviewOutcome::Rejected { reason, .. } => { + TorchPreviewOutcome::Rejected { reason, message } => { let reason = match reason { TorchPreviewRejectionReason::Unsupported => { TorchRuntimePreviewRejectionReason::Unsupported @@ -4139,10 +4139,30 @@ impl TryFrom TorchRuntimePreviewRejectionReason::Inconclusive } }; - Self::Rejected { - reason, - message: reason.message(), - } + // The manager's message is static but may still contain private text. + // Only these known stage messages are safe to expose verbatim. + let message = match reason { + TorchRuntimePreviewRejectionReason::Unsupported + if message + == "No compatible official Torch wheel was found for this version, build, and Python selection." => + { + message + } + TorchRuntimePreviewRejectionReason::Inconclusive + if matches!( + message, + "The managed Python catalog exceeded the bounded candidate scan." + | "The managed Python catalog could not be verified." + | "Official wheel discovery did not complete conclusively." + | "No stable native CPython candidate is available from the managed provider." + | "The managed Python interpreter could not be provisioned." + ) => + { + message + } + _ => reason.message(), + }; + Self::Rejected { reason, message } } }) } @@ -4198,22 +4218,88 @@ mod torch_preview_contract_tests { #[cfg(feature = "inference-plugins")] #[test] - fn manager_rejection_message_cannot_cross_rpc_boundary() { - let outcome = pumas_app_manager::version_manager::TorchPreviewOutcome::Rejected { - reason: - pumas_app_manager::version_manager::TorchPreviewRejectionReason::ValidationFailed, - message: "private resolver stderr /secret/path", + fn manager_rejection_message_survives_rpc_boundary() { + use pumas_app_manager::version_manager::{ + TorchPreviewOutcome, TorchPreviewRejectionReason, }; - let projected: TorchRuntimePreviewOutcome = outcome.try_into().unwrap(); - let value = RpcOutcome::TorchRuntimePreview(projected) - .into_value() - .unwrap(); - assert_eq!(value["reason"], "validation_failed"); - assert_eq!( - value["message"], - TorchRuntimePreviewRejectionReason::ValidationFailed.message() - ); - assert!(!value.to_string().contains("private resolver")); + + for (reason, expected_reason, message) in [ + ( + TorchPreviewRejectionReason::Unsupported, + "unsupported", + "No compatible official Torch wheel was found for this version, build, and Python selection.", + ), + ( + TorchPreviewRejectionReason::Inconclusive, + "inconclusive", + "The managed Python catalog exceeded the bounded candidate scan.", + ), + ( + TorchPreviewRejectionReason::Inconclusive, + "inconclusive", + "The managed Python catalog could not be verified.", + ), + ( + TorchPreviewRejectionReason::Inconclusive, + "inconclusive", + "Official wheel discovery did not complete conclusively.", + ), + ( + TorchPreviewRejectionReason::Inconclusive, + "inconclusive", + "No stable native CPython candidate is available from the managed provider.", + ), + ( + TorchPreviewRejectionReason::Inconclusive, + "inconclusive", + "The managed Python interpreter could not be provisioned.", + ), + ( + TorchPreviewRejectionReason::Inconclusive, + "inconclusive", + "Wheel resolution did not complete conclusively.", + ), + ] { + let outcome = TorchPreviewOutcome::Rejected { reason, message }; + let projected: TorchRuntimePreviewOutcome = outcome.try_into().unwrap(); + let value = RpcOutcome::TorchRuntimePreview(projected) + .into_value() + .unwrap(); + assert_eq!(value["reason"], expected_reason); + assert_eq!(value["message"], message); + } + } + + #[cfg(feature = "inference-plugins")] + #[test] + fn manager_unknown_or_mismatched_rejection_message_is_redacted() { + use pumas_app_manager::version_manager::{ + TorchPreviewOutcome, TorchPreviewRejectionReason, + }; + + for (reason, message, expected_reason, expected_message) in [ + ( + TorchPreviewRejectionReason::ValidationFailed, + "private resolver stderr /secret/path", + "validation_failed", + TorchRuntimePreviewRejectionReason::ValidationFailed.message(), + ), + ( + TorchPreviewRejectionReason::Unsupported, + "The managed Python catalog could not be verified.", + "unsupported", + TorchRuntimePreviewRejectionReason::Unsupported.message(), + ), + ] { + let outcome = TorchPreviewOutcome::Rejected { reason, message }; + let projected: TorchRuntimePreviewOutcome = outcome.try_into().unwrap(); + let value = RpcOutcome::TorchRuntimePreview(projected) + .into_value() + .unwrap(); + assert_eq!(value["reason"], expected_reason); + assert_eq!(value["message"], expected_message); + assert!(!value.to_string().contains(message)); + } } #[cfg(feature = "inference-plugins")] diff --git a/torch-server/resolve_runtime.py b/torch-server/resolve_runtime.py index e7c62cbe..b420d7a4 100644 --- a/torch-server/resolve_runtime.py +++ b/torch-server/resolve_runtime.py @@ -911,6 +911,8 @@ def main() -> None: "-m", "pip", "--isolated", + "--cache-dir", + str(args.output / "pip-cache"), "install", "--dry-run", "--report", diff --git a/torch-server/tests/test_resolve_runtime.py b/torch-server/tests/test_resolve_runtime.py index 8742fe21..e4610206 100644 --- a/torch-server/tests/test_resolve_runtime.py +++ b/torch-server/tests/test_resolve_runtime.py @@ -452,6 +452,7 @@ def fake_run(command, **_kwargs): return SimpleNamespace(returncode=0, stdout="", stderr="") with tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) / "resolver workspace with spaces" with ( patch.object( resolver.sys, @@ -467,7 +468,7 @@ def fake_run(command, **_kwargs): "--torch-sha256", digest, "--output", - directory, + str(output), ], ), patch.object(resolver.sys, "platform", "darwin"), @@ -484,11 +485,12 @@ def fake_run(command, **_kwargs): resolver.main() self.assertEqual(len(commands), 1) command = commands[0] + self.assertEqual(command[command.index("--cache-dir") + 1], str(output / "pip-cache")) self.assertIn(f"torch @ {wheel}#sha256={digest}", command) self.assertNotIn("torch==2.14.0", command) self.assertTrue(set(resolver.CORE).issubset(command)) self.assertEqual( - json.loads((pathlib.Path(directory) / "resolution.json").read_text())["torch"], + json.loads((output / "resolution.json").read_text())["torch"], "2.14.0", ) From 6aafe9e600a686fb8fe12360abb086e941de8c01 Mon Sep 17 00:00:00 2001 From: MrScripty Date: Sat, 26 Sep 2026 11:34:36 -0700 Subject: [PATCH 02/19] fix(torch-ui): show install activity and readable controls --- .../execution-ledger.md | 34 +++++++++ .../plan.md | 29 ++++---- .../README.md | 19 +++++ .../ui-interaction-follow-up.json | 73 +++++++++++++++++++ frontend/src/components/Header.test.tsx | 4 +- frontend/src/components/HeaderControls.tsx | 2 +- .../components/InstallDialogContent.test.tsx | 11 +++ .../src/components/InstallDialogContent.tsx | 10 ++- .../components/TorchInstallPreview.test.tsx | 40 ++++++++++ .../src/components/TorchInstallPreview.tsx | 35 +++++++-- .../src/components/VersionListItem.test.tsx | 22 ++++++ .../src/components/VersionListItemButton.tsx | 32 ++++---- .../components/app-panels/TorchPanel.test.tsx | 13 ++++ .../src/components/app-panels/TorchPanel.tsx | 2 +- 14 files changed, 285 insertions(+), 41 deletions(-) create mode 100644 docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/ui-interaction-follow-up.json diff --git a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md index 7f863597..6c7956c2 100644 --- a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md +++ b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md @@ -592,3 +592,37 @@ Windows/macOS E2E, provider-license integration, packaged desktop Torch installation, CUDA/device execution, and v2.14.0 Tuldok/image generation are still open; X1–X7 remain pending. + +## 2026-09-26 — Torch install feedback and readability follow-up + +- Reproduced the remaining packaged UI complaints in the exact Linux v0.7.0 + AppImage path. The Install row opened its review but gave too little feedback; + the preview CTA used dark text and the artifact check only changed its label. +- The Install row now has a visible label and clearer hover/pressed/focus + feedback. The review CTA uses light primary text on a dark green-accent + surface. Artifact checking and the pre-progress Torch install phase display + a spinner and explicit status; the header status is larger, brighter, and a + polite live region. The immediate Cancel action has readable text and + keyboard focus feedback. +- Browser-level interaction against the rebuilt AppImage at 800×1000 confirmed + green Install-row hover, a darker pressed state, white review/CTA text, the + prerequisite explanation while Install is disabled, and an immediate + “Checking official Torch artifacts…” live status on click. The separate + source tests verify the header and dialog show “Installing Torch v2.14.0 · + Starting installation…” before the first backend progress response. +- Local artifacts: AppImage SHA-256 + `34a5ffc88f5f42aa03bd9ed72ead37e5ca385838037247b74186a67d9d128aaf`; deb + SHA-256 + `fea54b4694d7ddb77a797327a9e15d0f0e66e0cf4f2d38fc9169feb4a4811386`. + Artifact naming/resource checks and extracted AppImage/deb RPC health checks + passed. The initial sandboxed build could not resolve GitHub for the + AppImage helper; the authorized rebuild with helper download access passed. +- Frontend acceptance: 60 focused tests, TypeScript check, scoped ESLint, + frontend production build, and `git diff --check` passed. Astra high and Sol + xhigh completed read-only repair reviews with no remaining blocker. +- The follow-up records UI feedback only; it did not complete a new artifact + preview or Torch install. The prior successful cu132 install remains tied to + its historical AppImage hash in the [UI report](reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md). + Both builds are local candidates and do not replace the toolbar-linked public + release. Windows/macOS packaged install, CUDA/device execution, and Tuldok + image generation remain unverified. diff --git a/docs/plans/torch-cross-platform-runtime-management/plan.md b/docs/plans/torch-cross-platform-runtime-management/plan.md index 885c3f40..ca15ca37 100644 --- a/docs/plans/torch-cross-platform-runtime-management/plan.md +++ b/docs/plans/torch-cross-platform-runtime-management/plan.md @@ -30,20 +30,21 @@ generation remain unverified; existing Tuldok image evidence remains scoped to its recorded Torch 2.10 tuple. Current-branch Linux v0.7.0 AppImage and deb candidates were rebuilt with the -generated CPython notices and progress-feedback repair. Artifact naming and -extracted resource hashes pass; both extracted backends start and pass -`/health`. The packaged Electron archive contains the -`get_torch_release_options` bridge method. The latest local AppImage SHA-256 is -`a28f302822ce99a9d687797606574c93a5afb9c184f293526b16b972294a670b`; the deb +generated CPython notices and Torch install feedback/readability repair. +Artifact naming and extracted resource hashes pass; both extracted backends +start and pass `/health`. The packaged Electron archive contains the +`get_torch_release_options` bridge method. The current local AppImage SHA-256 is +`34a5ffc88f5f42aa03bd9ed72ead37e5ca385838037247b74186a67d9d128aaf`; the deb SHA-256 is -`d8effc33ba37466171c5fae0178e2555064850e40544c93249966fedbf4bce88`. -The AppImage passed the Linux v2.14.0 cu132 UI install recorded in the -[packaged UI acceptance report](reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md). -These are local build outputs; they do not replace the toolbar-linked public -v0.7.0 assets (published 2026-09-17). The generated attribution inventory -includes all three CPython full-archive notice supersets and verifies 371 -package entries, 78 hashed inputs, and 57 retained legal texts against -target-specific evidence. +`fea54b4694d7ddb77a797327a9e15d0f0e66e0cf4f2d38fc9169feb4a4811386`. +The prior successful Linux v2.14.0 cu132 UI install remains recorded with its +then-current AppImage hash in the [packaged UI acceptance report](reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md). +The report also records the interaction/readability check on the current +candidate; that follow-up did not claim another install. These are local build +outputs; they do not replace the toolbar-linked public v0.7.0 assets (published +2026-09-17). The generated attribution inventory includes all three CPython +full-archive notice supersets and verifies 371 package entries, 78 hashed +inputs, and 57 retained legal texts against target-specific evidence. The earlier local Linux package candidate recorded in the [packaged Linux backend acceptance](reports/v2.14.0-linux-packaged-cpu-acceptance/README.md) @@ -303,7 +304,7 @@ resolver, manager, desktop, and platform runtime owners. | X4 | Windows x64 and macOS arm64 discovery returns only exact official wheels whose tags match the provisioned native CPython; CPU/MPS/CUDA choices follow this contract | Native wheel fixtures and wrong-target rejection passed; manual native RPC run `36223106097` resolved exact official CPU wheel tags on Linux, Windows, and macOS arm64. CUDA hardware execution and macOS MPS acceleration remain untested | partial | | X5 | Preview retains the exact distribution version, release/build, official wheel URL/hash, Python provider artifact identity, interpreter fingerprint, and complete dependencies; install stages and verifies that exact identity before publication | Run `36223106097` resolved 25 hashed CPU/Core artifacts and passed v2.14.0 install on all three shipped targets; each report retains the exact Torch wheel URL/hash, dependency set, and managed interpreter identity (see X2 reports) | partial | | X6 | Installed versions can be inspected, explicitly selected, started with health/protocol checks, and stopped by their owned generation; cancellation, timeout, RPC shutdown, and failed cleanup do not leak a resolver, installer, sidecar, interpreter provisioner, or unregistered runtime | Run `36223106097` passed second-session restart, fresh CPU operation, probe revalidation, protocol 3 sidecar trial/stop, and graceful shutdown on Linux, Windows, and macOS (see X2 reports); broader cancellation/timeout/failure cleanup remains | partial | -| X7 | The same manager choices and lifecycle are reachable through packaged desktop controls and the existing RPC API; the user never has to choose Python, and the desktop offers no unsupported adapter | Linux v0.7.0 AppImage UI installed v2.14.0 cu132 through the normal review flow at 800×1000. The first sampled install state showed the current phase in the header, a visible Cancel control, and indeterminate setup bars without a false percentage; the runtime reached Ready. See the [UI acceptance report](reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md). Packaged Windows/macOS installation remains unverified | partial | +| X7 | The same manager choices and lifecycle are reachable through packaged desktop controls and the existing RPC API; the user never has to choose Python, and the desktop offers no unsupported adapter | Linux v0.7.0 AppImage UI installed v2.14.0 cu132 through the normal review flow at 800×1000. The first sampled install state showed the current phase in the header, a visible Cancel control, and indeterminate setup bars without a false percentage; the runtime reached Ready. The current AppImage candidate also passes the interaction/readability follow-up at the same report, including visible row hover/press, light text, and immediate artifact-check status. The follow-up does not claim a second runtime install. Packaged Windows/macOS installation remains unverified | partial | Plan acceptance is `pending` until every required row is satisfied on its declared native environment. Cross-compilation, Linux simulation, packaging, diff --git a/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md b/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md index d4b08964..60e0767a 100644 --- a/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md +++ b/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md @@ -35,3 +35,22 @@ The exact machine-readable result is [acceptance.json](acceptance.json). This is a local candidate only and was not uploaded to the toolbar-linked public release. The successful CPU tensor operation does not qualify CUDA device use, Tuldok image generation, or Windows/macOS packaged installation. + +## 2026-09-26 interaction and readability follow-up + +The rebuilt local v0.7.0 AppImage (`34a5ffc88f5f42aa03bd9ed72ead37e5ca385838037247b74186a67d9d128aaf`) +was opened at the same 800×1000 viewport. Browser-level pointer input on the +Torch v2.14.0 Install row showed the green hover state, darker pressed state, +and scale response. The review heading and Install text rendered white; the +Install action explained why it remains disabled until the artifact check +finishes. Clicking Check showed an animated status immediately: +“Checking official Torch artifacts… Package files may download to Pumas’ +private cache.” The footer changed to the checking explanation at the same +time. Frontend tests also verify the header and dialog announce +“Installing Torch v2.14.0 · Starting installation…” before the first backend +progress response, with a visible Cancel action. + +This follow-up verifies interaction feedback and text contrast in the rebuilt +package. It did not complete a new artifact preview or Torch installation; the +successful runtime installation above remains tied to the AppImage hash in +`acceptance.json`. See the exact [follow-up evidence](ui-interaction-follow-up.json). diff --git a/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/ui-interaction-follow-up.json b/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/ui-interaction-follow-up.json new file mode 100644 index 00000000..49d35b13 --- /dev/null +++ b/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/ui-interaction-follow-up.json @@ -0,0 +1,73 @@ +{ + "status": "passed_ui_feedback_only", + "date": "2026-09-26", + "environment": "Linux x86_64 local AppImage with an isolated Pumas library profile", + "execution_mode": "manual-packaged-ui", + "application_version": "0.7.0", + "application_artifacts": { + "appimage": { + "name": "Pumas.Library-0.7.0.AppImage", + "sha256": "34a5ffc88f5f42aa03bd9ed72ead37e5ca385838037247b74186a67d9d128aaf" + }, + "deb": { + "name": "pumas-library-electron_0.7.0_amd64.deb", + "sha256": "fea54b4694d7ddb77a797327a9e15d0f0e66e0cf4f2d38fc9169feb4a4811386" + } + }, + "viewport": { + "width": 800, + "height": 1000 + }, + "observations": { + "install_row": { + "label": "Install", + "hover": { + "foreground": "rgb(82, 255, 82)", + "background": "rgb(38, 38, 38)", + "border": "rgb(82, 255, 82)", + "transform": "scale(1.05)" + }, + "pressed": { + "active": true, + "foreground": "rgb(82, 255, 82)", + "background": "rgb(29, 29, 29)" + } + }, + "review": { + "heading_foreground": "rgb(255, 255, 255)", + "body_foreground": "rgb(255, 255, 255)", + "install_cta_foreground": "rgb(255, 255, 255)", + "install_cta_background": "rgba(82, 255, 82, 0.14)", + "install_cta_disabled_before_artifact_review": true, + "install_cta_explanation": "Check the selected combination to review its artifacts and enable installation." + }, + "artifact_check": { + "hover_background": "rgba(82, 255, 82, 0.12)", + "pressed_background": "rgba(82, 255, 82, 0.157)", + "first_paint_status": "Checking official Torch artifacts… Package files may download to Pumas’ private cache.", + "button_label": "Checking…", + "install_cta_explanation": "Checking the selected combination before installation." + }, + "install_status": { + "header_and_dialog_status": "Installing Torch v2.14.0 · Starting installation…", + "packaged_header_foreground": "rgb(255, 255, 255)", + "packaged_header_font_size": "12px", + "verified_by": [ + "Header.test.tsx pending Torch status coverage", + "InstallDialogContent.test.tsx pre-progress status and Cancel coverage" + ] + } + }, + "verification": { + "focused_frontend_tests": "60 passed", + "frontend_typecheck": "passed", + "scoped_eslint": "passed", + "linux_artifact_contract": "passed", + "appimage_and_deb_rpc_smoke": "passed" + }, + "limits": [ + "This follow-up confirms interaction feedback and text contrast; it did not complete a new artifact preview or Torch installation.", + "The prior successful Torch installation remains recorded separately in acceptance.json and applies to the AppImage SHA recorded there.", + "CUDA/device execution, Windows/macOS packaged installation, and Tuldok image generation were not tested." + ] +} diff --git a/frontend/src/components/Header.test.tsx b/frontend/src/components/Header.test.tsx index 6bac49d9..07c02c3c 100644 --- a/frontend/src/components/Header.test.tsx +++ b/frontend/src/components/Header.test.tsx @@ -146,7 +146,9 @@ describe('Header Component', () => { it('shows the pending and current Torch installation phase in the header', () => { const { rerender } = render(
); - expect(screen.getByText(/Starting installation…/)).toBeInTheDocument(); + const pendingStatus = screen.getByRole('status'); + expect(pendingStatus).toHaveTextContent('Installing Torch v2.14.0 · Starting installation…'); + expect(pendingStatus).toHaveClass('text-xs', 'font-medium', 'text-[hsl(var(--text-primary))]'); rerender(
-
+
{status.text}
diff --git a/frontend/src/components/InstallDialogContent.test.tsx b/frontend/src/components/InstallDialogContent.test.tsx index fa04dcfe..baa72f57 100644 --- a/frontend/src/components/InstallDialogContent.test.tsx +++ b/frontend/src/components/InstallDialogContent.test.tsx @@ -94,6 +94,17 @@ function renderInstallDialogContent(overrides: Partial { + it('shows a visible Torch install status and spinner before backend progress arrives', () => { + renderInstallDialogContent({ appId: 'torch', installingVersion: 'v2.14.0' }); + + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('Installing Torch v2.14.0 · Starting installation…'); + expect(status.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel current installation' })).toHaveClass( + 'text-[hsl(var(--text-primary))]', 'hover:bg-[hsl(var(--surface-interactive-hover))]', 'focus-visible:outline-2' + ); + }); + it('renders notices and routes install, release-link, and remove-error actions', async () => { const removeError = new Error('remove failed'); const { props } = renderInstallDialogContent({ diff --git a/frontend/src/components/InstallDialogContent.tsx b/frontend/src/components/InstallDialogContent.tsx index 6459ac84..3ac0fbac 100644 --- a/frontend/src/components/InstallDialogContent.tsx +++ b/frontend/src/components/InstallDialogContent.tsx @@ -72,6 +72,7 @@ export function InstallDialogContent({ onReportRemoveError, }: InstallDialogContentProps) { const activity = getInstallActivityPresentation({ appId, installingTag: installingVersion, progress }); + const torchTag = installingVersion ?? progress?.tag; return (
@@ -91,9 +92,12 @@ export function InstallDialogContent({ {activity.active && (appId === 'torch' || !progress) && ( -
- {activity.phase} - +
+ + +
)} diff --git a/frontend/src/components/TorchInstallPreview.test.tsx b/frontend/src/components/TorchInstallPreview.test.tsx index 62abaf0b..398d915e 100644 --- a/frontend/src/components/TorchInstallPreview.test.tsx +++ b/frontend/src/components/TorchInstallPreview.test.tsx @@ -58,6 +58,46 @@ beforeEach(() => { }); describe('TorchInstallPreview', () => { + it('announces loading and explains why installation is disabled until artifacts are checked', async () => { + let finishOptions!: (options: TorchRuntimeOptions) => void; + getPresetOptions.mockImplementationOnce(() => new Promise((resolve) => { finishOptions = resolve; })); + render(); + + const region = screen.getByRole('region', { name: 'Torch installation preview for v2.14.0' }); + expect(region).toHaveClass('text-[hsl(var(--text-primary))]'); + expect(screen.getByText(/The first Install click opens this review.*may fetch package files into Pumas’ private cache.*Installing into the managed Torch environment starts only after you confirm Install below/)).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Loading Torch choices…'); + const install = screen.getByRole('button', { name: 'Install reviewed artifacts' }); + expect(install).toBeDisabled(); + expect(install).toHaveAccessibleDescription('Loading Torch choices before artifact review.'); + expect(install).toHaveClass('text-[hsl(var(--text-primary))]', 'bg-[hsl(var(--accent-success)/0.14)]', 'hover:bg-[hsl(var(--accent-success)/0.22)]', 'active:bg-[hsl(var(--accent-success)/0.3)]', 'active:scale-[0.98]', 'focus-visible:outline-2'); + + await act(async () => { finishOptions(preset); }); + const check = await screen.findByRole('button', { name: 'Check selected combination' }); + expect(check).toBeEnabled(); + expect(check).toHaveClass('hover:border-[hsl(var(--accent-success))]', 'active:scale-[0.98]', 'focus-visible:outline-2'); + expect(install).toHaveAccessibleDescription('Check the selected combination to review its artifacts and enable installation.'); + fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); + await waitFor(() => expect(install).toBeEnabled()); + expect(install).toHaveAccessibleDescription('Artifact check complete. Confirm to install this Torch version.'); + }); + + it('shows an immediate visible status while checking official Torch artifacts', async () => { + getPreview.mockImplementationOnce(() => new Promise(() => undefined)); + render(); + + const check = await screen.findByRole('button', { name: 'Check selected combination' }); + fireEvent.click(check); + + const status = await screen.findByRole('status'); + expect(status).toHaveTextContent(/Checking official Torch artifacts/); + expect(status.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Checking…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Install reviewed artifacts' })).toHaveAccessibleDescription( + 'Checking the selected combination before installation.' + ); + }); + it('uses the manager recommendation with core Torch and no required profile selectors', async () => { const onInstall = vi.fn(); render(); diff --git a/frontend/src/components/TorchInstallPreview.tsx b/frontend/src/components/TorchInstallPreview.tsx index 39df4867..663c3378 100644 --- a/frontend/src/components/TorchInstallPreview.tsx +++ b/frontend/src/components/TorchInstallPreview.tsx @@ -219,15 +219,29 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev && Boolean(preview.python) && preview.python !== 'auto' && (!fixedPreset || preview.python === runtimeOptions.preset.python) && preview.adapter === adapter; + const installHint = previewExpired + ? 'This review expired. Check the selected combination again to install.' + : loadingOptions + ? 'Loading Torch choices before artifact review.' + : probing + ? 'Checking the selected combination before installation.' + : !build || !adapter + ? 'Choose a build and dependency profile, then check the combination.' + : !canInstall + ? 'Check the selected combination to review its artifacts and enable installation.' + : 'Artifact check complete. Confirm to install this Torch version.'; return ( -
+
-

Review Torch {tag}

+

+ The first Install click opens this review. Checking may fetch package files into Pumas’ private cache. Installing into the managed Torch environment starts only after you confirm Install below. +

{fixedPreset ? 'Review fixed preset wheel highlights. The complete bundled lock is checked during installation; saved runtime checks can be inspected afterward.' @@ -244,7 +258,7 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev Pumas installs a private Python version that matches the selected Torch release and its dependencies. Python does not need to be installed on this computer.

- {loadingOptions ? : runtimeOptions && (releaseOptions || isPresetRelease) && ( + {loadingOptions ?

: runtimeOptions && (releaseOptions || isPresetRelease) && ( <> {isPresetRelease &&
@@ -264,13 +278,13 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev invalidatePreview(); const nextBuild = event.target.value; setBuild(nextBuild); - }} className="mt-1 block w-full rounded border bg-[hsl(var(--surface-control))] p-2"> + }} className="mt-1 block w-full rounded border bg-[hsl(var(--surface-control))] p-2 text-[hsl(var(--text-primary))]"> {availableBuilds.map((choice) => )} @@ -282,8 +296,12 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev {!fixedPreset && releaseOptions?.status === 'none' &&

No official wheel matches were found in this scan.

} {!fixedPreset && releaseOptions && !releaseOptions.completeScan && releaseOptions.status !== 'inconclusive' &&

This wheel scan was incomplete. Other combinations may still exist.

} {!fixedPreset && releaseOptions?.issues.map((issue, index) =>

{issue}

)} - )} @@ -351,9 +369,10 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev )}
- +

{installHint}

); diff --git a/frontend/src/components/VersionListItem.test.tsx b/frontend/src/components/VersionListItem.test.tsx index 8b7875dd..66674137 100644 --- a/frontend/src/components/VersionListItem.test.tsx +++ b/frontend/src/components/VersionListItem.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { VersionListItem } from './VersionListItem'; import type { InstallationProgress, VersionRelease } from '../hooks/useVersions'; @@ -87,6 +88,9 @@ describe('VersionListItem', () => { expect(screen.getByText('1.2.3')).toBeInTheDocument(); expect(screen.getByText('Pre')).toBeInTheDocument(); expect(screen.getByText('1.00 GB')).toBeInTheDocument(); + const installAction = screen.getByRole('button', { name: 'Install version' }); + expect(installAction).toHaveTextContent('Install'); + expect(installAction).toHaveClass('hover:bg-[hsl(var(--surface-interactive-hover))]', 'active:scale-[0.97]', 'focus-visible:outline-2'); fireEvent.pointerEnter(container.firstChild as Element); fireEvent.pointerLeave(container.firstChild as Element); @@ -135,6 +139,24 @@ describe('VersionListItem', () => { expect(props.onRemove).toHaveBeenCalledTimes(1); }); + it('announces Uninstall on keyboard focus and removes the installed version with Enter or Space', async () => { + const user = userEvent.setup(); + const { props } = renderVersionListItem({ isInstalled: true }); + expect(screen.getByRole('button', { name: 'Ready' })).toBeInTheDocument(); + await user.tab(); + await user.tab(); + const uninstall = screen.getByRole('button', { name: 'Uninstall version' }); + expect(uninstall).toHaveTextContent('Uninstall'); + expect(uninstall).toHaveClass('border-[hsl(var(--accent-error))]'); + await user.keyboard('{Enter}'); + await user.keyboard(' '); + expect(props.onRemove).toHaveBeenCalledTimes(2); + expect(screen.getByRole('button', { name: 'Uninstall version' })).toBeInTheDocument(); + + await user.tab(); + expect(screen.getByRole('button', { name: 'Ready' })).toBeInTheDocument(); + }); + it('renders installing dependency progress and cancel affordances', () => { const { props, rerender } = renderVersionListItem({ isInstalling: true, diff --git a/frontend/src/components/VersionListItemButton.tsx b/frontend/src/components/VersionListItemButton.tsx index c9ca4acd..2c6e552d 100644 --- a/frontend/src/components/VersionListItemButton.tsx +++ b/frontend/src/components/VersionListItemButton.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from 'react'; +import { useState, type CSSProperties } from 'react'; import { motion } from 'framer-motion'; import { Check, Download, Pause, X, XCircle } from 'lucide-react'; import { formatGB } from '../utils/installationFormatters'; @@ -24,7 +24,7 @@ function getInstallButtonClassName({ isInstalling, }: Pick): string { const baseClass = - 'flex items-center gap-2 px-3 py-2 rounded text-sm font-medium transition-colors border w-[120px] min-w-[120px] overflow-hidden'; + 'flex items-center gap-2 px-3 py-2 rounded text-sm font-medium transition-colors border w-[120px] min-w-[120px] overflow-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[hsl(var(--accent-success))] active:scale-[0.97]'; if (isInstalling) { return `${baseClass} ${ @@ -39,7 +39,7 @@ function getInstallButtonClassName({ if (isInstalled || displayState.isComplete) { return `${baseClass} bg-[hsl(var(--accent-success))]/20 border-[hsl(var(--accent-success))]/60 text-[hsl(var(--text-primary))]`; } - return `${baseClass} bg-[hsl(var(--surface-control))] border-[hsl(var(--border-control))] text-[hsl(var(--text-primary))] hover:border-[hsl(var(--accent-success))] hover:text-[hsl(var(--accent-success))]`; + return `${baseClass} bg-[hsl(var(--surface-control))] border-[hsl(var(--border-control))] text-[hsl(var(--text-primary))] hover:bg-[hsl(var(--surface-interactive-hover))] hover:border-[hsl(var(--accent-success))] hover:text-[hsl(var(--accent-success))] active:bg-[hsl(var(--surface-lowest))]`; } function ReadyButtonContent() { @@ -146,12 +146,11 @@ function InstallButtonContent({ }) { return ( <> - - {displayState.totalBytes && ( - - {formatGB(displayState.totalBytes)} - - )} + + + Install + {displayState.totalBytes && {formatGB(displayState.totalBytes)}} + ); } @@ -185,6 +184,11 @@ export function VersionListItemButton({ onInstall, onRemove, }: VersionListItemButtonProps) { + const [isFocused, setIsFocused] = useState(false); + const showUninstall = isInstalled && (displayState.showUninstall || isFocused); + const buttonDisplayState = showUninstall === displayState.showUninstall + ? displayState + : { ...displayState, showUninstall }; const canCancel = isInstalling && !isInstalled; const handleButtonClick = () => { if (canCancel) { @@ -200,22 +204,24 @@ export function VersionListItemButton({ return ( setIsFocused(true)} + onBlur={() => setIsFocused(false)} onPointerEnter={canCancel ? onCancelMouseEnter : undefined} onPointerLeave={canCancel ? onCancelMouseLeave : undefined} whileHover={!canCancel ? { scale: 1.05 } : {}} whileTap={!canCancel ? { scale: 0.96 } : {}} className={getInstallButtonClassName({ - displayState, + displayState: buttonDisplayState, isCancelHovered, isInstalled, isInstalling: canCancel, })} > {getButtonContent({ - displayState, + displayState: buttonDisplayState, isCancelHovered, isInstalled, isInstalling: canCancel, diff --git a/frontend/src/components/app-panels/TorchPanel.test.tsx b/frontend/src/components/app-panels/TorchPanel.test.tsx index ee296c87..d8b6d903 100644 --- a/frontend/src/components/app-panels/TorchPanel.test.tsx +++ b/frontend/src/components/app-panels/TorchPanel.test.tsx @@ -130,6 +130,17 @@ function expectActiveVersionNotDefault(tag: string) { } describe('TorchPanel shared version controls', () => { + it('uses readable semantic text for profile guidance', () => { + render( undefined), + installVersion: vi.fn(async () => true), + switchVersion: vi.fn(async () => true), + removeVersion: vi.fn(async () => true), + setDefaultVersion: vi.fn(async () => undefined), + }} />); + expect(screen.getByText(/Create a Torch runtime profile/)).toHaveClass('text-[hsl(var(--text-secondary))]'); + }); + it('installs, activates, and removes versions through the shared manager flow', async () => { let finishRefresh!: () => void; const forcedRefresh = new Promise((resolve) => { @@ -188,6 +199,7 @@ describe('TorchPanel shared version controls', () => { const candidateRow = getVersionRow(candidateTag); const [installButton] = within(candidateRow).getAllByRole('button'); if (!installButton) throw new TypeError('Expected the candidate install button'); + expect(installButton).toHaveTextContent('Install'); vi.stubGlobal('electronAPI', { get_torch_release_options: vi.fn().mockResolvedValue({ tag: candidateTag, status: 'matches', completeScan: true, @@ -215,6 +227,7 @@ describe('TorchPanel shared version controls', () => { }); fireEvent.click(installButton); expect(actions.installVersion).not.toHaveBeenCalled(); + expect(screen.getByRole('region', { name: `Torch installation preview for ${candidateTag}` })).toHaveClass('text-[hsl(var(--text-primary))]'); fireEvent.click(await screen.findByRole('button', { name: 'Check selected combination' })); const reviewedInstallButton = screen.getByRole('button', { name: 'Install reviewed artifacts' }); await waitFor(() => expect(reviewedInstallButton).toBeEnabled()); diff --git a/frontend/src/components/app-panels/TorchPanel.tsx b/frontend/src/components/app-panels/TorchPanel.tsx index 68d35b58..ca12cfa2 100644 --- a/frontend/src/components/app-panels/TorchPanel.tsx +++ b/frontend/src/components/app-panels/TorchPanel.tsx @@ -46,7 +46,7 @@ export function TorchPanel({ {!isManagerOpen && ( <> -

+

Create a Torch runtime profile, then use Serve on the image model. {' '}{gatewayUrl ? 'Copy the Pumas gateway URL above into Tuldok to generate images.' From 8d897b89bde05532bc95585147398d5c5ddc4887 Mon Sep 17 00:00:00 2001 From: MrScripty Date: Sat, 26 Sep 2026 12:19:36 -0700 Subject: [PATCH 03/19] fix(torch): bound artifact preview and reuse pip cache --- .../execution-ledger.md | 51 +++++++ .../components/TorchInstallPreview.test.tsx | 3 +- .../src/components/TorchInstallPreview.tsx | 2 +- .../src/version_manager/installer/torch.rs | 6 +- .../src/version_manager/torch_preview.rs | 124 ++++++++++++++++-- .../src/version_manager/torch_workspace.rs | 44 +++++++ rust/crates/pumas-rpc/src/contract.rs | 12 ++ torch-server/resolve_runtime.py | 8 +- torch-server/tests/test_resolve_runtime.py | 5 +- 9 files changed, 237 insertions(+), 18 deletions(-) diff --git a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md index 6c7956c2..7ae898e2 100644 --- a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md +++ b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md @@ -626,3 +626,54 @@ Both builds are local candidates and do not replace the toolbar-linked public release. Windows/macOS packaged install, CUDA/device execution, and Tuldok image generation remain unverified. + +## 2026-09-26 — Torch artifact probe timeout and cache reuse repair + +- The reported `cu132 · Python selected automatically · none: probe + inconclusive` result led to a source-level diagnosis. A pip resolution + timeout and an unclassified pip failure both mapped to the same generic + message. Preview cached package files in a temporary resolver directory, + while installation used a separate persistent pip cache. +- The full preview now has one 13-minute deadline across discovery, managed + Python provisioning, and every automatic-Python candidate. This stays below + Electron's 15-minute preview RPC deadline. When it expires, the operation + cancels and drains owned child processes before returning a typed inconclusive + result with a timeout-specific message. Unclassified pip failure has its own + safe message; the RPC boundary exposes only these allowlisted diagnostics. +- Preview and installation now use the same canonical Pumas-managed pip cache; + the managed-CPython depot remains separate. This permits pip to reuse eligible + cached wheel responses. It does not guarantee reuse because pip may revalidate + artifacts or fetch them again. The review UI now says that large wheels may be + downloaded and that completed cache entries can be reused. +- Verification passed: 116 focused Torch/app-manager tests, 4 Torch preview RPC + contract tests, 44 Torch resolver tests, 20 preview UI tests, and + `git diff --check`. Astra high and Sol xhigh completed read-only reviews; no + remaining source blocker was reported. +- The user-reported run itself was not captured, so the original result cannot + be distinguished between its 180-second timeout and an unclassified pip + error. Packaged preview-to-install cache reuse has not yet been exercised; + this repair is source-tested only and does not claim a new Torch install or + broader desktop/device/Tuldok acceptance. + +## 2026-09-26 — Local Linux package rebuild for Torch probe repair + +- Rebuilt the optimized `pumas-rpc`, frontend, and local Electron v0.7.0 Linux + packages from the repaired worktree. The AppImage at + `electron/release/Pumas.Library-0.7.0.AppImage` is 154,855,688 bytes with + SHA-256 `b39f869e32001e3755e57746c4a20841d0447537f57af885de80d48e3aa19075`; + the Debian package is 120,460,880 bytes with SHA-256 + `f6b8525e4452f17cae2021360fa2736a2cb2a6195cdfee7236a763b254514c40`. +- The artifact checker accepted both packages. The package smoke extracted each + installer, matched bundled RPC/frontend/license resources to build inputs, + and passed `/health` for both bundled RPC processes. The x86_64 Windows GNU + app-manager test target compiled successfully; this is cross-target compile + evidence only. +- Local verification: 116 focused Torch/app-manager tests, 4 Torch preview RPC + contract tests, 44 resolver tests, 20 preview UI tests, frontend type-check, + release-attribution check, and Linux package smoke passed. No actual Torch + package resolution or install was run, so timeout handling and cache reuse + have not been observed against the user's cu132 selection. +- This local build overwrites the repository's v0.7.0 Linux installer files. + It was not published and does not change the desktop toolbar link. Native + Windows/macOS packages, device use, and Tuldok image generation remain + unverified. diff --git a/frontend/src/components/TorchInstallPreview.test.tsx b/frontend/src/components/TorchInstallPreview.test.tsx index 398d915e..382bcbe7 100644 --- a/frontend/src/components/TorchInstallPreview.test.tsx +++ b/frontend/src/components/TorchInstallPreview.test.tsx @@ -90,7 +90,8 @@ describe('TorchInstallPreview', () => { fireEvent.click(check); const status = await screen.findByRole('status'); - expect(status).toHaveTextContent(/Checking official Torch artifacts/); + expect(status).toHaveTextContent(/Resolving official Torch packages/); + expect(status).toHaveTextContent(/shared cache so installation can reuse completed downloads/); expect(status.querySelector('.animate-spin')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Checking…' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Install reviewed artifacts' })).toHaveAccessibleDescription( diff --git a/frontend/src/components/TorchInstallPreview.tsx b/frontend/src/components/TorchInstallPreview.tsx index 663c3378..101dac9d 100644 --- a/frontend/src/components/TorchInstallPreview.tsx +++ b/frontend/src/components/TorchInstallPreview.tsx @@ -298,7 +298,7 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev {!fixedPreset && releaseOptions?.issues.map((issue, index) =>

{issue}

)} {probing &&

} -
-

Review Torch {tag}

-

- The first Install click opens this review. Checking may fetch package files into Pumas’ private cache. Installing into the managed Torch environment starts only after you confirm Install below. -

-

- {fixedPreset - ? 'Review fixed preset wheel highlights. The complete bundled lock is checked during installation; saved runtime checks can be inspected afterward.' - : 'Check the exact official artifacts before installing.'} - {' '}Installation does not select or start this version. -

-

- This check resolves package files and hashes. Device use, image generation, and socket startup need later runtime checks. -

-

- This flow installs official binary wheels only. Source compilation is a separate unsupported path and is never used as a fallback. -

-

- Pumas installs a private Python version that matches the selected Torch release and its dependencies. Python does not need to be installed on this computer. -

-
- {loadingOptions ?

: runtimeOptions && (releaseOptions || isPresetRelease) && ( - <> - {isPresetRelease &&
- - -
} - {fixedPreset &&

This release has one qualified Python 3.12 / CUDA 13.0 build.

} - {fixedPreset ?

Recommended setup: {build} · {runtimeOptions.preset.python} · bundled image dependencies (fixed).

: <> - {build &&

{releaseOptions?.recommended?.build === build ? 'Recommended setup' : 'Selected setup'}: {build} · Python selected automatically · {adapter === 'flux2' ? 'Pumas image dependencies' : 'Core runtime only'}.

} - {!build && releaseOptions?.combinations.length ?

No setup was recommended. Open Advanced setup and choose a build to continue.

: null} - {releaseOptions?.recommendationNote &&

{releaseOptions.recommendationNote}

} - {adapter === 'flux2' &&

Pumas image dependencies are selected by Pumas for its FLUX.2 path; they are not part of upstream Torch.

} -
- Advanced setup -
- - -
-
- } - {!fixedPreset && loadingReleaseOptions &&

Discovering official wheels…

} - {!fixedPreset && releaseOptions?.status === 'inconclusive' &&

Release discovery was inconclusive. Some official wheels may exist; retry later or review the available combinations.

} - {!fixedPreset && releaseOptions?.status === 'none' &&

No official wheel matches were found in this scan.

} - {!fixedPreset && releaseOptions && !releaseOptions.completeScan && releaseOptions.status !== 'inconclusive' &&

This wheel scan was incomplete. Other combinations may still exist.

} - {!fixedPreset && releaseOptions?.issues.map((issue, index) =>

{issue}

)} - {probing &&

-

} - - - )} - {!fixedPreset && releaseError &&
-

{releaseError}

- -
} - {checks.length > 0 &&
- Checked combinations -
    {checks.map((check) =>
  • {check.label}: {checkStatusLabel(check.status)}
  • )}
-
} - {error &&

Probe inconclusive: {error}

} - {rejection &&

{reasonLabel(rejection.reason)}: {rejection.message}

} - {!fixedPreset && rejection?.reason === 'unsupported' &&
- - {alternativesError &&

Alternative search inconclusive: {alternativesError}

} - {alternatives &&
- Official Torch wheel matches only -

Other dependencies have not been checked. Choose a match to run the normal exact preview for the full environment.

-

Checked builds: {eligibleCheckedBuilds.join(', ') || 'none'}

- {alternatives.status === 'none' &&

No official Torch wheel matches were found for this release and Python.

} - {alternatives.status === 'inconclusive' &&

Alternative search was inconclusive.

} - {alternatives.issues.map((issue, index) =>

{issue}

)} - {eligibleAlternatives.length > 0 &&
    {eligibleAlternatives.map((match) => ( -
  • - - {match.wheelUrl &&

    Torch wheel: {match.wheelUrl}

    } - {match.sha256 &&

    SHA-256: {match.sha256}

    } -
  • - ))}
} -
} -
} - {preview && ( -
-
-

{fixedPreset ? 'Fixed preset highlights' : 'Exact artifacts resolved'}

-

{fixedPreset - ? `${preview.artifacts.length} fixed preset wheel highlight${preview.artifacts.length === 1 ? '' : 's'} · ${preview.build} · ${preview.python}` - : `${preview.artifacts.length} exact artifacts resolved · ${preview.build} · ${preview.python} · ${preview.adapter === 'none' ? 'Core runtime only' : preview.adapter}`}

-
-

Selected Python: {preview.python}

- {fixedPreset &&

Additional bundled dependencies and their URLs are selected during installation.

} - {preview.qualification === 'unverified' &&

Artifact resolution is unverified; trial this installation before selecting it as default.

} - {previewExpired &&

Preview expired. Check selected combination again before installing.

} - {preview.artifacts.length > 0 &&
-

{fixedPreset ? 'Preset wheel highlight URLs and SHA-256 hashes' : 'Exact artifact URLs and SHA-256 hashes'}

-
    {preview.artifacts.map((artifact) => ( -
  • - {artifact.name} {artifact.version}
    - URL: {artifact.url}
    - SHA-256: {artifact.sha256} -
  • - ))}
-
} +
+

Install Torch {tag}

+

+ Pumas will choose the build and a private Python version for this computer. Core Torch dependencies are included. +

+

+ Package and Python resolution, wheel downloads, and installation happen within the install task after you confirm below. Installation does not select or start this version. +

+

+ This flow uses official binary wheels only. Device use, image generation, and socket startup need later runtime checks. +

- )} + {loadingOptions &&

} + {starting &&

} + {error &&

{error}

} + {error && !runtimeOptions && !loadingOptions && }
- -

{installHint}

+

+ {loadingOptions ? 'Loading local Torch choices.' : starting ? 'Starting the install task.' : 'Confirm to start package resolution and installation.'} +

); diff --git a/frontend/src/components/VersionListItemState.ts b/frontend/src/components/VersionListItemState.ts index aae58e28..6814b575 100644 --- a/frontend/src/components/VersionListItemState.ts +++ b/frontend/src/components/VersionListItemState.ts @@ -36,7 +36,7 @@ function getRingPercent(progress: InstallationProgress | null, indeterminate: bo } function getPackageLabel(progress: InstallationProgress | null, activity: InstallActivityPresentation): string { - if (!progress || activity.indeterminate && progress.stage === 'setup') return activity.phase; + if (!progress || activity.indeterminate) return activity.phase; if (progress.stage === 'download') { const downloadPercent = getDownloadPercent(progress); if (downloadPercent !== null && (progress.downloaded_bytes > 0 || downloadPercent > 0)) { diff --git a/frontend/src/components/app-panels/TorchPanel.test.tsx b/frontend/src/components/app-panels/TorchPanel.test.tsx index d8b6d903..2cb46019 100644 --- a/frontend/src/components/app-panels/TorchPanel.test.tsx +++ b/frontend/src/components/app-panels/TorchPanel.test.tsx @@ -200,23 +200,17 @@ describe('TorchPanel shared version controls', () => { const [installButton] = within(candidateRow).getAllByRole('button'); if (!installButton) throw new TypeError('Expected the candidate install button'); expect(installButton).toHaveTextContent('Install'); + const discoverRelease = vi.fn(); vi.stubGlobal('electronAPI', { - get_torch_release_options: vi.fn().mockResolvedValue({ - tag: candidateTag, status: 'matches', completeScan: true, - checkedChannels: ['cpu'], - combinations: [{ build: 'cpu', python: 'python3.12', wheelUrl: 'https://download.pytorch.org/whl/cpu/torch.whl' }], - issues: [], detectedGpuVendors: [], - driverStatus: { nvidia: 'not_present', amd: 'not_present' }, - recommended: { build: 'cpu', python: 'python3.12' }, recommendationNote: null, - }), + get_torch_release_options: discoverRelease, get_torch_runtime_options: vi.fn().mockResolvedValue({ - builds: ['cpu'], pythons: [{ id: 'python3.12', label: 'Python 3.12' }], + builds: ['cpu'], defaultBuild: 'auto', pythons: [], adapters: ['none'], bundledPresetAvailable: false, defaultAdapter: 'none', preset: { tag: 'v2.9.1', build: 'cu130', python: 'python3.12', adapter: 'bundled' }, }), preview_torch_runtime: vi.fn().mockResolvedValue({ - status: 'resolved', preview: { - previewId: 'preview-1', expiresInSeconds: 300, tag: candidateTag, build: 'cpu', python: 'python3.12', + status: 'ready', preview: { + previewId: 'preview-1', expiresInSeconds: 300, tag: candidateTag, build: 'auto', python: 'auto', adapter: 'none', qualification: 'unverified', artifacts: [], }, }), @@ -228,10 +222,10 @@ describe('TorchPanel shared version controls', () => { fireEvent.click(installButton); expect(actions.installVersion).not.toHaveBeenCalled(); expect(screen.getByRole('region', { name: `Torch installation preview for ${candidateTag}` })).toHaveClass('text-[hsl(var(--text-primary))]'); - fireEvent.click(await screen.findByRole('button', { name: 'Check selected combination' })); - const reviewedInstallButton = screen.getByRole('button', { name: 'Install reviewed artifacts' }); - await waitFor(() => expect(reviewedInstallButton).toBeEnabled()); - fireEvent.click(reviewedInstallButton); + const confirmInstallButton = screen.getByRole('button', { name: 'Install Torch' }); + await waitFor(() => expect(confirmInstallButton).toBeEnabled()); + expect(discoverRelease).not.toHaveBeenCalled(); + fireEvent.click(confirmInstallButton); await waitFor(() => { expect(actions.installVersion).toHaveBeenCalledWith(candidateTag, 'preview-1'); expect(within(getVersionRow(candidateTag)).getByRole('button', { name: 'Ready' })) diff --git a/frontend/src/generated/desktop-contract.ts b/frontend/src/generated/desktop-contract.ts index ae8ff956..5840014e 100644 --- a/frontend/src/generated/desktop-contract.ts +++ b/frontend/src/generated/desktop-contract.ts @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 67dd940c814b877de830883b563b559875d539d93a9158aba6cb812ffba3419a. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 2746aabed7ca8f2468fbdf4a2af56857fdbeb27466a24a67fb7a3e8bfb36736b. DO NOT EDIT. import { validateAvailableVersionsOutcome, validateBackendStatusOutcome, validateCancelInstallationOutcome, validateCatalogSearchOutcome, validateCheckVersionDependenciesOutcome, validateCheckVersionDependenciesParams, validateConversionCancelledOutcome, validateConversionEnvironmentOutcome, validateConversionListOutcome, validateConversionProgressResponse, validateConversionSetupStartedOutcome, validateConversionSetupStatusOutcome, validateConversionStartedOutcome, validateDownloadIdParams, validateDownloadListOutcome, validateDownloadMutationOutcome, validateDownloadStartedOutcome, validateDownloadStatusOutcome, validateFindTorchAlternativesParams, validateGetBackendSetupParams, validateGetHfDownloadDetailsParams, validateGetReleaseDependenciesOutcome, validateGetReleaseDependenciesParams, validateGetTorchPreviewReportParams, validateGetTorchReleaseOptionsParams, validateGetTorchRuntimeProbeParams, validateGithubCacheStatusOutcome, validateHfDownloadDetailsOutcome, validateInferenceSettingsOutcome, validateInstallVersionDependenciesOutcome, validateInstallVersionDependenciesParams, validateInstallVersionOutcome, validateInstallVersionParams, validateInstallationProgressOutcome, validateInstalledVersionsOutcome, validateLibraryModelMetadataOutcome, validateLinkHealthOutcome, validateModelIndexRefreshOutcome, validateModelsOutcome, validatePartialDownloadOutcome, validatePreviewTorchRuntimeParams, validatePublicError, validateRecoverDownloadParams, validateRemoveVersionOutcome, validateRouterProfileSyncStatus, validateRuntimeLaunchOutcome, validateRuntimeLaunchParams, validateRuntimeRunningOutcome, validateRuntimeStopOutcome, validateSearchCatalogParams, validateSelectedVersionOutcome, validateSetDefaultVersionOutcome, validateSetDefaultVersionParams, validateStartBackendSetupParams, validateStartConversionSetupParams, validateStopRuntimeProfileGenerationOutcome, validateStopRuntimeProfileGenerationParams, validateSuccessOutcome, validateSupportedQuantTypesOutcome, validateSwitchVersionOutcome, validateSwitchVersionParams, validateTorchDriverAvailability, validateTorchGpuVendor, validateTorchReleaseCombination, validateTorchReleaseDriverStatus, validateTorchReleaseOptionsOutcome, validateTorchReleaseOptionsStatus, validateTorchReleaseRecommendation, validateTorchRuntimePreview, validateTorchRuntimePreviewArtifact, validateTorchRuntimePreviewOutcome, validateTorchRuntimePreviewQualification, validateTorchRuntimePreviewRejectionReason, validateTrialTorchRuntimeOutcome, validateTrialTorchRuntimeParams, validateUpdateInferenceSettingsOutcome, validateUpdateInferenceSettingsParams, validateUpdateModelNotesOutcome, validateUpdateModelNotesParams, validateValidateInstallationsOutcome, validateVersionInfoOutcome, validateVersionStatusOutcome } from './desktop-contract.validators.js'; export type AvailableVersionsOutcome = (AvailableVersionsSuccess) | (AvailableVersionsRateLimited); export type AvailableVersionsRateLimited = { "error": string; "rate_limited": true; "retry_after_secs": number | null; "success": false }; @@ -93,7 +93,7 @@ export type RouterObservationState = "connecting" | "current" | "unavailable"; export type RouterProfileSyncStatus = ({ "catalog_state": "current" | "pending"; "generation": number; "last_error": string | null; "observation_state": RouterObservationState; "pending_model_ids": ReadonlyArray; "profile_id": string }) | ({ "catalog_state": "uncertain"; "generation": number; "last_error": string | null; "observation_state": "unavailable"; "pending_model_ids": ReadonlyArray; "profile_id": string }); export type RuntimeInstallationProgress = { "completedAt": string | null; "completedDependencies": number; "completedItems": ReadonlyArray; "currentItem": string | null; "dependencyCount": number | null; "downloadSpeed": number | null; "downloadedBytes": number; "error": string | null; "etaSeconds": number | null; "logPath": string | null; "overallProgress": number | null; "stage": RuntimeInstallationStage; "stageProgress": number | null; "startedAt": string; "success": boolean | null; "tag": string; "totalSize": number | null }; export type RuntimeInstallationProgressItem = { "completedAt": string; "name": string; "size": number | null; "type": string }; -export type RuntimeInstallationStage = "download" | "extract" | "venv" | "dependencies" | "setup"; +export type RuntimeInstallationStage = "resolving" | "download" | "extract" | "venv" | "dependencies" | "setup"; export type RuntimeLaunchOutcome = { "error"?: string; "log_path"?: string; "ready"?: boolean; "success": boolean }; export type RuntimeLaunchParams = Readonly>; export type RuntimeRunningOutcome = boolean; @@ -123,7 +123,7 @@ export type TorchReleaseOptionsStatus = "matches" | "none" | "inconclusive"; export type TorchReleaseRecommendation = { "build": string; "python": string }; export type TorchRuntimePreview = { "adapter": string; "artifacts": ReadonlyArray; "build": string; "expiresInSeconds": number; "previewId": string; "python": string; "qualification": TorchRuntimePreviewQualification; "tag": string }; export type TorchRuntimePreviewArtifact = { "name": string; "sha256": string; "url": string; "version": string }; -export type TorchRuntimePreviewOutcome = ({ "preview": TorchRuntimePreview; "status": "resolved" }) | ({ "message": string; "reason": TorchRuntimePreviewRejectionReason; "status": "rejected" }); +export type TorchRuntimePreviewOutcome = ({ "preview": TorchRuntimePreview; "status": "ready" }) | ({ "preview": TorchRuntimePreview; "status": "resolved" }) | ({ "message": string; "reason": TorchRuntimePreviewRejectionReason; "status": "rejected" }); export type TorchRuntimePreviewQualification = "qualified" | "unverified"; export type TorchRuntimePreviewRejectionReason = "unsupported" | "validation_failed" | "network_inconclusive" | "inconclusive"; export type TrialTorchRuntimeOutcome = { "capabilities": ReadonlyArray; "cleanup": string; "error": string | null; "generation": string | null; "healthStatus": string; "profileId": string; "protocol": number | null; "startedByTrial": boolean; "startupStatus": string; "success": boolean; "tag": string }; diff --git a/frontend/src/generated/desktop-contract.validators.d.ts b/frontend/src/generated/desktop-contract.validators.d.ts index 8a0ca871..c1038b56 100644 --- a/frontend/src/generated/desktop-contract.validators.d.ts +++ b/frontend/src/generated/desktop-contract.validators.d.ts @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 67dd940c814b877de830883b563b559875d539d93a9158aba6cb812ffba3419a. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 2746aabed7ca8f2468fbdf4a2af56857fdbeb27466a24a67fb7a3e8bfb36736b. DO NOT EDIT. export declare function validateAvailableVersionsOutcome(value: unknown): boolean; export declare function validateBackendStatusOutcome(value: unknown): boolean; export declare function validateCancelInstallationOutcome(value: unknown): boolean; diff --git a/frontend/src/generated/desktop-contract.validators.js b/frontend/src/generated/desktop-contract.validators.js index abfafbec..6d62c8d7 100644 --- a/frontend/src/generated/desktop-contract.validators.js +++ b/frontend/src/generated/desktop-contract.validators.js @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 67dd940c814b877de830883b563b559875d539d93a9158aba6cb812ffba3419a. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 2746aabed7ca8f2468fbdf4a2af56857fdbeb27466a24a67fb7a3e8bfb36736b. DO NOT EDIT. var __getOwnPropNames = Object.getOwnPropertyNames; var __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; @@ -9140,7 +9140,7 @@ function validate66(data, { instancePath = "", parentData, parentDataProperty, r var validateInstallationProgressOutcome = validate74; var schema82 = { "additionalProperties": false, "properties": { "completedAt": { "type": ["string", "null"] }, "completedDependencies": { "maximum": 4294967295, "minimum": 0, "type": "integer" }, "completedItems": { "items": { "$ref": "#/definitions/RuntimeInstallationProgressItem" }, "type": "array" }, "currentItem": { "type": ["string", "null"] }, "dependencyCount": { "maximum": 4294967295, "minimum": 0, "type": ["integer", "null"] }, "downloadSpeed": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "downloadedBytes": { "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, "error": { "type": ["string", "null"] }, "etaSeconds": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "logPath": { "type": ["string", "null"] }, "overallProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "stage": { "$ref": "#/definitions/RuntimeInstallationStage" }, "stageProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "startedAt": { "type": "string" }, "success": { "type": ["boolean", "null"] }, "tag": { "type": "string" }, "totalSize": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] } }, "required": ["tag", "startedAt", "stage", "stageProgress", "overallProgress", "currentItem", "downloadSpeed", "etaSeconds", "totalSize", "downloadedBytes", "dependencyCount", "completedDependencies", "completedItems", "error", "completedAt", "success", "logPath"], "type": "object" }; var schema83 = { "additionalProperties": false, "properties": { "completedAt": { "type": "string" }, "name": { "type": "string" }, "size": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] }, "type": { "type": "string" } }, "required": ["name", "type", "size", "completedAt"], "type": "object" }; -var schema84 = { "enum": ["download", "extract", "venv", "dependencies", "setup"], "type": "string" }; +var schema84 = { "enum": ["resolving", "download", "extract", "venv", "dependencies", "setup"], "type": "string" }; function validate75(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; @@ -9474,7 +9474,7 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } - if (!(data16 === "download" || data16 === "extract" || data16 === "venv" || data16 === "dependencies" || data16 === "setup")) { + if (!(data16 === "resolving" || data16 === "download" || data16 === "extract" || data16 === "venv" || data16 === "dependencies" || data16 === "setup")) { validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/enum", keyword: "enum", params: { allowedValues: schema84.enum }, message: "must be equal to one of the allowed values" }]; return false; } @@ -16558,8 +16558,8 @@ function validate133(data, { instancePath = "", parentData, parentDataProperty, } errors++; } - if ("resolved" !== data1) { - const err3 = { instancePath: instancePath + "/status", schemaPath: "#/oneOf/0/properties/status/const", keyword: "const", params: { allowedValue: "resolved" }, message: "must be equal to constant" }; + if ("ready" !== data1) { + const err3 = { instancePath: instancePath + "/status", schemaPath: "#/oneOf/0/properties/status/const", keyword: "const", params: { allowedValue: "ready" }, message: "must be equal to constant" }; if (vErrors === null) { vErrors = [err3]; } else { @@ -16593,7 +16593,7 @@ function validate133(data, { instancePath = "", parentData, parentDataProperty, if (errors === _errs7) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing1; - if (data.status === void 0 && (missing1 = "status") || data.reason === void 0 && (missing1 = "reason") || data.message === void 0 && (missing1 = "message")) { + if (data.status === void 0 && (missing1 = "status") || data.preview === void 0 && (missing1 = "preview")) { const err5 = { instancePath, schemaPath: "#/oneOf/1/required", keyword: "required", params: { missingProperty: missing1 }, message: "must have required property '" + missing1 + "'" }; if (vErrors === null) { vErrors = [err5]; @@ -16604,7 +16604,7 @@ function validate133(data, { instancePath = "", parentData, parentDataProperty, } else { const _errs9 = errors; for (const key1 in data) { - if (!(key1 === "message" || key1 === "reason" || key1 === "status")) { + if (!(key1 === "preview" || key1 === "status")) { const err6 = { instancePath, schemaPath: "#/oneOf/1/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key1 }, message: "must NOT have additional properties" }; if (vErrors === null) { vErrors = [err6]; @@ -16616,27 +16616,31 @@ function validate133(data, { instancePath = "", parentData, parentDataProperty, } } if (_errs9 === errors) { - if (data.message !== void 0) { + if (data.preview !== void 0) { const _errs10 = errors; - if (typeof data.message !== "string") { - const err7 = { instancePath: instancePath + "/message", schemaPath: "#/oneOf/1/properties/message/type", keyword: "type", params: { type: "string" }, message: "must be string" }; - if (vErrors === null) { - vErrors = [err7]; - } else { - vErrors.push(err7); - } - errors++; + if (!validate134(data.preview, { instancePath: instancePath + "/preview", parentData: data, parentDataProperty: "preview", rootData })) { + vErrors = vErrors === null ? validate134.errors : vErrors.concat(validate134.errors); + errors = vErrors.length; } var valid2 = _errs10 === errors; } else { var valid2 = true; } if (valid2) { - if (data.reason !== void 0) { - let data3 = data.reason; - const _errs12 = errors; + if (data.status !== void 0) { + let data3 = data.status; + const _errs11 = errors; if (typeof data3 !== "string") { - const err8 = { instancePath: instancePath + "/reason", schemaPath: "#/definitions/TorchRuntimePreviewRejectionReason/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + const err7 = { instancePath: instancePath + "/status", schemaPath: "#/oneOf/1/properties/status/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err7]; + } else { + vErrors.push(err7); + } + errors++; + } + if ("resolved" !== data3) { + const err8 = { instancePath: instancePath + "/status", schemaPath: "#/oneOf/1/properties/status/const", keyword: "const", params: { allowedValue: "resolved" }, message: "must be equal to constant" }; if (vErrors === null) { vErrors = [err8]; } else { @@ -16644,75 +16648,157 @@ function validate133(data, { instancePath = "", parentData, parentDataProperty, } errors++; } - if (!(data3 === "unsupported" || data3 === "validation_failed" || data3 === "network_inconclusive" || data3 === "inconclusive")) { - const err9 = { instancePath: instancePath + "/reason", schemaPath: "#/definitions/TorchRuntimePreviewRejectionReason/enum", keyword: "enum", params: { allowedValues: schema160.enum }, message: "must be equal to one of the allowed values" }; + var valid2 = _errs11 === errors; + } else { + var valid2 = true; + } + } + } + } + } else { + const err9 = { instancePath, schemaPath: "#/oneOf/1/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err9]; + } else { + vErrors.push(err9); + } + errors++; + } + } + var _valid0 = _errs7 === errors; + if (_valid0 && valid0) { + valid0 = false; + passing0 = [passing0, 1]; + } else { + if (_valid0) { + valid0 = true; + passing0 = 1; + } + const _errs13 = errors; + if (errors === _errs13) { + if (data && typeof data == "object" && !Array.isArray(data)) { + let missing2; + if (data.status === void 0 && (missing2 = "status") || data.reason === void 0 && (missing2 = "reason") || data.message === void 0 && (missing2 = "message")) { + const err10 = { instancePath, schemaPath: "#/oneOf/2/required", keyword: "required", params: { missingProperty: missing2 }, message: "must have required property '" + missing2 + "'" }; + if (vErrors === null) { + vErrors = [err10]; + } else { + vErrors.push(err10); + } + errors++; + } else { + const _errs15 = errors; + for (const key2 in data) { + if (!(key2 === "message" || key2 === "reason" || key2 === "status")) { + const err11 = { instancePath, schemaPath: "#/oneOf/2/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key2 }, message: "must NOT have additional properties" }; + if (vErrors === null) { + vErrors = [err11]; + } else { + vErrors.push(err11); + } + errors++; + break; + } + } + if (_errs15 === errors) { + if (data.message !== void 0) { + const _errs16 = errors; + if (typeof data.message !== "string") { + const err12 = { instancePath: instancePath + "/message", schemaPath: "#/oneOf/2/properties/message/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { - vErrors = [err9]; + vErrors = [err12]; } else { - vErrors.push(err9); + vErrors.push(err12); } errors++; } - var valid2 = _errs12 === errors; + var valid3 = _errs16 === errors; } else { - var valid2 = true; + var valid3 = true; } - if (valid2) { - if (data.status !== void 0) { - let data4 = data.status; - const _errs15 = errors; - if (typeof data4 !== "string") { - const err10 = { instancePath: instancePath + "/status", schemaPath: "#/oneOf/1/properties/status/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (valid3) { + if (data.reason !== void 0) { + let data5 = data.reason; + const _errs18 = errors; + if (typeof data5 !== "string") { + const err13 = { instancePath: instancePath + "/reason", schemaPath: "#/definitions/TorchRuntimePreviewRejectionReason/type", keyword: "type", params: { type: "string" }, message: "must be string" }; if (vErrors === null) { - vErrors = [err10]; + vErrors = [err13]; } else { - vErrors.push(err10); + vErrors.push(err13); } errors++; } - if ("rejected" !== data4) { - const err11 = { instancePath: instancePath + "/status", schemaPath: "#/oneOf/1/properties/status/const", keyword: "const", params: { allowedValue: "rejected" }, message: "must be equal to constant" }; + if (!(data5 === "unsupported" || data5 === "validation_failed" || data5 === "network_inconclusive" || data5 === "inconclusive")) { + const err14 = { instancePath: instancePath + "/reason", schemaPath: "#/definitions/TorchRuntimePreviewRejectionReason/enum", keyword: "enum", params: { allowedValues: schema160.enum }, message: "must be equal to one of the allowed values" }; if (vErrors === null) { - vErrors = [err11]; + vErrors = [err14]; } else { - vErrors.push(err11); + vErrors.push(err14); } errors++; } - var valid2 = _errs15 === errors; + var valid3 = _errs18 === errors; } else { - var valid2 = true; + var valid3 = true; + } + if (valid3) { + if (data.status !== void 0) { + let data6 = data.status; + const _errs21 = errors; + if (typeof data6 !== "string") { + const err15 = { instancePath: instancePath + "/status", schemaPath: "#/oneOf/2/properties/status/type", keyword: "type", params: { type: "string" }, message: "must be string" }; + if (vErrors === null) { + vErrors = [err15]; + } else { + vErrors.push(err15); + } + errors++; + } + if ("rejected" !== data6) { + const err16 = { instancePath: instancePath + "/status", schemaPath: "#/oneOf/2/properties/status/const", keyword: "const", params: { allowedValue: "rejected" }, message: "must be equal to constant" }; + if (vErrors === null) { + vErrors = [err16]; + } else { + vErrors.push(err16); + } + errors++; + } + var valid3 = _errs21 === errors; + } else { + var valid3 = true; + } } } } } - } - } else { - const err12 = { instancePath, schemaPath: "#/oneOf/1/type", keyword: "type", params: { type: "object" }, message: "must be object" }; - if (vErrors === null) { - vErrors = [err12]; } else { - vErrors.push(err12); + const err17 = { instancePath, schemaPath: "#/oneOf/2/type", keyword: "type", params: { type: "object" }, message: "must be object" }; + if (vErrors === null) { + vErrors = [err17]; + } else { + vErrors.push(err17); + } + errors++; } - errors++; } - } - var _valid0 = _errs7 === errors; - if (_valid0 && valid0) { - valid0 = false; - passing0 = [passing0, 1]; - } else { - if (_valid0) { - valid0 = true; - passing0 = 1; + var _valid0 = _errs13 === errors; + if (_valid0 && valid0) { + valid0 = false; + passing0 = [passing0, 2]; + } else { + if (_valid0) { + valid0 = true; + passing0 = 2; + } } } if (!valid0) { - const err13 = { instancePath, schemaPath: "#/oneOf", keyword: "oneOf", params: { passingSchemas: passing0 }, message: "must match exactly one schema in oneOf" }; + const err18 = { instancePath, schemaPath: "#/oneOf", keyword: "oneOf", params: { passingSchemas: passing0 }, message: "must match exactly one schema in oneOf" }; if (vErrors === null) { - vErrors = [err13]; + vErrors = [err18]; } else { - vErrors.push(err13); + vErrors.push(err18); } errors++; validate133.errors = vErrors; @@ -16730,54 +16816,54 @@ function validate133(data, { instancePath = "", parentData, parentDataProperty, validate133.errors = vErrors; return errors === 0; } -var validateTorchRuntimePreviewQualification = validate136; +var validateTorchRuntimePreviewQualification = validate137; var schema161 = { "$schema": "http://json-schema.org/draft-07/schema#", "enum": ["qualified", "unverified"], "title": "TorchRuntimePreviewQualification", "type": "string" }; -function validate136(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +function validate137(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (typeof data !== "string") { - validate136.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate137.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } if (!(data === "qualified" || data === "unverified")) { - validate136.errors = [{ instancePath, schemaPath: "#/enum", keyword: "enum", params: { allowedValues: schema161.enum }, message: "must be equal to one of the allowed values" }]; + validate137.errors = [{ instancePath, schemaPath: "#/enum", keyword: "enum", params: { allowedValues: schema161.enum }, message: "must be equal to one of the allowed values" }]; return false; } - validate136.errors = vErrors; + validate137.errors = vErrors; return errors === 0; } -var validateTorchRuntimePreviewRejectionReason = validate137; +var validateTorchRuntimePreviewRejectionReason = validate138; var schema162 = { "$schema": "http://json-schema.org/draft-07/schema#", "enum": ["unsupported", "validation_failed", "network_inconclusive", "inconclusive"], "title": "TorchRuntimePreviewRejectionReason", "type": "string" }; -function validate137(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +function validate138(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (typeof data !== "string") { - validate137.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate138.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } if (!(data === "unsupported" || data === "validation_failed" || data === "network_inconclusive" || data === "inconclusive")) { - validate137.errors = [{ instancePath, schemaPath: "#/enum", keyword: "enum", params: { allowedValues: schema162.enum }, message: "must be equal to one of the allowed values" }]; + validate138.errors = [{ instancePath, schemaPath: "#/enum", keyword: "enum", params: { allowedValues: schema162.enum }, message: "must be equal to one of the allowed values" }]; return false; } - validate137.errors = vErrors; + validate138.errors = vErrors; return errors === 0; } -var validateTrialTorchRuntimeOutcome = validate138; +var validateTrialTorchRuntimeOutcome = validate139; var schema163 = { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { "capabilities": { "items": { "type": "string" }, "type": "array" }, "cleanup": { "type": "string" }, "error": { "type": ["string", "null"] }, "generation": { "type": ["string", "null"] }, "healthStatus": { "type": "string" }, "profileId": { "type": "string" }, "protocol": { "maximum": 4294967295, "minimum": 0, "type": ["integer", "null"] }, "startedByTrial": { "type": "boolean" }, "startupStatus": { "type": "string" }, "success": { "type": "boolean" }, "tag": { "type": "string" } }, "required": ["success", "tag", "profileId", "startupStatus", "healthStatus", "protocol", "capabilities", "generation", "startedByTrial", "error", "cleanup"], "title": "TrialTorchRuntimeOutcome", "type": "object" }; -function validate138(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +function validate139(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; if (data.success === void 0 && (missing0 = "success") || data.tag === void 0 && (missing0 = "tag") || data.profileId === void 0 && (missing0 = "profileId") || data.startupStatus === void 0 && (missing0 = "startupStatus") || data.healthStatus === void 0 && (missing0 = "healthStatus") || data.protocol === void 0 && (missing0 = "protocol") || data.capabilities === void 0 && (missing0 = "capabilities") || data.generation === void 0 && (missing0 = "generation") || data.startedByTrial === void 0 && (missing0 = "startedByTrial") || data.error === void 0 && (missing0 = "error") || data.cleanup === void 0 && (missing0 = "cleanup")) { - validate138.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; + validate139.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { const _errs1 = errors; for (const key0 in data) { if (!func2.call(schema163.properties, key0)) { - validate138.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; + validate139.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; return false; break; } @@ -16793,7 +16879,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, for (let i0 = 0; i0 < len0; i0++) { const _errs4 = errors; if (typeof data0[i0] !== "string") { - validate138.errors = [{ instancePath: instancePath + "/capabilities/" + i0, schemaPath: "#/properties/capabilities/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate139.errors = [{ instancePath: instancePath + "/capabilities/" + i0, schemaPath: "#/properties/capabilities/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid1 = _errs4 === errors; @@ -16802,7 +16888,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate138.errors = [{ instancePath: instancePath + "/capabilities", schemaPath: "#/properties/capabilities/type", keyword: "type", params: { type: "array" }, message: "must be array" }]; + validate139.errors = [{ instancePath: instancePath + "/capabilities", schemaPath: "#/properties/capabilities/type", keyword: "type", params: { type: "array" }, message: "must be array" }]; return false; } } @@ -16814,7 +16900,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, if (data.cleanup !== void 0) { const _errs6 = errors; if (typeof data.cleanup !== "string") { - validate138.errors = [{ instancePath: instancePath + "/cleanup", schemaPath: "#/properties/cleanup/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate139.errors = [{ instancePath: instancePath + "/cleanup", schemaPath: "#/properties/cleanup/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs6 === errors; @@ -16826,7 +16912,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, let data3 = data.error; const _errs8 = errors; if (typeof data3 !== "string" && data3 !== null) { - validate138.errors = [{ instancePath: instancePath + "/error", schemaPath: "#/properties/error/type", keyword: "type", params: { type: schema163.properties.error.type }, message: "must be string,null" }]; + validate139.errors = [{ instancePath: instancePath + "/error", schemaPath: "#/properties/error/type", keyword: "type", params: { type: schema163.properties.error.type }, message: "must be string,null" }]; return false; } var valid0 = _errs8 === errors; @@ -16838,7 +16924,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, let data4 = data.generation; const _errs10 = errors; if (typeof data4 !== "string" && data4 !== null) { - validate138.errors = [{ instancePath: instancePath + "/generation", schemaPath: "#/properties/generation/type", keyword: "type", params: { type: schema163.properties.generation.type }, message: "must be string,null" }]; + validate139.errors = [{ instancePath: instancePath + "/generation", schemaPath: "#/properties/generation/type", keyword: "type", params: { type: schema163.properties.generation.type }, message: "must be string,null" }]; return false; } var valid0 = _errs10 === errors; @@ -16849,7 +16935,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, if (data.healthStatus !== void 0) { const _errs12 = errors; if (typeof data.healthStatus !== "string") { - validate138.errors = [{ instancePath: instancePath + "/healthStatus", schemaPath: "#/properties/healthStatus/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate139.errors = [{ instancePath: instancePath + "/healthStatus", schemaPath: "#/properties/healthStatus/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs12 === errors; @@ -16860,7 +16946,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, if (data.profileId !== void 0) { const _errs14 = errors; if (typeof data.profileId !== "string") { - validate138.errors = [{ instancePath: instancePath + "/profileId", schemaPath: "#/properties/profileId/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate139.errors = [{ instancePath: instancePath + "/profileId", schemaPath: "#/properties/profileId/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs14 === errors; @@ -16872,17 +16958,17 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, let data7 = data.protocol; const _errs16 = errors; if (!(typeof data7 == "number" && (!(data7 % 1) && !isNaN(data7)) && isFinite(data7)) && data7 !== null) { - validate138.errors = [{ instancePath: instancePath + "/protocol", schemaPath: "#/properties/protocol/type", keyword: "type", params: { type: schema163.properties.protocol.type }, message: "must be integer,null" }]; + validate139.errors = [{ instancePath: instancePath + "/protocol", schemaPath: "#/properties/protocol/type", keyword: "type", params: { type: schema163.properties.protocol.type }, message: "must be integer,null" }]; return false; } if (errors === _errs16) { if (typeof data7 == "number" && isFinite(data7)) { if (data7 > 4294967295 || isNaN(data7)) { - validate138.errors = [{ instancePath: instancePath + "/protocol", schemaPath: "#/properties/protocol/maximum", keyword: "maximum", params: { comparison: "<=", limit: 4294967295 }, message: "must be <= 4294967295" }]; + validate139.errors = [{ instancePath: instancePath + "/protocol", schemaPath: "#/properties/protocol/maximum", keyword: "maximum", params: { comparison: "<=", limit: 4294967295 }, message: "must be <= 4294967295" }]; return false; } else { if (data7 < 0 || isNaN(data7)) { - validate138.errors = [{ instancePath: instancePath + "/protocol", schemaPath: "#/properties/protocol/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + validate139.errors = [{ instancePath: instancePath + "/protocol", schemaPath: "#/properties/protocol/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; return false; } } @@ -16896,7 +16982,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, if (data.startedByTrial !== void 0) { const _errs18 = errors; if (typeof data.startedByTrial !== "boolean") { - validate138.errors = [{ instancePath: instancePath + "/startedByTrial", schemaPath: "#/properties/startedByTrial/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; + validate139.errors = [{ instancePath: instancePath + "/startedByTrial", schemaPath: "#/properties/startedByTrial/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; return false; } var valid0 = _errs18 === errors; @@ -16907,7 +16993,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, if (data.startupStatus !== void 0) { const _errs20 = errors; if (typeof data.startupStatus !== "string") { - validate138.errors = [{ instancePath: instancePath + "/startupStatus", schemaPath: "#/properties/startupStatus/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate139.errors = [{ instancePath: instancePath + "/startupStatus", schemaPath: "#/properties/startupStatus/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs20 === errors; @@ -16918,7 +17004,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, if (data.success !== void 0) { const _errs22 = errors; if (typeof data.success !== "boolean") { - validate138.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; + validate139.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; return false; } var valid0 = _errs22 === errors; @@ -16929,7 +17015,7 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, if (data.tag !== void 0) { const _errs24 = errors; if (typeof data.tag !== "string") { - validate138.errors = [{ instancePath: instancePath + "/tag", schemaPath: "#/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate139.errors = [{ instancePath: instancePath + "/tag", schemaPath: "#/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs24 === errors; @@ -16949,15 +17035,15 @@ function validate138(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate138.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate139.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } - validate138.errors = vErrors; + validate139.errors = vErrors; return errors === 0; } -var validateTrialTorchRuntimeParams = validate139; -function validate139(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +var validateTrialTorchRuntimeParams = validate140; +function validate140(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; const _errs0 = errors; @@ -17118,7 +17204,7 @@ function validate139(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err10); } errors++; - validate139.errors = vErrors; + validate140.errors = vErrors; return false; } else { errors = _errs0; @@ -17130,24 +17216,24 @@ function validate139(data, { instancePath = "", parentData, parentDataProperty, } } } - validate139.errors = vErrors; + validate140.errors = vErrors; return errors === 0; } -var validateUpdateInferenceSettingsOutcome = validate140; -function validate140(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +var validateUpdateInferenceSettingsOutcome = validate141; +function validate141(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; if (data.success === void 0 && (missing0 = "success") || data.model_id === void 0 && (missing0 = "model_id")) { - validate140.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; + validate141.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { const _errs1 = errors; for (const key0 in data) { if (!(key0 === "model_id" || key0 === "success")) { - validate140.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; + validate141.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; return false; break; } @@ -17156,7 +17242,7 @@ function validate140(data, { instancePath = "", parentData, parentDataProperty, if (data.model_id !== void 0) { const _errs2 = errors; if (typeof data.model_id !== "string") { - validate140.errors = [{ instancePath: instancePath + "/model_id", schemaPath: "#/properties/model_id/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate141.errors = [{ instancePath: instancePath + "/model_id", schemaPath: "#/properties/model_id/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs2 === errors; @@ -17168,11 +17254,11 @@ function validate140(data, { instancePath = "", parentData, parentDataProperty, let data1 = data.success; const _errs4 = errors; if (typeof data1 !== "boolean") { - validate140.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; + validate141.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; return false; } if (true !== data1) { - validate140.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/const", keyword: "const", params: { allowedValue: true }, message: "must be equal to constant" }]; + validate141.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/const", keyword: "const", params: { allowedValue: true }, message: "must be equal to constant" }]; return false; } var valid0 = _errs4 === errors; @@ -17183,19 +17269,19 @@ function validate140(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate140.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate141.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } - validate140.errors = vErrors; + validate141.errors = vErrors; return errors === 0; } -var validateUpdateInferenceSettingsParams = validate141; +var validateUpdateInferenceSettingsParams = validate142; var schema167 = { "additionalProperties": false, "properties": { "constraints": { "anyOf": [{ "$ref": "#/definitions/InferenceConstraintsInput" }, { "type": "null" }] }, "default": { "$ref": "#/definitions/DesktopJsonValue" }, "description": { "default": null, "type": ["string", "null"] }, "key": { "type": "string" }, "label": { "type": "string" }, "param_type": { "$ref": "#/definitions/ParamType" } }, "required": ["key", "label", "param_type", "default"], "type": "object" }; var schema170 = { "description": "Data type for an inference parameter.", "enum": ["Number", "Integer", "String", "Boolean"], "type": "string" }; var schema168 = { "additionalProperties": false, "properties": { "allowed_values": { "anyOf": [{ "type": "null" }, { "items": { "$ref": "#/definitions/DesktopJsonValue" }, "type": "array" }] }, "max": { "default": null, "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "min": { "default": null, "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] } }, "type": "object" }; -var wrapper4 = { validate: validate144 }; -function validate144(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +var wrapper4 = { validate: validate145 }; +function validate145(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; const _errs0 = errors; @@ -17342,7 +17428,7 @@ function validate144(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err8); } errors++; - validate144.errors = vErrors; + validate145.errors = vErrors; return false; } else { errors = _errs0; @@ -17354,10 +17440,10 @@ function validate144(data, { instancePath = "", parentData, parentDataProperty, } } } - validate144.errors = vErrors; + validate145.errors = vErrors; return errors === 0; } -function validate143(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +function validate144(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (errors === 0) { @@ -17365,7 +17451,7 @@ function validate143(data, { instancePath = "", parentData, parentDataProperty, const _errs1 = errors; for (const key0 in data) { if (!(key0 === "allowed_values" || key0 === "max" || key0 === "min")) { - validate143.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; + validate144.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; return false; break; } @@ -17396,8 +17482,8 @@ function validate143(data, { instancePath = "", parentData, parentDataProperty, const len0 = data0.length; for (let i0 = 0; i0 < len0; i0++) { const _errs8 = errors; - if (!validate144(data0[i0], { instancePath: instancePath + "/allowed_values/" + i0, parentData: data0, parentDataProperty: i0, rootData })) { - vErrors = vErrors === null ? validate144.errors : vErrors.concat(validate144.errors); + if (!validate145(data0[i0], { instancePath: instancePath + "/allowed_values/" + i0, parentData: data0, parentDataProperty: i0, rootData })) { + vErrors = vErrors === null ? validate145.errors : vErrors.concat(validate145.errors); errors = vErrors.length; } var valid2 = _errs8 === errors; @@ -17426,7 +17512,7 @@ function validate143(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err2); } errors++; - validate143.errors = vErrors; + validate144.errors = vErrors; return false; } else { errors = _errs3; @@ -17447,17 +17533,17 @@ function validate143(data, { instancePath = "", parentData, parentDataProperty, let data2 = data.max; const _errs9 = errors; if (!(typeof data2 == "number" && isFinite(data2)) && data2 !== null) { - validate143.errors = [{ instancePath: instancePath + "/max", schemaPath: "#/properties/max/type", keyword: "type", params: { type: schema168.properties.max.type }, message: "must be number,null" }]; + validate144.errors = [{ instancePath: instancePath + "/max", schemaPath: "#/properties/max/type", keyword: "type", params: { type: schema168.properties.max.type }, message: "must be number,null" }]; return false; } if (errors === _errs9) { if (typeof data2 == "number" && isFinite(data2)) { if (data2 > 17976931348623157e292 || isNaN(data2)) { - validate143.errors = [{ instancePath: instancePath + "/max", schemaPath: "#/properties/max/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; + validate144.errors = [{ instancePath: instancePath + "/max", schemaPath: "#/properties/max/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; return false; } else { if (data2 < -17976931348623157e292 || isNaN(data2)) { - validate143.errors = [{ instancePath: instancePath + "/max", schemaPath: "#/properties/max/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; + validate144.errors = [{ instancePath: instancePath + "/max", schemaPath: "#/properties/max/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; return false; } } @@ -17472,17 +17558,17 @@ function validate143(data, { instancePath = "", parentData, parentDataProperty, let data3 = data.min; const _errs11 = errors; if (!(typeof data3 == "number" && isFinite(data3)) && data3 !== null) { - validate143.errors = [{ instancePath: instancePath + "/min", schemaPath: "#/properties/min/type", keyword: "type", params: { type: schema168.properties.min.type }, message: "must be number,null" }]; + validate144.errors = [{ instancePath: instancePath + "/min", schemaPath: "#/properties/min/type", keyword: "type", params: { type: schema168.properties.min.type }, message: "must be number,null" }]; return false; } if (errors === _errs11) { if (typeof data3 == "number" && isFinite(data3)) { if (data3 > 17976931348623157e292 || isNaN(data3)) { - validate143.errors = [{ instancePath: instancePath + "/min", schemaPath: "#/properties/min/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; + validate144.errors = [{ instancePath: instancePath + "/min", schemaPath: "#/properties/min/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; return false; } else { if (data3 < -17976931348623157e292 || isNaN(data3)) { - validate143.errors = [{ instancePath: instancePath + "/min", schemaPath: "#/properties/min/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; + validate144.errors = [{ instancePath: instancePath + "/min", schemaPath: "#/properties/min/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; return false; } } @@ -17496,27 +17582,27 @@ function validate143(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate143.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate144.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } - validate143.errors = vErrors; + validate144.errors = vErrors; return errors === 0; } -function validate142(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +function validate143(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; if (data.key === void 0 && (missing0 = "key") || data.label === void 0 && (missing0 = "label") || data.param_type === void 0 && (missing0 = "param_type") || data.default === void 0 && (missing0 = "default")) { - validate142.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; + validate143.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { const _errs1 = errors; for (const key0 in data) { if (!(key0 === "constraints" || key0 === "default" || key0 === "description" || key0 === "key" || key0 === "label" || key0 === "param_type")) { - validate142.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; + validate143.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; return false; break; } @@ -17528,8 +17614,8 @@ function validate142(data, { instancePath = "", parentData, parentDataProperty, const _errs3 = errors; let valid1 = false; const _errs4 = errors; - if (!validate143(data0, { instancePath: instancePath + "/constraints", parentData: data, parentDataProperty: "constraints", rootData })) { - vErrors = vErrors === null ? validate143.errors : vErrors.concat(validate143.errors); + if (!validate144(data0, { instancePath: instancePath + "/constraints", parentData: data, parentDataProperty: "constraints", rootData })) { + vErrors = vErrors === null ? validate144.errors : vErrors.concat(validate144.errors); errors = vErrors.length; } var _valid0 = _errs4 === errors; @@ -17556,7 +17642,7 @@ function validate142(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err1); } errors++; - validate142.errors = vErrors; + validate143.errors = vErrors; return false; } else { errors = _errs3; @@ -17575,8 +17661,8 @@ function validate142(data, { instancePath = "", parentData, parentDataProperty, if (valid0) { if (data.default !== void 0) { const _errs7 = errors; - if (!validate144(data.default, { instancePath: instancePath + "/default", parentData: data, parentDataProperty: "default", rootData })) { - vErrors = vErrors === null ? validate144.errors : vErrors.concat(validate144.errors); + if (!validate145(data.default, { instancePath: instancePath + "/default", parentData: data, parentDataProperty: "default", rootData })) { + vErrors = vErrors === null ? validate145.errors : vErrors.concat(validate145.errors); errors = vErrors.length; } var valid0 = _errs7 === errors; @@ -17588,7 +17674,7 @@ function validate142(data, { instancePath = "", parentData, parentDataProperty, let data2 = data.description; const _errs8 = errors; if (typeof data2 !== "string" && data2 !== null) { - validate142.errors = [{ instancePath: instancePath + "/description", schemaPath: "#/properties/description/type", keyword: "type", params: { type: schema167.properties.description.type }, message: "must be string,null" }]; + validate143.errors = [{ instancePath: instancePath + "/description", schemaPath: "#/properties/description/type", keyword: "type", params: { type: schema167.properties.description.type }, message: "must be string,null" }]; return false; } var valid0 = _errs8 === errors; @@ -17599,7 +17685,7 @@ function validate142(data, { instancePath = "", parentData, parentDataProperty, if (data.key !== void 0) { const _errs10 = errors; if (typeof data.key !== "string") { - validate142.errors = [{ instancePath: instancePath + "/key", schemaPath: "#/properties/key/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate143.errors = [{ instancePath: instancePath + "/key", schemaPath: "#/properties/key/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs10 === errors; @@ -17610,7 +17696,7 @@ function validate142(data, { instancePath = "", parentData, parentDataProperty, if (data.label !== void 0) { const _errs12 = errors; if (typeof data.label !== "string") { - validate142.errors = [{ instancePath: instancePath + "/label", schemaPath: "#/properties/label/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate143.errors = [{ instancePath: instancePath + "/label", schemaPath: "#/properties/label/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs12 === errors; @@ -17622,11 +17708,11 @@ function validate142(data, { instancePath = "", parentData, parentDataProperty, let data5 = data.param_type; const _errs14 = errors; if (typeof data5 !== "string") { - validate142.errors = [{ instancePath: instancePath + "/param_type", schemaPath: "#/definitions/ParamType/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate143.errors = [{ instancePath: instancePath + "/param_type", schemaPath: "#/definitions/ParamType/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } if (!(data5 === "Number" || data5 === "Integer" || data5 === "String" || data5 === "Boolean")) { - validate142.errors = [{ instancePath: instancePath + "/param_type", schemaPath: "#/definitions/ParamType/enum", keyword: "enum", params: { allowedValues: schema170.enum }, message: "must be equal to one of the allowed values" }]; + validate143.errors = [{ instancePath: instancePath + "/param_type", schemaPath: "#/definitions/ParamType/enum", keyword: "enum", params: { allowedValues: schema170.enum }, message: "must be equal to one of the allowed values" }]; return false; } var valid0 = _errs14 === errors; @@ -17641,14 +17727,14 @@ function validate142(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate142.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate143.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } - validate142.errors = vErrors; + validate143.errors = vErrors; return errors === 0; } -function validate141(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +function validate142(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; const _errs0 = errors; @@ -17705,8 +17791,8 @@ function validate141(data, { instancePath = "", parentData, parentDataProperty, const len0 = data1.length; for (let i0 = 0; i0 < len0; i0++) { const _errs8 = errors; - if (!validate142(data1[i0], { instancePath: instancePath + "/settings/" + i0, parentData: data1, parentDataProperty: i0, rootData })) { - vErrors = vErrors === null ? validate142.errors : vErrors.concat(validate142.errors); + if (!validate143(data1[i0], { instancePath: instancePath + "/settings/" + i0, parentData: data1, parentDataProperty: i0, rootData })) { + vErrors = vErrors === null ? validate143.errors : vErrors.concat(validate143.errors); errors = vErrors.length; } var valid2 = _errs8 === errors; @@ -17780,8 +17866,8 @@ function validate141(data, { instancePath = "", parentData, parentDataProperty, const len1 = data3.length; for (let i1 = 0; i1 < len1; i1++) { const _errs14 = errors; - if (!validate142(data3[i1], { instancePath: instancePath + "/inference_settings/" + i1, parentData: data3, parentDataProperty: i1, rootData })) { - vErrors = vErrors === null ? validate142.errors : vErrors.concat(validate142.errors); + if (!validate143(data3[i1], { instancePath: instancePath + "/inference_settings/" + i1, parentData: data3, parentDataProperty: i1, rootData })) { + vErrors = vErrors === null ? validate143.errors : vErrors.concat(validate143.errors); errors = vErrors.length; } var valid4 = _errs14 === errors; @@ -17871,8 +17957,8 @@ function validate141(data, { instancePath = "", parentData, parentDataProperty, const len2 = data6.length; for (let i2 = 0; i2 < len2; i2++) { const _errs22 = errors; - if (!validate142(data6[i2], { instancePath: instancePath + "/inferenceSettings/" + i2, parentData: data6, parentDataProperty: i2, rootData })) { - vErrors = vErrors === null ? validate142.errors : vErrors.concat(validate142.errors); + if (!validate143(data6[i2], { instancePath: instancePath + "/inferenceSettings/" + i2, parentData: data6, parentDataProperty: i2, rootData })) { + vErrors = vErrors === null ? validate143.errors : vErrors.concat(validate143.errors); errors = vErrors.length; } var valid6 = _errs22 === errors; @@ -17978,8 +18064,8 @@ function validate141(data, { instancePath = "", parentData, parentDataProperty, const len3 = data10.length; for (let i3 = 0; i3 < len3; i3++) { const _errs32 = errors; - if (!validate142(data10[i3], { instancePath: instancePath + "/settings/" + i3, parentData: data10, parentDataProperty: i3, rootData })) { - vErrors = vErrors === null ? validate142.errors : vErrors.concat(validate142.errors); + if (!validate143(data10[i3], { instancePath: instancePath + "/settings/" + i3, parentData: data10, parentDataProperty: i3, rootData })) { + vErrors = vErrors === null ? validate143.errors : vErrors.concat(validate143.errors); errors = vErrors.length; } var valid8 = _errs32 === errors; @@ -18053,8 +18139,8 @@ function validate141(data, { instancePath = "", parentData, parentDataProperty, const len4 = data12.length; for (let i4 = 0; i4 < len4; i4++) { const _errs38 = errors; - if (!validate142(data12[i4], { instancePath: instancePath + "/inference_settings/" + i4, parentData: data12, parentDataProperty: i4, rootData })) { - vErrors = vErrors === null ? validate142.errors : vErrors.concat(validate142.errors); + if (!validate143(data12[i4], { instancePath: instancePath + "/inference_settings/" + i4, parentData: data12, parentDataProperty: i4, rootData })) { + vErrors = vErrors === null ? validate143.errors : vErrors.concat(validate143.errors); errors = vErrors.length; } var valid10 = _errs38 === errors; @@ -18144,8 +18230,8 @@ function validate141(data, { instancePath = "", parentData, parentDataProperty, const len5 = data15.length; for (let i5 = 0; i5 < len5; i5++) { const _errs46 = errors; - if (!validate142(data15[i5], { instancePath: instancePath + "/inferenceSettings/" + i5, parentData: data15, parentDataProperty: i5, rootData })) { - vErrors = vErrors === null ? validate142.errors : vErrors.concat(validate142.errors); + if (!validate143(data15[i5], { instancePath: instancePath + "/inferenceSettings/" + i5, parentData: data15, parentDataProperty: i5, rootData })) { + vErrors = vErrors === null ? validate143.errors : vErrors.concat(validate143.errors); errors = vErrors.length; } var valid12 = _errs46 === errors; @@ -18211,7 +18297,7 @@ function validate141(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err30); } errors++; - validate141.errors = vErrors; + validate142.errors = vErrors; return false; } else { errors = _errs0; @@ -18223,11 +18309,11 @@ function validate141(data, { instancePath = "", parentData, parentDataProperty, } } } - validate141.errors = vErrors; + validate142.errors = vErrors; return errors === 0; } -var validateUpdateModelNotesOutcome = validate154; -function validate154(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +var validateUpdateModelNotesOutcome = validate155; +function validate155(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; const _errs0 = errors; @@ -18444,7 +18530,7 @@ function validate154(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err14); } errors++; - validate154.errors = vErrors; + validate155.errors = vErrors; return false; } else { errors = _errs0; @@ -18456,12 +18542,12 @@ function validate154(data, { instancePath = "", parentData, parentDataProperty, } } } - validate154.errors = vErrors; + validate155.errors = vErrors; return errors === 0; } -var validateUpdateModelNotesParams = validate155; +var validateUpdateModelNotesParams = validate156; var schema174 = { "anyOf": [{ "additionalProperties": false, "properties": { "model_id": { "type": "string" }, "notes": { "default": null, "type": ["string", "null"] } }, "required": ["model_id"], "type": "object" }, { "additionalProperties": false, "properties": { "model_id": { "type": "string" }, "model_notes": { "default": null, "type": ["string", "null"] } }, "required": ["model_id"], "type": "object" }, { "additionalProperties": false, "properties": { "modelId": { "type": "string" }, "notes": { "default": null, "type": ["string", "null"] } }, "required": ["modelId"], "type": "object" }, { "additionalProperties": false, "properties": { "modelId": { "type": "string" }, "model_notes": { "default": null, "type": ["string", "null"] } }, "required": ["modelId"], "type": "object" }] }; -function validate155(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +function validate156(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; const _errs0 = errors; @@ -18776,7 +18862,7 @@ function validate155(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err20); } errors++; - validate155.errors = vErrors; + validate156.errors = vErrors; return false; } else { errors = _errs0; @@ -18788,24 +18874,24 @@ function validate155(data, { instancePath = "", parentData, parentDataProperty, } } } - validate155.errors = vErrors; + validate156.errors = vErrors; return errors === 0; } -var validateValidateInstallationsOutcome = validate156; -function validate156(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +var validateValidateInstallationsOutcome = validate157; +function validate157(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; if (data.removed_tags === void 0 && (missing0 = "removed_tags") || data.orphaned_dirs === void 0 && (missing0 = "orphaned_dirs") || data.valid_count === void 0 && (missing0 = "valid_count")) { - validate156.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; + validate157.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { const _errs1 = errors; for (const key0 in data) { if (!(key0 === "orphaned_dirs" || key0 === "removed_tags" || key0 === "valid_count")) { - validate156.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; + validate157.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; return false; break; } @@ -18821,7 +18907,7 @@ function validate156(data, { instancePath = "", parentData, parentDataProperty, for (let i0 = 0; i0 < len0; i0++) { const _errs4 = errors; if (typeof data0[i0] !== "string") { - validate156.errors = [{ instancePath: instancePath + "/orphaned_dirs/" + i0, schemaPath: "#/properties/orphaned_dirs/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate157.errors = [{ instancePath: instancePath + "/orphaned_dirs/" + i0, schemaPath: "#/properties/orphaned_dirs/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid1 = _errs4 === errors; @@ -18830,7 +18916,7 @@ function validate156(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate156.errors = [{ instancePath: instancePath + "/orphaned_dirs", schemaPath: "#/properties/orphaned_dirs/type", keyword: "type", params: { type: "array" }, message: "must be array" }]; + validate157.errors = [{ instancePath: instancePath + "/orphaned_dirs", schemaPath: "#/properties/orphaned_dirs/type", keyword: "type", params: { type: "array" }, message: "must be array" }]; return false; } } @@ -18849,7 +18935,7 @@ function validate156(data, { instancePath = "", parentData, parentDataProperty, for (let i1 = 0; i1 < len1; i1++) { const _errs8 = errors; if (typeof data2[i1] !== "string") { - validate156.errors = [{ instancePath: instancePath + "/removed_tags/" + i1, schemaPath: "#/properties/removed_tags/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate157.errors = [{ instancePath: instancePath + "/removed_tags/" + i1, schemaPath: "#/properties/removed_tags/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid2 = _errs8 === errors; @@ -18858,7 +18944,7 @@ function validate156(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate156.errors = [{ instancePath: instancePath + "/removed_tags", schemaPath: "#/properties/removed_tags/type", keyword: "type", params: { type: "array" }, message: "must be array" }]; + validate157.errors = [{ instancePath: instancePath + "/removed_tags", schemaPath: "#/properties/removed_tags/type", keyword: "type", params: { type: "array" }, message: "must be array" }]; return false; } } @@ -18871,17 +18957,17 @@ function validate156(data, { instancePath = "", parentData, parentDataProperty, let data4 = data.valid_count; const _errs10 = errors; if (!(typeof data4 == "number" && (!(data4 % 1) && !isNaN(data4)) && isFinite(data4))) { - validate156.errors = [{ instancePath: instancePath + "/valid_count", schemaPath: "#/properties/valid_count/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; + validate157.errors = [{ instancePath: instancePath + "/valid_count", schemaPath: "#/properties/valid_count/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; return false; } if (errors === _errs10) { if (typeof data4 == "number" && isFinite(data4)) { if (data4 > 9007199254740991 || isNaN(data4)) { - validate156.errors = [{ instancePath: instancePath + "/valid_count", schemaPath: "#/properties/valid_count/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; + validate157.errors = [{ instancePath: instancePath + "/valid_count", schemaPath: "#/properties/valid_count/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; return false; } else { if (data4 < 0 || isNaN(data4)) { - validate156.errors = [{ instancePath: instancePath + "/valid_count", schemaPath: "#/properties/valid_count/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + validate157.errors = [{ instancePath: instancePath + "/valid_count", schemaPath: "#/properties/valid_count/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; return false; } } @@ -18896,28 +18982,28 @@ function validate156(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate156.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate157.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } - validate156.errors = vErrors; + validate157.errors = vErrors; return errors === 0; } -var validateVersionInfoOutcome = validate157; -function validate157(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +var validateVersionInfoOutcome = validate158; +function validate158(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; if (data.success === void 0 && (missing0 = "success") || data.info === void 0 && (missing0 = "info")) { - validate157.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; + validate158.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { const _errs1 = errors; for (const key0 in data) { if (!(key0 === "info" || key0 === "success")) { - validate157.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; + validate158.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; return false; break; } @@ -18931,13 +19017,13 @@ function validate157(data, { instancePath = "", parentData, parentDataProperty, if (data0 && typeof data0 == "object" && !Array.isArray(data0)) { let missing1; if (data0.tag === void 0 && (missing1 = "tag") || data0.installed === void 0 && (missing1 = "installed") || data0.size === void 0 && (missing1 = "size")) { - validate157.errors = [{ instancePath: instancePath + "/info", schemaPath: "#/definitions/RuntimeVersionInfo/required", keyword: "required", params: { missingProperty: missing1 }, message: "must have required property '" + missing1 + "'" }]; + validate158.errors = [{ instancePath: instancePath + "/info", schemaPath: "#/definitions/RuntimeVersionInfo/required", keyword: "required", params: { missingProperty: missing1 }, message: "must have required property '" + missing1 + "'" }]; return false; } else { const _errs5 = errors; for (const key1 in data0) { if (!(key1 === "installed" || key1 === "size" || key1 === "tag")) { - validate157.errors = [{ instancePath: instancePath + "/info", schemaPath: "#/definitions/RuntimeVersionInfo/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key1 }, message: "must NOT have additional properties" }]; + validate158.errors = [{ instancePath: instancePath + "/info", schemaPath: "#/definitions/RuntimeVersionInfo/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key1 }, message: "must NOT have additional properties" }]; return false; break; } @@ -18946,7 +19032,7 @@ function validate157(data, { instancePath = "", parentData, parentDataProperty, if (data0.installed !== void 0) { const _errs6 = errors; if (typeof data0.installed !== "boolean") { - validate157.errors = [{ instancePath: instancePath + "/info/installed", schemaPath: "#/definitions/RuntimeVersionInfo/properties/installed/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; + validate158.errors = [{ instancePath: instancePath + "/info/installed", schemaPath: "#/definitions/RuntimeVersionInfo/properties/installed/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; return false; } var valid2 = _errs6 === errors; @@ -18957,7 +19043,7 @@ function validate157(data, { instancePath = "", parentData, parentDataProperty, if (data0.size !== void 0) { const _errs8 = errors; if (data0.size !== null) { - validate157.errors = [{ instancePath: instancePath + "/info/size", schemaPath: "#/definitions/RuntimeVersionInfo/properties/size/type", keyword: "type", params: { type: "null" }, message: "must be null" }]; + validate158.errors = [{ instancePath: instancePath + "/info/size", schemaPath: "#/definitions/RuntimeVersionInfo/properties/size/type", keyword: "type", params: { type: "null" }, message: "must be null" }]; return false; } var valid2 = _errs8 === errors; @@ -18968,7 +19054,7 @@ function validate157(data, { instancePath = "", parentData, parentDataProperty, if (data0.tag !== void 0) { const _errs10 = errors; if (typeof data0.tag !== "string") { - validate157.errors = [{ instancePath: instancePath + "/info/tag", schemaPath: "#/definitions/RuntimeVersionInfo/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate158.errors = [{ instancePath: instancePath + "/info/tag", schemaPath: "#/definitions/RuntimeVersionInfo/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid2 = _errs10 === errors; @@ -18980,7 +19066,7 @@ function validate157(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate157.errors = [{ instancePath: instancePath + "/info", schemaPath: "#/definitions/RuntimeVersionInfo/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate158.errors = [{ instancePath: instancePath + "/info", schemaPath: "#/definitions/RuntimeVersionInfo/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } @@ -18993,11 +19079,11 @@ function validate157(data, { instancePath = "", parentData, parentDataProperty, let data4 = data.success; const _errs12 = errors; if (typeof data4 !== "boolean") { - validate157.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; + validate158.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; return false; } if (true !== data4) { - validate157.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/const", keyword: "const", params: { allowedValue: true }, message: "must be equal to constant" }]; + validate158.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/const", keyword: "const", params: { allowedValue: true }, message: "must be equal to constant" }]; return false; } var valid0 = _errs12 === errors; @@ -19008,29 +19094,29 @@ function validate157(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate157.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate158.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } - validate157.errors = vErrors; + validate158.errors = vErrors; return errors === 0; } -var validateVersionStatusOutcome = validate158; +var validateVersionStatusOutcome = validate159; var schema179 = { "additionalProperties": false, "properties": { "activeVersion": { "type": ["string", "null"] }, "defaultVersion": { "type": ["string", "null"] }, "installedCount": { "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, "versions": { "additionalProperties": { "$ref": "#/definitions/RuntimeVersionEntry" }, "type": "object" } }, "required": ["installedCount", "activeVersion", "defaultVersion", "versions"], "type": "object" }; -function validate160(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +function validate161(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; if (data.isActive === void 0 && (missing0 = "isActive") || data.dependencies === void 0 && (missing0 = "dependencies")) { - validate160.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; + validate161.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { const _errs1 = errors; for (const key0 in data) { if (!(key0 === "dependencies" || key0 === "isActive")) { - validate160.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; + validate161.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; return false; break; } @@ -19044,13 +19130,13 @@ function validate160(data, { instancePath = "", parentData, parentDataProperty, if (data0 && typeof data0 == "object" && !Array.isArray(data0)) { let missing1; if (data0.installed === void 0 && (missing1 = "installed") || data0.missing === void 0 && (missing1 = "missing")) { - validate160.errors = [{ instancePath: instancePath + "/dependencies", schemaPath: "#/definitions/RuntimeVersionDependencies/required", keyword: "required", params: { missingProperty: missing1 }, message: "must have required property '" + missing1 + "'" }]; + validate161.errors = [{ instancePath: instancePath + "/dependencies", schemaPath: "#/definitions/RuntimeVersionDependencies/required", keyword: "required", params: { missingProperty: missing1 }, message: "must have required property '" + missing1 + "'" }]; return false; } else { const _errs5 = errors; for (const key1 in data0) { if (!(key1 === "installed" || key1 === "missing")) { - validate160.errors = [{ instancePath: instancePath + "/dependencies", schemaPath: "#/definitions/RuntimeVersionDependencies/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key1 }, message: "must NOT have additional properties" }]; + validate161.errors = [{ instancePath: instancePath + "/dependencies", schemaPath: "#/definitions/RuntimeVersionDependencies/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key1 }, message: "must NOT have additional properties" }]; return false; break; } @@ -19066,7 +19152,7 @@ function validate160(data, { instancePath = "", parentData, parentDataProperty, for (let i0 = 0; i0 < len0; i0++) { const _errs8 = errors; if (typeof data1[i0] !== "string") { - validate160.errors = [{ instancePath: instancePath + "/dependencies/installed/" + i0, schemaPath: "#/definitions/RuntimeVersionDependencies/properties/installed/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate161.errors = [{ instancePath: instancePath + "/dependencies/installed/" + i0, schemaPath: "#/definitions/RuntimeVersionDependencies/properties/installed/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid3 = _errs8 === errors; @@ -19075,7 +19161,7 @@ function validate160(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate160.errors = [{ instancePath: instancePath + "/dependencies/installed", schemaPath: "#/definitions/RuntimeVersionDependencies/properties/installed/type", keyword: "type", params: { type: "array" }, message: "must be array" }]; + validate161.errors = [{ instancePath: instancePath + "/dependencies/installed", schemaPath: "#/definitions/RuntimeVersionDependencies/properties/installed/type", keyword: "type", params: { type: "array" }, message: "must be array" }]; return false; } } @@ -19094,7 +19180,7 @@ function validate160(data, { instancePath = "", parentData, parentDataProperty, for (let i1 = 0; i1 < len1; i1++) { const _errs12 = errors; if (typeof data3[i1] !== "string") { - validate160.errors = [{ instancePath: instancePath + "/dependencies/missing/" + i1, schemaPath: "#/definitions/RuntimeVersionDependencies/properties/missing/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + validate161.errors = [{ instancePath: instancePath + "/dependencies/missing/" + i1, schemaPath: "#/definitions/RuntimeVersionDependencies/properties/missing/items/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid4 = _errs12 === errors; @@ -19103,7 +19189,7 @@ function validate160(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate160.errors = [{ instancePath: instancePath + "/dependencies/missing", schemaPath: "#/definitions/RuntimeVersionDependencies/properties/missing/type", keyword: "type", params: { type: "array" }, message: "must be array" }]; + validate161.errors = [{ instancePath: instancePath + "/dependencies/missing", schemaPath: "#/definitions/RuntimeVersionDependencies/properties/missing/type", keyword: "type", params: { type: "array" }, message: "must be array" }]; return false; } } @@ -19115,7 +19201,7 @@ function validate160(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate160.errors = [{ instancePath: instancePath + "/dependencies", schemaPath: "#/definitions/RuntimeVersionDependencies/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate161.errors = [{ instancePath: instancePath + "/dependencies", schemaPath: "#/definitions/RuntimeVersionDependencies/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } @@ -19127,7 +19213,7 @@ function validate160(data, { instancePath = "", parentData, parentDataProperty, if (data.isActive !== void 0) { const _errs14 = errors; if (typeof data.isActive !== "boolean") { - validate160.errors = [{ instancePath: instancePath + "/isActive", schemaPath: "#/properties/isActive/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; + validate161.errors = [{ instancePath: instancePath + "/isActive", schemaPath: "#/properties/isActive/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; return false; } var valid0 = _errs14 === errors; @@ -19138,27 +19224,27 @@ function validate160(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate160.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate161.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } - validate160.errors = vErrors; + validate161.errors = vErrors; return errors === 0; } -function validate159(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +function validate160(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; if (data.installedCount === void 0 && (missing0 = "installedCount") || data.activeVersion === void 0 && (missing0 = "activeVersion") || data.defaultVersion === void 0 && (missing0 = "defaultVersion") || data.versions === void 0 && (missing0 = "versions")) { - validate159.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; + validate160.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { const _errs1 = errors; for (const key0 in data) { if (!(key0 === "activeVersion" || key0 === "defaultVersion" || key0 === "installedCount" || key0 === "versions")) { - validate159.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; + validate160.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; return false; break; } @@ -19168,7 +19254,7 @@ function validate159(data, { instancePath = "", parentData, parentDataProperty, let data0 = data.activeVersion; const _errs2 = errors; if (typeof data0 !== "string" && data0 !== null) { - validate159.errors = [{ instancePath: instancePath + "/activeVersion", schemaPath: "#/properties/activeVersion/type", keyword: "type", params: { type: schema179.properties.activeVersion.type }, message: "must be string,null" }]; + validate160.errors = [{ instancePath: instancePath + "/activeVersion", schemaPath: "#/properties/activeVersion/type", keyword: "type", params: { type: schema179.properties.activeVersion.type }, message: "must be string,null" }]; return false; } var valid0 = _errs2 === errors; @@ -19180,7 +19266,7 @@ function validate159(data, { instancePath = "", parentData, parentDataProperty, let data1 = data.defaultVersion; const _errs4 = errors; if (typeof data1 !== "string" && data1 !== null) { - validate159.errors = [{ instancePath: instancePath + "/defaultVersion", schemaPath: "#/properties/defaultVersion/type", keyword: "type", params: { type: schema179.properties.defaultVersion.type }, message: "must be string,null" }]; + validate160.errors = [{ instancePath: instancePath + "/defaultVersion", schemaPath: "#/properties/defaultVersion/type", keyword: "type", params: { type: schema179.properties.defaultVersion.type }, message: "must be string,null" }]; return false; } var valid0 = _errs4 === errors; @@ -19192,17 +19278,17 @@ function validate159(data, { instancePath = "", parentData, parentDataProperty, let data2 = data.installedCount; const _errs6 = errors; if (!(typeof data2 == "number" && (!(data2 % 1) && !isNaN(data2)) && isFinite(data2))) { - validate159.errors = [{ instancePath: instancePath + "/installedCount", schemaPath: "#/properties/installedCount/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; + validate160.errors = [{ instancePath: instancePath + "/installedCount", schemaPath: "#/properties/installedCount/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; return false; } if (errors === _errs6) { if (typeof data2 == "number" && isFinite(data2)) { if (data2 > 9007199254740991 || isNaN(data2)) { - validate159.errors = [{ instancePath: instancePath + "/installedCount", schemaPath: "#/properties/installedCount/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; + validate160.errors = [{ instancePath: instancePath + "/installedCount", schemaPath: "#/properties/installedCount/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; return false; } else { if (data2 < 0 || isNaN(data2)) { - validate159.errors = [{ instancePath: instancePath + "/installedCount", schemaPath: "#/properties/installedCount/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + validate160.errors = [{ instancePath: instancePath + "/installedCount", schemaPath: "#/properties/installedCount/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; return false; } } @@ -19220,8 +19306,8 @@ function validate159(data, { instancePath = "", parentData, parentDataProperty, if (data3 && typeof data3 == "object" && !Array.isArray(data3)) { for (const key1 in data3) { const _errs11 = errors; - if (!validate160(data3[key1], { instancePath: instancePath + "/versions/" + key1.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data3, parentDataProperty: key1, rootData })) { - vErrors = vErrors === null ? validate160.errors : vErrors.concat(validate160.errors); + if (!validate161(data3[key1], { instancePath: instancePath + "/versions/" + key1.replace(/~/g, "~0").replace(/\//g, "~1"), parentData: data3, parentDataProperty: key1, rootData })) { + vErrors = vErrors === null ? validate161.errors : vErrors.concat(validate161.errors); errors = vErrors.length; } var valid1 = _errs11 === errors; @@ -19230,7 +19316,7 @@ function validate159(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate159.errors = [{ instancePath: instancePath + "/versions", schemaPath: "#/properties/versions/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate160.errors = [{ instancePath: instancePath + "/versions", schemaPath: "#/properties/versions/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } @@ -19244,27 +19330,27 @@ function validate159(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate159.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate160.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } - validate159.errors = vErrors; + validate160.errors = vErrors; return errors === 0; } -function validate158(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { +function validate159(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { let vErrors = null; let errors = 0; if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; if (data.success === void 0 && (missing0 = "success") || data.status === void 0 && (missing0 = "status")) { - validate158.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; + validate159.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { const _errs1 = errors; for (const key0 in data) { if (!(key0 === "status" || key0 === "success")) { - validate158.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; + validate159.errors = [{ instancePath, schemaPath: "#/additionalProperties", keyword: "additionalProperties", params: { additionalProperty: key0 }, message: "must NOT have additional properties" }]; return false; break; } @@ -19272,8 +19358,8 @@ function validate158(data, { instancePath = "", parentData, parentDataProperty, if (_errs1 === errors) { if (data.status !== void 0) { const _errs2 = errors; - if (!validate159(data.status, { instancePath: instancePath + "/status", parentData: data, parentDataProperty: "status", rootData })) { - vErrors = vErrors === null ? validate159.errors : vErrors.concat(validate159.errors); + if (!validate160(data.status, { instancePath: instancePath + "/status", parentData: data, parentDataProperty: "status", rootData })) { + vErrors = vErrors === null ? validate160.errors : vErrors.concat(validate160.errors); errors = vErrors.length; } var valid0 = _errs2 === errors; @@ -19285,11 +19371,11 @@ function validate158(data, { instancePath = "", parentData, parentDataProperty, let data1 = data.success; const _errs3 = errors; if (typeof data1 !== "boolean") { - validate158.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; + validate159.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; return false; } if (true !== data1) { - validate158.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/const", keyword: "const", params: { allowedValue: true }, message: "must be equal to constant" }]; + validate159.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/const", keyword: "const", params: { allowedValue: true }, message: "must be equal to constant" }]; return false; } var valid0 = _errs3 === errors; @@ -19300,11 +19386,11 @@ function validate158(data, { instancePath = "", parentData, parentDataProperty, } } } else { - validate158.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; + validate159.errors = [{ instancePath, schemaPath: "#/type", keyword: "type", params: { type: "object" }, message: "must be object" }]; return false; } } - validate158.errors = vErrors; + validate159.errors = vErrors; return errors === 0; } export { diff --git a/frontend/src/types/torch-install.ts b/frontend/src/types/torch-install.ts index d455de3c..3374516c 100644 --- a/frontend/src/types/torch-install.ts +++ b/frontend/src/types/torch-install.ts @@ -12,6 +12,7 @@ export type TorchReleaseOptionsOutcome = DesktopTorchReleaseOptionsOutcome; export interface TorchRuntimeOptions { builds: string[]; + defaultBuild?: string; pythons: Array<{ id: string; label: string }>; adapters: string[]; bundledPresetAvailable: boolean; diff --git a/frontend/src/types/versions.ts b/frontend/src/types/versions.ts index 13d7e326..eb1b3fda 100644 --- a/frontend/src/types/versions.ts +++ b/frontend/src/types/versions.ts @@ -25,7 +25,7 @@ export type VersionInfo = import('../generated/desktop-contract').VersionInfoOut export interface InstallationProgress { tag: string; started_at: string; - stage: 'download' | 'extract' | 'venv' | 'dependencies' | 'setup'; + stage: 'resolving' | 'download' | 'extract' | 'venv' | 'dependencies' | 'setup'; stage_progress: number; overall_progress: number; current_item: string | null; diff --git a/frontend/src/utils/installActivityPresentation.ts b/frontend/src/utils/installActivityPresentation.ts index d27be210..8e468998 100644 --- a/frontend/src/utils/installActivityPresentation.ts +++ b/frontend/src/utils/installActivityPresentation.ts @@ -1,6 +1,7 @@ import type { InstallationProgress } from '../types/versions'; const STAGE_LABELS: Record = { + resolving: 'Preparing Torch packages', download: 'Downloading', extract: 'Extracting', venv: 'Creating Environment', @@ -27,7 +28,7 @@ export function getInstallActivityPresentation({ const terminal = Boolean(currentProgress?.completed_at || currentProgress?.error); const active = Boolean((installingTag || currentProgress?.tag) && !terminal); const indeterminate = active && (!currentProgress || ( - appId === 'torch' && currentProgress.stage === 'setup' && currentProgress.stage_progress <= 0 + appId === 'torch' && (currentProgress.stage === 'resolving' || currentProgress.stage === 'setup') && currentProgress.stage_progress <= 0 )); const stageLabel = currentProgress ? STAGE_LABELS[currentProgress.stage] : null; diff --git a/rust/crates/pumas-app-manager/src/version_manager/installer.rs b/rust/crates/pumas-app-manager/src/version_manager/installer.rs index f76608e8..cc0b3a80 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/installer.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/installer.rs @@ -18,6 +18,10 @@ pub(crate) struct TorchInstallPlan { pub(crate) interpreter_hash: String, pub(crate) managed_python: ManagedPythonIdentity, } +pub(crate) enum TorchInstallInput { + Selection(super::torch_preview::TorchInstallSelection), + Resolved(TorchInstallPlan), +} #[cfg(test)] pub(crate) use torch::TorchPublicationPause; @@ -491,16 +495,16 @@ impl VersionInstaller { release: &GitHubRelease, progress_tx: mpsc::Sender, ) -> Result<()> { - self.install_version_with_torch_plan(tag, release, progress_tx, None) + self.install_version_with_torch_input(tag, release, progress_tx, None) .await } - pub(crate) async fn install_version_with_torch_plan( + pub(crate) async fn install_version_with_torch_input( &self, tag: &str, release: &GitHubRelease, progress_tx: mpsc::Sender, - torch_plan: Option, + torch_input: Option, ) -> Result<()> { match self.app_id { AppId::Ollama => self.install_ollama_binary(tag, release, progress_tx).await, @@ -509,7 +513,7 @@ impl VersionInstaller { .await } AppId::Torch => { - self.install_torch_runtime(tag, release, progress_tx, torch_plan) + self.install_torch_runtime(tag, release, progress_tx, torch_input) .await } AppId::OnnxRuntime => Err(PumasError::Other( diff --git a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs index 0d4b523b..0a280ec4 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs @@ -1201,27 +1201,34 @@ fn failed(message: impl Into) -> PumasError { } } -fn managed_python_record(plan: &super::TorchInstallPlan) -> serde_json::Value { +fn managed_python_identity_record( + managed_python: &super::super::managed_python::ManagedPythonIdentity, + interpreter_hash: &str, +) -> serde_json::Value { serde_json::json!({ "provider": { "name": "uv", - "version": plan.managed_python.uv_version, - "archiveSha256": plan.managed_python.uv_archive_sha256, + "version": managed_python.uv_version, + "archiveSha256": managed_python.uv_archive_sha256, }, "distribution": { "implementation": "CPython", - "version": plan.managed_python.version, - "catalogKey": plan.managed_python.catalog_key, - "sourceUrl": plan.managed_python.source_url, - "targetTriple": plan.managed_python.target_triple, + "version": managed_python.version, + "catalogKey": managed_python.catalog_key, + "sourceUrl": managed_python.source_url, + "targetTriple": managed_python.target_triple, }, "executable": { - "path": plan.managed_python.executable, - "sha256": plan.interpreter_hash, + "path": managed_python.executable, + "sha256": interpreter_hash, }, }) } +fn managed_python_record(plan: &super::TorchInstallPlan) -> serde_json::Value { + managed_python_identity_record(&plan.managed_python, &plan.interpreter_hash) +} + fn record_managed_python(runtime: &Path, plan: &super::TorchInstallPlan) -> Result<()> { let path = runtime.join("runtime.json"); let bytes = std::fs::read(&path).map_err(PumasError::from)?; @@ -1238,7 +1245,700 @@ fn record_managed_python(runtime: &Path, plan: &super::TorchInstallPlan) -> Resu .map_err(PumasError::from) } +fn cuda_build_for_driver(version: (u32, u32, u32)) -> Option<&'static str> { + if version >= (580, 0, 0) { + Some("cu134") + } else if version >= (525, 60, 13) { + Some("cu129") + } else if version >= (450, 80, 2) { + Some("cu118") + } else if version >= (440, 33, 0) { + Some("cu102") + } else if version >= (418, 39, 0) { + Some("cu101") + } else if version >= (410, 48, 0) { + Some("cu100") + } else { + None + } +} + +fn cuda_build_candidates_for_driver(version: (u32, u32, u32)) -> Vec<&'static str> { + let Some(maximum) = cuda_build_for_driver(version) + .and_then(|build| build.strip_prefix("cu")) + .and_then(|channel| channel.parse::().ok()) + else { + return Vec::new(); + }; + // BUILDS is a fixed, ascending channel list. The maximum above follows + // the same Linux NVIDIA driver floors used for release recommendations. + super::super::torch_preview::BUILDS + .iter() + .rev() + .copied() + .filter(|build| { + build + .strip_prefix("cu") + .and_then(|channel| channel.parse::().ok()) + .is_some_and(|channel| (100..=maximum).contains(&channel)) + }) + .take(4) + .collect() +} + +fn automatic_torch_builds_for_driver(version: (u32, u32, u32)) -> Vec<&'static str> { + let mut builds = cuda_build_candidates_for_driver(version); + builds.push("cpu"); + builds +} + +fn retry_torch_python_candidate(exit_code: Option, automatic: bool) -> bool { + automatic && matches!(exit_code, Some(2 | 4)) +} + +fn retry_direct_torch_attempt( + exit_code: Option, + python_auto: bool, + cpu_fallback: bool, +) -> bool { + retry_torch_python_candidate(exit_code, python_auto || cpu_fallback) +} + +async fn automatic_torch_builds() -> Vec<&'static str> { + if !cfg!(all(target_os = "linux", target_arch = "x86_64")) { + return vec!["cpu"]; + } + let mut command = Command::new("nvidia-smi"); + command + .kill_on_drop(true) + .args(["--query-gpu=driver_version", "--format=csv,noheader"]); + let Ok(Ok(output)) = tokio::time::timeout(Duration::from_secs(3), command.output()).await + else { + return vec!["cpu"]; + }; + if !output.status.success() { + return vec!["cpu"]; + } + let Ok(text) = std::str::from_utf8(&output.stdout) else { + return vec!["cpu"]; + }; + let mut versions = text.lines().map(|line| { + let mut parts = line.trim().split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next().unwrap_or("0").parse::().ok()?; + let patch = parts.next().unwrap_or("0").parse::().ok()?; + parts.next().is_none().then_some((major, minor, patch)) + }); + let Some(Some(mut lowest)) = versions.next() else { + return vec!["cpu"]; + }; + for version in versions { + let Some(version) = version else { + return vec!["cpu"]; + }; + lowest = lowest.min(version); + } + automatic_torch_builds_for_driver(lowest) +} + +#[derive(Deserialize)] +struct DirectTorchResolution { + release: String, + torch: String, + build: String, + python: String, + interpreter: String, + implementation: String, + platform: String, + machine: String, + adapter: String, + artifacts: Vec, +} + +fn validate_direct_torch_report( + resolution: &DirectTorchResolution, + report: &serde_json::Value, + requirements: &str, + version: &str, + build: &str, + minor: &str, + adapter: &str, + python: &Path, +) -> Result<()> { + let expected_torch = if cfg!(target_os = "macos") && build == "cpu" { + version.to_owned() + } else { + format!("{version}+{build}") + }; + if resolution.release != version + || resolution.torch != expected_torch + || resolution.build != build + || resolution.python != minor + || resolution.adapter != adapter + || resolution.implementation != "cpython" + || Path::new(&resolution.interpreter) != python + || resolution.artifacts.is_empty() + || !match std::env::consts::OS { + "linux" => resolution.machine == "x86_64" && resolution.platform.starts_with("Linux-"), + "windows" => { + resolution.machine == "AMD64" && resolution.platform.starts_with("Windows-") + } + "macos" => resolution.machine == "arm64" && resolution.platform.starts_with("macOS-"), + _ => false, + } + { + return Err(failed( + "Torch installation report differs from the selection or host", + )); + } + let entries = report["install"] + .as_array() + .ok_or_else(|| failed("Torch pip installation report has no artifact list"))?; + if entries.len() != resolution.artifacts.len() + || requirements.lines().count() != resolution.artifacts.len() + { + return Err(failed( + "Torch report and hash lock have different artifact counts", + )); + } + let mut names = std::collections::HashSet::new(); + let mut torch_count = 0; + for (index, artifact) in resolution.artifacts.iter().enumerate() { + if artifact.name.is_empty() + || !artifact.name.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"-.".contains(&byte) + }) + || !names.insert(artifact.name.as_str()) + || artifact.sha256.len() != 64 + || !artifact.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(failed( + "Torch report contains an invalid distribution or SHA-256", + )); + } + let url = reqwest::Url::parse(&artifact.url) + .map_err(|_| failed("Torch report contains an invalid artifact URL"))?; + let trusted_source = url.scheme() == "https" + && !artifact.url.chars().any(char::is_whitespace) + && url.username().is_empty() + && url.password().is_none() + && matches!(url.port(), None | Some(443)) + && url.query().is_none() + && url.fragment().is_none() + && url.path().to_ascii_lowercase().ends_with(".whl") + && if artifact.name == "torch" || artifact.name == "torchvision" { + matches!( + url.host_str(), + Some("download.pytorch.org" | "download-r2.pytorch.org") + ) && url.path().starts_with(&format!("/whl/{build}/")) + } else { + (url.host_str() == Some("files.pythonhosted.org") + && url.path().starts_with("/packages/")) + || (matches!( + url.host_str(), + Some("download.pytorch.org" | "download-r2.pytorch.org") + ) && url.path().starts_with("/whl/")) + }; + if !trusted_source { + return Err(failed("Torch report contains an untrusted wheel source")); + } + if artifact.name == "torch" { + torch_count += 1; + if artifact.version != expected_torch { + return Err(failed( + "Torch wheel version differs from the selected release", + )); + } + } + if artifact.name == "torchvision" { + if adapter == "none" + || !(artifact.version.ends_with(&format!("+{build}")) + || (cfg!(target_os = "macos") + && build == "cpu" + && artifact.version.split('.').count() == 3 + && artifact + .version + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'.'))) + { + return Err(failed( + "Torchvision build differs from selected Torch build", + )); + } + } + let item = &entries[index]; + let report_name = item["metadata"]["name"] + .as_str() + .map(|name| name.to_ascii_lowercase().replace('_', "-")); + if report_name.as_deref() != Some(artifact.name.as_str()) + || item["metadata"]["version"].as_str() != Some(artifact.version.as_str()) + || item["download_info"]["url"].as_str() != Some(artifact.url.as_str()) + || item["download_info"]["archive_info"]["hashes"]["sha256"].as_str() + != Some(artifact.sha256.as_str()) + || requirements.lines().nth(index) + != Some( + format!( + "{} @ {} --hash=sha256:{}", + artifact.name, artifact.url, artifact.sha256 + ) + .as_str(), + ) + { + return Err(failed("Torch report, resolution, and hash lock disagree")); + } + } + if torch_count != 1 { + return Err(failed("Torch report must contain one selected Torch wheel")); + } + for required in [ + "torch", + "fastapi", + "uvicorn", + "psutil", + "pillow", + "safetensors", + ] { + if !names.contains(required) { + return Err(failed("Torch report omitted a required core dependency")); + } + } + if adapter == "flux2" { + for required in [ + "torchvision", + "diffusers", + "transformers", + "accelerate", + "peft", + "sentencepiece", + "protobuf", + ] { + if !names.contains(required) { + return Err(failed("Torch report omitted an image dependency")); + } + } + } + Ok(()) +} + +enum DirectTorchAttempt { + Installed(PathBuf), + Retry, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StagedFilesManifest { + files: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StagedFile { + path: String, + sha256: String, + size: u64, +} + +fn validate_staged_files(target: &Path, manifest: &StagedFilesManifest) -> Result<()> { + if manifest.files.is_empty() || manifest.files.len() > 200_000 { + return Err(failed("Torch staged file manifest is empty or oversized")); + } + let mut expected = std::collections::HashMap::new(); + for item in &manifest.files { + let path = Path::new(&item.path); + if item.path.len() > 1024 + || item.path.contains('\\') + || path + .components() + .any(|part| !matches!(part, std::path::Component::Normal(_))) + || item.sha256.len() != 64 + || !item.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + || expected.insert(item.path.as_str(), item).is_some() + { + return Err(failed("Torch staged file manifest has an invalid entry")); + } + } + let mut seen = 0; + for entry in walkdir::WalkDir::new(target).follow_links(false) { + let entry = entry.map_err(|_| failed("Cannot inspect Torch staged packages"))?; + if entry.file_type().is_symlink() { + return Err(failed("Torch staged packages contain a symlink")); + } + if entry.file_type().is_dir() { + continue; + } + if !entry.file_type().is_file() { + return Err(failed("Torch staged packages contain a special file")); + } + let relative = entry + .path() + .strip_prefix(target) + .map_err(|_| failed("Torch staged file escaped its target"))?; + let name = relative + .to_str() + .ok_or_else(|| failed("Torch staged file name is invalid"))? + .replace(std::path::MAIN_SEPARATOR, "/"); + let item = expected + .get(name.as_str()) + .ok_or_else(|| failed("Torch staged packages contain an unreported file"))?; + let mut file = std::fs::File::open(entry.path()).map_err(PumasError::from)?; + let mut hasher = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 1024 * 1024]; + loop { + use std::io::Read; + let count = file.read(&mut buffer).map_err(PumasError::from)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + size += count as u64; + } + if size != item.size || format!("{:x}", hasher.finalize()) != item.sha256 { + return Err(failed( + "Torch staged file differs from the validated wheel manifest", + )); + } + seen += 1; + } + if seen != expected.len() { + return Err(failed("Torch staged wheel manifest names missing files")); + } + Ok(()) +} + +fn move_verified_packages(target: &Path, runtime: &Path, python_minor: &str) -> Result<()> { + #[cfg(windows)] + let packages = runtime.join("venv").join("Lib").join("site-packages"); + #[cfg(not(windows))] + let packages = runtime + .join("venv") + .join("lib") + .join(format!("python{python_minor}")) + .join("site-packages"); + std::fs::create_dir_all(&packages).map_err(PumasError::from)?; + let entries = std::fs::read_dir(target) + .map_err(PumasError::from)? + .collect::>>() + .map_err(PumasError::from)?; + if entries.is_empty() { + return Err(failed("Torch installer staged no packages")); + } + for entry in &entries { + let file_type = entry.file_type().map_err(PumasError::from)?; + let destination = packages.join(entry.file_name()); + if file_type.is_symlink() || destination.exists() { + return Err(failed( + "Torch package staging contains a link or venv collision", + )); + } + } + for entry in entries { + std::fs::rename(entry.path(), packages.join(entry.file_name())) + .map_err(PumasError::from)?; + } + std::fs::remove_dir(target).map_err(PumasError::from)?; + Ok(()) +} + +fn validate_and_move_direct_torch_packages( + resolution: &DirectTorchResolution, + report: &serde_json::Value, + requirements: &str, + version: &str, + build: &str, + minor: &str, + adapter: &str, + python: &Path, + target: &Path, + runtime: &Path, + manifest: &StagedFilesManifest, +) -> Result<()> { + validate_direct_torch_report( + resolution, + report, + requirements, + version, + build, + minor, + adapter, + python, + )?; + validate_staged_files(target, manifest)?; + move_verified_packages(target, runtime, minor) +} + impl VersionInstaller { + async fn run_provider_with_cancel(&self, operation: F) -> Result + where + F: std::future::Future>, + { + let outcome = tokio::select! { + biased; + _ = async { + loop { + if self.cancel_flag.load(Ordering::SeqCst) { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } => None, + result = operation => Some(result), + }; + match outcome { + Some(result) => result, + None => { + // The losing provider future has been dropped before draining all + // registered children, including its newly abandoned uv child. + self.torch_cleanup.drain_child_slots().await?; + Err(failed("Installation cancelled by user")) + } + } + } + + async fn stage_selected_torch_runtime( + &self, + selection: &super::super::torch_preview::TorchInstallSelection, + staging: &std::sync::Arc, + log_path: &Path, + progress_tx: &mpsc::Sender, + ) -> Result { + if selection.adapter != "none" && selection.adapter != "flux2" { + return Err(failed( + "This Torch dependency profile requires a resolved plan", + )); + } + let builds: Vec = if selection.build == "auto" { + automatic_torch_builds() + .await + .into_iter() + .map(str::to_owned) + .collect() + } else if super::super::torch_preview::valid_torch_channel(&selection.build) { + vec![selection.build.clone()] + } else { + return Err(failed("Invalid Torch build channel")); + }; + self.check_cancelled()?; + let python_root = self.launcher_root.join("launcher-data/managed-python"); + let candidates = self + .run_provider_with_cancel( + super::super::torch_alternatives::managed_torch_candidate_minors( + &python_root, + &self.torch_cleanup, + ), + ) + .await?; + self.check_cancelled()?; + let minors = if selection.python == "auto" { + candidates + } else { + let selected = selection.python.strip_prefix("python"); + selected + .filter(|selected| candidates.iter().any(|candidate| candidate == *selected)) + .map(str::to_owned) + .into_iter() + .collect() + }; + if minors.is_empty() { + return Err(failed( + "No managed CPython candidate is available for Torch", + )); + } + for build in builds { + for minor in &minors { + self.check_cancelled()?; + let attempt = self + .stage_selected_torch_attempt( + selection, + &build, + minor, + &python_root, + staging, + log_path, + progress_tx, + ) + .await?; + match attempt { + DirectTorchAttempt::Installed(runtime) => return Ok(runtime), + DirectTorchAttempt::Retry => { + let runtime = staging.path().join("runtime"); + spawn_blocking_with_stage(staging.clone(), move || { + std::fs::remove_dir_all(runtime) + }) + .await + .map_err(|error| { + failed(format!("Torch retry cleanup task failed: {error}")) + })? + .map_err(PumasError::from)?; + } + } + } + } + Err(failed( + "No managed Python candidate has compatible Torch wheels and dependencies", + )) + } + + async fn stage_selected_torch_attempt( + &self, + selection: &super::super::torch_preview::TorchInstallSelection, + build: &str, + minor: &str, + python_root: &Path, + staging: &std::sync::Arc, + log_path: &Path, + progress_tx: &mpsc::Sender, + ) -> Result { + let managed_python = self + .run_provider_with_cancel(async { + super::super::managed_python::ensure_managed_torch_interpreter( + python_root, + &self.torch_cleanup, + minor, + ) + .await + .map_err(|error| failed(error.message)) + }) + .await?; + self.check_cancelled()?; + let interpreter_hash = format!( + "{:x}", + Sha256::digest(std::fs::read(&managed_python.executable).map_err(PumasError::from)?) + ); + + let runtime = staging.path().join("runtime"); + let runtime_for_write = runtime.clone(); + spawn_blocking_with_stage(staging.clone(), move || { + write_embedded_torch_runtime(&runtime_for_write) + }) + .await + .map_err(|error| failed(format!("Runtime staging task failed: {error}")))??; + std::fs::remove_file(runtime.join("requirements.txt")).map_err(PumasError::from)?; + std::fs::remove_file(runtime.join("validate_runtime.py")).map_err(PumasError::from)?; + let mut venv = Command::new(&managed_python.executable); + venv.args(["-I", "-m", "venv"]).arg(runtime.join("venv")); + self.run_runtime_command( + venv, + log_path, + &format!("Creating managed Python {minor} environment"), + progress_tx, + Some(staging.clone()), + ) + .await?; + + let version = selection + .tag + .strip_prefix('v') + .ok_or_else(|| failed("Invalid Torch release tag"))?; + let python = pumas_library::platform::paths::venv_python(&runtime); + let packages = runtime.join("staged-packages"); + let resolver_output = runtime.join("resolver-result"); + let pip_cache = super::super::torch_workspace::managed_pip_cache_dir(&self.launcher_root)?; + let mut install = Command::new(&python); + install + .arg("-I") + .arg(runtime.join("resolve_runtime.py")) + .args(["--install", "--version", version, "--build", build]) + .args(["--adapter", &selection.adapter]) + .arg("--target") + .arg(&packages) + .arg("--output") + .arg(&resolver_output) + .arg("--cache-dir") + .arg(pip_cache); + let status = self + .run_runtime_command_status( + install, + log_path, + &format!( + "Resolving and installing official Torch {build} wheels for Python {minor}" + ), + progress_tx, + Some(staging.clone()), + ) + .await?; + if !status.success() { + if retry_direct_torch_attempt( + status.code(), + selection.python == "auto", + selection.build == "auto" && build != "cpu", + ) { + return Ok(DirectTorchAttempt::Retry); + } + return Err(failed(format!( + "Torch package installation failed ({status}); see installation log" + ))); + } + // No staged package code runs until the resolver has validated the + // installation report and published its lock and resolution manifest. + let resolution: DirectTorchResolution = serde_json::from_slice( + &std::fs::read(resolver_output.join("resolution.json")).map_err(PumasError::from)?, + ) + .map_err(|error| failed(format!("Invalid Torch installation report: {error}")))?; + let report: serde_json::Value = serde_json::from_slice( + &std::fs::read(resolver_output.join("pip-resolution.json")) + .map_err(PumasError::from)?, + ) + .map_err(|error| failed(format!("Invalid Torch pip report: {error}")))?; + let requirements = std::fs::read_to_string(resolver_output.join("requirements.txt")) + .map_err(PumasError::from)?; + let manifest: StagedFilesManifest = serde_json::from_slice( + &std::fs::read(resolver_output.join("installed-files.json")) + .map_err(PumasError::from)?, + ) + .map_err(|error| failed(format!("Invalid Torch staged file manifest: {error}")))?; + validate_and_move_direct_torch_packages( + &resolution, + &report, + &requirements, + version, + build, + minor, + &selection.adapter, + &python, + &packages, + &runtime, + &manifest, + )?; + for name in [ + "requirements.txt", + "resolution.json", + "pip-resolution.json", + "installed-files.json", + ] { + std::fs::copy(resolver_output.join(name), runtime.join(name)) + .map_err(PumasError::from)?; + } + let recipe = serde_json::json!({ + "recipe_id": format!("upstream-install-{}-{}-{}-{}", selection.tag, build, minor, selection.adapter), + "protocol": SUPPORTED_TORCH_PROTOCOL, + "capabilities": [TORCH_IMAGE_GENERATION_CAPABILITY], + "qualification": "not verified by Pumas", + "build": build, + "python": format!("python{minor}"), + "adapter": selection.adapter, + "managed_python": managed_python_identity_record(&managed_python, &interpreter_hash), + "artifacts": resolution.artifacts, + }); + std::fs::write( + runtime.join("runtime.json"), + serde_json::to_vec_pretty(&recipe).map_err(|error| failed(error.to_string()))?, + ) + .map_err(PumasError::from)?; + let mut probe = Command::new(&python); + probe.arg(runtime.join("probe_runtime.py")); + self.run_runtime_command( + probe, + log_path, + "Checking installed Torch identity and CPU operation", + progress_tx, + Some(staging.clone()), + ) + .await?; + Ok(DirectTorchAttempt::Installed(runtime)) + } + async fn stage_resolved_torch_runtime( &self, plan: &TorchInstallPlan, @@ -1339,7 +2039,7 @@ impl VersionInstaller { tag: &str, release: &GitHubRelease, progress_tx: mpsc::Sender, - plan: Option, + input: Option, ) -> Result<()> { let _attempt = self .torch_attempt_lock @@ -1347,7 +2047,7 @@ impl VersionInstaller { .map_err(|_| failed("Torch installation already active"))?; self.torch_control.start(); let result = self - .install_torch_runtime_inner(tag, release, progress_tx, plan) + .install_torch_runtime_inner(tag, release, progress_tx, input) .await; self.torch_control.finish(); result @@ -1358,7 +2058,7 @@ impl VersionInstaller { tag: &str, release: &GitHubRelease, progress_tx: mpsc::Sender, - plan: Option, + input: Option, ) -> Result<()> { if !cfg!(any( all(target_os = "linux", target_arch = "x86_64"), @@ -1376,16 +2076,25 @@ impl VersionInstaller { "Unsupported upstream PyTorch release or mismatched tag", )); } - let recipe = if cfg!(all(target_os = "linux", target_arch = "x86_64")) - && plan - .as_ref() - .is_none_or(|p| p.preview.qualification == "qualified") + let plan = match input.as_ref() { + Some(TorchInstallInput::Resolved(plan)) => Some(plan), + _ => None, + }; + let selection = match input.as_ref() { + Some(TorchInstallInput::Selection(selection)) => Some(selection), + _ => None, + }; + let recipe = if selection.is_none() + && cfg!(all(target_os = "linux", target_arch = "x86_64")) + && plan.is_none_or(|p| p.preview.qualification == "qualified") { torch_recipe_for_tag(tag) } else { None }; - if plan.as_ref().is_some_and(|p| p.preview.tag != tag) { + if plan.is_some_and(|p| p.preview.tag != tag) + || selection.is_some_and(|selection| selection.tag != tag) + { return Err(failed("Torch preview tag mismatch")); } if !tag @@ -1450,7 +2159,7 @@ impl VersionInstaller { )); } } - if recipe.is_none() && plan.is_none() { + if recipe.is_none() && input.is_none() { #[cfg(test)] if self.torch_stage_override.is_none() { return Err(failed( @@ -1483,7 +2192,8 @@ impl VersionInstaller { .stage_torch_runtime( tag, recipe, - plan.as_ref(), + plan, + selection, &staging, &log_path, &progress_tx, @@ -1544,7 +2254,7 @@ impl VersionInstaller { &destination, &progress_tx, staging.clone(), - plan.as_ref() + plan .map(|plan| format!("Python {}", plan.managed_python.version)), ) .await; @@ -1597,6 +2307,7 @@ impl VersionInstaller { _tag: &str, recipe_spec: Option<&TorchRuntimeRecipe>, plan: Option<&TorchInstallPlan>, + selection: Option<&super::super::torch_preview::TorchInstallSelection>, staging: &std::sync::Arc, log_path: &Path, progress_tx: &mpsc::Sender, @@ -1610,6 +2321,11 @@ impl VersionInstaller { .stage_resolved_torch_runtime(plan, staging, log_path, progress_tx) .await; } + if let Some(selection) = selection { + return self + .stage_selected_torch_runtime(selection, staging, log_path, progress_tx) + .await; + } let recipe_spec = recipe_spec.expect("checked above"); let runtime = staging.path().join("runtime"); let runtime_for_write = runtime.clone(); @@ -1754,12 +2470,32 @@ impl VersionInstaller { pub(super) async fn run_runtime_command( &self, - mut command: Command, + command: Command, log_path: &Path, stage: &str, progress_tx: &mpsc::Sender, stage_lease: Option>, ) -> Result<()> { + let status = self + .run_runtime_command_status(command, log_path, stage, progress_tx, stage_lease) + .await?; + if status.success() { + Ok(()) + } else { + Err(failed(format!( + "{stage} failed ({status}); see installation log" + ))) + } + } + + async fn run_runtime_command_status( + &self, + mut command: Command, + log_path: &Path, + stage: &str, + progress_tx: &mpsc::Sender, + stage_lease: Option>, + ) -> Result { self.check_cancelled()?; if let Some(stage_lease) = &stage_lease { let scratch = stage_lease.scratch_path()?; @@ -1817,13 +2553,7 @@ impl VersionInstaller { let status = observed .map_err(PumasError::from)? .ok_or_else(|| failed("Missing terminal installer status"))?; - return if status.success() { - Ok(()) - } else { - Err(failed(format!( - "{stage} failed ({status}); see installation log" - ))) - }; + return Ok(status); } tokio::time::sleep(Duration::from_millis(100)).await; } @@ -1834,6 +2564,199 @@ impl VersionInstaller { mod managed_python_provenance_tests { use super::*; + #[test] + fn direct_install_report_requires_trusted_hash_locked_wheels_before_probe() { + let torch_version = if cfg!(target_os = "macos") { + "2.14.0" + } else { + "2.14.0+cpu" + }; + let artifacts = [ + "torch", + "fastapi", + "uvicorn", + "psutil", + "pillow", + "safetensors", + ] + .into_iter() + .map(|name| crate::version_manager::TorchArtifact { + name: name.into(), + version: if name == "torch" { + torch_version.into() + } else { + "1.0".into() + }, + url: if name == "torch" { + "https://download.pytorch.org/whl/cpu/torch/torch-fixture.whl".into() + } else { + format!("https://files.pythonhosted.org/packages/{name}-fixture.whl") + }, + sha256: "a".repeat(64), + }) + .collect::>(); + let (platform, machine) = match std::env::consts::OS { + "linux" => ("Linux-fixture", "x86_64"), + "windows" => ("Windows-fixture", "AMD64"), + "macos" => ("macOS-fixture", "arm64"), + _ => return, + }; + let mut resolution = DirectTorchResolution { + release: "2.14.0".into(), + torch: torch_version.into(), + build: "cpu".into(), + python: "3.12".into(), + interpreter: "/staged/venv/python".into(), + implementation: "cpython".into(), + platform: platform.into(), + machine: machine.into(), + adapter: "none".into(), + artifacts, + }; + let report = serde_json::json!({ + "install": resolution.artifacts.iter().map(|artifact| serde_json::json!({ + "metadata": {"name": artifact.name, "version": artifact.version}, + "download_info": {"url": artifact.url, "archive_info": {"hashes": {"sha256": artifact.sha256}}} + })).collect::>() + }); + let requirements = resolution + .artifacts + .iter() + .map(|artifact| { + format!( + "{} @ {} --hash=sha256:{}", + artifact.name, artifact.url, artifact.sha256 + ) + }) + .collect::>() + .join("\n"); + let python = Path::new("/staged/venv/python"); + let check = |resolution: &DirectTorchResolution, report: &serde_json::Value, lock: &str| { + validate_direct_torch_report( + resolution, report, lock, "2.14.0", "cpu", "3.12", "none", python, + ) + }; + assert!(check(&resolution, &report, &requirements).is_ok()); + resolution.artifacts[0].url = "https://example.com/torch.whl".into(); + assert!(check(&resolution, &report, &requirements).is_err()); + resolution.artifacts[0].url = report["install"][0]["download_info"]["url"] + .as_str() + .unwrap() + .into(); + resolution.artifacts[0].sha256 = "invalid".into(); + assert!(check(&resolution, &report, &requirements).is_err()); + resolution.artifacts[0].sha256 = "a".repeat(64); + assert!(check(&resolution, &report, "torch @ https://download.pytorch.org/whl/cpu/torch/torch-fixture.whl --hash=sha256:bad").is_err()); + + let stage = tempfile::tempdir().unwrap(); + let runtime = stage.path().join("runtime"); + let target = runtime.join("staged-packages"); + std::fs::create_dir_all(&target).unwrap(); + let contents = "raise RuntimeError('executed')"; + std::fs::write(target.join("verified.pth"), contents).unwrap(); + let manifest = StagedFilesManifest { + files: vec![StagedFile { + path: "verified.pth".into(), + sha256: format!("{:x}", Sha256::digest(contents.as_bytes())), + size: contents.len() as u64, + }], + }; + let publish = |lock: &str| { + validate_and_move_direct_torch_packages( + &resolution, + &report, + lock, + "2.14.0", + "cpu", + "3.12", + "none", + python, + &target, + &runtime, + &manifest, + ) + }; + assert!(publish("malformed lock").is_err()); + assert!(target.join("verified.pth").exists()); + #[cfg(windows)] + let installed = runtime.join("venv/Lib/site-packages/verified.pth"); + #[cfg(not(windows))] + let installed = runtime.join("venv/lib/python3.12/site-packages/verified.pth"); + assert!(!installed.exists()); + std::fs::write(target.join("unreported.pth"), "evil").unwrap(); + assert!(publish(&requirements).is_err()); + assert!(!installed.exists()); + std::fs::remove_file(target.join("unreported.pth")).unwrap(); + publish(&requirements).unwrap(); + assert!(installed.exists()); + assert!(!target.exists()); + } + + #[test] + fn automatic_cuda_channel_uses_existing_linux_driver_floors() { + assert_eq!(cuda_build_for_driver((579, 99, 99)), Some("cu129")); + assert_eq!(cuda_build_for_driver((580, 0, 0)), Some("cu134")); + assert_eq!(cuda_build_for_driver((525, 60, 13)), Some("cu129")); + assert_eq!(cuda_build_for_driver((450, 80, 2)), Some("cu118")); + assert_eq!(cuda_build_for_driver((440, 33, 0)), Some("cu102")); + assert_eq!(cuda_build_for_driver((409, 99, 99)), None); + } + + #[test] + fn automatic_build_candidates_try_supported_cuda_channels_before_cpu() { + let builds = automatic_torch_builds_for_driver((580, 0, 0)); + assert_eq!(builds, ["cu134", "cu132", "cu130", "cu129", "cpu"]); + assert!(!builds.contains(&"cu75")); + + // An unsupported cu134 wheel can proceed to cu132 and stop there. + assert!(retry_direct_torch_attempt(Some(2), false, true)); + assert_eq!(builds[1], "cu132"); + assert!(!retry_direct_torch_attempt(Some(1), false, true)); + } + + #[test] + fn automatic_build_candidates_reach_cpu_after_unsupported_cuda_wheels() { + let builds = automatic_torch_builds_for_driver((525, 60, 13)); + assert_eq!(builds, ["cu129", "cu128", "cu126", "cu124", "cpu"]); + assert!(builds[..builds.len() - 1] + .iter() + .all(|_| retry_direct_torch_attempt(Some(4), false, true))); + assert_eq!(automatic_torch_builds_for_driver((409, 99, 99)), ["cpu"]); + } + + #[test] + fn automatic_python_fallback_only_accepts_conclusive_wheel_failures() { + for code in [2, 4] { + assert!(retry_torch_python_candidate(Some(code), true)); + assert!(!retry_torch_python_candidate(Some(code), false)); + assert!(retry_direct_torch_attempt(Some(code), false, true)); + assert!(!retry_direct_torch_attempt(Some(code), false, false)); + } + for code in [1, 3, 75, 124] { + assert!(!retry_torch_python_candidate(Some(code), true)); + } + assert!(!retry_torch_python_candidate(None, true)); + } + + #[test] + fn validated_staged_packages_move_into_venv_only_without_collisions() { + let root = tempfile::tempdir().unwrap(); + let runtime = root.path().join("runtime"); + let target = runtime.join("staged-packages"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("torch.py"), "trusted staged wheel").unwrap(); + move_verified_packages(&target, &runtime, "3.12").unwrap(); + #[cfg(windows)] + let installed = runtime.join("venv/Lib/site-packages/torch.py"); + #[cfg(not(windows))] + let installed = runtime.join("venv/lib/python3.12/site-packages/torch.py"); + assert_eq!( + std::fs::read_to_string(installed).unwrap(), + "trusted staged wheel" + ); + assert!(!target.exists()); + } + #[tokio::test] async fn installed_runtime_recipe_retains_managed_python_provider_identity() { let root = tempfile::tempdir().unwrap(); diff --git a/rust/crates/pumas-app-manager/src/version_manager/mod.rs b/rust/crates/pumas-app-manager/src/version_manager/mod.rs index 09365bea..50a0a0a7 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/mod.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/mod.rs @@ -91,6 +91,35 @@ async fn path_exists(path: &Path) -> Result { .map_err(|err| PumasError::io_with_path(err, path)) } +async fn wait_for_install_cancel(cancel_flag: Arc) { + loop { + if cancel_flag.load(Ordering::SeqCst) { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn run_torch_operation_with_cancel( + operation: F, + cancel_flag: Arc, + cleanup: Arc, +) -> Result +where + F: std::future::Future>, +{ + tokio::select! { + biased; + _ = wait_for_install_cancel(cancel_flag) => { + cleanup.drain_residual_child_slots().await?; + Err(PumasError::InstallationFailed { + message: "Installation cancelled by user".into(), + }) + } + result = operation => result, + } +} + fn active_version_path(root: &Path, app_id: AppId) -> PathBuf { // Preserve the established native-runtime marker; Torch must not overwrite // llama.cpp selection when a user activates an image runtime. @@ -136,6 +165,7 @@ pub struct VersionManager { #[cfg(test)] torch_admission_pause: Option>, torch_previews: torch_preview::TorchPreviews, + torch_install_selections: torch_preview::TorchInstallSelections, #[cfg(test)] torch_publication_pause: Option>, #[cfg(test)] @@ -274,6 +304,7 @@ impl VersionManager { #[cfg(test)] torch_admission_pause: None, torch_previews: Arc::new(Mutex::new(Default::default())), + torch_install_selections: Arc::new(Mutex::new(Default::default())), #[cfg(test)] torch_publication_pause: None, #[cfg(test)] @@ -717,6 +748,12 @@ impl VersionManager { tag: &str, preview_id: Option<&str>, ) -> Result> { + if self.app_id == AppId::Torch && torch_alternatives::stable_release_version(tag).is_none() + { + return Err(PumasError::VersionNotFound { + tag: tag.to_owned(), + }); + } // Check if already installed { let state = self.state.read().await; @@ -734,9 +771,14 @@ impl VersionManager { message: "Torch version manager is shutting down".into(), }); } - // Resolve before recording installation state: discovery failure must not - // leave a phantom installation that can never complete. - let release = self.resolve_installable_release(tag).await?; + // Release lookup and package resolution are part of installation work for + // Torch. The UI/API must be able to enter a cancellable, visible state + // without waiting for network discovery first. + let release = if self.app_id == AppId::Torch { + None + } else { + Some(self.resolve_installable_release(tag).await?) + }; #[cfg(test)] if let Some(pause) = &self.torch_admission_pause { pause.reached.notify_one(); @@ -747,64 +789,33 @@ impl VersionManager { message: "Torch version manager is shutting down".into(), }); } - let generate_bundled_preset_preview = - self.app_id == AppId::Torch && preview_id.is_none() && tag == "v2.9.1"; - #[cfg(test)] - let generate_bundled_preset_preview = generate_bundled_preset_preview - && self.torch_stage_override.is_none() - && self.torch_admission_pause.is_none(); - let generated_preset_preview_id = if generate_bundled_preset_preview { - match self - .preview_torch_runtime("v2.9.1", "cu130", "auto", "bundled") - .await? - { - TorchPreviewOutcome::Resolved { preview } => Some(preview.preview_id), - TorchPreviewOutcome::Rejected { reason, message } => { - return Err(PumasError::InstallationFailed { - message: format!("Torch preview {reason:?}: {message}"), - }); - } - } - } else { - None - }; - let retained_preview_id = preview_id.or(generated_preset_preview_id.as_deref()); - let torch_plan = if self.app_id == AppId::Torch { - match retained_preview_id { - Some(id) => { - let mut previews = self.torch_previews.lock().await; - let retained = - previews - .remove(id) - .ok_or_else(|| PumasError::InstallationFailed { - message: "Torch preview expired or unknown".into(), - })?; - if retained.created.elapsed() >= Duration::from_secs(30 * 60) - || retained.preview.tag != tag - { - return Err(PumasError::InstallationFailed { - message: "Torch preview does not match requested tag or has expired" - .into(), - }); - } - Some(installer::TorchInstallPlan { - preview: retained.preview, - requirements: retained.requirements, - resolution: retained.resolution, - report: retained.report, - interpreter_path: retained.interpreter_path, - interpreter_hash: retained.interpreter_hash, - managed_python: retained.managed_python, - }) - } - #[cfg(test)] - None if self.torch_stage_override.is_some() => None, - None => { - return Err(PumasError::InstallationFailed { - message: "A retained preview ID is required for this Torch runtime".into(), - }) - } + let torch_selection = if self.app_id == AppId::Torch { + #[cfg(test)] + if preview_id.is_none() && self.torch_stage_override.is_some() { + None + } else { + Some(match preview_id { + Some(id) => self.consume_torch_install_selection(id, tag).await?, + None => torch_preview::TorchInstallSelection { + tag: tag.to_owned(), + build: "auto".to_owned(), + python: "auto".to_owned(), + adapter: "none".to_owned(), + created: std::time::Instant::now(), + }, + }) } + #[cfg(not(test))] + Some(match preview_id { + Some(id) => self.consume_torch_install_selection(id, tag).await?, + None => torch_preview::TorchInstallSelection { + tag: tag.to_owned(), + build: "auto".to_owned(), + python: "auto".to_owned(), + adapter: "none".to_owned(), + created: std::time::Instant::now(), + }, + }) } else if preview_id.is_some() { return Err(PumasError::InstallationFailed { message: "Preview IDs are only valid for Torch".into(), @@ -867,12 +878,55 @@ impl VersionManager { let progress_tracker = self.progress_tracker.clone(); let torch_control = self.torch_control.clone(); let app_id = self.app_id; + let manager = self.clone(); tokio::spawn(async move { let _install_guard = install_guard; - let result = installer - .install_version_with_torch_plan(&tag, &release, tx.clone(), torch_plan) - .await; + if app_id == AppId::Torch { + let mut tracker = progress_tracker.write().await; + tracker.start_installation(&tag, None, None, None); + tracker.update_stage( + pumas_library::models::InstallationStage::Resolving, + 0.0, + Some("Preparing managed Python and resolving Torch packages"), + ); + drop(tracker); + let _ = tx + .send(ProgressUpdate::Setup { + message: "Preparing managed Python and resolving Torch packages…".into(), + }) + .await; + } + let result = async { + let (release, torch_input) = if app_id == AppId::Torch { + // The tag was admitted as stable semver. Wheel availability is + // established by the staged pip install, not GitHub metadata. + let release = Self::torch_release_for_install(&tag)?; + let input = match torch_selection { + Some(selection) if selection.adapter == "bundled" => { + let plan = run_torch_operation_with_cancel( + manager.resolve_torch_install_selection(&selection), + manager.cancel_flag.clone(), + manager.torch_cleanup.clone(), + ) + .await?; + Some(installer::TorchInstallInput::Resolved(plan)) + } + Some(selection) => Some(installer::TorchInstallInput::Selection(selection)), + None => None, + }; + (release, input) + } else { + ( + release.expect("non-Torch install has a resolved release"), + None, + ) + }; + installer + .install_version_with_torch_input(&tag, &release, tx.clone(), torch_input) + .await + } + .await; if app_id == AppId::Torch { torch_control.finish(); @@ -893,6 +947,11 @@ impl VersionManager { } // Send final status + if let Err(error) = &result { + let mut tracker = progress_tracker.write().await; + tracker.set_error(&error.to_string()); + tracker.complete_installation(false); + } let _ = tx .send(match result { Ok(_) => ProgressUpdate::Completed { success: true }, @@ -953,6 +1012,29 @@ impl VersionManager { }) } + fn torch_release_for_install(tag: &str) -> Result { + let version = torch_alternatives::stable_release_version(tag).ok_or_else(|| { + PumasError::VersionNotFound { + tag: tag.to_owned(), + } + })?; + Ok(pumas_library::network::GitHubRelease { + tag_name: tag.to_owned(), + name: format!("PyTorch {version}"), + // GitHub publication time is unknown without a metadata request. + published_at: String::new(), + body: None, + tarball_url: None, + zipball_url: None, + prerelease: false, + assets: Vec::new(), + html_url: format!("https://github.com/pytorch/pytorch/releases/tag/{tag}"), + total_size: None, + archive_size: None, + dependencies_size: None, + }) + } + async fn ensure_torch_stopped(&self, tag: &str) -> Result<()> { if self.app_id != AppId::Torch { return Ok(()); @@ -1221,6 +1303,207 @@ mod tests { (manager, root) } + #[tokio::test] + async fn torch_choices_and_selection_token_are_local_fast_and_single_use() { + let (manager, root) = create_torch_test_manager().await; + let options = tokio::time::timeout(Duration::from_secs(2), manager.torch_runtime_options()) + .await + .expect("local Torch options must return promptly") + .unwrap(); + assert_eq!(options["defaultBuild"], "auto"); + assert_eq!(options["pythons"][0]["id"], "auto"); + assert!(!root.path().join("launcher-data/managed-python").exists()); + let release_options = tokio::time::timeout( + Duration::from_secs(2), + manager.discover_torch_release_options("v2.14.0"), + ) + .await + .expect("advisory release options must return promptly") + .unwrap(); + assert_eq!( + release_options.status, + TorchReleaseOptionsStatus::Inconclusive + ); + assert!(!release_options.complete_scan); + assert!(!root.path().join("launcher-data/managed-python").exists()); + + let outcome = tokio::time::timeout( + Duration::from_secs(2), + manager.preview_torch_runtime("v2.14.0", "auto", "auto", "none"), + ) + .await + .expect("selection token creation must return promptly") + .unwrap(); + let TorchPreviewOutcome::Ready { preview } = outcome else { + panic!("a valid selection must be ready without artifact resolution"); + }; + assert!(preview.artifacts.is_empty()); + assert_eq!(preview.qualification, "unverified"); + assert!(!root.path().join("launcher-data/managed-python").exists()); + assert!(manager + .consume_torch_install_selection(&preview.preview_id, "v2.14.1") + .await + .is_err()); + + let TorchPreviewOutcome::Ready { preview } = manager + .preview_torch_runtime("v2.14.0", "auto", "auto", "none") + .await + .unwrap() + else { + panic!("a valid selection must be ready"); + }; + assert!(manager + .consume_torch_install_selection(&preview.preview_id, "v2.14.0") + .await + .is_ok()); + assert!(manager + .consume_torch_install_selection(&preview.preview_id, "v2.14.0") + .await + .is_err()); + + manager.torch_install_selections.lock().await.insert( + "expired-selection".into(), + torch_preview::TorchInstallSelection { + tag: "v2.14.0".into(), + build: "auto".into(), + python: "auto".into(), + adapter: "none".into(), + created: std::time::Instant::now() + - torch_preview::PREVIEW_TTL + - Duration::from_secs(1), + }, + ); + assert!(manager + .consume_torch_install_selection("expired-selection", "v2.14.0") + .await + .is_err()); + } + + #[tokio::test] + async fn torch_admission_returns_during_tracker_contention_then_enters_visible_cancellable_state( + ) { + let (manager, _root) = create_torch_test_manager().await; + let tracker_lock = manager.progress_tracker.write().await; + let mut updates = + tokio::time::timeout(Duration::from_secs(2), manager.install_version("v2.14.0")) + .await + .expect("install admission must return before network resolution") + .unwrap(); + assert!(manager.is_installing().await); + let cancel_manager = manager.clone(); + let cancel = tokio::spawn(async move { cancel_manager.cancel_installation().await }); + tokio::time::timeout(Duration::from_secs(2), async { + while !manager.cancel_flag.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .expect("cancellation must be recorded while progress tracking is contended"); + drop(tracker_lock); + assert!(cancel.await.unwrap().unwrap()); + assert!(matches!( + updates.recv().await, + Some(ProgressUpdate::Setup { message }) if message.contains("resolving Torch packages") + )); + let progress = manager.get_installation_progress().await.unwrap(); + assert!(matches!( + progress.stage, + Some( + pumas_library::models::InstallationStage::Resolving + | pumas_library::models::InstallationStage::Download + ) + )); + assert_eq!(progress.overall_progress, Some(0.0)); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(5), updates.recv()) + .await + .unwrap(), + Some(ProgressUpdate::Error { message }) if message.contains("cancelled") + )); + assert!(!manager.is_installing().await); + } + + #[tokio::test] + async fn torch_direct_install_enters_stage_without_release_cache_or_network_lookup() { + let (manager, root) = create_torch_test_manager().await; + let cache = root.path().join("launcher-data/cache"); + assert!(std::fs::read_dir(&cache).unwrap().next().is_none()); + let reached = Arc::new(AtomicBool::new(false)); + let reached_in_stage = reached.clone(); + let manager = manager.with_torch_stage_override(move |_| { + reached_in_stage.store(true, Ordering::SeqCst); + Err(PumasError::InstallationFailed { + message: "stage reached without release lookup".into(), + }) + }); + let mut updates = manager.install_version("v2.14.0").await.unwrap(); + let error = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(ProgressUpdate::Error { message }) = updates.recv().await { + break message; + } + } + }) + .await + .expect("Torch install should reach staging without release lookup"); + assert!(reached.load(Ordering::SeqCst)); + assert!(error.contains("stage reached without release lookup")); + assert!(!cache.join("github-releases-pytorch-pytorch.json").exists()); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn cancelling_install_resolution_drains_owned_resolver_process_group() { + let workspace = Arc::new(tempfile::tempdir().unwrap()); + let pid_path = workspace.path().join("install-resolver.pid"); + let cleanup = Arc::new(installer::TorchCleanupTasks::default()); + let cancel_flag = Arc::new(AtomicBool::new(false)); + let mut command = tokio::process::Command::new("sh"); + command + .arg("-c") + .arg("echo $$ > \"$1\"; sleep 30") + .arg("sh") + .arg(&pid_path); + let operation_workspace = workspace.clone(); + let operation_cleanup = cleanup.clone(); + let operation = async move { + let run = torch_preview::run_preview_resolver( + command, + &operation_workspace, + Duration::from_secs(30), + &operation_cleanup, + ) + .await?; + Err::<(), _>(PumasError::Other(format!( + "Unexpected resolver completion: {run:?}" + ))) + }; + let cancel_for_task = cancel_flag.clone(); + let cancel_task = tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(5), async { + while !pid_path.exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + cancel_for_task.store(true, Ordering::SeqCst); + }); + let result = run_torch_operation_with_cancel(operation, cancel_flag, cleanup.clone()).await; + cancel_task.await.unwrap(); + assert!(matches!( + result, + Err(PumasError::InstallationFailed { message }) if message.contains("cancelled") + )); + let pid: i32 = std::fs::read_to_string(workspace.path().join("install-resolver.pid")) + .unwrap() + .trim() + .parse() + .unwrap(); + assert!(!pumas_library::platform::linux_group::group_has_live_members(pid).unwrap()); + cleanup.drain().await.unwrap(); + } + #[test] fn torch_startup_defers_only_lock_contention() { let deferred = @@ -2047,20 +2330,39 @@ mod tests { } #[tokio::test] - async fn torch_release_rejection_does_not_leave_an_install_in_progress() { + async fn empty_cached_torch_release_list_does_not_gate_installation() { let root = TempDir::new().unwrap(); let cache = root.path().join("launcher-data/cache"); let releases = pumas_library::network::ReleasesCache::new(cache, Duration::from_secs(3600)); releases.set_disk(AppId::Torch.github_repo(), &[]).unwrap(); + let reached = Arc::new(AtomicBool::new(false)); + let reached_in_stage = reached.clone(); let manager = VersionManager::new(root.path(), AppId::Torch) .await - .unwrap(); + .unwrap() + .with_torch_stage_override(move |_| { + reached_in_stage.store(true, Ordering::SeqCst); + Err(PumasError::InstallationFailed { + message: "stage reached after empty release cache".into(), + }) + }); + let mut updates = manager.install_version("v2.10.0").await.unwrap(); assert!(matches!( - manager.install_version("v2.10.0").await, - Err(PumasError::VersionNotFound { .. }) + updates.recv().await, + Some(ProgressUpdate::Setup { .. }) + )); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(5), updates.recv()) + .await + .unwrap(), + Some(ProgressUpdate::Error { message }) if message.contains("stage reached after empty release cache") )); + assert!(reached.load(Ordering::SeqCst)); assert!(!manager.is_installing().await); - assert!(manager.get_installation_progress().await.is_none()); + assert!(manager + .get_installation_progress() + .await + .is_some_and(|progress| progress.success == Some(false))); } #[tokio::test] diff --git a/rust/crates/pumas-app-manager/src/version_manager/progress.rs b/rust/crates/pumas-app-manager/src/version_manager/progress.rs index a6663281..b92157bd 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/progress.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/progress.rs @@ -449,7 +449,8 @@ impl InstallationProgressTracker { fn calculate_overall_progress(&self, state: &InstallationProgressState) -> f32 { let stage_weight = state.stage.weight(); let stage_start = match state.stage { - InstallationStage::Download => 0.0, + InstallationStage::Resolving => 0.0, + InstallationStage::Download => InstallationStage::Resolving.cumulative_weight(), InstallationStage::Extract => InstallationStage::Download.cumulative_weight(), InstallationStage::Venv => InstallationStage::Extract.cumulative_weight(), InstallationStage::Dependencies => InstallationStage::Venv.cumulative_weight(), diff --git a/rust/crates/pumas-app-manager/src/version_manager/torch_alternatives.rs b/rust/crates/pumas-app-manager/src/version_manager/torch_alternatives.rs index 0473ab78..1622f82e 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/torch_alternatives.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/torch_alternatives.rs @@ -703,7 +703,7 @@ fn stable_version(tag: &str) -> Option<&str> { } } -fn stable_release_version(tag: &str) -> Option<&str> { +pub(super) fn stable_release_version(tag: &str) -> Option<&str> { stable_version(tag.strip_prefix('v')?) } @@ -896,11 +896,48 @@ impl VersionManager { } impl VersionManager { - /// Discover this release's exact official Torch wheels for managed Python candidates. - /// A wheel match is a choice to preview, not a resolved dependency plan. + /// Return an immediate advisory result. This method performs no network, + /// Python provisioning, or subprocess work; an unknown result never blocks + /// the install request. pub async fn discover_torch_release_options( &self, tag: &str, + ) -> Result { + if self.app_id != AppId::Torch { + return Err(unsupported_input( + "Torch release options require a Torch manager", + )); + } + stable_release_version(tag) + .ok_or_else(|| unsupported_input("Select a stable upstream Torch tag"))?; + if TorchHostTarget::current().is_none() { + return Err(unsupported_input( + "Torch release options require Linux x86_64, Windows x86_64, or macOS arm64", + )); + } + Ok(TorchReleaseOptionsDiscovery { + tag: tag.to_owned(), + status: TorchReleaseOptionsStatus::Inconclusive, + complete_scan: false, + checked_channels: Vec::new(), + combinations: Vec::new(), + issues: vec![ + "Compatibility was not checked. Start installation to resolve packages.".to_owned(), + ], + detected_gpu_vendors: Vec::new(), + driver_status: TorchReleaseDriverStatus { + nvidia: TorchReleaseDriverAvailability::Unknown, + amd: TorchReleaseDriverAvailability::Unknown, + }, + recommended: None, + recommendation_note: "Availability is resolved during installation.".to_owned(), + }) + } + + /// Resolve official Torch wheel choices during an admitted installation. + pub(crate) async fn discover_torch_release_options_for_install( + &self, + tag: &str, ) -> Result { if self.app_id != AppId::Torch { return Err(unsupported_input( diff --git a/rust/crates/pumas-app-manager/src/version_manager/torch_preview.rs b/rust/crates/pumas-app-manager/src/version_manager/torch_preview.rs index c18b7097..7a8373b2 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/torch_preview.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/torch_preview.rs @@ -62,13 +62,12 @@ pub(super) const BUILDS: &[&str] = &[ "rocm7.14", ]; const ADAPTERS: &[&str] = &["none", "flux2"]; -const PREVIEW_TTL: Duration = Duration::from_secs(30 * 60); +pub(super) const PREVIEW_TTL: Duration = Duration::from_secs(30 * 60); const MAX_RETAINED_TORCH_PREVIEWS: usize = 32; +const MAX_RETAINED_TORCH_SELECTIONS: usize = 32; const RESOLVER_STDERR_TAIL_BYTES: u64 = 8 * 1024; -// Reserve two minutes for owned-child cleanup inside Electron's 15-minute RPC deadline. -const TORCH_RESOLVER_TIMEOUT: Duration = Duration::from_secs(13 * 60); const TORCH_RESOLVER_TIMEOUT_MESSAGE: &str = - "Torch artifact checking timed out. Cached wheel downloads may be reused if you retry."; + "Torch package resolution reached its safety limit. Cancel the installation or retry."; const TORCH_RESOLVER_FAILURE_MESSAGE: &str = "Pip could not complete dependency resolution. Wheel availability is inconclusive; check network or package-index access and retry."; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -368,6 +367,15 @@ fn supported_torch_build(build: &str) -> bool { } } +fn is_python_selection(python: &str) -> bool { + let Some(version) = python.strip_prefix("python3.") else { + return false; + }; + !version.is_empty() + && version.bytes().all(|byte| byte.is_ascii_digit()) + && version.parse::().is_ok_and(|minor| minor >= 10) +} + fn insert_retained_torch_preview( previews: &mut HashMap, preview_id: String, @@ -392,6 +400,16 @@ fn is_bundled_preset(tag: &str, build: &str, python: &str, adapter: &str) -> boo tag == "v2.9.1" && build == "cu130" && python == "python3.12" && adapter == "bundled" } +fn requires_release_metadata_for_preview( + tag: &str, + build: &str, + python: &str, + adapter: &str, + release_verified: bool, +) -> bool { + !release_verified && !is_bundled_preset(tag, build, python, adapter) +} + /// Preserve catalog order while retaining only Python ABIs with an exact /// official wheel for this release and build. An incomplete scan cannot prove /// absence, so callers must not provision or try a lower minor on that basis. @@ -628,6 +646,62 @@ mod tests { ); } + #[test] + fn pinned_bundled_preview_does_not_require_github_release_metadata() { + assert!(!requires_release_metadata_for_preview( + "v2.9.1", + "cu130", + "python3.12", + "bundled", + false, + )); + assert!(requires_release_metadata_for_preview( + "v2.9.1", "cu130", "auto", "bundled", false, + )); + assert!(requires_release_metadata_for_preview( + "v2.9.0", + "cu130", + "python3.12", + "bundled", + false, + )); + assert!(requires_release_metadata_for_preview( + "v2.9.1", + "cpu", + "python3.12", + "bundled", + false, + )); + assert!(requires_release_metadata_for_preview( + "v2.9.1", + "cu130", + "python3.13", + "bundled", + false, + )); + } + + #[tokio::test] + async fn public_bundled_selection_requires_explicit_python312_without_release_cache() { + let root = tempfile::tempdir().unwrap(); + let manager = VersionManager::new(root.path(), AppId::Torch) + .await + .unwrap(); + let ready = manager + .preview_torch_runtime("v2.9.1", "cu130", "python3.12", "bundled") + .await + .unwrap(); + assert!(matches!(ready, TorchPreviewOutcome::Ready { .. })); + assert!(manager + .preview_torch_runtime("v2.9.1", "cu130", "auto", "bundled") + .await + .is_err()); + assert!(!root + .path() + .join("launcher-data/cache/github-releases-pytorch-pytorch.json") + .exists()); + } + #[test] fn preview_accepts_canonical_future_channels_without_xpu_or_paths() { for build in ["cpu", "cu136", "cu999", "rocm8.0", "rocm8.0.1"] { @@ -768,10 +842,7 @@ mod tests { .unwrap(); assert_eq!(timeout["status"], "rejected"); assert_eq!(timeout["reason"], "inconclusive"); - assert_eq!( - timeout["message"], - "Torch artifact checking timed out. Cached wheel downloads may be reused if you retry." - ); + assert_eq!(timeout["message"], TORCH_RESOLVER_TIMEOUT_MESSAGE); } #[test] @@ -862,56 +933,6 @@ mod tests { assert_eq!(value["message"], TORCH_RESOLVER_TIMEOUT_MESSAGE); } - #[tokio::test] - async fn preview_deadline_cancels_and_drains_a_child_during_preparation() { - let workspace = std::sync::Arc::new(tempfile::tempdir().unwrap()); - let pid_path = workspace.path().join("preparation.pid"); - let operation_workspace = workspace.clone(); - let cleanup = std::sync::Arc::new(super::installer::TorchCleanupTasks::default()); - let operation_cleanup = cleanup.clone(); - let resolver_cleanup = operation_cleanup.clone(); - let deadline = tokio::time::Instant::now() + Duration::from_millis(100); - let task = tokio::spawn(async move { - let mut command = Command::new("sh"); - command - .arg("-c") - .arg("echo $$ > \"$1\"; sleep 30") - .arg("sh") - .arg(&pid_path); - let operation = async move { - let run = run_preview_resolver( - command, - &operation_workspace, - Duration::from_secs(30), - &resolver_cleanup, - ) - .await?; - Err(failed(format!("Unexpected resolver completion: {run:?}"))) - }; - run_torch_preview_with_deadline(deadline, &operation_cleanup, operation).await - }); - - tokio::time::timeout(Duration::from_secs(5), async { - while !workspace.path().join("preparation.pid").exists() { - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("preparation child did not start"); - let outcome = task.await.unwrap().unwrap(); - let value = serde_json::to_value(outcome).unwrap(); - assert_eq!(value["status"], "rejected"); - assert_eq!(value["reason"], "inconclusive"); - assert_eq!(value["message"], TORCH_RESOLVER_TIMEOUT_MESSAGE); - let pid: i32 = std::fs::read_to_string(workspace.path().join("preparation.pid")) - .unwrap() - .trim() - .parse() - .unwrap(); - assert!(!pumas_library::platform::linux_group::group_has_live_members(pid).unwrap()); - cleanup.drain().await.unwrap(); - } - #[tokio::test] async fn preview_resolver_cleans_child_after_leader_exits() { let workspace = std::sync::Arc::new(tempfile::tempdir().unwrap()); @@ -1235,6 +1256,9 @@ impl TorchPreviewRejectionReason { #[derive(Clone, Debug, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum TorchPreviewOutcome { + Ready { + preview: TorchPreview, + }, Resolved { preview: TorchPreview, }, @@ -1257,6 +1281,17 @@ pub(crate) struct RetainedTorchPreview { pub(crate) type TorchPreviews = Arc>>; +#[derive(Clone, Debug)] +pub(crate) struct TorchInstallSelection { + pub tag: String, + pub build: String, + pub python: String, + pub adapter: String, + pub created: Instant, +} + +pub(crate) type TorchInstallSelections = Arc>>; + #[derive(Deserialize)] struct Resolution { release: String, @@ -1402,24 +1437,6 @@ fn resolver_rejection(run: PreviewResolverRun) -> Option { Some(TorchPreviewOutcome::Rejected { reason, message }) } -async fn run_torch_preview_with_deadline( - deadline: tokio::time::Instant, - cleanup: &super::installer::TorchCleanupTasks, - operation: F, -) -> Result -where - F: std::future::Future>, -{ - match tokio::time::timeout_at(deadline, operation).await { - Ok(outcome) => outcome, - Err(_) => { - cleanup.drain_residual_child_slots().await?; - Ok(resolver_rejection(PreviewResolverRun::TimedOut) - .expect("a timed out resolver must be rejected")) - } - } -} - pub(super) async fn run_preview_resolver( mut command: Command, workspace: &std::sync::Arc, @@ -1573,22 +1590,8 @@ impl VersionManager { if self.app_id != AppId::Torch { return Err(failed("Torch manager required")); } - let python_candidates = super::torch_alternatives::managed_torch_candidate_minors( - &self.launcher_root.join("launcher-data/managed-python"), - &self.torch_cleanup, - ) - .await - .unwrap_or_default(); - let pythons: Vec<_> = python_candidates - .iter() - .map(|minor| { - let python = format!("python{minor}"); - serde_json::json!({"id": python, "label": format!("Pumas-managed CPython {minor}")}) - }) - .collect(); let linux_x64 = cfg!(all(target_os = "linux", target_arch = "x86_64")); - let bundled_preset_available = - linux_x64 && python_candidates.iter().any(|minor| minor == "3.12"); + let bundled_preset_available = linux_x64; let adapters: &[&str] = if linux_x64 { ADAPTERS } else { &["none"] }; let builds: Vec<&str> = BUILDS .iter() @@ -1611,7 +1614,7 @@ impl VersionManager { "qualification": if preset { "qualified" } else { "unverified" } })); } Ok( - serde_json::json!({"builds": builds, "pythons": pythons, "adapters": adapters, + serde_json::json!({"builds": builds, "defaultBuild":"auto", "pythons": [{"id":"auto", "label":"Select automatically"}], "adapters": adapters, "bundledPresetAvailable": bundled_preset_available, "defaultAdapter": "none", "preset": {"tag":"v2.9.1", "build":"cu130", "python":"python3.12", "adapter":"bundled"}, @@ -1626,9 +1629,140 @@ impl VersionManager { python: &str, adapter: &str, ) -> Result { - let deadline = tokio::time::Instant::now() + TORCH_RESOLVER_TIMEOUT; - let operation = self.preview_torch_runtime_until(tag, build, python, adapter, deadline); - run_torch_preview_with_deadline(deadline, &self.torch_cleanup, operation).await + if self.app_id != AppId::Torch + || !(build == "auto" || supported_torch_build(build)) + || !(python == "auto" || is_python_selection(python)) + || (!ADAPTERS.contains(&adapter) && adapter != "bundled") + || !safe_torch_tag(tag) + || super::torch_alternatives::stable_release_version(tag).is_none() + || !cfg!(any( + all(target_os = "linux", target_arch = "x86_64"), + all( + target_os = "windows", + target_arch = "x86_64", + target_env = "msvc" + ), + all(target_os = "macos", target_arch = "aarch64") + )) + { + return Err(failed("Invalid Torch installation selection")); + } + if adapter == "bundled" && !is_bundled_preset(tag, build, python, adapter) { + return Err(failed( + "Bundled adapters require the v2.9.1 CUDA 13.0/Python 3.12 preset", + )); + } + + let id = preview_token()?; + let selection = TorchInstallSelection { + tag: tag.to_owned(), + build: build.to_owned(), + python: python.to_owned(), + adapter: adapter.to_owned(), + created: Instant::now(), + }; + { + let mut selections = self.torch_install_selections.lock().await; + selections.retain(|_, retained| retained.created.elapsed() < PREVIEW_TTL); + while selections.len() >= MAX_RETAINED_TORCH_SELECTIONS { + if let Some(oldest) = selections + .iter() + .min_by_key(|(_, retained)| retained.created) + .map(|(id, _)| id.clone()) + { + selections.remove(&oldest); + } else { + break; + } + } + selections.insert(id.clone(), selection); + } + + Ok(TorchPreviewOutcome::Ready { + preview: TorchPreview { + preview_id: id, + tag: tag.to_owned(), + build: build.to_owned(), + python: python.to_owned(), + adapter: adapter.to_owned(), + artifacts: Vec::new(), + qualification: "unverified".to_owned(), + expires_in_seconds: PREVIEW_TTL.as_secs(), + }, + }) + } + + pub(crate) async fn consume_torch_install_selection( + &self, + preview_id: &str, + tag: &str, + ) -> Result { + let Some(selection) = self + .torch_install_selections + .lock() + .await + .remove(preview_id) + else { + return Err(failed( + "Torch installation selection expired or was already used", + )); + }; + if selection.created.elapsed() >= PREVIEW_TTL || selection.tag != tag { + return Err(failed( + "Torch installation selection does not match the requested tag or has expired", + )); + } + Ok(selection) + } + + pub(crate) async fn resolve_torch_install_selection( + &self, + selection: &TorchInstallSelection, + ) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(2 * 60 * 60); + let outcome = self + .preview_torch_runtime_until( + &selection.tag, + &selection.build, + &selection.python, + &selection.adapter, + deadline, + ) + .await?; + match outcome { + TorchPreviewOutcome::Resolved { preview } => { + self.take_retained_torch_install_plan(&preview.preview_id) + .await + } + TorchPreviewOutcome::Rejected { message, .. } => Err(failed(message)), + TorchPreviewOutcome::Ready { .. } => Err(failed( + "Torch package resolution returned an invalid selection-only result", + )), + } + } + + async fn take_retained_torch_install_plan( + &self, + preview_id: &str, + ) -> Result { + let retained = self + .torch_previews + .lock() + .await + .remove(preview_id) + .ok_or_else(|| failed("Resolved Torch install plan expired or disappeared"))?; + if retained.created.elapsed() >= PREVIEW_TTL { + return Err(failed("Resolved Torch install plan expired")); + } + Ok(installer::TorchInstallPlan { + preview: retained.preview, + requirements: retained.requirements, + resolution: retained.resolution, + report: retained.report, + interpreter_path: retained.interpreter_path, + interpreter_hash: retained.interpreter_hash, + managed_python: retained.managed_python, + }) } async fn preview_torch_runtime_until( @@ -1640,7 +1774,7 @@ impl VersionManager { resolver_deadline: tokio::time::Instant, ) -> Result { if self.app_id != AppId::Torch - || !supported_torch_build(build) + || !(build == "auto" || supported_torch_build(build)) || (!ADAPTERS.contains(&adapter) && adapter != "bundled") { return Err(failed("Invalid Torch preview selection")); @@ -1662,6 +1796,7 @@ impl VersionManager { )); } let python_root = self.launcher_root.join("launcher-data/managed-python"); + let mut install_build = build.to_owned(); let candidates = match super::torch_alternatives::managed_torch_candidate_minors( &python_root, &self.torch_cleanup, @@ -1701,9 +1836,42 @@ impl VersionManager { } vec![TorchPythonPreviewCandidate::managed_python(minor)] } else { - let discovery = self.discover_torch_release_options(tag).await?; + let discovery = self.discover_torch_release_options_for_install(tag).await?; release_verified = true; - let Some(mut exact_candidates) = auto_wheel_candidates(&candidates, build, &discovery) + if install_build == "auto" { + if !discovery.complete_scan { + return Ok(TorchPreviewOutcome::Rejected { + reason: TorchPreviewRejectionReason::Inconclusive, + message: "Official wheel discovery did not complete conclusively.", + }); + } + let recommended_build = discovery + .recommended + .as_ref() + .filter(|recommendation| { + discovery.combinations.iter().any(|combination| { + combination.build == recommendation.build + && combination.python == recommendation.python + }) + }) + .map(|recommendation| recommendation.build.clone()) + .or_else(|| { + discovery + .combinations + .iter() + .find(|combination| combination.build == "cpu") + .map(|combination| combination.build.clone()) + }); + let Some(recommended_build) = recommended_build else { + return Ok(TorchPreviewOutcome::Rejected { + reason: TorchPreviewRejectionReason::Unsupported, + message: "No compatible official Torch wheel was found for this host and release.", + }); + }; + install_build = recommended_build; + } + let Some(mut exact_candidates) = + auto_wheel_candidates(&candidates, &install_build, &discovery) else { return Ok(TorchPreviewOutcome::Rejected { reason: TorchPreviewRejectionReason::Inconclusive, @@ -1736,7 +1904,13 @@ impl VersionManager { "No stable native CPython candidate is available from the managed provider.", }); } - if !release_verified { + if requires_release_metadata_for_preview( + tag, + &install_build, + python, + adapter, + release_verified, + ) { self.resolve_installable_release(tag).await?; } let mut search = TorchPythonPreviewSearch::new( @@ -1767,7 +1941,7 @@ impl VersionManager { let outcome = self .preview_torch_runtime_with_interpreter( tag, - build, + &install_build, adapter, &managed, candidate.wheel.as_ref(), diff --git a/rust/crates/pumas-core/src/models/version.rs b/rust/crates/pumas-core/src/models/version.rs index 545d19e4..3738aee2 100644 --- a/rust/crates/pumas-core/src/models/version.rs +++ b/rust/crates/pumas-core/src/models/version.rs @@ -67,6 +67,7 @@ pub struct DependencyStatus { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum InstallationStage { + Resolving, Download, Extract, Venv, @@ -78,6 +79,7 @@ impl InstallationStage { /// Get the weight of this stage for progress calculation. pub fn weight(&self) -> f32 { match self { + InstallationStage::Resolving => 0.0, InstallationStage::Download => 0.15, InstallationStage::Extract => 0.05, InstallationStage::Venv => 0.05, @@ -89,6 +91,7 @@ impl InstallationStage { /// Get the cumulative weight up to and including this stage. pub fn cumulative_weight(&self) -> f32 { match self { + InstallationStage::Resolving => 0.0, InstallationStage::Download => 0.15, InstallationStage::Extract => 0.20, InstallationStage::Venv => 0.25, @@ -185,6 +188,7 @@ mod tests { #[test] fn test_installation_stage_weights() { let total: f32 = [ + InstallationStage::Resolving, InstallationStage::Download, InstallationStage::Extract, InstallationStage::Venv, diff --git a/rust/crates/pumas-rpc/src/contract.rs b/rust/crates/pumas-rpc/src/contract.rs index 904680e4..5476dc65 100644 --- a/rust/crates/pumas-rpc/src/contract.rs +++ b/rust/crates/pumas-rpc/src/contract.rs @@ -1742,6 +1742,7 @@ pub(crate) struct RuntimeInstallationProgress { #[serde(rename_all = "lowercase")] #[cfg_attr(feature = "export-contract", derive(schemars::JsonSchema))] enum RuntimeInstallationStage { + Resolving, Download, Extract, Venv, @@ -1791,6 +1792,7 @@ impl InstallationProgressOutcome { tag: value.tag.ok_or_else(missing)?, started_at: value.started_at.ok_or_else(missing)?, stage: match value.stage.ok_or_else(missing)? { + InstallationStage::Resolving => RuntimeInstallationStage::Resolving, InstallationStage::Download => RuntimeInstallationStage::Download, InstallationStage::Extract => RuntimeInstallationStage::Extract, InstallationStage::Venv => RuntimeInstallationStage::Venv, @@ -1899,6 +1901,22 @@ mod installation_progress_contract_tests { ); } + #[test] + fn resolving_progress_stage_has_an_explicit_wire_value() { + use pumas_library::models::InstallationStage; + let mut value = installation_progress_fixture(); + value.stage = Some(InstallationStage::Resolving); + let encoded = + serde_json::to_value(InstallationProgressOutcome::new(Some(value)).unwrap()).unwrap(); + assert_eq!(encoded["stage"], "resolving"); + let schema = desktop_contract_schema().unwrap(); + assert!(schema["schemas"]["RuntimeInstallationStage"]["enum"] + .as_array() + .unwrap() + .iter() + .any(|stage| stage == "resolving")); + } + #[test] fn installation_progress_preserves_terminal_and_nonfinite_wire() { for success in [false, true] { @@ -3985,6 +4003,9 @@ pub(crate) struct PreviewTorchRuntimeParams { #[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] #[cfg_attr(feature = "export-contract", derive(schemars::JsonSchema))] pub(crate) enum TorchRuntimePreviewOutcome { + Ready { + preview: TorchRuntimePreview, + }, Resolved { preview: TorchRuntimePreview, }, @@ -4086,6 +4107,37 @@ fn torch_runtime_preview_fixture() -> TorchRuntimePreview { } } +#[cfg(feature = "inference-plugins")] +impl TryFrom for TorchRuntimePreview { + type Error = PumasError; + + fn try_from( + preview: pumas_app_manager::version_manager::TorchPreview, + ) -> Result { + let qualification = + TorchRuntimePreviewQualification::try_from(preview.qualification.as_str())?; + Ok(Self { + preview_id: preview.preview_id, + tag: preview.tag, + build: preview.build, + python: preview.python, + adapter: preview.adapter, + artifacts: preview + .artifacts + .into_iter() + .map(|artifact| TorchRuntimePreviewArtifact { + name: artifact.name, + version: artifact.version, + url: artifact.url, + sha256: artifact.sha256, + }) + .collect(), + qualification, + expires_in_seconds: preview.expires_in_seconds, + }) + } +} + #[cfg(feature = "inference-plugins")] impl TryFrom for TorchRuntimePreviewOutcome @@ -4099,31 +4151,12 @@ impl TryFrom TorchPreviewOutcome, TorchPreviewRejectionReason, }; Ok(match outcome { - TorchPreviewOutcome::Resolved { preview } => { - let qualification = - TorchRuntimePreviewQualification::try_from(preview.qualification.as_str())?; - Self::Resolved { - preview: TorchRuntimePreview { - preview_id: preview.preview_id, - tag: preview.tag, - build: preview.build, - python: preview.python, - adapter: preview.adapter, - artifacts: preview - .artifacts - .into_iter() - .map(|artifact| TorchRuntimePreviewArtifact { - name: artifact.name, - version: artifact.version, - url: artifact.url, - sha256: artifact.sha256, - }) - .collect(), - qualification, - expires_in_seconds: preview.expires_in_seconds, - }, - } - } + TorchPreviewOutcome::Ready { preview } => Self::Ready { + preview: preview.try_into()?, + }, + TorchPreviewOutcome::Resolved { preview } => Self::Resolved { + preview: preview.try_into()?, + }, TorchPreviewOutcome::Rejected { reason, message } => { let reason = match reason { TorchPreviewRejectionReason::Unsupported => { @@ -4176,6 +4209,22 @@ mod torch_preview_contract_tests { #[test] fn typed_preview_outcomes_have_closed_wire_shapes() { + let mut quick_selection = torch_runtime_preview_fixture(); + quick_selection.build = "auto".into(); + quick_selection.python = "auto".into(); + quick_selection.adapter = "none".into(); + quick_selection.artifacts.clear(); + quick_selection.qualification = TorchRuntimePreviewQualification::Unverified; + let ready = serde_json::to_value(TorchRuntimePreviewOutcome::Ready { + preview: quick_selection, + }) + .unwrap(); + assert_eq!(ready["status"], "ready"); + assert_eq!(ready["preview"]["artifacts"], serde_json::json!([])); + assert_eq!(ready["preview"]["qualification"], "unverified"); + assert_eq!(ready["preview"]["build"], "auto"); + assert_eq!(ready.as_object().unwrap().len(), 2); + let resolved = serde_json::to_value(TorchRuntimePreviewOutcome::Resolved { preview: torch_runtime_preview_fixture(), }) @@ -4218,6 +4267,30 @@ mod torch_preview_contract_tests { } } + #[cfg(feature = "inference-plugins")] + #[test] + fn manager_ready_selection_keeps_empty_artifacts_and_unverified_qualification() { + use pumas_app_manager::version_manager::{TorchPreview, TorchPreviewOutcome}; + + let preview = TorchPreview { + preview_id: "retained-selection".into(), + tag: "v2.14.0".into(), + build: "auto".into(), + python: "auto".into(), + adapter: "none".into(), + artifacts: Vec::new(), + qualification: "unverified".into(), + expires_in_seconds: 1800, + }; + let projected = + TorchRuntimePreviewOutcome::try_from(TorchPreviewOutcome::Ready { preview }).unwrap(); + let value = serde_json::to_value(projected).unwrap(); + assert_eq!(value["status"], "ready"); + assert_eq!(value["preview"]["previewId"], "retained-selection"); + assert_eq!(value["preview"]["artifacts"], serde_json::json!([])); + assert_eq!(value["preview"]["qualification"], "unverified"); + } + #[cfg(feature = "inference-plugins")] #[test] fn manager_rejection_message_survives_rpc_boundary() { @@ -4358,6 +4431,9 @@ mod torch_preview_contract_tests { fn export_includes_preview_outcome_and_fixtures() { let schema = desktop_contract_schema().unwrap(); assert!(schema["schemas"]["TorchRuntimePreviewOutcome"].is_object()); + assert!(schema["schemas"]["TorchRuntimePreviewOutcome"] + .to_string() + .contains("\"ready\"")); assert!(schema["schemas"]["TorchRuntimePreview"].is_object()); assert!(schema["schemas"]["TorchRuntimePreviewArtifact"].is_object()); assert!(schema["schemas"]["TorchRuntimePreviewQualification"].is_object()); diff --git a/torch-server/resolve_runtime.py b/torch-server/resolve_runtime.py index 7aff575b..3743be2e 100644 --- a/torch-server/resolve_runtime.py +++ b/torch-server/resolve_runtime.py @@ -1,13 +1,18 @@ -"""Resolve a selected official Torch wheel and an optional image adapter. +"""Resolve or stage-install an official Torch wheel and optional image adapter. -Run this with the interpreter that will own the environment. The pip report and -hash-locked requirements are retained so installation need not resolve again. +Run this with the interpreter that will own the environment. The report and +hash-locked requirements retain provenance for review before staged code is used. """ import argparse +import base64 +import csv +from email.parser import Parser from concurrent.futures import ThreadPoolExecutor, wait +import hashlib from html.parser import HTMLParser import json +import os import platform import re import subprocess @@ -15,10 +20,134 @@ import time from pathlib import Path from pip._vendor.packaging import tags as packaging_tags +from pip._vendor.packaging.utils import canonicalize_name +from pip._vendor.packaging.version import Version, InvalidVersion from urllib.parse import unquote, urljoin, urlparse, urlunparse from urllib.request import HTTPRedirectHandler, Request, build_opener CORE = ("fastapi", "uvicorn", "psutil", "pillow", "safetensors") + + +def staged_file_digest(path: Path) -> tuple[bytes, int]: + digest = hashlib.sha256() + size = 0 + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + size += len(chunk) + return digest.digest(), size + + +def installed_file_manifest(target: Path, artifacts: list[dict] | None = None) -> dict: + """Validate pip's staged wheel files against every installed RECORD.""" + if target.is_symlink() or not target.is_dir(): + raise ValueError("Staged package target is missing or linked") + records = sorted(target.glob("*.dist-info/RECORD")) + if not records: + raise ValueError("Staged wheels have no RECORD files") + if artifacts is not None: + expected = { + canonicalize_name(artifact["name"]): artifact["version"] for artifact in artifacts + } + if len(expected) != len(artifacts) or len(records) != len(expected): + raise ValueError("Staged wheel RECORD set differs from the pip report") + seen_distributions = set() + for record in records: + directory = record.parent.name.removesuffix(".dist-info") + if "-" not in directory: + raise ValueError("Staged distribution identity is malformed") + directory_name, directory_version = directory.rsplit("-", 1) + name = canonicalize_name(directory_name) + if name not in expected or name in seen_distributions: + raise ValueError("Staged wheel RECORD set differs from the pip report") + seen_distributions.add(name) + metadata_path = record.parent / "METADATA" + if metadata_path.is_symlink() or not metadata_path.is_file(): + raise ValueError("Staged distribution METADATA is missing or linked") + metadata = Parser().parsestr(metadata_path.read_text(encoding="utf-8")) + if len(metadata.get_all("Name", [])) != 1 or len(metadata.get_all("Version", [])) != 1: + raise ValueError("Staged distribution METADATA identity is malformed") + try: + expected_version = Version(expected[name]) + matches = ( + Version(directory_version) == expected_version + and Version(metadata["Version"]) == expected_version + ) + except InvalidVersion: + matches = False + if canonicalize_name(metadata["Name"]) != name or not matches: + raise ValueError("Staged distribution identity differs from the pip report") + claimed = set() + for record in records: + if record.is_symlink() or record.parent.is_symlink(): + raise ValueError("Staged wheel RECORD is linked") + with record.open(newline="", encoding="utf-8") as source: + for row in csv.reader(source): + if len(row) != 3: + raise ValueError("Malformed staged wheel RECORD") + name, recorded_hash, recorded_size = row + if name.startswith("../../bin/"): + script = name.removeprefix("../../bin/") + if not script or "/" in script or script in (".", ".."): + raise ValueError("Staged wheel RECORD escapes its target") + relative = "bin/" + script + else: + relative = name + path = Path(relative) + if ( + not relative + or path.is_absolute() + or "\\" in relative + or any(part in ("", ".", "..") for part in relative.split("/")) + ): + raise ValueError("Staged wheel RECORD escapes its target") + if relative in claimed: + raise ValueError("Duplicate staged wheel file") + file = target / path + if relative.endswith(".pyc") and not recorded_hash and not recorded_size: + # pip compiles these during installation; the wheel provides no + # digest. The interpreter will regenerate them from checked source. + if file.is_symlink(): + raise ValueError("Staged packages contain a symlink") + if file.exists(): + if not file.is_file(): + raise ValueError("Staged wheel RECORD names a special file") + file.unlink() + continue + if file.is_symlink() or not file.is_file(): + raise ValueError("Staged wheel RECORD names a missing or linked file") + claimed.add(relative) + if len(claimed) > 200_000: + raise ValueError("Staged package manifest is too large") + digest, size = staged_file_digest(file) + if file != record: + expected = "sha256=" + base64.urlsafe_b64encode(digest).rstrip(b"=").decode( + "ascii" + ) + if recorded_hash != expected or recorded_size != str(size): + raise ValueError("Staged wheel RECORD hash or size differs") + elif recorded_hash or recorded_size: + raise ValueError("Staged wheel RECORD must not self-hash") + files = [] + for root, dirs, names in os.walk(target, followlinks=False): + for name in (*dirs, *names): + file = Path(root) / name + if file.is_symlink(): + raise ValueError("Staged packages contain a symlink") + for name in names: + file = Path(root) / name + relative = file.relative_to(target).as_posix() + if relative not in claimed: + raise ValueError("Staged packages contain an unreported file") + digest, size = staged_file_digest(file) + files.append({"path": relative, "sha256": digest.hex(), "size": size}) + if len(files) > 200_000: + raise ValueError("Staged package manifest is too large") + if len(files) != len(claimed): + raise ValueError("Staged wheel RECORD contains files outside the target") + return {"files": sorted(files, key=lambda item: item["path"])} + + IMAGE = ( "torchvision", "diffusers==0.37.0", @@ -821,6 +950,8 @@ def main() -> None: parser.add_argument("--build") parser.add_argument("--adapter", choices=ADAPTERS, default="none") parser.add_argument("--output", type=Path) + parser.add_argument("--install", action="store_true") + parser.add_argument("--target", type=Path) parser.add_argument("--cache-dir", type=Path) parser.add_argument("--discover", action="store_true") parser.add_argument("--release-options", action="store_true") @@ -842,6 +973,8 @@ def main() -> None: or args.adapter != "none" or args.torch_wheel is not None or args.torch_sha256 is not None + or args.install + or args.target is not None ): parser.error("Release options accepts only --version and one or more --interpreter") if not args.interpreter: @@ -863,11 +996,17 @@ def main() -> None: parser.error("Resolution and discovery require --build") if args.python_candidate is not None: parser.error("--python-candidate is only valid with --release-options") + if args.install and args.target is None: + parser.error("--install requires an explicit staged --target") + if not args.install and args.target is not None: + parser.error("--target is only valid with --install") if args.torch_sha256 is not None and args.torch_wheel is None: parser.error("--torch-sha256 requires --torch-wheel") if not RELEASE_CHANNEL.fullmatch(args.build): parser.error("Select a canonical CPU, CUDA, or ROCm build channel") if args.discover: + if args.install: + parser.error("Discovery does not accept --install") if args.cache_dir is not None: parser.error("Discovery does not accept --cache-dir") if not args.selected_python or not args.interpreter: @@ -882,7 +1021,7 @@ def main() -> None: return if args.output is None: parser.error("Resolution requires --output") - if args.torch_wheel is None: + if args.torch_wheel is None and not args.install: parser.error("Resolution requires --torch-wheel from official discovery") try: target = native_target( @@ -892,10 +1031,13 @@ def main() -> None: sys.implementation.name, ) extras = adapter_requirements(args.adapter, args.version, args.build) - tags = {str(tag) for tag in packaging_tags.sys_tags()} - selected_wheel = wheel_match(args.torch_wheel, args.version, args.build, tags) - if selected_wheel is None or selected_wheel["wheelUrl"] != args.torch_wheel: - raise ValueError("Selected Torch wheel is not an exact compatible official artifact") + if args.torch_wheel is not None: + tags = {str(tag) for tag in packaging_tags.sys_tags()} + selected_wheel = wheel_match(args.torch_wheel, args.version, args.build, tags) + if selected_wheel is None or selected_wheel["wheelUrl"] != args.torch_wheel: + raise ValueError( + "Selected Torch wheel is not an exact compatible official artifact" + ) if args.torch_sha256 is not None and not re.fullmatch( r"[0-9a-fA-F]{64}", args.torch_sha256 ): @@ -903,6 +1045,14 @@ def main() -> None: except ValueError as error: parser.exit(2, f"{error}\n") args.output.mkdir(parents=True, exist_ok=True) + if args.install: + if any((args.output / name).exists() for name in ("requirements.txt", "resolution.json")): + parser.error("Install output must not contain a previous lock or resolution") + if args.target.is_symlink() or ( + args.target.exists() and (not args.target.is_dir() or any(args.target.iterdir())) + ): + parser.error("Staged --target must be an empty directory and not a symlink") + args.target.mkdir(parents=True, exist_ok=True) cache_dir = args.cache_dir or args.output / "pip-cache" cache_dir.mkdir(parents=True, exist_ok=True) report_path = args.output / "pip-resolution.json" @@ -917,10 +1067,10 @@ def main() -> None: "-m", "pip", "--isolated", + "install", "--cache-dir", str(cache_dir), - "install", - "--dry-run", + *(["--target", str(args.target)] if args.install else ["--dry-run"]), "--report", str(report_path), "--ignore-installed", @@ -929,8 +1079,12 @@ def main() -> None: f"https://download.pytorch.org/whl/{args.build}", "--extra-index-url", "https://pypi.org/simple", - f"torch @ {args.torch_wheel}" - + (f"#sha256={args.torch_sha256}" if args.torch_sha256 is not None else ""), + ( + f"torch @ {args.torch_wheel}" + + (f"#sha256={args.torch_sha256}" if args.torch_sha256 is not None else "") + ) + if args.torch_wheel is not None + else f"torch=={torch_requirement}", *CORE, *extras, ] @@ -948,11 +1102,14 @@ def main() -> None: report, args.version, args.build, args.adapter ) torch_artifact = next(item for item in resolution["artifacts"] if item["name"] == "torch") - if torch_artifact["url"] != args.torch_wheel or ( + if (args.torch_wheel is not None and torch_artifact["url"] != args.torch_wheel) or ( args.torch_sha256 is not None and torch_artifact["sha256"].lower() != args.torch_sha256.lower() ): raise ValueError("Resolved Torch artifact differs from the selected wheel") + manifest = ( + installed_file_manifest(args.target, resolution["artifacts"]) if args.install else None + ) except (KeyError, TypeError, ValueError, OSError) as error: parser.exit(3, f"Invalid wheel resolution: {error}\n") (args.output / "requirements.txt").write_text( @@ -961,6 +1118,10 @@ def main() -> None: (args.output / "resolution.json").write_text( json.dumps(resolution, indent=2) + "\n", encoding="utf-8", newline="\n" ) + if manifest is not None: + (args.output / "installed-files.json").write_text( + json.dumps(manifest, separators=(",", ":")) + "\n", encoding="utf-8", newline="\n" + ) if __name__ == "__main__": diff --git a/torch-server/tests/test_resolve_runtime.py b/torch-server/tests/test_resolve_runtime.py index 3381c9b3..f8decd0e 100644 --- a/torch-server/tests/test_resolve_runtime.py +++ b/torch-server/tests/test_resolve_runtime.py @@ -1,6 +1,8 @@ """Deterministic resolution checks; no network or large wheels.""" import importlib.util +import base64 +import hashlib import json import pathlib import re @@ -68,6 +70,267 @@ def report( class ResolverTests(unittest.TestCase): + def test_staged_manifest_requires_exact_recorded_file_set_and_hashes(self): + with tempfile.TemporaryDirectory() as directory: + target = pathlib.Path(directory) + metadata = target / "torch-2.10.0.dist-info" + metadata.mkdir() + wheel = target / "torch.pth" + wheel.write_bytes(b"safe staged data") + digest = ( + base64.urlsafe_b64encode(hashlib.sha256(wheel.read_bytes()).digest()) + .rstrip(b"=") + .decode() + ) + (target / "bin").mkdir() + script = target / "bin" / "torchcmd" + script.write_bytes(b"#!/bin/sh\n") + script_digest = ( + base64.urlsafe_b64encode(hashlib.sha256(script.read_bytes()).digest()) + .rstrip(b"=") + .decode() + ) + (target / "__pycache__").mkdir() + bytecode = target / "__pycache__" / "torch.cpython-312.pyc" + bytecode.write_bytes(b"pip generated") + record = metadata / "RECORD" + record.write_text( + f"torch.pth,sha256={digest},{wheel.stat().st_size}\n" + f"../../bin/torchcmd,sha256={script_digest},{script.stat().st_size}\n" + "__pycache__/torch.cpython-312.pyc,,\n" + "torch-2.10.0.dist-info/RECORD,,\n", + encoding="utf-8", + ) + manifest = resolver.installed_file_manifest(target) + self.assertEqual( + [item["path"] for item in manifest["files"]], + ["bin/torchcmd", "torch-2.10.0.dist-info/RECORD", "torch.pth"], + ) + self.assertFalse(bytecode.exists()) + (target / "unreported.pth").write_text("exec('bad')") + with self.assertRaisesRegex(ValueError, "unreported"): + resolver.installed_file_manifest(target) + (target / "unreported.pth").unlink() + wheel.write_bytes(b"changed") + with self.assertRaisesRegex(ValueError, "hash or size"): + resolver.installed_file_manifest(target) + wheel.write_bytes(b"safe staged data") + (target / "linked.pth").symlink_to(wheel) + with self.assertRaisesRegex(ValueError, "symlink"): + resolver.installed_file_manifest(target) + + def test_staged_distribution_identity_must_match_pip_report(self): + with tempfile.TemporaryDirectory() as directory: + target = pathlib.Path(directory) + dist = target / "torch-2.10.0+cpu.dist-info" + dist.mkdir() + metadata = dist / "METADATA" + good = "Metadata-Version: 2.1\nName: torch\nVersion: 2.10.0+cpu\n" + metadata.write_text(good, encoding="utf-8") + digest = ( + base64.urlsafe_b64encode(hashlib.sha256(metadata.read_bytes()).digest()) + .rstrip(b"=") + .decode() + ) + (dist / "RECORD").write_text( + f"{dist.name}/METADATA,sha256={digest},{metadata.stat().st_size}\n" + f"{dist.name}/RECORD,,\n", + encoding="utf-8", + ) + artifact = [{"name": "torch", "version": "2.10.0+cpu"}] + self.assertEqual(len(resolver.installed_file_manifest(target, artifact)["files"]), 2) + metadata.write_text(good.replace("2.10.0+cpu", "1.0"), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "identity differs"): + resolver.installed_file_manifest(target, artifact) + metadata.write_text(good, encoding="utf-8") + wrong_dist = target / "torch-1.0.dist-info" + dist.rename(wrong_dist) + with self.assertRaisesRegex(ValueError, "identity differs"): + resolver.installed_file_manifest(target, artifact) + + def test_install_mode_stages_once_and_validates_report_before_writing_lock(self): + fixture = report() + commands = [] + + def fake_run(command, **_kwargs): + commands.append(command) + self.assertIn("--target", command) + self.assertNotIn("--dry-run", command) + self.assertIn("--only-binary=:all:", command) + self.assertIn("torch==2.10.0+cpu", command) + pathlib.Path(command[command.index("--report") + 1]).write_text( + json.dumps(fixture), encoding="utf-8" + ) + target_dir = pathlib.Path(command[command.index("--target") + 1]) + for item in fixture["install"]: + name = item["metadata"]["name"] + version = item["metadata"]["version"] + metadata = target_dir / f"{name}-{version}.dist-info" + metadata.mkdir() + metadata_file = metadata / "METADATA" + metadata_file.write_text( + f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n", + encoding="utf-8", + ) + digest = ( + base64.urlsafe_b64encode(hashlib.sha256(metadata_file.read_bytes()).digest()) + .rstrip(b"=") + .decode() + ) + (metadata / "RECORD").write_text( + f"{metadata.name}/METADATA,sha256={digest},{metadata_file.stat().st_size}\n" + f"{metadata.name}/RECORD,,\n", + encoding="utf-8", + ) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + with tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) / "manifest" + target = pathlib.Path(directory) / "staged packages" + with ( + patch.object( + resolver.sys, + "argv", + [ + "resolve_runtime.py", + "--version", + "2.10.0", + "--build", + "cpu", + "--install", + "--target", + str(target), + "--output", + str(output), + ], + ), + patch.object(resolver.subprocess, "run", side_effect=fake_run), + ): + resolver.main() + self.assertEqual(len(commands), 1) + self.assertEqual(commands[0][commands[0].index("--target") + 1], str(target)) + self.assertTrue(target.is_dir()) + self.assertEqual( + json.loads((output / "resolution.json").read_text())["artifacts"][0]["sha256"], + "a" * 64, + ) + self.assertIn("--hash=sha256:" + "a" * 64, (output / "requirements.txt").read_text()) + self.assertEqual( + len(json.loads((output / "installed-files.json").read_text())["files"]), + 2 * len(fixture["install"]), + ) + + def test_install_mode_rejects_untrusted_or_hashless_report_before_lock_publication(self): + fixtures = { + "untrusted origin": report(url="https://example.com/torch.whl"), + "missing digest": report(), + "malformed report": {"install": "not a list"}, + } + fixtures["missing digest"]["install"][0]["download_info"]["archive_info"] = {} + for name, fixture in fixtures.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) / "manifest" + target = pathlib.Path(directory) / "staged packages" + + def fake_run(command, **_kwargs): + pathlib.Path(command[command.index("--report") + 1]).write_text( + json.dumps(fixture), encoding="utf-8" + ) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + with ( + patch.object( + resolver.sys, + "argv", + [ + "resolve_runtime.py", + "--version", + "2.10.0", + "--build", + "cpu", + "--install", + "--target", + str(target), + "--output", + str(output), + ], + ), + patch.object(resolver.subprocess, "run", side_effect=fake_run), + redirect_stderr(StringIO()), + ): + with self.assertRaises(SystemExit) as exit_result: + resolver.main() + self.assertEqual(exit_result.exception.code, 3) + self.assertFalse((output / "requirements.txt").exists()) + self.assertFalse((output / "resolution.json").exists()) + + def test_install_mode_requires_an_empty_explicit_staged_target(self): + with tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) / "manifest" + target = pathlib.Path(directory) / "staged packages" + target.mkdir() + (target / "existing.py").write_text("leave intact", encoding="utf-8") + with ( + patch.object( + resolver.sys, + "argv", + [ + "resolve_runtime.py", + "--version", + "2.10.0", + "--build", + "cpu", + "--install", + "--target", + str(target), + "--output", + str(output), + ], + ), + patch.object(resolver.subprocess, "run") as run, + redirect_stderr(StringIO()), + ): + with self.assertRaises(SystemExit) as exit_result: + resolver.main() + self.assertEqual(exit_result.exception.code, 2) + run.assert_not_called() + self.assertEqual((target / "existing.py").read_text(), "leave intact") + + def test_install_mode_reports_pip_failure_without_publishing_a_lock(self): + with tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) / "manifest" + target = pathlib.Path(directory) / "staged packages" + failed = SimpleNamespace( + returncode=1, + stdout="", + stderr="No matching distribution found for torch==2.10.0+cpu", + ) + with ( + patch.object( + resolver.sys, + "argv", + [ + "resolve_runtime.py", + "--version", + "2.10.0", + "--build", + "cpu", + "--install", + "--target", + str(target), + "--output", + str(output), + ], + ), + patch.object(resolver.subprocess, "run", return_value=failed), + redirect_stderr(StringIO()), + ): + with self.assertRaises(SystemExit) as exit_result: + resolver.main() + self.assertEqual(exit_result.exception.code, 4) + self.assertFalse((output / "requirements.txt").exists()) + self.assertFalse((output / "resolution.json").exists()) + def test_bootstrap_platform_tags_keep_only_the_native_host(self): cases = ( ("win32", "AMD64", ["win_amd64", "win32"], ["win_amd64"], "windows"), From c44627f940f7ec847ab0f4f2878a3ee77082428b Mon Sep 17 00:00:00 2001 From: MrScripty Date: Sat, 26 Sep 2026 15:51:07 -0700 Subject: [PATCH 05/19] Fix Torch RECORD validation and track network activity --- .../execution-ledger.md | 38 ++ .../plan.md | 23 + .../upstream-version-manager-progress.md | 34 ++ electron/src/generated/desktop-contract.ts | 4 +- .../desktop-contract.validators.d.ts | 2 +- .../generated/desktop-contract.validators.js | 229 +++++----- electron/tests/preload-rpc-contract.test.mjs | 3 +- .../components/ProgressDetailsView.test.tsx | 51 ++- .../src/components/ProgressDetailsView.tsx | 66 ++- frontend/src/components/StatusFooter.test.tsx | 30 ++ frontend/src/components/StatusFooter.tsx | 5 +- .../components/TorchInstallPreview.test.tsx | 1 + frontend/src/generated/desktop-contract.ts | 4 +- .../desktop-contract.validators.d.ts | 2 +- .../generated/desktop-contract.validators.js | 229 +++++----- .../installationProgressTracking.test.ts | 7 +- .../src/hooks/installationProgressTracking.ts | 16 +- .../src/hooks/useInstallationManager.test.ts | 4 + frontend/src/hooks/useInstallationManager.ts | 7 + frontend/src/types/versions.ts | 2 + .../src/version_manager/installer/torch.rs | 294 +++++++++++- .../src/version_manager/progress.rs | 239 +++++++++- rust/crates/pumas-core/src/models/version.rs | 4 + .../crates/pumas-core/src/network/activity.rs | 423 ++++++++++++++++++ rust/crates/pumas-core/src/network/mod.rs | 6 + rust/crates/pumas-rpc/src/contract.rs | 8 +- torch-server/resolve_runtime.py | 181 +++++++- torch-server/tests/test_resolve_runtime.py | 139 ++++++ 28 files changed, 1800 insertions(+), 251 deletions(-) create mode 100644 frontend/src/components/StatusFooter.test.tsx create mode 100644 rust/crates/pumas-core/src/network/activity.rs diff --git a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md index d739703a..b37fc832 100644 --- a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md +++ b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md @@ -1,5 +1,43 @@ # Execution Ledger: Cross-Platform Torch Runtime Management +## 2026-09-26 — Torch 2.14.0 RECORD validation repair and network activity + +- Diagnosed `launcher-data/logs/install-v2.14.0-1790458099378.log`: Torch + 2.14.0+cu132 and its dependencies downloaded and pip installed successfully, + then Pumas rejected the staged manifest with `Staged wheel RECORD escapes its + target`. SymPy's official wheel records `../../share/man/man1/isympy.1` under + pip's `--target` data scheme; pip correctly staged that file inside + `staged-packages/share/man/man1/`. +- Fixed RECORD normalization to map recognized pip target scheme roots + (`bin`, `share`, `Scripts`, and `Include`) into the staging root while + continuing to reject traversal and files outside the staged tree. A regression + test covers the SymPy-style share entry and rejects `../../etc/passwd` and a + nested share traversal. The exact cached SymPy wheel replay validated all + 1,573 staged files, including the man page. +- Added the reusable platform-neutral network activity registry and connected + Torch pip downloads plus the installer's existing download callback. It + tracks concurrent operation/transfer identity, cumulative payload bytes, + expected bytes, rate, state, and measurement coverage. Allowed source URLs are + copyable; unapproved URLs remain hidden while their byte rate is still + reported. Generic DownloadManager/Hugging Face adapters and OS-level + supplemental providers remain open; X8 is partial. +- Verification passed: 54 resolver tests, Ruff, 231 app-manager tests, 725 + frontend tests, frontend typecheck and production build, Rust formatting, + desktop RPC `/health`, artifact checks, extracted AppImage/deb resource and + `/health` smoke tests, and `git diff --check`. The release smoke needed local + loopback permission; the initial Electron build needed access to its GitHub + packaging helper. Astra high's architecture review and Sol xhigh's narrow + source-privacy/fixture review found no remaining blocker. +- Rebuilt local Linux v0.7.0 candidates: AppImage is 154,937,978 bytes with + SHA-256 `9125fc53b9ff0d6bfbc02e2f3394e799418883dc3e80a262ce2758065ddae76e`; + deb is 120,530,972 bytes with SHA-256 + `777488fcf931a1e72e5508d35ed2c4caa7b65adb6e87e744dd0367b8bba880d3`. + These local packages pass extracted-resource and backend health checks. The + failed Torch install was not repeated through the rebuilt desktop package; + no fresh end-to-end Torch runtime or image-generation claim is made. Local + builds do not update the public toolbar-linked release. Native Windows/macOS + package acceptance, CUDA/MPS execution, and Tuldok generation remain open. + ## 2026-09-26 — Direct-install local Linux release candidate - Rebuilt the local v0.7.0 RPC backend and Electron AppImage/deb from the diff --git a/docs/plans/torch-cross-platform-runtime-management/plan.md b/docs/plans/torch-cross-platform-runtime-management/plan.md index 4d662d76..a2679602 100644 --- a/docs/plans/torch-cross-platform-runtime-management/plan.md +++ b/docs/plans/torch-cross-platform-runtime-management/plan.md @@ -346,6 +346,28 @@ resolver, manager, desktop, and platform runtime owners. The target uv binary and its embedded interpreter catalog are pinned together. Do not use ambient uv or runtime `latest` URLs. +## Network activity monitoring + +- Use one platform-neutral activity registry for transfer observations, keyed + by operation and transfer IDs. Producers report cumulative bytes, optional + expected size/source, direction, state, measurement basis, and coverage; the + registry calculates rates from monotonic samples and never adds overlapping + payload and OS wire counters. +- The first adapter is Torch package installation. The existing installer + download callback and the pip wheel worker feed the same registry. The + desktop progress view shows the measured payload rate and a copyable URL only + for sources on Pumas' approved artifact allowlist; other sources still report + byte rate without exposing an unapproved link. +- Keep OS process monitoring optional. It may add partial wire-byte observations + under the same operation/transfer model, but permission-dependent OS counters + cannot identify a repository or artifact and must not be presented as the + exact package transfer. No Linux-only or administrator-required provider is + assumed by the shared API. +- Current coverage is partial: the generic `DownloadManager` and Hugging Face + stream paths still need adapters, and no OS process sampler is implemented. + Do not claim that all Pumas network traffic is visible until those producer + paths and platform permissions are tested. + ## Acceptance claims | ID | Observable criterion | Evidence required | Status | @@ -357,6 +379,7 @@ resolver, manager, desktop, and platform runtime owners. | X5 | The actual install attempt retains the upstream distribution version, build, official wheel URL/hash, Python provider artifact identity, interpreter fingerprint, and complete dependency report; validates installed package names/versions and the exact staged file manifest before publication | Historical run `36223106097` resolved 25 hashed CPU/Core artifacts through the previous preview path on all targets; current direct-install resolver and Rust provenance tests cover post-install checks. Native packaged direct-install acceptance is pending. | partial | | X6 | Installed versions can be inspected, explicitly selected, started with health/protocol checks, and stopped by their owned generation; cancellation, timeout, RPC shutdown, and failed cleanup do not leak a resolver, installer, sidecar, interpreter provisioner, or unregistered runtime | Run `36223106097` passed second-session restart, fresh CPU operation, probe revalidation, protocol 3 sidecar trial/stop, and graceful shutdown on Linux, Windows, and macOS (see X2 reports); broader cancellation/timeout/failure cleanup remains | partial | | X7 | The same manager choices and lifecycle are reachable through packaged desktop controls and the existing RPC API; the user never has to choose Python, and the desktop offers no unsupported adapter | In the historical Linux v0.7.0 AppImage UI install, the first sampled state at 9 seconds showed the current phase in the header, indeterminate bars, Cancel, and readable controls under the previous package-resolution flow. A separate interaction/readability follow-up observed immediate artifact-check status but did not install again. The direct-install local AppImage/deb are now built and pass extracted-resource/backend health checks; source/UI tests cover local selection and visible install state. Manual packaged install remains pending. Packaged Windows/macOS installation remains unverified. | partial | +| X8 | Network activity from each integrated producer uses the shared operation/transfer registry with explicit payload-versus-wire basis, coverage, lifecycle, and source; UI rates are derived from cumulative samples and artifact links are validated | Torch pip and installer progress use the registry; copyable allowlisted wheel URLs and active/idle speed are covered by Rust and frontend tests. Generic `DownloadManager` and Hugging Face adapters, OS telemetry providers, and cross-platform permission behavior remain open. | partial | Plan acceptance is `pending` until every required row is satisfied on its declared native environment. Cross-compilation, Linux simulation, packaging, diff --git a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md index 7e5c1a5c..50f3a939 100644 --- a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md +++ b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md @@ -7,6 +7,40 @@ inventory, not a completed Pumas desktop acceptance. The prior evidence below uses a separate, exact Torch 2.10 tuple and does not qualify a newly resolved upstream release. +## 2026-09-26 — Torch 2.14.0 staged RECORD failure and repaired local package + +The reported install log shows that Torch 2.14.0+cu132 and its dependencies +downloaded and pip installed into private staging; the failure occurred during +Pumas' staged-file manifest validation. SymPy 1.14.0's wheel places +`isympy.1` under `share/man/man1` using pip's valid `../../share/...` target +scheme. Pumas previously recognized the `../../bin/...` scheme only, so it +misclassified this in-stage file as a path escape. + +The resolver now maps the known pip target scheme roots (`bin`, `share`, +`Scripts`, and `Include`) into the stage before applying containment, file, +size, and hash validation. Regression coverage accepts the SymPy share entry +and still rejects paths into `/etc` or outside the stage. Python tests replayed +the cached SymPy wheel and validated 1,573 staged files. Verification also +passed 54 resolver tests, Ruff, 231 app-manager tests, 725 frontend tests, +frontend typecheck/build, Rust formatting, RPC health, Linux artifact checks, +and extracted AppImage/deb resource and backend health smoke tests. + +The rebuilt local AppImage is 154,937,978 bytes, SHA-256 +`9125fc53b9ff0d6bfbc02e2f3394e799418883dc3e80a262ce2758065ddae76e`; the deb +is 120,530,972 bytes, SHA-256 +`777488fcf931a1e72e5508d35ed2c4caa7b65adb6e87e744dd0367b8bba880d3`. These +are local, unpublished candidates and do not update the toolbar-linked release. +The rebuilt desktop package has not yet been used for a fresh full Torch +install, and this repair provides no new CUDA, Tuldok image, Windows, or macOS +runtime evidence. + +The same work adds a shared network activity registry used by Torch wheel +downloads and installer progress. It exposes payload rates across approved and +unapproved sources, while only approved HTTPS artifact URLs are copyable. +Generic DownloadManager and Hugging Face producers, OS-level supplementary +sampling, and native cross-platform permissions remain unimplemented; network +monitoring acceptance is partial. + ## 2026-09-26 — Replace the long Torch check with the real install attempt The owner rejected the previous multi-minute compatibility check. For the diff --git a/electron/src/generated/desktop-contract.ts b/electron/src/generated/desktop-contract.ts index 5840014e..414d4d01 100644 --- a/electron/src/generated/desktop-contract.ts +++ b/electron/src/generated/desktop-contract.ts @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 2746aabed7ca8f2468fbdf4a2af56857fdbeb27466a24a67fb7a3e8bfb36736b. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. import { validateAvailableVersionsOutcome, validateBackendStatusOutcome, validateCancelInstallationOutcome, validateCatalogSearchOutcome, validateCheckVersionDependenciesOutcome, validateCheckVersionDependenciesParams, validateConversionCancelledOutcome, validateConversionEnvironmentOutcome, validateConversionListOutcome, validateConversionProgressResponse, validateConversionSetupStartedOutcome, validateConversionSetupStatusOutcome, validateConversionStartedOutcome, validateDownloadIdParams, validateDownloadListOutcome, validateDownloadMutationOutcome, validateDownloadStartedOutcome, validateDownloadStatusOutcome, validateFindTorchAlternativesParams, validateGetBackendSetupParams, validateGetHfDownloadDetailsParams, validateGetReleaseDependenciesOutcome, validateGetReleaseDependenciesParams, validateGetTorchPreviewReportParams, validateGetTorchReleaseOptionsParams, validateGetTorchRuntimeProbeParams, validateGithubCacheStatusOutcome, validateHfDownloadDetailsOutcome, validateInferenceSettingsOutcome, validateInstallVersionDependenciesOutcome, validateInstallVersionDependenciesParams, validateInstallVersionOutcome, validateInstallVersionParams, validateInstallationProgressOutcome, validateInstalledVersionsOutcome, validateLibraryModelMetadataOutcome, validateLinkHealthOutcome, validateModelIndexRefreshOutcome, validateModelsOutcome, validatePartialDownloadOutcome, validatePreviewTorchRuntimeParams, validatePublicError, validateRecoverDownloadParams, validateRemoveVersionOutcome, validateRouterProfileSyncStatus, validateRuntimeLaunchOutcome, validateRuntimeLaunchParams, validateRuntimeRunningOutcome, validateRuntimeStopOutcome, validateSearchCatalogParams, validateSelectedVersionOutcome, validateSetDefaultVersionOutcome, validateSetDefaultVersionParams, validateStartBackendSetupParams, validateStartConversionSetupParams, validateStopRuntimeProfileGenerationOutcome, validateStopRuntimeProfileGenerationParams, validateSuccessOutcome, validateSupportedQuantTypesOutcome, validateSwitchVersionOutcome, validateSwitchVersionParams, validateTorchDriverAvailability, validateTorchGpuVendor, validateTorchReleaseCombination, validateTorchReleaseDriverStatus, validateTorchReleaseOptionsOutcome, validateTorchReleaseOptionsStatus, validateTorchReleaseRecommendation, validateTorchRuntimePreview, validateTorchRuntimePreviewArtifact, validateTorchRuntimePreviewOutcome, validateTorchRuntimePreviewQualification, validateTorchRuntimePreviewRejectionReason, validateTrialTorchRuntimeOutcome, validateTrialTorchRuntimeParams, validateUpdateInferenceSettingsOutcome, validateUpdateInferenceSettingsParams, validateUpdateModelNotesOutcome, validateUpdateModelNotesParams, validateValidateInstallationsOutcome, validateVersionInfoOutcome, validateVersionStatusOutcome } from './desktop-contract.validators.js'; export type AvailableVersionsOutcome = (AvailableVersionsSuccess) | (AvailableVersionsRateLimited); export type AvailableVersionsRateLimited = { "error": string; "rate_limited": true; "retry_after_secs": number | null; "success": false }; @@ -91,7 +91,7 @@ export type RemoveVersionOutcome = { "success": boolean }; export type RouterCatalogState = "current" | "pending" | "uncertain"; export type RouterObservationState = "connecting" | "current" | "unavailable"; export type RouterProfileSyncStatus = ({ "catalog_state": "current" | "pending"; "generation": number; "last_error": string | null; "observation_state": RouterObservationState; "pending_model_ids": ReadonlyArray; "profile_id": string }) | ({ "catalog_state": "uncertain"; "generation": number; "last_error": string | null; "observation_state": "unavailable"; "pending_model_ids": ReadonlyArray; "profile_id": string }); -export type RuntimeInstallationProgress = { "completedAt": string | null; "completedDependencies": number; "completedItems": ReadonlyArray; "currentItem": string | null; "dependencyCount": number | null; "downloadSpeed": number | null; "downloadedBytes": number; "error": string | null; "etaSeconds": number | null; "logPath": string | null; "overallProgress": number | null; "stage": RuntimeInstallationStage; "stageProgress": number | null; "startedAt": string; "success": boolean | null; "tag": string; "totalSize": number | null }; +export type RuntimeInstallationProgress = { "completedAt": string | null; "completedDependencies": number; "completedItems": ReadonlyArray; "currentItem": string | null; "dependencyCount": number | null; "downloadActive": boolean; "downloadSourceUrl": string | null; "downloadSpeed": number | null; "downloadedBytes": number; "error": string | null; "etaSeconds": number | null; "logPath": string | null; "overallProgress": number | null; "stage": RuntimeInstallationStage; "stageProgress": number | null; "startedAt": string; "success": boolean | null; "tag": string; "totalSize": number | null }; export type RuntimeInstallationProgressItem = { "completedAt": string; "name": string; "size": number | null; "type": string }; export type RuntimeInstallationStage = "resolving" | "download" | "extract" | "venv" | "dependencies" | "setup"; export type RuntimeLaunchOutcome = { "error"?: string; "log_path"?: string; "ready"?: boolean; "success": boolean }; diff --git a/electron/src/generated/desktop-contract.validators.d.ts b/electron/src/generated/desktop-contract.validators.d.ts index c1038b56..886c729d 100644 --- a/electron/src/generated/desktop-contract.validators.d.ts +++ b/electron/src/generated/desktop-contract.validators.d.ts @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 2746aabed7ca8f2468fbdf4a2af56857fdbeb27466a24a67fb7a3e8bfb36736b. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. export declare function validateAvailableVersionsOutcome(value: unknown): boolean; export declare function validateBackendStatusOutcome(value: unknown): boolean; export declare function validateCancelInstallationOutcome(value: unknown): boolean; diff --git a/electron/src/generated/desktop-contract.validators.js b/electron/src/generated/desktop-contract.validators.js index 6d62c8d7..1ffe18e9 100644 --- a/electron/src/generated/desktop-contract.validators.js +++ b/electron/src/generated/desktop-contract.validators.js @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 2746aabed7ca8f2468fbdf4a2af56857fdbeb27466a24a67fb7a3e8bfb36736b. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. var __getOwnPropNames = Object.getOwnPropertyNames; var __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; @@ -9138,7 +9138,7 @@ function validate66(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } var validateInstallationProgressOutcome = validate74; -var schema82 = { "additionalProperties": false, "properties": { "completedAt": { "type": ["string", "null"] }, "completedDependencies": { "maximum": 4294967295, "minimum": 0, "type": "integer" }, "completedItems": { "items": { "$ref": "#/definitions/RuntimeInstallationProgressItem" }, "type": "array" }, "currentItem": { "type": ["string", "null"] }, "dependencyCount": { "maximum": 4294967295, "minimum": 0, "type": ["integer", "null"] }, "downloadSpeed": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "downloadedBytes": { "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, "error": { "type": ["string", "null"] }, "etaSeconds": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "logPath": { "type": ["string", "null"] }, "overallProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "stage": { "$ref": "#/definitions/RuntimeInstallationStage" }, "stageProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "startedAt": { "type": "string" }, "success": { "type": ["boolean", "null"] }, "tag": { "type": "string" }, "totalSize": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] } }, "required": ["tag", "startedAt", "stage", "stageProgress", "overallProgress", "currentItem", "downloadSpeed", "etaSeconds", "totalSize", "downloadedBytes", "dependencyCount", "completedDependencies", "completedItems", "error", "completedAt", "success", "logPath"], "type": "object" }; +var schema82 = { "additionalProperties": false, "properties": { "completedAt": { "type": ["string", "null"] }, "completedDependencies": { "maximum": 4294967295, "minimum": 0, "type": "integer" }, "completedItems": { "items": { "$ref": "#/definitions/RuntimeInstallationProgressItem" }, "type": "array" }, "currentItem": { "type": ["string", "null"] }, "dependencyCount": { "maximum": 4294967295, "minimum": 0, "type": ["integer", "null"] }, "downloadActive": { "type": "boolean" }, "downloadSourceUrl": { "type": ["string", "null"] }, "downloadSpeed": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "downloadedBytes": { "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, "error": { "type": ["string", "null"] }, "etaSeconds": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "logPath": { "type": ["string", "null"] }, "overallProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "stage": { "$ref": "#/definitions/RuntimeInstallationStage" }, "stageProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "startedAt": { "type": "string" }, "success": { "type": ["boolean", "null"] }, "tag": { "type": "string" }, "totalSize": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] } }, "required": ["tag", "startedAt", "stage", "stageProgress", "overallProgress", "currentItem", "downloadSourceUrl", "downloadActive", "downloadSpeed", "etaSeconds", "totalSize", "downloadedBytes", "dependencyCount", "completedDependencies", "completedItems", "error", "completedAt", "success", "logPath"], "type": "object" }; var schema83 = { "additionalProperties": false, "properties": { "completedAt": { "type": "string" }, "name": { "type": "string" }, "size": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] }, "type": { "type": "string" } }, "required": ["name", "type", "size", "completedAt"], "type": "object" }; var schema84 = { "enum": ["resolving", "download", "extract", "venv", "dependencies", "setup"], "type": "string" }; function validate75(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { @@ -9147,7 +9147,7 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; - if (data.tag === void 0 && (missing0 = "tag") || data.startedAt === void 0 && (missing0 = "startedAt") || data.stage === void 0 && (missing0 = "stage") || data.stageProgress === void 0 && (missing0 = "stageProgress") || data.overallProgress === void 0 && (missing0 = "overallProgress") || data.currentItem === void 0 && (missing0 = "currentItem") || data.downloadSpeed === void 0 && (missing0 = "downloadSpeed") || data.etaSeconds === void 0 && (missing0 = "etaSeconds") || data.totalSize === void 0 && (missing0 = "totalSize") || data.downloadedBytes === void 0 && (missing0 = "downloadedBytes") || data.dependencyCount === void 0 && (missing0 = "dependencyCount") || data.completedDependencies === void 0 && (missing0 = "completedDependencies") || data.completedItems === void 0 && (missing0 = "completedItems") || data.error === void 0 && (missing0 = "error") || data.completedAt === void 0 && (missing0 = "completedAt") || data.success === void 0 && (missing0 = "success") || data.logPath === void 0 && (missing0 = "logPath")) { + if (data.tag === void 0 && (missing0 = "tag") || data.startedAt === void 0 && (missing0 = "startedAt") || data.stage === void 0 && (missing0 = "stage") || data.stageProgress === void 0 && (missing0 = "stageProgress") || data.overallProgress === void 0 && (missing0 = "overallProgress") || data.currentItem === void 0 && (missing0 = "currentItem") || data.downloadSourceUrl === void 0 && (missing0 = "downloadSourceUrl") || data.downloadActive === void 0 && (missing0 = "downloadActive") || data.downloadSpeed === void 0 && (missing0 = "downloadSpeed") || data.etaSeconds === void 0 && (missing0 = "etaSeconds") || data.totalSize === void 0 && (missing0 = "totalSize") || data.downloadedBytes === void 0 && (missing0 = "downloadedBytes") || data.dependencyCount === void 0 && (missing0 = "dependencyCount") || data.completedDependencies === void 0 && (missing0 = "completedDependencies") || data.completedItems === void 0 && (missing0 = "completedItems") || data.error === void 0 && (missing0 = "error") || data.completedAt === void 0 && (missing0 = "completedAt") || data.success === void 0 && (missing0 = "success") || data.logPath === void 0 && (missing0 = "logPath")) { validate75.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { @@ -9343,83 +9343,69 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.downloadSpeed !== void 0) { - let data10 = data.downloadSpeed; + if (data.downloadActive !== void 0) { const _errs24 = errors; - if (!(typeof data10 == "number" && isFinite(data10)) && data10 !== null) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/type", keyword: "type", params: { type: schema82.properties.downloadSpeed.type }, message: "must be number,null" }]; + if (typeof data.downloadActive !== "boolean") { + validate75.errors = [{ instancePath: instancePath + "/downloadActive", schemaPath: "#/properties/downloadActive/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; return false; } - if (errors === _errs24) { - if (typeof data10 == "number" && isFinite(data10)) { - if (data10 > 17976931348623157e292 || isNaN(data10)) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; - return false; - } else { - if (data10 < -17976931348623157e292 || isNaN(data10)) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; - return false; - } - } - } - } var valid0 = _errs24 === errors; } else { var valid0 = true; } if (valid0) { - if (data.downloadedBytes !== void 0) { - let data11 = data.downloadedBytes; + if (data.downloadSourceUrl !== void 0) { + let data11 = data.downloadSourceUrl; const _errs26 = errors; - if (!(typeof data11 == "number" && (!(data11 % 1) && !isNaN(data11)) && isFinite(data11))) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; + if (typeof data11 !== "string" && data11 !== null) { + validate75.errors = [{ instancePath: instancePath + "/downloadSourceUrl", schemaPath: "#/properties/downloadSourceUrl/type", keyword: "type", params: { type: schema82.properties.downloadSourceUrl.type }, message: "must be string,null" }]; return false; } - if (errors === _errs26) { - if (typeof data11 == "number" && isFinite(data11)) { - if (data11 > 9007199254740991 || isNaN(data11)) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; - return false; - } else { - if (data11 < 0 || isNaN(data11)) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; - return false; - } - } - } - } var valid0 = _errs26 === errors; } else { var valid0 = true; } if (valid0) { - if (data.error !== void 0) { - let data12 = data.error; + if (data.downloadSpeed !== void 0) { + let data12 = data.downloadSpeed; const _errs28 = errors; - if (typeof data12 !== "string" && data12 !== null) { - validate75.errors = [{ instancePath: instancePath + "/error", schemaPath: "#/properties/error/type", keyword: "type", params: { type: schema82.properties.error.type }, message: "must be string,null" }]; + if (!(typeof data12 == "number" && isFinite(data12)) && data12 !== null) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/type", keyword: "type", params: { type: schema82.properties.downloadSpeed.type }, message: "must be number,null" }]; return false; } + if (errors === _errs28) { + if (typeof data12 == "number" && isFinite(data12)) { + if (data12 > 17976931348623157e292 || isNaN(data12)) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; + return false; + } else { + if (data12 < -17976931348623157e292 || isNaN(data12)) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; + return false; + } + } + } + } var valid0 = _errs28 === errors; } else { var valid0 = true; } if (valid0) { - if (data.etaSeconds !== void 0) { - let data13 = data.etaSeconds; + if (data.downloadedBytes !== void 0) { + let data13 = data.downloadedBytes; const _errs30 = errors; - if (!(typeof data13 == "number" && isFinite(data13)) && data13 !== null) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/type", keyword: "type", params: { type: schema82.properties.etaSeconds.type }, message: "must be number,null" }]; + if (!(typeof data13 == "number" && (!(data13 % 1) && !isNaN(data13)) && isFinite(data13))) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; return false; } if (errors === _errs30) { if (typeof data13 == "number" && isFinite(data13)) { - if (data13 > 17976931348623157e292 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; + if (data13 > 9007199254740991 || isNaN(data13)) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; return false; } else { - if (data13 < -17976931348623157e292 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; + if (data13 < 0 || isNaN(data13)) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; return false; } } @@ -9430,11 +9416,11 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.logPath !== void 0) { - let data14 = data.logPath; + if (data.error !== void 0) { + let data14 = data.error; const _errs32 = errors; if (typeof data14 !== "string" && data14 !== null) { - validate75.errors = [{ instancePath: instancePath + "/logPath", schemaPath: "#/properties/logPath/type", keyword: "type", params: { type: schema82.properties.logPath.type }, message: "must be string,null" }]; + validate75.errors = [{ instancePath: instancePath + "/error", schemaPath: "#/properties/error/type", keyword: "type", params: { type: schema82.properties.error.type }, message: "must be string,null" }]; return false; } var valid0 = _errs32 === errors; @@ -9442,21 +9428,21 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.overallProgress !== void 0) { - let data15 = data.overallProgress; + if (data.etaSeconds !== void 0) { + let data15 = data.etaSeconds; const _errs34 = errors; if (!(typeof data15 == "number" && isFinite(data15)) && data15 !== null) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/type", keyword: "type", params: { type: schema82.properties.overallProgress.type }, message: "must be number,null" }]; + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/type", keyword: "type", params: { type: schema82.properties.etaSeconds.type }, message: "must be number,null" }]; return false; } if (errors === _errs34) { if (typeof data15 == "number" && isFinite(data15)) { - if (data15 > 34028234663852886e22 || isNaN(data15)) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; + if (data15 > 17976931348623157e292 || isNaN(data15)) { + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; return false; } else { - if (data15 < -34028234663852886e22 || isNaN(data15)) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; + if (data15 < -17976931348623157e292 || isNaN(data15)) { + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; return false; } } @@ -9467,15 +9453,11 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.stage !== void 0) { - let data16 = data.stage; + if (data.logPath !== void 0) { + let data16 = data.logPath; const _errs36 = errors; - if (typeof data16 !== "string") { - validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; - return false; - } - if (!(data16 === "resolving" || data16 === "download" || data16 === "extract" || data16 === "venv" || data16 === "dependencies" || data16 === "setup")) { - validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/enum", keyword: "enum", params: { allowedValues: schema84.enum }, message: "must be equal to one of the allowed values" }]; + if (typeof data16 !== "string" && data16 !== null) { + validate75.errors = [{ instancePath: instancePath + "/logPath", schemaPath: "#/properties/logPath/type", keyword: "type", params: { type: schema82.properties.logPath.type }, message: "must be string,null" }]; return false; } var valid0 = _errs36 === errors; @@ -9483,58 +9465,76 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.stageProgress !== void 0) { - let data17 = data.stageProgress; - const _errs39 = errors; + if (data.overallProgress !== void 0) { + let data17 = data.overallProgress; + const _errs38 = errors; if (!(typeof data17 == "number" && isFinite(data17)) && data17 !== null) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/type", keyword: "type", params: { type: schema82.properties.stageProgress.type }, message: "must be number,null" }]; + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/type", keyword: "type", params: { type: schema82.properties.overallProgress.type }, message: "must be number,null" }]; return false; } - if (errors === _errs39) { + if (errors === _errs38) { if (typeof data17 == "number" && isFinite(data17)) { if (data17 > 34028234663852886e22 || isNaN(data17)) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; return false; } else { if (data17 < -34028234663852886e22 || isNaN(data17)) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; return false; } } } } - var valid0 = _errs39 === errors; + var valid0 = _errs38 === errors; } else { var valid0 = true; } if (valid0) { - if (data.startedAt !== void 0) { - const _errs41 = errors; - if (typeof data.startedAt !== "string") { - validate75.errors = [{ instancePath: instancePath + "/startedAt", schemaPath: "#/properties/startedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + if (data.stage !== void 0) { + let data18 = data.stage; + const _errs40 = errors; + if (typeof data18 !== "string") { + validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + return false; + } + if (!(data18 === "resolving" || data18 === "download" || data18 === "extract" || data18 === "venv" || data18 === "dependencies" || data18 === "setup")) { + validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/enum", keyword: "enum", params: { allowedValues: schema84.enum }, message: "must be equal to one of the allowed values" }]; return false; } - var valid0 = _errs41 === errors; + var valid0 = _errs40 === errors; } else { var valid0 = true; } if (valid0) { - if (data.success !== void 0) { - let data19 = data.success; + if (data.stageProgress !== void 0) { + let data19 = data.stageProgress; const _errs43 = errors; - if (typeof data19 !== "boolean" && data19 !== null) { - validate75.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: schema82.properties.success.type }, message: "must be boolean,null" }]; + if (!(typeof data19 == "number" && isFinite(data19)) && data19 !== null) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/type", keyword: "type", params: { type: schema82.properties.stageProgress.type }, message: "must be number,null" }]; return false; } + if (errors === _errs43) { + if (typeof data19 == "number" && isFinite(data19)) { + if (data19 > 34028234663852886e22 || isNaN(data19)) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; + return false; + } else { + if (data19 < -34028234663852886e22 || isNaN(data19)) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; + return false; + } + } + } + } var valid0 = _errs43 === errors; } else { var valid0 = true; } if (valid0) { - if (data.tag !== void 0) { + if (data.startedAt !== void 0) { const _errs45 = errors; - if (typeof data.tag !== "string") { - validate75.errors = [{ instancePath: instancePath + "/tag", schemaPath: "#/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + if (typeof data.startedAt !== "string") { + validate75.errors = [{ instancePath: instancePath + "/startedAt", schemaPath: "#/properties/startedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs45 === errors; @@ -9542,29 +9542,54 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.totalSize !== void 0) { - let data21 = data.totalSize; + if (data.success !== void 0) { + let data21 = data.success; const _errs47 = errors; - if (!(typeof data21 == "number" && (!(data21 % 1) && !isNaN(data21)) && isFinite(data21)) && data21 !== null) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/type", keyword: "type", params: { type: schema82.properties.totalSize.type }, message: "must be integer,null" }]; + if (typeof data21 !== "boolean" && data21 !== null) { + validate75.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: schema82.properties.success.type }, message: "must be boolean,null" }]; return false; } - if (errors === _errs47) { - if (typeof data21 == "number" && isFinite(data21)) { - if (data21 > 9007199254740991 || isNaN(data21)) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; + var valid0 = _errs47 === errors; + } else { + var valid0 = true; + } + if (valid0) { + if (data.tag !== void 0) { + const _errs49 = errors; + if (typeof data.tag !== "string") { + validate75.errors = [{ instancePath: instancePath + "/tag", schemaPath: "#/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + return false; + } + var valid0 = _errs49 === errors; + } else { + var valid0 = true; + } + if (valid0) { + if (data.totalSize !== void 0) { + let data23 = data.totalSize; + const _errs51 = errors; + if (!(typeof data23 == "number" && (!(data23 % 1) && !isNaN(data23)) && isFinite(data23)) && data23 !== null) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/type", keyword: "type", params: { type: schema82.properties.totalSize.type }, message: "must be integer,null" }]; return false; - } else { - if (data21 < 0 || isNaN(data21)) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; - return false; + } + if (errors === _errs51) { + if (typeof data23 == "number" && isFinite(data23)) { + if (data23 > 9007199254740991 || isNaN(data23)) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; + return false; + } else { + if (data23 < 0 || isNaN(data23)) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + return false; + } + } } } + var valid0 = _errs51 === errors; + } else { + var valid0 = true; } } - var valid0 = _errs47 === errors; - } else { - var valid0 = true; } } } diff --git a/electron/tests/preload-rpc-contract.test.mjs b/electron/tests/preload-rpc-contract.test.mjs index 69bf9832..3f104d08 100644 --- a/electron/tests/preload-rpc-contract.test.mjs +++ b/electron/tests/preload-rpc-contract.test.mjs @@ -459,7 +459,8 @@ test('installation progress rejects malformed replies and preserves nullable cam const harness = loadCompiledPreload(); const valid = { tag: ' vλ.1 ', startedAt: 'started', stage: 'dependencies', stageProgress: 125.5, - overallProgress: 107.25, currentItem: 'torch', downloadSpeed: 42.5, + overallProgress: 107.25, currentItem: 'torch', downloadSourceUrl: null, + downloadActive: false, downloadSpeed: 42.5, etaSeconds: 0.5, totalSize: 1024, downloadedBytes: 512, dependencyCount: 2, completedDependencies: 1, completedItems: [{ name: 'torch', type: 'package', size: null, completedAt: 'done' }], diff --git a/frontend/src/components/ProgressDetailsView.test.tsx b/frontend/src/components/ProgressDetailsView.test.tsx index 80853b54..e330454d 100644 --- a/frontend/src/components/ProgressDetailsView.test.tsx +++ b/frontend/src/components/ProgressDetailsView.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { ProgressDetailsView } from './ProgressDetailsView'; import type { InstallationProgress } from '../hooks/useVersions'; @@ -68,6 +68,55 @@ describe('ProgressDetailsView', () => { expect(screen.getByRole('progressbar', { name: 'Final Setup progress' })).toHaveAttribute('aria-valuenow', '40'); }); + it('shows measured network speed and copies the active wheel source', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + try { + const { rerender } = render(); + + expect(screen.getByText('Network download')).toBeInTheDocument(); + expect(screen.getByText('12.0 MB/s')).toBeInTheDocument(); + expect(screen.getByText('Latest file source')).toBeInTheDocument(); + expect(screen.getByText('https://download.pytorch.org/whl/cu134/torch-2.14.0.whl')).toBeInTheDocument(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Copy file source' })); + await Promise.resolve(); + }); + expect(writeText).toHaveBeenCalledWith('https://download.pytorch.org/whl/cu134/torch-2.14.0.whl'); + expect(screen.getByText('Copied')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Idle')).toBeInTheDocument(); + } finally { + vi.unstubAllGlobals(); + } + }); + it('renders dependency progress details and routes detail actions', () => { const onBackToList = vi.fn(); const onToggleCompletedItems = vi.fn(); diff --git a/frontend/src/components/ProgressDetailsView.tsx b/frontend/src/components/ProgressDetailsView.tsx index 90a4acb7..21eefe5d 100644 --- a/frontend/src/components/ProgressDetailsView.tsx +++ b/frontend/src/components/ProgressDetailsView.tsx @@ -5,9 +5,12 @@ * Extracted from InstallDialog.tsx */ +import { useEffect, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Download, + Copy, + Check, Package, FolderArchive, Settings, @@ -92,6 +95,48 @@ function getInstallationOutcome( return null; } +function CopyDownloadSource({ source }: { source: string }) { + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'failed'>('idle'); + + useEffect(() => { + setCopyStatus('idle'); + }, [source]); + + const copySource = async () => { + try { + await navigator.clipboard.writeText(source); + setCopyStatus('copied'); + } catch { + setCopyStatus('failed'); + } + }; + + return ( +
+
+ Latest file source + +
+ + {source} + + {copyStatus === 'failed' && ( +

+ Clipboard unavailable. Select the source text to copy it. +

+ )} +
+ ); +} + interface ProgressDetailsViewProps { appId?: string; progress: InstallationProgress; @@ -127,6 +172,10 @@ export function ProgressDetailsView({ : outcome?.kind === 'cancelled' ? 'text-[hsl(var(--accent-warning))]' : 'text-[hsl(var(--accent-error))]'; + const downloadActive = progress.download_active ?? progress.download_speed !== null; + const downloadStatus = progress.download_speed !== null + ? formatSpeed(progress.download_speed) + : downloadActive ? 'Waiting…' : 'Idle'; return (
@@ -206,15 +255,18 @@ export function ProgressDetailsView({
{/* Stage-specific Stats */} - {progress.download_speed !== null && ( + {(progress.download_speed !== null || progress.download_source_url) && (
-
- - Speed +
+
+ + Network download +
+ + {downloadStatus} +
- - {formatSpeed(progress.download_speed)} - + {progress.download_source_url && }
)} diff --git a/frontend/src/components/StatusFooter.test.tsx b/frontend/src/components/StatusFooter.test.tsx new file mode 100644 index 00000000..89ff7916 --- /dev/null +++ b/frontend/src/components/StatusFooter.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { StatusFooter } from './StatusFooter'; + +describe('StatusFooter', () => { + it('shows Torch setup download speed while retaining stage and progress, with a fallback when speed clears', () => { + const progress = { + tag: 'v2.14.0', + started_at: '2026-04-12T00:00:00Z', + stage: 'setup' as const, + stage_progress: 40, + overall_progress: 95, + current_item: 'Installing Torch wheel', + download_speed: 12 * 1024 * 1024, + eta_seconds: null, + total_size: null, + downloaded_bytes: 0, + dependency_count: null, + completed_dependencies: 0, + completed_items: [], + error: null, + }; + + const { rerender } = render(); + expect(screen.getByText('Finalizing setup · 12.0 MB/s · 95% complete')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('Finalizing setup · 95% complete')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/StatusFooter.tsx b/frontend/src/components/StatusFooter.tsx index ca686623..148a34d7 100644 --- a/frontend/src/components/StatusFooter.tsx +++ b/frontend/src/components/StatusFooter.tsx @@ -98,10 +98,13 @@ export const StatusFooter: React.FC = ({ }; const stageName = stageNames[installationProgress.stage as keyof typeof stageNames] || 'Installing'; + const speedInfo = installationProgress.stage === 'setup' && installationProgress.download_speed !== null + ? ` · ${formatSpeed(installationProgress.download_speed)}` + : ''; return { icon: Download, - text: `${stageName} · ${installationProgress.overall_progress}% complete`, + text: `${stageName}${speedInfo} · ${installationProgress.overall_progress}% complete`, color: 'text-accent-info', bgColor: 'bg-[hsl(var(--accent-info)/0.1)]', spinning: false diff --git a/frontend/src/components/TorchInstallPreview.test.tsx b/frontend/src/components/TorchInstallPreview.test.tsx index 17f51777..9d93d96f 100644 --- a/frontend/src/components/TorchInstallPreview.test.tsx +++ b/frontend/src/components/TorchInstallPreview.test.tsx @@ -57,6 +57,7 @@ describe('TorchInstallPreview', () => { tag: 'v2.14.0', startedAt: '2026-09-26T00:00:00Z', stage: 'resolving', stageProgress: 0, overallProgress: 0, currentItem: 'Preparing managed Python and resolving Torch packages', + downloadSourceUrl: null, downloadActive: false, downloadSpeed: null, etaSeconds: null, totalSize: null, downloadedBytes: 0, dependencyCount: null, completedDependencies: 0, completedItems: [], error: null, completedAt: null, success: null, logPath: null, diff --git a/frontend/src/generated/desktop-contract.ts b/frontend/src/generated/desktop-contract.ts index 5840014e..414d4d01 100644 --- a/frontend/src/generated/desktop-contract.ts +++ b/frontend/src/generated/desktop-contract.ts @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 2746aabed7ca8f2468fbdf4a2af56857fdbeb27466a24a67fb7a3e8bfb36736b. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. import { validateAvailableVersionsOutcome, validateBackendStatusOutcome, validateCancelInstallationOutcome, validateCatalogSearchOutcome, validateCheckVersionDependenciesOutcome, validateCheckVersionDependenciesParams, validateConversionCancelledOutcome, validateConversionEnvironmentOutcome, validateConversionListOutcome, validateConversionProgressResponse, validateConversionSetupStartedOutcome, validateConversionSetupStatusOutcome, validateConversionStartedOutcome, validateDownloadIdParams, validateDownloadListOutcome, validateDownloadMutationOutcome, validateDownloadStartedOutcome, validateDownloadStatusOutcome, validateFindTorchAlternativesParams, validateGetBackendSetupParams, validateGetHfDownloadDetailsParams, validateGetReleaseDependenciesOutcome, validateGetReleaseDependenciesParams, validateGetTorchPreviewReportParams, validateGetTorchReleaseOptionsParams, validateGetTorchRuntimeProbeParams, validateGithubCacheStatusOutcome, validateHfDownloadDetailsOutcome, validateInferenceSettingsOutcome, validateInstallVersionDependenciesOutcome, validateInstallVersionDependenciesParams, validateInstallVersionOutcome, validateInstallVersionParams, validateInstallationProgressOutcome, validateInstalledVersionsOutcome, validateLibraryModelMetadataOutcome, validateLinkHealthOutcome, validateModelIndexRefreshOutcome, validateModelsOutcome, validatePartialDownloadOutcome, validatePreviewTorchRuntimeParams, validatePublicError, validateRecoverDownloadParams, validateRemoveVersionOutcome, validateRouterProfileSyncStatus, validateRuntimeLaunchOutcome, validateRuntimeLaunchParams, validateRuntimeRunningOutcome, validateRuntimeStopOutcome, validateSearchCatalogParams, validateSelectedVersionOutcome, validateSetDefaultVersionOutcome, validateSetDefaultVersionParams, validateStartBackendSetupParams, validateStartConversionSetupParams, validateStopRuntimeProfileGenerationOutcome, validateStopRuntimeProfileGenerationParams, validateSuccessOutcome, validateSupportedQuantTypesOutcome, validateSwitchVersionOutcome, validateSwitchVersionParams, validateTorchDriverAvailability, validateTorchGpuVendor, validateTorchReleaseCombination, validateTorchReleaseDriverStatus, validateTorchReleaseOptionsOutcome, validateTorchReleaseOptionsStatus, validateTorchReleaseRecommendation, validateTorchRuntimePreview, validateTorchRuntimePreviewArtifact, validateTorchRuntimePreviewOutcome, validateTorchRuntimePreviewQualification, validateTorchRuntimePreviewRejectionReason, validateTrialTorchRuntimeOutcome, validateTrialTorchRuntimeParams, validateUpdateInferenceSettingsOutcome, validateUpdateInferenceSettingsParams, validateUpdateModelNotesOutcome, validateUpdateModelNotesParams, validateValidateInstallationsOutcome, validateVersionInfoOutcome, validateVersionStatusOutcome } from './desktop-contract.validators.js'; export type AvailableVersionsOutcome = (AvailableVersionsSuccess) | (AvailableVersionsRateLimited); export type AvailableVersionsRateLimited = { "error": string; "rate_limited": true; "retry_after_secs": number | null; "success": false }; @@ -91,7 +91,7 @@ export type RemoveVersionOutcome = { "success": boolean }; export type RouterCatalogState = "current" | "pending" | "uncertain"; export type RouterObservationState = "connecting" | "current" | "unavailable"; export type RouterProfileSyncStatus = ({ "catalog_state": "current" | "pending"; "generation": number; "last_error": string | null; "observation_state": RouterObservationState; "pending_model_ids": ReadonlyArray; "profile_id": string }) | ({ "catalog_state": "uncertain"; "generation": number; "last_error": string | null; "observation_state": "unavailable"; "pending_model_ids": ReadonlyArray; "profile_id": string }); -export type RuntimeInstallationProgress = { "completedAt": string | null; "completedDependencies": number; "completedItems": ReadonlyArray; "currentItem": string | null; "dependencyCount": number | null; "downloadSpeed": number | null; "downloadedBytes": number; "error": string | null; "etaSeconds": number | null; "logPath": string | null; "overallProgress": number | null; "stage": RuntimeInstallationStage; "stageProgress": number | null; "startedAt": string; "success": boolean | null; "tag": string; "totalSize": number | null }; +export type RuntimeInstallationProgress = { "completedAt": string | null; "completedDependencies": number; "completedItems": ReadonlyArray; "currentItem": string | null; "dependencyCount": number | null; "downloadActive": boolean; "downloadSourceUrl": string | null; "downloadSpeed": number | null; "downloadedBytes": number; "error": string | null; "etaSeconds": number | null; "logPath": string | null; "overallProgress": number | null; "stage": RuntimeInstallationStage; "stageProgress": number | null; "startedAt": string; "success": boolean | null; "tag": string; "totalSize": number | null }; export type RuntimeInstallationProgressItem = { "completedAt": string; "name": string; "size": number | null; "type": string }; export type RuntimeInstallationStage = "resolving" | "download" | "extract" | "venv" | "dependencies" | "setup"; export type RuntimeLaunchOutcome = { "error"?: string; "log_path"?: string; "ready"?: boolean; "success": boolean }; diff --git a/frontend/src/generated/desktop-contract.validators.d.ts b/frontend/src/generated/desktop-contract.validators.d.ts index c1038b56..886c729d 100644 --- a/frontend/src/generated/desktop-contract.validators.d.ts +++ b/frontend/src/generated/desktop-contract.validators.d.ts @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 2746aabed7ca8f2468fbdf4a2af56857fdbeb27466a24a67fb7a3e8bfb36736b. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. export declare function validateAvailableVersionsOutcome(value: unknown): boolean; export declare function validateBackendStatusOutcome(value: unknown): boolean; export declare function validateCancelInstallationOutcome(value: unknown): boolean; diff --git a/frontend/src/generated/desktop-contract.validators.js b/frontend/src/generated/desktop-contract.validators.js index 6d62c8d7..1ffe18e9 100644 --- a/frontend/src/generated/desktop-contract.validators.js +++ b/frontend/src/generated/desktop-contract.validators.js @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 2746aabed7ca8f2468fbdf4a2af56857fdbeb27466a24a67fb7a3e8bfb36736b. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. var __getOwnPropNames = Object.getOwnPropertyNames; var __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; @@ -9138,7 +9138,7 @@ function validate66(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } var validateInstallationProgressOutcome = validate74; -var schema82 = { "additionalProperties": false, "properties": { "completedAt": { "type": ["string", "null"] }, "completedDependencies": { "maximum": 4294967295, "minimum": 0, "type": "integer" }, "completedItems": { "items": { "$ref": "#/definitions/RuntimeInstallationProgressItem" }, "type": "array" }, "currentItem": { "type": ["string", "null"] }, "dependencyCount": { "maximum": 4294967295, "minimum": 0, "type": ["integer", "null"] }, "downloadSpeed": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "downloadedBytes": { "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, "error": { "type": ["string", "null"] }, "etaSeconds": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "logPath": { "type": ["string", "null"] }, "overallProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "stage": { "$ref": "#/definitions/RuntimeInstallationStage" }, "stageProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "startedAt": { "type": "string" }, "success": { "type": ["boolean", "null"] }, "tag": { "type": "string" }, "totalSize": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] } }, "required": ["tag", "startedAt", "stage", "stageProgress", "overallProgress", "currentItem", "downloadSpeed", "etaSeconds", "totalSize", "downloadedBytes", "dependencyCount", "completedDependencies", "completedItems", "error", "completedAt", "success", "logPath"], "type": "object" }; +var schema82 = { "additionalProperties": false, "properties": { "completedAt": { "type": ["string", "null"] }, "completedDependencies": { "maximum": 4294967295, "minimum": 0, "type": "integer" }, "completedItems": { "items": { "$ref": "#/definitions/RuntimeInstallationProgressItem" }, "type": "array" }, "currentItem": { "type": ["string", "null"] }, "dependencyCount": { "maximum": 4294967295, "minimum": 0, "type": ["integer", "null"] }, "downloadActive": { "type": "boolean" }, "downloadSourceUrl": { "type": ["string", "null"] }, "downloadSpeed": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "downloadedBytes": { "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, "error": { "type": ["string", "null"] }, "etaSeconds": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "logPath": { "type": ["string", "null"] }, "overallProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "stage": { "$ref": "#/definitions/RuntimeInstallationStage" }, "stageProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "startedAt": { "type": "string" }, "success": { "type": ["boolean", "null"] }, "tag": { "type": "string" }, "totalSize": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] } }, "required": ["tag", "startedAt", "stage", "stageProgress", "overallProgress", "currentItem", "downloadSourceUrl", "downloadActive", "downloadSpeed", "etaSeconds", "totalSize", "downloadedBytes", "dependencyCount", "completedDependencies", "completedItems", "error", "completedAt", "success", "logPath"], "type": "object" }; var schema83 = { "additionalProperties": false, "properties": { "completedAt": { "type": "string" }, "name": { "type": "string" }, "size": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] }, "type": { "type": "string" } }, "required": ["name", "type", "size", "completedAt"], "type": "object" }; var schema84 = { "enum": ["resolving", "download", "extract", "venv", "dependencies", "setup"], "type": "string" }; function validate75(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { @@ -9147,7 +9147,7 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; - if (data.tag === void 0 && (missing0 = "tag") || data.startedAt === void 0 && (missing0 = "startedAt") || data.stage === void 0 && (missing0 = "stage") || data.stageProgress === void 0 && (missing0 = "stageProgress") || data.overallProgress === void 0 && (missing0 = "overallProgress") || data.currentItem === void 0 && (missing0 = "currentItem") || data.downloadSpeed === void 0 && (missing0 = "downloadSpeed") || data.etaSeconds === void 0 && (missing0 = "etaSeconds") || data.totalSize === void 0 && (missing0 = "totalSize") || data.downloadedBytes === void 0 && (missing0 = "downloadedBytes") || data.dependencyCount === void 0 && (missing0 = "dependencyCount") || data.completedDependencies === void 0 && (missing0 = "completedDependencies") || data.completedItems === void 0 && (missing0 = "completedItems") || data.error === void 0 && (missing0 = "error") || data.completedAt === void 0 && (missing0 = "completedAt") || data.success === void 0 && (missing0 = "success") || data.logPath === void 0 && (missing0 = "logPath")) { + if (data.tag === void 0 && (missing0 = "tag") || data.startedAt === void 0 && (missing0 = "startedAt") || data.stage === void 0 && (missing0 = "stage") || data.stageProgress === void 0 && (missing0 = "stageProgress") || data.overallProgress === void 0 && (missing0 = "overallProgress") || data.currentItem === void 0 && (missing0 = "currentItem") || data.downloadSourceUrl === void 0 && (missing0 = "downloadSourceUrl") || data.downloadActive === void 0 && (missing0 = "downloadActive") || data.downloadSpeed === void 0 && (missing0 = "downloadSpeed") || data.etaSeconds === void 0 && (missing0 = "etaSeconds") || data.totalSize === void 0 && (missing0 = "totalSize") || data.downloadedBytes === void 0 && (missing0 = "downloadedBytes") || data.dependencyCount === void 0 && (missing0 = "dependencyCount") || data.completedDependencies === void 0 && (missing0 = "completedDependencies") || data.completedItems === void 0 && (missing0 = "completedItems") || data.error === void 0 && (missing0 = "error") || data.completedAt === void 0 && (missing0 = "completedAt") || data.success === void 0 && (missing0 = "success") || data.logPath === void 0 && (missing0 = "logPath")) { validate75.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { @@ -9343,83 +9343,69 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.downloadSpeed !== void 0) { - let data10 = data.downloadSpeed; + if (data.downloadActive !== void 0) { const _errs24 = errors; - if (!(typeof data10 == "number" && isFinite(data10)) && data10 !== null) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/type", keyword: "type", params: { type: schema82.properties.downloadSpeed.type }, message: "must be number,null" }]; + if (typeof data.downloadActive !== "boolean") { + validate75.errors = [{ instancePath: instancePath + "/downloadActive", schemaPath: "#/properties/downloadActive/type", keyword: "type", params: { type: "boolean" }, message: "must be boolean" }]; return false; } - if (errors === _errs24) { - if (typeof data10 == "number" && isFinite(data10)) { - if (data10 > 17976931348623157e292 || isNaN(data10)) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; - return false; - } else { - if (data10 < -17976931348623157e292 || isNaN(data10)) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; - return false; - } - } - } - } var valid0 = _errs24 === errors; } else { var valid0 = true; } if (valid0) { - if (data.downloadedBytes !== void 0) { - let data11 = data.downloadedBytes; + if (data.downloadSourceUrl !== void 0) { + let data11 = data.downloadSourceUrl; const _errs26 = errors; - if (!(typeof data11 == "number" && (!(data11 % 1) && !isNaN(data11)) && isFinite(data11))) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; + if (typeof data11 !== "string" && data11 !== null) { + validate75.errors = [{ instancePath: instancePath + "/downloadSourceUrl", schemaPath: "#/properties/downloadSourceUrl/type", keyword: "type", params: { type: schema82.properties.downloadSourceUrl.type }, message: "must be string,null" }]; return false; } - if (errors === _errs26) { - if (typeof data11 == "number" && isFinite(data11)) { - if (data11 > 9007199254740991 || isNaN(data11)) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; - return false; - } else { - if (data11 < 0 || isNaN(data11)) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; - return false; - } - } - } - } var valid0 = _errs26 === errors; } else { var valid0 = true; } if (valid0) { - if (data.error !== void 0) { - let data12 = data.error; + if (data.downloadSpeed !== void 0) { + let data12 = data.downloadSpeed; const _errs28 = errors; - if (typeof data12 !== "string" && data12 !== null) { - validate75.errors = [{ instancePath: instancePath + "/error", schemaPath: "#/properties/error/type", keyword: "type", params: { type: schema82.properties.error.type }, message: "must be string,null" }]; + if (!(typeof data12 == "number" && isFinite(data12)) && data12 !== null) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/type", keyword: "type", params: { type: schema82.properties.downloadSpeed.type }, message: "must be number,null" }]; return false; } + if (errors === _errs28) { + if (typeof data12 == "number" && isFinite(data12)) { + if (data12 > 17976931348623157e292 || isNaN(data12)) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; + return false; + } else { + if (data12 < -17976931348623157e292 || isNaN(data12)) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; + return false; + } + } + } + } var valid0 = _errs28 === errors; } else { var valid0 = true; } if (valid0) { - if (data.etaSeconds !== void 0) { - let data13 = data.etaSeconds; + if (data.downloadedBytes !== void 0) { + let data13 = data.downloadedBytes; const _errs30 = errors; - if (!(typeof data13 == "number" && isFinite(data13)) && data13 !== null) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/type", keyword: "type", params: { type: schema82.properties.etaSeconds.type }, message: "must be number,null" }]; + if (!(typeof data13 == "number" && (!(data13 % 1) && !isNaN(data13)) && isFinite(data13))) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; return false; } if (errors === _errs30) { if (typeof data13 == "number" && isFinite(data13)) { - if (data13 > 17976931348623157e292 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; + if (data13 > 9007199254740991 || isNaN(data13)) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; return false; } else { - if (data13 < -17976931348623157e292 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; + if (data13 < 0 || isNaN(data13)) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; return false; } } @@ -9430,11 +9416,11 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.logPath !== void 0) { - let data14 = data.logPath; + if (data.error !== void 0) { + let data14 = data.error; const _errs32 = errors; if (typeof data14 !== "string" && data14 !== null) { - validate75.errors = [{ instancePath: instancePath + "/logPath", schemaPath: "#/properties/logPath/type", keyword: "type", params: { type: schema82.properties.logPath.type }, message: "must be string,null" }]; + validate75.errors = [{ instancePath: instancePath + "/error", schemaPath: "#/properties/error/type", keyword: "type", params: { type: schema82.properties.error.type }, message: "must be string,null" }]; return false; } var valid0 = _errs32 === errors; @@ -9442,21 +9428,21 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.overallProgress !== void 0) { - let data15 = data.overallProgress; + if (data.etaSeconds !== void 0) { + let data15 = data.etaSeconds; const _errs34 = errors; if (!(typeof data15 == "number" && isFinite(data15)) && data15 !== null) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/type", keyword: "type", params: { type: schema82.properties.overallProgress.type }, message: "must be number,null" }]; + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/type", keyword: "type", params: { type: schema82.properties.etaSeconds.type }, message: "must be number,null" }]; return false; } if (errors === _errs34) { if (typeof data15 == "number" && isFinite(data15)) { - if (data15 > 34028234663852886e22 || isNaN(data15)) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; + if (data15 > 17976931348623157e292 || isNaN(data15)) { + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; return false; } else { - if (data15 < -34028234663852886e22 || isNaN(data15)) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; + if (data15 < -17976931348623157e292 || isNaN(data15)) { + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; return false; } } @@ -9467,15 +9453,11 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.stage !== void 0) { - let data16 = data.stage; + if (data.logPath !== void 0) { + let data16 = data.logPath; const _errs36 = errors; - if (typeof data16 !== "string") { - validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; - return false; - } - if (!(data16 === "resolving" || data16 === "download" || data16 === "extract" || data16 === "venv" || data16 === "dependencies" || data16 === "setup")) { - validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/enum", keyword: "enum", params: { allowedValues: schema84.enum }, message: "must be equal to one of the allowed values" }]; + if (typeof data16 !== "string" && data16 !== null) { + validate75.errors = [{ instancePath: instancePath + "/logPath", schemaPath: "#/properties/logPath/type", keyword: "type", params: { type: schema82.properties.logPath.type }, message: "must be string,null" }]; return false; } var valid0 = _errs36 === errors; @@ -9483,58 +9465,76 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.stageProgress !== void 0) { - let data17 = data.stageProgress; - const _errs39 = errors; + if (data.overallProgress !== void 0) { + let data17 = data.overallProgress; + const _errs38 = errors; if (!(typeof data17 == "number" && isFinite(data17)) && data17 !== null) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/type", keyword: "type", params: { type: schema82.properties.stageProgress.type }, message: "must be number,null" }]; + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/type", keyword: "type", params: { type: schema82.properties.overallProgress.type }, message: "must be number,null" }]; return false; } - if (errors === _errs39) { + if (errors === _errs38) { if (typeof data17 == "number" && isFinite(data17)) { if (data17 > 34028234663852886e22 || isNaN(data17)) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; return false; } else { if (data17 < -34028234663852886e22 || isNaN(data17)) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; return false; } } } } - var valid0 = _errs39 === errors; + var valid0 = _errs38 === errors; } else { var valid0 = true; } if (valid0) { - if (data.startedAt !== void 0) { - const _errs41 = errors; - if (typeof data.startedAt !== "string") { - validate75.errors = [{ instancePath: instancePath + "/startedAt", schemaPath: "#/properties/startedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + if (data.stage !== void 0) { + let data18 = data.stage; + const _errs40 = errors; + if (typeof data18 !== "string") { + validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + return false; + } + if (!(data18 === "resolving" || data18 === "download" || data18 === "extract" || data18 === "venv" || data18 === "dependencies" || data18 === "setup")) { + validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/enum", keyword: "enum", params: { allowedValues: schema84.enum }, message: "must be equal to one of the allowed values" }]; return false; } - var valid0 = _errs41 === errors; + var valid0 = _errs40 === errors; } else { var valid0 = true; } if (valid0) { - if (data.success !== void 0) { - let data19 = data.success; + if (data.stageProgress !== void 0) { + let data19 = data.stageProgress; const _errs43 = errors; - if (typeof data19 !== "boolean" && data19 !== null) { - validate75.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: schema82.properties.success.type }, message: "must be boolean,null" }]; + if (!(typeof data19 == "number" && isFinite(data19)) && data19 !== null) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/type", keyword: "type", params: { type: schema82.properties.stageProgress.type }, message: "must be number,null" }]; return false; } + if (errors === _errs43) { + if (typeof data19 == "number" && isFinite(data19)) { + if (data19 > 34028234663852886e22 || isNaN(data19)) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; + return false; + } else { + if (data19 < -34028234663852886e22 || isNaN(data19)) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; + return false; + } + } + } + } var valid0 = _errs43 === errors; } else { var valid0 = true; } if (valid0) { - if (data.tag !== void 0) { + if (data.startedAt !== void 0) { const _errs45 = errors; - if (typeof data.tag !== "string") { - validate75.errors = [{ instancePath: instancePath + "/tag", schemaPath: "#/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + if (typeof data.startedAt !== "string") { + validate75.errors = [{ instancePath: instancePath + "/startedAt", schemaPath: "#/properties/startedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs45 === errors; @@ -9542,29 +9542,54 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.totalSize !== void 0) { - let data21 = data.totalSize; + if (data.success !== void 0) { + let data21 = data.success; const _errs47 = errors; - if (!(typeof data21 == "number" && (!(data21 % 1) && !isNaN(data21)) && isFinite(data21)) && data21 !== null) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/type", keyword: "type", params: { type: schema82.properties.totalSize.type }, message: "must be integer,null" }]; + if (typeof data21 !== "boolean" && data21 !== null) { + validate75.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: schema82.properties.success.type }, message: "must be boolean,null" }]; return false; } - if (errors === _errs47) { - if (typeof data21 == "number" && isFinite(data21)) { - if (data21 > 9007199254740991 || isNaN(data21)) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; + var valid0 = _errs47 === errors; + } else { + var valid0 = true; + } + if (valid0) { + if (data.tag !== void 0) { + const _errs49 = errors; + if (typeof data.tag !== "string") { + validate75.errors = [{ instancePath: instancePath + "/tag", schemaPath: "#/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + return false; + } + var valid0 = _errs49 === errors; + } else { + var valid0 = true; + } + if (valid0) { + if (data.totalSize !== void 0) { + let data23 = data.totalSize; + const _errs51 = errors; + if (!(typeof data23 == "number" && (!(data23 % 1) && !isNaN(data23)) && isFinite(data23)) && data23 !== null) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/type", keyword: "type", params: { type: schema82.properties.totalSize.type }, message: "must be integer,null" }]; return false; - } else { - if (data21 < 0 || isNaN(data21)) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; - return false; + } + if (errors === _errs51) { + if (typeof data23 == "number" && isFinite(data23)) { + if (data23 > 9007199254740991 || isNaN(data23)) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; + return false; + } else { + if (data23 < 0 || isNaN(data23)) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + return false; + } + } } } + var valid0 = _errs51 === errors; + } else { + var valid0 = true; } } - var valid0 = _errs47 === errors; - } else { - var valid0 = true; } } } diff --git a/frontend/src/hooks/installationProgressTracking.test.ts b/frontend/src/hooks/installationProgressTracking.test.ts index 28264822..b9ac8f10 100644 --- a/frontend/src/hooks/installationProgressTracking.test.ts +++ b/frontend/src/hooks/installationProgressTracking.test.ts @@ -11,6 +11,8 @@ describe('projectInstallationProgress', () => { stageProgress: 125.5, overallProgress: 107.25, currentItem: 'torch', + downloadSourceUrl: 'https://download.pytorch.org/whl/cu134/torch-2.14.0.whl', + downloadActive: true, downloadSpeed: 42.5, etaSeconds: 0.5, totalSize: 1024, @@ -31,6 +33,8 @@ describe('projectInstallationProgress', () => { stage_progress: 125.5, overall_progress: 107.25, current_item: 'torch', + download_source_url: 'https://download.pytorch.org/whl/cu134/torch-2.14.0.whl', + download_active: true, download_speed: 42.5, eta_seconds: 0.5, total_size: 1024, @@ -50,6 +54,7 @@ describe('projectInstallationProgress', () => { tag: '', startedAt: '', stage: 'download', downloadedBytes: 0, completedDependencies: 0, completedItems: [], stageProgress: null, overallProgress: null, currentItem: null, downloadSpeed: null, + downloadSourceUrl: null, downloadActive: false, etaSeconds: null, totalSize: null, dependencyCount: null, error: null, completedAt: null, success: null, logPath: null, }; @@ -57,7 +62,7 @@ describe('projectInstallationProgress', () => { expect(projectInstallationProgress(wire)).toMatchObject({ tag: '', started_at: '', stage: 'download', stage_progress: 0, overall_progress: 0, downloaded_bytes: 0, completed_dependencies: 0, - completed_items: [], completed_at: undefined, success: undefined, + completed_items: [], download_active: false, completed_at: undefined, success: undefined, }); }); }); diff --git a/frontend/src/hooks/installationProgressTracking.ts b/frontend/src/hooks/installationProgressTracking.ts index 654a4a63..50187de2 100644 --- a/frontend/src/hooks/installationProgressTracking.ts +++ b/frontend/src/hooks/installationProgressTracking.ts @@ -15,6 +15,7 @@ import { export interface InstallationProgressTrackerState { lastDownloadTag: string | null; lastStage: InstallationProgress['stage'] | null; + lastDownloadSourceUrl: string | null; networkState: NetworkStatusState; } @@ -24,6 +25,7 @@ export function resetInstallationProgressTracking( resetNetworkStatusState(state.networkState); state.lastDownloadTag = null; state.lastStage = null; + state.lastDownloadSourceUrl = null; } export function projectInstallationProgress( @@ -36,6 +38,8 @@ export function projectInstallationProgress( stage_progress: progress.stageProgress ?? 0, overall_progress: progress.overallProgress ?? 0, current_item: progress.currentItem, + download_source_url: progress.downloadSourceUrl, + download_active: progress.downloadActive, download_speed: progress.downloadSpeed, eta_seconds: progress.etaSeconds, total_size: progress.totalSize, @@ -106,6 +110,7 @@ function synchronizeTrackerState( if (progress.tag !== trackerState.lastDownloadTag) { trackerState.lastDownloadTag = progress.tag || null; trackerState.lastStage = progress.stage; + trackerState.lastDownloadSourceUrl = progress.download_source_url ?? null; resetNetworkStatusState(trackerState.networkState); trackerState.networkState.lastDownload = { bytes: downloadedBytes, speed, ts: now }; trackerState.networkState.topSpeed = speed || 0; @@ -116,6 +121,11 @@ function synchronizeTrackerState( trackerState.networkState.downloadSamples = []; trackerState.lastStage = progress.stage; } + + if ((progress.download_source_url ?? null) !== trackerState.lastDownloadSourceUrl) { + trackerState.networkState.downloadSamples = []; + trackerState.lastDownloadSourceUrl = progress.download_source_url ?? null; + } } export function normalizeInstallationProgress( @@ -148,7 +158,11 @@ export function normalizeInstallationProgress( stage_progress: progress.stage_progress || 0, overall_progress: progress.overall_progress || 0, current_item: progress.current_item || null, - download_speed: progress.download_speed ?? (averageSpeed > 0 ? averageSpeed : null), + download_source_url: progress.download_source_url ?? null, + download_active: progress.download_active ?? false, + download_speed: progress.download_source_url + ? progress.download_speed + : progress.download_speed ?? (averageSpeed > 0 ? averageSpeed : null), eta_seconds: etaSeconds, total_size: expectedTotal ?? progress.total_size ?? null, downloaded_bytes: downloadedBytes, diff --git a/frontend/src/hooks/useInstallationManager.test.ts b/frontend/src/hooks/useInstallationManager.test.ts index 14c7d375..7e523026 100644 --- a/frontend/src/hooks/useInstallationManager.test.ts +++ b/frontend/src/hooks/useInstallationManager.test.ts @@ -103,6 +103,8 @@ const activeProgress: InstallationProgress = { stage_progress: 50, overall_progress: 25, current_item: 'archive.zip', + download_source_url: null, + download_active: true, download_speed: 1024, eta_seconds: 30, total_size: 4096, @@ -120,6 +122,8 @@ const activeWireProgress: RuntimeInstallationProgress = { stageProgress: activeProgress.stage_progress, overallProgress: activeProgress.overall_progress, currentItem: activeProgress.current_item, + downloadSourceUrl: activeProgress.download_source_url ?? null, + downloadActive: activeProgress.download_active ?? false, downloadSpeed: activeProgress.download_speed, etaSeconds: activeProgress.eta_seconds, totalSize: activeProgress.total_size, diff --git a/frontend/src/hooks/useInstallationManager.ts b/frontend/src/hooks/useInstallationManager.ts index 257fda3e..7d92ab0a 100644 --- a/frontend/src/hooks/useInstallationManager.ts +++ b/frontend/src/hooks/useInstallationManager.ts @@ -77,6 +77,7 @@ export function useInstallationManager({ const renderIdentityRef = useRef({ appId: resolvedAppId, enabled: isEnabled }); const lastDownloadTagRef = useRef(null); const lastStageRef = useRef(null); + const lastDownloadSourceUrlRef = useRef(null); const pendingInstallTagRef = useRef(null); const missingProgressPollsRef = useRef(0); const networkStateRef = useRef(createNetworkStatusState()); @@ -93,10 +94,12 @@ export function useInstallationManager({ resetInstallationProgressTracking({ lastDownloadTag: lastDownloadTagRef.current, lastStage: lastStageRef.current, + lastDownloadSourceUrl: lastDownloadSourceUrlRef.current, networkState: networkStateRef.current, }); lastDownloadTagRef.current = null; lastStageRef.current = null; + lastDownloadSourceUrlRef.current = null; pendingInstallTagRef.current = null; missingProgressPollsRef.current = 0; }, []); @@ -204,6 +207,7 @@ export function useInstallationManager({ const trackerState = { lastDownloadTag: lastDownloadTagRef.current, lastStage: lastStageRef.current, + lastDownloadSourceUrl: lastDownloadSourceUrlRef.current, networkState: networkStateRef.current, }; const { adjustedProgress, networkStatus } = normalizeInstallationProgress( @@ -214,6 +218,7 @@ export function useInstallationManager({ ); lastDownloadTagRef.current = trackerState.lastDownloadTag; lastStageRef.current = trackerState.lastStage; + lastDownloadSourceUrlRef.current = trackerState.lastDownloadSourceUrl; setInstallationProgress(adjustedProgress); setInstallNetworkStatus(networkStatus); return true; @@ -223,6 +228,7 @@ export function useInstallationManager({ const trackerState = { lastDownloadTag: lastDownloadTagRef.current, lastStage: lastStageRef.current, + lastDownloadSourceUrl: lastDownloadSourceUrlRef.current, networkState: networkStateRef.current, }; const { adjustedProgress } = normalizeInstallationProgress( @@ -233,6 +239,7 @@ export function useInstallationManager({ ); lastDownloadTagRef.current = trackerState.lastDownloadTag; lastStageRef.current = trackerState.lastStage; + lastDownloadSourceUrlRef.current = trackerState.lastDownloadSourceUrl; if (progress.success) { await onRefreshVersions(); diff --git a/frontend/src/types/versions.ts b/frontend/src/types/versions.ts index eb1b3fda..ca968060 100644 --- a/frontend/src/types/versions.ts +++ b/frontend/src/types/versions.ts @@ -29,6 +29,8 @@ export interface InstallationProgress { stage_progress: number; overall_progress: number; current_item: string | null; + download_source_url?: string | null; + download_active?: boolean; download_speed: number | null; eta_seconds: number | null; total_size: number | null; diff --git a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs index 0a280ec4..1659831c 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs @@ -11,8 +11,72 @@ use std::time::Duration; use tokio::process::Command; const MAX_TORCH_ORPHAN_QUARANTINES: usize = 2; +const MAX_TORCH_DOWNLOAD_SOURCE_BYTES: usize = 2048; +const TORCH_DOWNLOAD_SPEED_STALE_AFTER: Duration = Duration::from_secs(2); pub(super) const TORCH_PUBLISHING_MARKER: &[u8] = b"metadata pending"; +#[derive(Debug, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +struct TorchDownloadProgress { + #[serde(default)] + source_url: Option, + active: bool, + downloaded_bytes: u64, + total_bytes: Option, + speed_bytes_per_sec: Option, +} + +fn trusted_torch_download_source(source: &str) -> bool { + if source.len() > MAX_TORCH_DOWNLOAD_SOURCE_BYTES { + return false; + } + let Ok(url) = reqwest::Url::parse(source) else { + return false; + }; + let official_torch = matches!( + url.host_str(), + Some("download.pytorch.org" | "download-r2.pytorch.org") + ) && url.path().starts_with("/whl/"); + let official_pypi = + url.host_str() == Some("files.pythonhosted.org") && url.path().starts_with("/packages/"); + let official_nunchaku = url.host_str() == Some("github.com") + && url + .path() + .starts_with("/nunchux-ai/nunchaku/releases/download/"); + url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && matches!(url.port(), None | Some(443)) + && url.query().is_none() + && url.fragment().is_none() + && url.path().to_ascii_lowercase().ends_with(".whl") + && (official_torch || official_pypi || official_nunchaku) +} + +fn read_torch_download_progress(path: &Path) -> Option { + let metadata = std::fs::symlink_metadata(path).ok()?; + if !metadata.file_type().is_file() || metadata.len() > 4096 { + return None; + } + let progress: TorchDownloadProgress = + serde_json::from_slice(&std::fs::read(path).ok()?).ok()?; + if progress + .source_url + .as_deref() + .is_some_and(|source| !trusted_torch_download_source(source)) + || progress + .speed_bytes_per_sec + .is_some_and(|speed| !speed.is_finite() || speed < 0.0) + || (!progress.active && progress.speed_bytes_per_sec.is_some()) + || progress + .total_bytes + .is_some_and(|total| progress.downloaded_bytes > total) + { + return None; + } + Some(progress) +} + /// This file is permanent: unlinking it could let another process lock a new /// inode while an existing installer still holds the old one. #[derive(Clone)] @@ -1834,6 +1898,7 @@ impl VersionInstaller { let python = pumas_library::platform::paths::venv_python(&runtime); let packages = runtime.join("staged-packages"); let resolver_output = runtime.join("resolver-result"); + let download_progress_path = resolver_output.join("download-progress.json"); let pip_cache = super::super::torch_workspace::managed_pip_cache_dir(&self.launcher_root)?; let mut install = Command::new(&python); install @@ -1841,6 +1906,8 @@ impl VersionInstaller { .arg(runtime.join("resolve_runtime.py")) .args(["--install", "--version", version, "--build", build]) .args(["--adapter", &selection.adapter]) + .arg("--progress-file") + .arg(&download_progress_path) .arg("--target") .arg(&packages) .arg("--output") @@ -1848,7 +1915,7 @@ impl VersionInstaller { .arg("--cache-dir") .arg(pip_cache); let status = self - .run_runtime_command_status( + .run_runtime_command_status_with_download_progress( install, log_path, &format!( @@ -1856,6 +1923,7 @@ impl VersionInstaller { ), progress_tx, Some(staging.clone()), + Some(&download_progress_path), ) .await?; if !status.success() { @@ -1999,8 +2067,13 @@ impl VersionInstaller { .map_err(PumasError::from)?; let python = pumas_library::platform::paths::venv_python(&runtime); let pip_cache = super::super::torch_workspace::managed_pip_cache_dir(&self.launcher_root)?; - let mut install = Command::new(pumas_library::platform::paths::venv_pip(&runtime)); + let download_progress_path = runtime.join("download-progress.json"); + let mut install = Command::new(&python); install + .arg("-I") + .arg(runtime.join("resolve_runtime.py")) + .arg("--_pumas-pip-progress-worker") + .arg(&download_progress_path) .args([ "--isolated", "install", @@ -2013,14 +2086,21 @@ impl VersionInstaller { .arg(runtime.join("requirements.txt")) .arg("--cache-dir") .arg(pip_cache); - self.run_runtime_command( - install, - log_path, - "Installing resolved wheel artifacts", - progress_tx, - Some(staging.clone()), - ) - .await?; + let status = self + .run_runtime_command_status_with_download_progress( + install, + log_path, + "Installing resolved wheel artifacts", + progress_tx, + Some(staging.clone()), + Some(&download_progress_path), + ) + .await?; + if !status.success() { + return Err(failed( + "Installing resolved wheel artifacts failed; see installation log", + )); + } let mut probe = Command::new(&python); probe.arg(runtime.join("probe_runtime.py")); self.run_runtime_command( @@ -2407,8 +2487,13 @@ impl VersionInstaller { .await?; let python = pumas_library::platform::paths::venv_python(&runtime); let pip_cache = super::super::torch_workspace::managed_pip_cache_dir(&self.launcher_root)?; - let mut install = Command::new(pumas_library::platform::paths::venv_pip(&runtime)); + let download_progress_path = runtime.join("download-progress.json"); + let mut install = Command::new(&python); install + .arg("-I") + .arg(runtime.join("resolve_runtime.py")) + .arg("--_pumas-pip-progress-worker") + .arg(&download_progress_path) .args([ "--isolated", "install", @@ -2420,14 +2505,21 @@ impl VersionInstaller { .arg(runtime.join("requirements.txt")) .arg("--cache-dir") .arg(pip_cache); - self.run_runtime_command( - install, - log_path, - "Installing locked runtime dependencies", - progress_tx, - Some(staging.clone()), - ) - .await?; + let status = self + .run_runtime_command_status_with_download_progress( + install, + log_path, + "Installing locked runtime dependencies", + progress_tx, + Some(staging.clone()), + Some(&download_progress_path), + ) + .await?; + if !status.success() { + return Err(failed( + "Installing locked runtime dependencies failed; see installation log", + )); + } let mut validate = Command::new(&python); validate .arg(runtime.join("validate_runtime.py")) @@ -2489,12 +2581,32 @@ impl VersionInstaller { } async fn run_runtime_command_status( + &self, + command: Command, + log_path: &Path, + stage: &str, + progress_tx: &mpsc::Sender, + stage_lease: Option>, + ) -> Result { + self.run_runtime_command_status_with_download_progress( + command, + log_path, + stage, + progress_tx, + stage_lease, + None, + ) + .await + } + + async fn run_runtime_command_status_with_download_progress( &self, mut command: Command, log_path: &Path, stage: &str, progress_tx: &mpsc::Sender, stage_lease: Option>, + download_progress_path: Option<&Path>, ) -> Result { self.check_cancelled()?; if let Some(stage_lease) = &stage_lease { @@ -2530,10 +2642,46 @@ impl VersionInstaller { child.attach_cleanup_lease(stage_lease); } let deadline = tokio::time::Instant::now() + Duration::from_secs(3600); + let mut last_download_progress: Option = None; + let mut last_download_progress_change_at: Option = None; + let mut download_speed_expired = false; loop { let observed = child.observe_exit(); let cancelled = self.cancel_flag.load(Ordering::SeqCst); let timed_out = tokio::time::Instant::now() >= deadline; + if let Some(path) = download_progress_path { + if let Some(download_progress) = read_torch_download_progress(path) { + if last_download_progress.as_ref() != Some(&download_progress) { + self.progress_tracker.write().await.update_network_transfer( + download_progress.source_url.as_deref(), + download_progress.active, + download_progress.downloaded_bytes, + download_progress.total_bytes, + download_progress.speed_bytes_per_sec, + ); + last_download_progress = Some(download_progress); + last_download_progress_change_at = Some(tokio::time::Instant::now()); + download_speed_expired = false; + } else if !download_speed_expired + && last_download_progress.as_ref().is_some_and(|progress| { + progress.active && progress.speed_bytes_per_sec.is_some() + }) + && last_download_progress_change_at.is_some_and(|changed_at| { + changed_at.elapsed() >= TORCH_DOWNLOAD_SPEED_STALE_AFTER + }) + { + let progress = last_download_progress.as_ref().expect("checked above"); + self.progress_tracker.write().await.update_network_transfer( + progress.source_url.as_deref(), + true, + progress.downloaded_bytes, + progress.total_bytes, + None, + ); + download_speed_expired = true; + } + } + } if cancelled || timed_out || !matches!(&observed, Ok(None)) { drop(child); self.torch_cleanup @@ -2544,6 +2692,9 @@ impl VersionInstaller { "Torch installer cleanup incomplete; owned process cleanup remains pending", ) })?; + if let Some(path) = download_progress_path { + let _ = std::fs::remove_file(path); + } self.check_cancelled()?; if timed_out { return Err(failed(format!( @@ -2560,6 +2711,111 @@ impl VersionInstaller { } } +#[cfg(test)] +mod download_progress_tests { + use super::*; + + #[test] + fn progress_source_only_accepts_official_https_wheel_hosts() { + for source in [ + "https://download.pytorch.org/whl/cu134/torch-2.14.0.whl", + "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0.whl", + "https://files.pythonhosted.org/packages/ab/pkg-1.0.whl", + "https://github.com/nunchux-ai/nunchaku/releases/download/v1.2.0/nunchaku-1.2.0.whl", + ] { + assert!(trusted_torch_download_source(source), "{source}"); + } + for source in [ + "http://files.pythonhosted.org/packages/pkg-1.0.whl", + "https://user:secret@files.pythonhosted.org/packages/pkg-1.0.whl", + "https://files.pythonhosted.org/packages/pkg-1.0.whl?token=secret", + "https://example.com/packages/pkg-1.0.whl", + "https://files.pythonhosted.org/packages/pkg-1.0.tar.gz", + ] { + assert!(!trusted_torch_download_source(source), "{source}"); + } + } + + #[test] + fn progress_file_rejects_untrusted_sources_and_bad_rates() { + let workspace = tempfile::tempdir().unwrap(); + let path = workspace.path().join("download-progress.json"); + std::fs::write( + &path, + serde_json::json!({ + "source_url":"https://example.com/torch.whl", + "active":true, + "downloaded_bytes":1, + "total_bytes":2, + "speed_bytes_per_sec":1.0 + }) + .to_string(), + ) + .unwrap(); + assert!(read_torch_download_progress(&path).is_none()); + + std::fs::write( + &path, + serde_json::json!({ + "source_url":"https://files.pythonhosted.org/packages/torch.whl", + "active":true, + "downloaded_bytes":3, + "total_bytes":2, + "speed_bytes_per_sec":1.0 + }) + .to_string(), + ) + .unwrap(); + assert!(read_torch_download_progress(&path).is_none()); + + std::fs::write( + &path, + serde_json::json!({ + "source_url":"https://files.pythonhosted.org/packages/torch.whl", + "active":true, + "downloaded_bytes":1, + "total_bytes":2, + "speed_bytes_per_sec":10.0 + }) + .to_string(), + ) + .unwrap(); + assert_eq!( + read_torch_download_progress(&path), + Some(TorchDownloadProgress { + source_url: Some("https://files.pythonhosted.org/packages/torch.whl".into()), + active: true, + downloaded_bytes: 1, + total_bytes: Some(2), + speed_bytes_per_sec: Some(10.0), + }) + ); + + std::fs::write( + &path, + serde_json::json!({ + "source_url":null, + "active":true, + "downloaded_bytes":1, + "total_bytes":2, + "speed_bytes_per_sec":1.0 + }) + .to_string(), + ) + .unwrap(); + assert_eq!( + read_torch_download_progress(&path), + Some(TorchDownloadProgress { + source_url: None, + active: true, + downloaded_bytes: 1, + total_bytes: Some(2), + speed_bytes_per_sec: Some(1.0), + }) + ); + } +} + #[cfg(test)] mod managed_python_provenance_tests { use super::*; diff --git a/rust/crates/pumas-app-manager/src/version_manager/progress.rs b/rust/crates/pumas-app-manager/src/version_manager/progress.rs index b92157bd..956c04a6 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/progress.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/progress.rs @@ -4,10 +4,15 @@ //! and persistence for recovery. use pumas_library::models::{InstallationProgress, InstallationProgressItem, InstallationStage}; +use pumas_library::network::{ + ActivityUpdate, CopyableSource, MeasurementBasis, MeasurementCoverage, NetworkActivityRegistry, + OperationId, TransferDirection, TransferId, TransferState, +}; use pumas_library::{PumasError, Result}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::Mutex; +use std::time::Instant; use tokio::fs; use tracing::{debug, warn}; @@ -99,6 +104,8 @@ pub struct InstallationProgressTracker { state_filename: String, /// Current installation state. state: Mutex>, + /// Transfer observations shared by the library's network producers. + network_activity: NetworkActivityRegistry, } /// Internal state for tracking. @@ -111,6 +118,10 @@ struct InstallationProgressState { stage_progress: f32, overall_progress: f32, current_item: Option, + #[serde(default)] + download_source_url: Option, + #[serde(default)] + download_active: bool, download_speed: Option, eta_seconds: Option, total_size: Option, @@ -134,6 +145,7 @@ impl InstallationProgressTracker { cache_dir, state_filename: "installation-state.json".to_string(), state: Mutex::new(None), + network_activity: NetworkActivityRegistry::new(), } } @@ -180,6 +192,7 @@ impl InstallationProgressTracker { dependency_count: Option, log_path: Option<&str>, ) { + self.clear_network_activity(); let state = InstallationProgressState { tag: tag.to_string(), started_at: chrono::Utc::now().to_rfc3339(), @@ -187,6 +200,8 @@ impl InstallationProgressTracker { stage_progress: 0.0, overall_progress: 0.0, current_item: None, + download_source_url: None, + download_active: false, download_speed: None, eta_seconds: None, total_size, @@ -215,11 +230,15 @@ impl InstallationProgressTracker { progress: f32, current_item: Option<&str>, ) { + self.clear_network_activity(); let mut guard = self.state.lock().unwrap(); if let Some(ref mut state) = *guard { state.stage = stage; state.stage_progress = progress.clamp(0.0, 100.0); state.current_item = current_item.map(String::from); + state.download_source_url = None; + state.download_active = false; + state.download_speed = None; state.overall_progress = self.calculate_overall_progress(state); } drop(guard); @@ -233,17 +252,29 @@ impl InstallationProgressTracker { total_bytes: Option, speed_bytes_per_sec: Option, ) { + let active = total_bytes.map_or(true, |total| downloaded_bytes < total); + let measured_speed = self + .record_network_activity( + "installer-download", + None, + active, + downloaded_bytes, + total_bytes, + ) + .or(speed_bytes_per_sec); let mut guard = self.state.lock().unwrap(); if let Some(ref mut state) = *guard { state.downloaded_bytes = downloaded_bytes; + state.download_source_url = None; + state.download_active = active; if let Some(total) = total_bytes { state.total_size = Some(total); state.stage_progress = (downloaded_bytes as f32 / total as f32) * 100.0; } - state.download_speed = speed_bytes_per_sec; + state.download_speed = active.then_some(measured_speed).flatten(); // Calculate ETA - if let (Some(speed), Some(total)) = (speed_bytes_per_sec, state.total_size) { + if let (Some(speed), Some(total)) = (measured_speed, state.total_size) { if speed > 0.0 && downloaded_bytes < total { let remaining = total - downloaded_bytes; state.eta_seconds = Some(remaining as f64 / speed); @@ -256,6 +287,97 @@ impl InstallationProgressTracker { let _ = self.persist_state(); } + /// Record network transfer details without changing stage completion. + pub fn update_network_transfer( + &mut self, + source_url: Option<&str>, + active: bool, + downloaded_bytes: u64, + total_bytes: Option, + speed_bytes_per_sec: Option, + ) { + let transfer_key = source_url.map_or_else( + || "torch-pip-download".to_owned(), + |source| format!("torch-wheel:{source}"), + ); + let measured_speed = self + .record_network_activity( + &transfer_key, + source_url, + active, + downloaded_bytes, + total_bytes, + ) + .or(speed_bytes_per_sec); + let mut guard = self.state.lock().unwrap(); + if let Some(ref mut state) = *guard { + state.download_source_url = source_url.map(str::to_owned); + state.download_active = active; + state.downloaded_bytes = downloaded_bytes; + state.total_size = total_bytes; + state.download_speed = active + .then_some(measured_speed) + .flatten() + .filter(|speed| speed.is_finite() && *speed >= 0.0); + } + drop(guard); + let _ = self.persist_state(); + } + + fn record_network_activity( + &self, + transfer_key: &str, + source_url: Option<&str>, + active: bool, + downloaded_bytes: u64, + total_bytes: Option, + ) -> Option { + let (operation_id, transfer_id) = { + let guard = self.state.lock().unwrap(); + let state = guard.as_ref()?; + let operation_id = OperationId(format!("{}:{}", state.tag, state.started_at)); + let transfer_id = TransferId(format!("{}:{transfer_key}", operation_id.0)); + (operation_id, transfer_id) + }; + let activity = self + .network_activity + .update(ActivityUpdate { + operation_id, + transfer_id: transfer_id.clone(), + direction: TransferDirection::Download, + source: Some(CopyableSource::new(source_url, None)), + expected_bytes: total_bytes, + cumulative_bytes: Some(downloaded_bytes), + state: if active { + TransferState::Active + } else { + TransferState::Completed + }, + basis: MeasurementBasis::Payload, + coverage: MeasurementCoverage::Complete, + sampled_at: Instant::now(), + }) + .ok()?; + if !active { + self.network_activity.remove(&transfer_id); + } + activity.rate_bytes_per_second + } + + fn clear_network_activity(&self) { + let operation_id = { + let guard = self.state.lock().unwrap(); + guard + .as_ref() + .map(|state| OperationId(format!("{}:{}", state.tag, state.started_at))) + }; + if let Some(operation_id) = operation_id { + for activity in self.network_activity.for_operation(&operation_id) { + self.network_activity.remove(&activity.transfer_id); + } + } + } + /// Update dependency installation progress. pub fn update_dependency_progress( &mut self, @@ -363,10 +485,13 @@ impl InstallationProgressTracker { /// Mark installation as completed. pub fn complete_installation(&mut self, success: bool) { + self.clear_network_activity(); let mut guard = self.state.lock().unwrap(); if let Some(ref mut state) = *guard { state.completed_at = Some(chrono::Utc::now().to_rfc3339()); state.success = Some(success); + state.download_active = false; + state.download_speed = None; if success { state.stage_progress = 100.0; state.overall_progress = 100.0; @@ -387,6 +512,8 @@ impl InstallationProgressTracker { stage_progress: Some(state.stage_progress), overall_progress: Some(state.overall_progress), current_item: state.current_item.clone(), + download_source_url: state.download_source_url.clone(), + download_active: state.download_active, download_speed: state.download_speed, eta_seconds: state.eta_seconds, total_size: state.total_size, @@ -541,6 +668,8 @@ impl InstallationProgressTracker { stage_progress: Some(state.stage_progress), overall_progress: Some(state.overall_progress), current_item: state.current_item.clone(), + download_source_url: state.download_source_url.clone(), + download_active: state.download_active, download_speed: state.download_speed, eta_seconds: state.eta_seconds, total_size: state.total_size, @@ -629,6 +758,112 @@ mod tests { assert!(state.eta_seconds.is_some()); } + #[test] + fn network_transfer_preserves_source_and_speed_without_advancing_stage() { + let temp_dir = TempDir::new().unwrap(); + let mut tracker = InstallationProgressTracker::new(temp_dir.path().to_path_buf()); + tracker.start_installation("v2.14.0", None, None, None); + tracker.update_stage( + InstallationStage::Setup, + 0.0, + Some("Resolving Torch packages"), + ); + tracker.update_network_transfer( + Some("https://download.pytorch.org/whl/cu134/torch-2.14.0.whl"), + true, + 1024, + Some(4096), + Some(512.0), + ); + + let state = tracker.get_current_state().unwrap(); + assert_eq!(state.stage, Some(InstallationStage::Setup)); + assert_eq!(state.stage_progress, Some(0.0)); + assert!(state.download_active); + assert_eq!( + state.download_source_url.as_deref(), + Some("https://download.pytorch.org/whl/cu134/torch-2.14.0.whl") + ); + assert_eq!(state.downloaded_bytes, Some(1024)); + assert_eq!(state.total_size, Some(4096)); + assert_eq!(state.download_speed, Some(512.0)); + } + + #[test] + fn stage_change_clears_previous_network_source_and_speed() { + let temp_dir = TempDir::new().unwrap(); + let mut tracker = InstallationProgressTracker::new(temp_dir.path().to_path_buf()); + tracker.start_installation("v2.14.0", None, None, None); + tracker.update_network_transfer( + Some("https://files.pythonhosted.org/packages/pkg-1.0.whl"), + true, + 20, + Some(40), + Some(10.0), + ); + tracker.update_stage( + InstallationStage::Setup, + 0.0, + Some("Checking installed Torch"), + ); + + let state = tracker.get_current_state().unwrap(); + assert_eq!(state.download_source_url, None); + assert!(!state.download_active); + assert_eq!(state.download_speed, None); + } + + #[test] + fn completed_transfer_and_installation_clear_active_rate() { + let temp_dir = TempDir::new().unwrap(); + let mut tracker = InstallationProgressTracker::new(temp_dir.path().to_path_buf()); + tracker.start_installation("v2.14.0", None, None, None); + tracker.update_network_transfer( + Some("https://files.pythonhosted.org/packages/pkg-1.0.whl"), + true, + 20, + Some(40), + Some(10.0), + ); + tracker.update_network_transfer( + Some("https://files.pythonhosted.org/packages/pkg-1.0.whl"), + false, + 40, + Some(40), + None, + ); + let transfer = tracker.get_current_state().unwrap(); + assert!(!transfer.download_active); + assert_eq!(transfer.download_speed, None); + + tracker.update_network_transfer( + Some("https://files.pythonhosted.org/packages/pkg-1.0.whl"), + true, + 20, + Some(40), + Some(10.0), + ); + tracker.complete_installation(false); + let terminal = tracker.get_current_state().unwrap(); + assert!(!terminal.download_active); + assert_eq!(terminal.download_speed, None); + } + + #[test] + fn network_activity_measures_downloads_without_exposing_unapproved_sources() { + let temp_dir = TempDir::new().unwrap(); + let mut tracker = InstallationProgressTracker::new(temp_dir.path().to_path_buf()); + tracker.start_installation("v2.14.0", None, None, None); + tracker.update_network_transfer(None, true, 0, Some(40), None); + tracker.update_network_transfer(None, true, 40, Some(40), None); + + let state = tracker.get_current_state().unwrap(); + assert_eq!(state.download_source_url, None); + assert!(state + .download_speed + .is_some_and(|speed| speed.is_finite() && speed > 0.0)); + } + #[test] fn test_progress_tracker_complete_package() { let temp_dir = TempDir::new().unwrap(); diff --git a/rust/crates/pumas-core/src/models/version.rs b/rust/crates/pumas-core/src/models/version.rs index 3738aee2..9c911b8b 100644 --- a/rust/crates/pumas-core/src/models/version.rs +++ b/rust/crates/pumas-core/src/models/version.rs @@ -129,6 +129,10 @@ pub struct InstallationProgress { #[serde(default)] pub current_item: Option, #[serde(default)] + pub download_source_url: Option, + #[serde(default)] + pub download_active: bool, + #[serde(default)] pub download_speed: Option, #[serde(default)] pub eta_seconds: Option, diff --git a/rust/crates/pumas-core/src/network/activity.rs b/rust/crates/pumas-core/src/network/activity.rs new file mode 100644 index 00000000..d27b4d47 --- /dev/null +++ b/rust/crates/pumas-core/src/network/activity.rs @@ -0,0 +1,423 @@ +//! Source-agnostic observations of concurrent network transfers. +//! +//! Producers report cumulative samples for each transfer. A transfer ID identifies one +//! stream of samples; an operation ID groups streams belonging to one user operation. +//! The registry never combines different transfers or measurement bases into one byte +//! total, since doing so could count the same traffic twice. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Instant; +use url::Url; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct OperationId(pub String); + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TransferId(pub String); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferDirection { + Download, + Upload, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferState { + Active, + Idle, + Completed, + Failed, + Cancelled, +} + +/// What the byte counter measures. Payload and wire bytes must not be added together. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeasurementBasis { + Payload, + Wire, + Unknown, +} + +/// How much of the transfer's traffic the producer can observe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeasurementCoverage { + Complete, + Partial, + Unknown, + Unsupported, +} + +/// Safe display/copy metadata. URLs retain only their origin, omitting credentials, +/// path, query, and fragment. Labels are caller-provided and must contain no secrets. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CopyableSource { + url: Option, + label: Option, +} + +impl CopyableSource { + pub fn new(url: Option<&str>, label: Option<&str>) -> Self { + let url = url.and_then(|raw| { + let parsed = Url::parse(raw).ok()?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host().is_none() { + return None; + } + Some(parsed.origin().ascii_serialization()) + }); + let label = label + .map(|raw| { + raw.chars() + .filter(|ch| !ch.is_control()) + .take(120) + .collect::() + .trim() + .to_owned() + }) + .filter(|label| !label.is_empty()); + Self { url, label } + } + + pub fn url(&self) -> Option<&str> { + self.url.as_deref() + } + + pub fn label(&self) -> Option<&str> { + self.label.as_deref() + } +} + +/// One cumulative observation. `None` means the producer has no byte count. +/// A decrease in `cumulative_bytes` starts a new rate baseline for a retry/reset. +#[derive(Debug, Clone)] +pub struct ActivityUpdate { + pub operation_id: OperationId, + pub transfer_id: TransferId, + pub direction: TransferDirection, + pub source: Option, + pub expected_bytes: Option, + pub cumulative_bytes: Option, + pub state: TransferState, + pub basis: MeasurementBasis, + pub coverage: MeasurementCoverage, + pub sampled_at: Instant, +} + +#[derive(Debug, Clone)] +pub struct TransferActivity { + pub operation_id: OperationId, + pub transfer_id: TransferId, + pub direction: TransferDirection, + pub source: Option, + pub expected_bytes: Option, + pub cumulative_bytes: Option, + /// Bytes per second over the latest valid pair of samples. `None` means no + /// reliable rate is available; an observed idle/final transfer has rate zero. + pub rate_bytes_per_second: Option, + pub state: TransferState, + pub basis: MeasurementBasis, + pub coverage: MeasurementCoverage, + pub sampled_at: Instant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActivityError { + /// A transfer ID cannot be reused for a different operation or direction. + TransferIdentityConflict, + /// An older sample cannot overwrite a newer observation. + OutOfOrderSample, +} + +#[derive(Debug, Clone)] +struct Entry { + activity: TransferActivity, + rate_baseline: Option<(u64, Instant)>, +} + +/// A thread-safe registry of independent transfer observations. +#[derive(Debug, Default)] +pub struct NetworkActivityRegistry { + transfers: Mutex>, +} + +impl NetworkActivityRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Insert or update one transfer and return its current snapshot. + pub fn update(&self, update: ActivityUpdate) -> Result { + let mut transfers = self + .transfers + .lock() + .expect("network activity registry poisoned"); + if let Some(entry) = transfers.get_mut(&update.transfer_id) { + if entry.activity.operation_id != update.operation_id + || entry.activity.direction != update.direction + { + return Err(ActivityError::TransferIdentityConflict); + } + if update.sampled_at < entry.activity.sampled_at { + return Err(ActivityError::OutOfOrderSample); + } + + let measurable = update.basis != MeasurementBasis::Unknown + && matches!( + update.coverage, + MeasurementCoverage::Complete | MeasurementCoverage::Partial + ); + let same_measurement = + entry.activity.basis == update.basis && entry.activity.coverage == update.coverage; + let reset = update + .cumulative_bytes + .zip(entry.activity.cumulative_bytes) + .is_some_and(|(current, previous)| current < previous); + let rate = if update.state != TransferState::Active { + Some(0.0) + } else if measurable && same_measurement && !reset { + match (entry.rate_baseline, update.cumulative_bytes) { + (Some((previous, at)), Some(current)) if current >= previous => update + .sampled_at + .checked_duration_since(at) + .filter(|elapsed| !elapsed.is_zero()) + .map(|elapsed| (current - previous) as f64 / elapsed.as_secs_f64()), + _ => None, + } + } else { + None + }; + + // A zero-time sample remains pending against the earlier baseline. A + // counter reset or measurement change establishes a fresh baseline. + if update.state != TransferState::Active + || reset + || !same_measurement + || update.cumulative_bytes.is_none() + || entry.rate_baseline.is_none() + || update.sampled_at > entry.rate_baseline.unwrap().1 + { + entry.rate_baseline = update + .cumulative_bytes + .map(|bytes| (bytes, update.sampled_at)); + } + if let Some(source) = update.source { + entry.activity.source = Some(source); + } + if let Some(expected) = update.expected_bytes { + entry.activity.expected_bytes = Some(expected); + } + entry.activity.cumulative_bytes = update.cumulative_bytes; + entry.activity.rate_bytes_per_second = rate; + entry.activity.state = update.state; + entry.activity.basis = update.basis; + entry.activity.coverage = update.coverage; + entry.activity.sampled_at = update.sampled_at; + Ok(entry.activity.clone()) + } else { + let activity = TransferActivity { + operation_id: update.operation_id, + transfer_id: update.transfer_id.clone(), + direction: update.direction, + source: update.source, + expected_bytes: update.expected_bytes, + cumulative_bytes: update.cumulative_bytes, + rate_bytes_per_second: (update.state != TransferState::Active).then_some(0.0), + state: update.state, + basis: update.basis, + coverage: update.coverage, + sampled_at: update.sampled_at, + }; + let rate_baseline = update + .cumulative_bytes + .map(|bytes| (bytes, update.sampled_at)); + transfers.insert( + update.transfer_id, + Entry { + activity: activity.clone(), + rate_baseline, + }, + ); + Ok(activity) + } + } + + pub fn get(&self, transfer_id: &TransferId) -> Option { + self.transfers + .lock() + .expect("network activity registry poisoned") + .get(transfer_id) + .map(|entry| entry.activity.clone()) + } + + pub fn for_operation(&self, operation_id: &OperationId) -> Vec { + let mut activities = self + .transfers + .lock() + .expect("network activity registry poisoned") + .values() + .filter(|entry| &entry.activity.operation_id == operation_id) + .map(|entry| entry.activity.clone()) + .collect::>(); + activities.sort_by(|a, b| a.transfer_id.0.cmp(&b.transfer_id.0)); + activities + } + + pub fn snapshot(&self) -> Vec { + let mut activities = self + .transfers + .lock() + .expect("network activity registry poisoned") + .values() + .map(|entry| entry.activity.clone()) + .collect::>(); + activities.sort_by(|a, b| a.transfer_id.0.cmp(&b.transfer_id.0)); + activities + } + + pub fn remove(&self, transfer_id: &TransferId) -> Option { + self.transfers + .lock() + .expect("network activity registry poisoned") + .remove(transfer_id) + .map(|entry| entry.activity) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::time::Duration; + + fn sample(operation: &str, transfer: &str, bytes: Option, at: Instant) -> ActivityUpdate { + ActivityUpdate { + operation_id: OperationId(operation.into()), + transfer_id: TransferId(transfer.into()), + direction: TransferDirection::Download, + source: None, + expected_bytes: None, + cumulative_bytes: bytes, + state: TransferState::Active, + basis: MeasurementBasis::Payload, + coverage: MeasurementCoverage::Complete, + sampled_at: at, + } + } + + #[test] + fn concurrent_operations_are_independent() { + let registry = Arc::new(NetworkActivityRegistry::new()); + let at = Instant::now(); + std::thread::scope(|scope| { + for (operation, transfer, bytes) in + [("a", "one", 10), ("b", "two", 30), ("a", "three", 50)] + { + let registry = Arc::clone(®istry); + scope.spawn(move || { + registry + .update(sample(operation, transfer, Some(bytes), at)) + .unwrap(); + }); + } + }); + assert_eq!(registry.for_operation(&OperationId("a".into())).len(), 2); + assert_eq!(registry.for_operation(&OperationId("b".into())).len(), 1); + assert_eq!(registry.snapshot().len(), 3); + assert_eq!( + registry + .update(sample("b", "one", Some(20), at)) + .unwrap_err(), + ActivityError::TransferIdentityConflict + ); + } + + #[test] + fn rate_uses_cumulative_delta_and_elapsed_time() { + let registry = NetworkActivityRegistry::new(); + let at = Instant::now(); + assert!(registry + .update(sample("a", "one", Some(100), at)) + .unwrap() + .rate_bytes_per_second + .is_none()); + let activity = registry + .update(sample("a", "one", Some(300), at + Duration::from_secs(2))) + .unwrap(); + assert_eq!(activity.rate_bytes_per_second, Some(100.0)); + assert_eq!(activity.cumulative_bytes, Some(300)); + assert_eq!( + registry + .update(sample("a", "one", Some(300), at + Duration::from_secs(3))) + .unwrap() + .rate_bytes_per_second, + Some(0.0) + ); + } + + #[test] + fn idle_and_final_states_clear_rate() { + let registry = NetworkActivityRegistry::new(); + let at = Instant::now(); + registry.update(sample("a", "one", Some(10), at)).unwrap(); + let mut next = sample("a", "one", Some(20), at + Duration::from_secs(1)); + assert_eq!( + registry.update(next.clone()).unwrap().rate_bytes_per_second, + Some(10.0) + ); + next.sampled_at += Duration::from_secs(1); + next.state = TransferState::Idle; + assert_eq!( + registry.update(next.clone()).unwrap().rate_bytes_per_second, + Some(0.0) + ); + next.sampled_at += Duration::from_secs(1); + next.state = TransferState::Completed; + assert_eq!( + registry.update(next).unwrap().rate_bytes_per_second, + Some(0.0) + ); + } + + #[test] + fn counter_reset_starts_a_new_rate_baseline() { + let registry = NetworkActivityRegistry::new(); + let at = Instant::now(); + registry.update(sample("a", "one", Some(100), at)).unwrap(); + let reset = registry + .update(sample("a", "one", Some(10), at + Duration::from_secs(1))) + .unwrap(); + assert_eq!(reset.rate_bytes_per_second, None); + assert_eq!( + registry + .update(sample("a", "one", Some(30), at + Duration::from_secs(2))) + .unwrap() + .rate_bytes_per_second, + Some(20.0) + ); + } + + #[test] + fn unknown_coverage_does_not_claim_a_rate() { + let registry = NetworkActivityRegistry::new(); + let at = Instant::now(); + let mut first = sample("a", "one", None, at); + first.basis = MeasurementBasis::Unknown; + first.coverage = MeasurementCoverage::Unsupported; + registry.update(first.clone()).unwrap(); + first.sampled_at += Duration::from_secs(1); + first.cumulative_bytes = Some(100); + let activity = registry.update(first).unwrap(); + assert_eq!(activity.coverage, MeasurementCoverage::Unsupported); + assert_eq!(activity.rate_bytes_per_second, None); + } + + #[test] + fn copyable_source_omits_sensitive_url_components() { + let source = CopyableSource::new( + Some("https://user:password@example.com/private/token?key=secret#fragment"), + Some(" source\nlabel "), + ); + assert_eq!(source.url(), Some("https://example.com")); + assert_eq!(source.label(), Some("sourcelabel")); + } +} diff --git a/rust/crates/pumas-core/src/network/mod.rs b/rust/crates/pumas-core/src/network/mod.rs index 818a5776..54face83 100644 --- a/rust/crates/pumas-core/src/network/mod.rs +++ b/rust/crates/pumas-core/src/network/mod.rs @@ -9,6 +9,7 @@ //! - NetworkManager for centralized connectivity management //! - WebSource traits for extensible web source registration +pub mod activity; mod circuit_breaker; mod client; mod download; @@ -17,6 +18,11 @@ mod manager; mod retry; mod web_source; +pub use activity::{ + ActivityError, ActivityUpdate, CopyableSource, MeasurementBasis, MeasurementCoverage, + NetworkActivityRegistry, OperationId, TransferActivity, TransferDirection, TransferId, + TransferState, +}; pub use circuit_breaker::{ CircuitBreaker, CircuitBreakerConfig, CircuitBreakerStats, CircuitState, }; diff --git a/rust/crates/pumas-rpc/src/contract.rs b/rust/crates/pumas-rpc/src/contract.rs index 5476dc65..c295689a 100644 --- a/rust/crates/pumas-rpc/src/contract.rs +++ b/rust/crates/pumas-rpc/src/contract.rs @@ -1724,6 +1724,8 @@ pub(crate) struct RuntimeInstallationProgress { stage_progress: Option, overall_progress: Option, current_item: Option, + download_source_url: Option, + download_active: bool, download_speed: Option, eta_seconds: Option, total_size: Option, @@ -1803,6 +1805,8 @@ impl InstallationProgressOutcome { stage_progress: value.stage_progress.filter(|value| value.is_finite()), overall_progress: value.overall_progress.filter(|value| value.is_finite()), current_item: value.current_item, + download_source_url: value.download_source_url, + download_active: value.download_active, download_speed: value.download_speed.filter(|value| value.is_finite()), eta_seconds: value.eta_seconds.filter(|value| value.is_finite()), total_size: value.total_size, @@ -1840,6 +1844,8 @@ fn installation_progress_fixture() -> pumas_library::models::InstallationProgres stage_progress: Some(125.5), overall_progress: Some(107.25), current_item: Some("torch".into()), + download_source_url: Some("https://download.pytorch.org/whl/cu134/torch-2.14.0.whl".into()), + download_active: true, download_speed: Some(42.5), eta_seconds: Some(0.5), total_size: Some(1024), @@ -1885,7 +1891,7 @@ mod installation_progress_contract_tests { #[test] fn installation_progress_preserves_literal_wire_and_null() { let value = installation_progress_fixture(); - let expected = serde_json::json!({"tag":" vλ.1 ","startedAt":"started","stage":"dependencies","stageProgress":125.5,"overallProgress":107.25,"currentItem":"torch","downloadSpeed":42.5,"etaSeconds":0.5,"totalSize":1024,"downloadedBytes":512,"dependencyCount":2,"completedDependencies":1,"completedItems":[{"name":"torch","type":"package","size":null,"completedAt":"item done"}],"error":null,"completedAt":null,"success":null,"logPath":"runtime/install.log"}); + let expected = serde_json::json!({"tag":" vλ.1 ","startedAt":"started","stage":"dependencies","stageProgress":125.5,"overallProgress":107.25,"currentItem":"torch","downloadSourceUrl":"https://download.pytorch.org/whl/cu134/torch-2.14.0.whl","downloadActive":true,"downloadSpeed":42.5,"etaSeconds":0.5,"totalSize":1024,"downloadedBytes":512,"dependencyCount":2,"completedDependencies":1,"completedItems":[{"name":"torch","type":"package","size":null,"completedAt":"item done"}],"error":null,"completedAt":null,"success":null,"logPath":"runtime/install.log"}); assert_eq!(serde_json::to_value(&value).unwrap(), expected); let outcome = InstallationProgressOutcome::new(Some(value)).unwrap(); assert_eq!(serde_json::to_value(&outcome).unwrap(), expected); diff --git a/torch-server/resolve_runtime.py b/torch-server/resolve_runtime.py index 3743be2e..7b615483 100644 --- a/torch-server/resolve_runtime.py +++ b/torch-server/resolve_runtime.py @@ -11,12 +11,14 @@ from concurrent.futures import ThreadPoolExecutor, wait import hashlib from html.parser import HTMLParser +import inspect import json import os import platform import re import subprocess import sys +import threading import time from pathlib import Path from pip._vendor.packaging import tags as packaging_tags @@ -26,6 +28,140 @@ from urllib.request import HTTPRedirectHandler, Request, build_opener CORE = ("fastapi", "uvicorn", "psutil", "pillow", "safetensors") +DOWNLOAD_PROGRESS_INTERVAL_SECONDS = 0.25 + + +def copyable_download_source(url: str) -> str | None: + """Keep progress links limited to the official HTTPS wheel hosts.""" + parsed = urlparse(url) + try: + port = parsed.port + except ValueError: + return None + if ( + parsed.scheme != "https" + or parsed.username is not None + or parsed.password is not None + or port not in (None, 443) + or parsed.query + or parsed.fragment + or not parsed.path.lower().endswith(".whl") + ): + return None + if parsed.hostname == "files.pythonhosted.org" and parsed.path.startswith("/packages/"): + return url + if parsed.hostname in {"download.pytorch.org", "download-r2.pytorch.org"} and parsed.path.startswith( + "/whl/" + ): + return url + if parsed.hostname == "github.com" and parsed.path.startswith( + "/nunchux-ai/nunchaku/releases/download/" + ): + return url + return None + + +def _write_download_progress( + progress_path: Path, + source_url: str | None, + active: bool, + downloaded_bytes: int, + total_bytes: int | None, + speed_bytes_per_sec: float | None, +) -> None: + """Atomically publish a private, best-effort progress snapshot.""" + payload = { + "source_url": source_url, + "active": active, + "downloaded_bytes": downloaded_bytes, + "total_bytes": total_bytes, + "speed_bytes_per_sec": speed_bytes_per_sec, + } + temporary = progress_path.with_name( + f".{progress_path.name}.{os.getpid()}.{threading.get_ident()}.tmp" + ) + try: + temporary.write_text(json.dumps(payload, separators=(",", ":")), encoding="utf-8") + os.replace(temporary, progress_path) + except OSError: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + + +def _track_download_chunks( + chunks, + source_url: str | None, + total_bytes: int | None, + progress_path: Path, + *, + clock=time.monotonic, + interval: float = DOWNLOAD_PROGRESS_INTERVAL_SECONDS, +): + last_reported_at = clock() + downloaded_bytes = 0 + last_reported_bytes = 0 + _write_download_progress(progress_path, source_url, True, downloaded_bytes, total_bytes, None) + for chunk in chunks: + yield chunk + downloaded_bytes += len(chunk) + now = clock() + if now - last_reported_at >= interval: + elapsed = max(now - last_reported_at, 0.001) + _write_download_progress( + progress_path, + source_url, + True, + downloaded_bytes, + total_bytes, + (downloaded_bytes - last_reported_bytes) / elapsed, + ) + last_reported_at = now + last_reported_bytes = downloaded_bytes + _write_download_progress(progress_path, source_url, False, downloaded_bytes, total_bytes, None) + + +def run_pip_progress_worker(progress_path: Path, pip_arguments: list[str]) -> int: + """Run pip while exposing its active HTTPS wheel transfer to the parent.""" + try: + from pip._internal.cli.main import main as pip_main + except ImportError: + return subprocess.run( + [sys.executable, "-I", "-m", "pip", *pip_arguments], check=False + ).returncode + + try: + from pip._internal.network import download as pip_download + original_prepare_download = getattr(pip_download, "_prepare_download", None) + signature = inspect.signature(original_prepare_download) + except (ImportError, TypeError, ValueError): + return pip_main(pip_arguments) + if original_prepare_download is None or tuple(signature.parameters) != ( + "response", + "link", + "progress_bar", + ): + return pip_main(pip_arguments) + + def prepare_download(response, link, progress_bar): + chunks = original_prepare_download(response, link, progress_bar) + try: + if pip_download.is_from_cache(response): + return chunks + source_url = copyable_download_source(link.url_without_fragment) + total_bytes = pip_download._get_http_response_size(response) + return _track_download_chunks(chunks, source_url, total_bytes, progress_path) + except Exception: + # Progress is best-effort: an unexpected pip helper/API change must + # not turn an otherwise valid installation into a failure. + return chunks + + pip_download._prepare_download = prepare_download + try: + return pip_main(pip_arguments) + finally: + pip_download._prepare_download = original_prepare_download def staged_file_digest(path: Path) -> tuple[bytes, int]: @@ -86,11 +222,15 @@ def installed_file_manifest(target: Path, artifacts: list[dict] | None = None) - if len(row) != 3: raise ValueError("Malformed staged wheel RECORD") name, recorded_hash, recorded_size = row - if name.startswith("../../bin/"): - script = name.removeprefix("../../bin/") - if not script or "/" in script or script in (".", ".."): - raise ValueError("Staged wheel RECORD escapes its target") - relative = "bin/" + script + if name.startswith("../../"): + relocated = name.removeprefix("../../") + root, separator, nested = relocated.partition("/") + if not separator or root not in {"bin", "share", "Scripts", "Include"}: + raise ValueError( + f"Staged wheel RECORD escapes its target: " + f"{record.parent.name} {name[:256]!r}" + ) + relative = root + "/" + nested else: relative = name path = Path(relative) @@ -100,7 +240,10 @@ def installed_file_manifest(target: Path, artifacts: list[dict] | None = None) - or "\\" in relative or any(part in ("", ".", "..") for part in relative.split("/")) ): - raise ValueError("Staged wheel RECORD escapes its target") + raise ValueError( + f"Staged wheel RECORD escapes its target: " + f"{record.parent.name} {name[:256]!r}" + ) if relative in claimed: raise ValueError("Duplicate staged wheel file") file = target / path @@ -953,6 +1096,7 @@ def main() -> None: parser.add_argument("--install", action="store_true") parser.add_argument("--target", type=Path) parser.add_argument("--cache-dir", type=Path) + parser.add_argument("--progress-file", type=Path) parser.add_argument("--discover", action="store_true") parser.add_argument("--release-options", action="store_true") parser.add_argument("--selected-python") @@ -970,6 +1114,7 @@ def main() -> None: or args.output or args.selected_python or args.cache_dir is not None + or args.progress_file is not None or args.adapter != "none" or args.torch_wheel is not None or args.torch_sha256 is not None @@ -998,6 +1143,8 @@ def main() -> None: parser.error("--python-candidate is only valid with --release-options") if args.install and args.target is None: parser.error("--install requires an explicit staged --target") + if args.progress_file is not None and not args.install: + parser.error("--progress-file is only valid with --install") if not args.install and args.target is not None: parser.error("--target is only valid with --install") if args.torch_sha256 is not None and args.torch_wheel is None: @@ -1021,6 +1168,11 @@ def main() -> None: return if args.output is None: parser.error("Resolution requires --output") + if args.progress_file is not None and ( + args.progress_file.name != "download-progress.json" + or args.progress_file.resolve().parent != args.output.resolve() + ): + parser.error("--progress-file must be download-progress.json inside --output") if args.torch_wheel is None and not args.install: parser.error("Resolution requires --torch-wheel from official discovery") try: @@ -1088,7 +1240,18 @@ def main() -> None: *CORE, *extras, ] - completed = subprocess.run(command, check=False, capture_output=True, text=True) + child_command = command + if args.progress_file is not None: + args.progress_file.unlink(missing_ok=True) + child_command = [ + sys.executable, + "-I", + str(Path(__file__).resolve()), + "--_pumas-pip-progress-worker", + str(args.progress_file), + *command[4:], + ] + completed = subprocess.run(child_command, check=False, capture_output=True, text=True) print(completed.stdout, end="", flush=True) print(completed.stderr, end="", file=sys.stderr, flush=True) if completed.returncode: @@ -1125,4 +1288,8 @@ def main() -> None: if __name__ == "__main__": + if sys.argv[1:2] == ["--_pumas-pip-progress-worker"]: + if len(sys.argv) < 3: + raise SystemExit(2) + raise SystemExit(run_pip_progress_worker(Path(sys.argv[2]), sys.argv[3:])) main() diff --git a/torch-server/tests/test_resolve_runtime.py b/torch-server/tests/test_resolve_runtime.py index f8decd0e..6c28ff32 100644 --- a/torch-server/tests/test_resolve_runtime.py +++ b/torch-server/tests/test_resolve_runtime.py @@ -70,6 +70,83 @@ def report( class ResolverTests(unittest.TestCase): + def test_copyable_download_sources_are_limited_to_official_wheel_urls(self): + trusted = ( + "https://files.pythonhosted.org/packages/ab/pkg-1.0-py3-none-any.whl", + "https://download.pytorch.org/whl/cu134/torch-2.14.0.whl", + "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0.whl", + "https://github.com/nunchux-ai/nunchaku/releases/download/v1.2.0/nunchaku-1.2.0.whl", + ) + rejected = ( + "http://files.pythonhosted.org/packages/pkg-1.0.whl", + "https://user:secret@files.pythonhosted.org/packages/pkg-1.0.whl", + "https://files.pythonhosted.org/packages/pkg-1.0.whl?token=secret", + "https://example.com/packages/pkg-1.0.whl", + "https://github.com/other/package/releases/download/v1/pkg-1.0.whl", + "https://files.pythonhosted.org/packages/pkg-1.0.tar.gz", + ) + for source in trusted: + with self.subTest(source=source): + self.assertEqual(resolver.copyable_download_source(source), source) + for source in rejected: + with self.subTest(source=source): + self.assertIsNone(resolver.copyable_download_source(source)) + + def test_progress_worker_tracks_unlisted_downloads_without_exposing_their_urls(self): + pip_download = importlib.import_module("pip._internal.network.download") + pip_cli = importlib.import_module("pip._internal.cli.main") + response = object() + link = SimpleNamespace(url_without_fragment="https://example.com/private.whl") + + def prepare_download(response, link, progress_bar): + del response, link, progress_bar + return iter((b"ab", b"cd")) + + def fake_pip_main(_arguments): + return list(pip_download._prepare_download(response, link, None)) and 0 + + with tempfile.TemporaryDirectory() as directory: + progress_path = pathlib.Path(directory) / "download-progress.json" + with ( + patch.object(pip_cli, "main", side_effect=fake_pip_main), + patch.object(pip_download, "_prepare_download", prepare_download), + patch.object(pip_download, "is_from_cache", return_value=False), + patch.object(pip_download, "_get_http_response_size", return_value=4), + ): + self.assertEqual( + resolver.run_pip_progress_worker(progress_path, ["install", "pkg"]), 0 + ) + + progress = json.loads(progress_path.read_text(encoding="utf-8")) + self.assertIsNone(progress["source_url"]) + self.assertFalse(progress["active"]) + self.assertEqual(progress["downloaded_bytes"], 4) + self.assertEqual(progress["total_bytes"], 4) + + def test_download_progress_reports_measured_rate_and_final_byte_count(self): + timestamps = iter((0.0, 0.5, 1.0)) + writes = [] + with ( + tempfile.TemporaryDirectory() as directory, + patch.object( + resolver, + "_write_download_progress", + side_effect=lambda *values: writes.append(values), + ), + ): + chunks = resolver._track_download_chunks( + iter((b"ab", b"cd")), + "https://files.pythonhosted.org/packages/pkg-1.0.whl", + 4, + pathlib.Path(directory) / "progress.json", + clock=lambda: next(timestamps), + ) + self.assertEqual(list(chunks), [b"ab", b"cd"]) + + self.assertEqual(len(writes), 4) + self.assertEqual(writes[1][2:], (True, 2, 4, 4.0)) + self.assertEqual(writes[-1][2:], (False, 4, 4, None)) + def test_staged_manifest_requires_exact_recorded_file_set_and_hashes(self): with tempfile.TemporaryDirectory() as directory: target = pathlib.Path(directory) @@ -148,6 +225,63 @@ def test_staged_distribution_identity_must_match_pip_report(self): with self.assertRaisesRegex(ValueError, "identity differs"): resolver.installed_file_manifest(target, artifact) + def test_staged_manifest_accepts_pip_target_data_files_with_share_paths(self): + with tempfile.TemporaryDirectory() as directory: + target = pathlib.Path(directory) + dist = target / "sympy-1.14.0.dist-info" + dist.mkdir() + metadata = dist / "METADATA" + metadata.write_text( + "Metadata-Version: 2.1\nName: sympy\nVersion: 1.14.0\n", + encoding="utf-8", + ) + executable = target / "bin" / "isympy" + executable.parent.mkdir() + executable.write_bytes(b"#!/bin/sh\n") + man_page = target / "share" / "man" / "man1" / "isympy.1" + man_page.parent.mkdir(parents=True) + man_page.write_bytes(b"SymPy interactive shell manual\n") + + def record_row(name, content): + digest = base64.urlsafe_b64encode(hashlib.sha256(content).digest()) + return f"{name},sha256={digest.rstrip(b'=').decode()},{len(content)}\n" + + record = dist / "RECORD" + record.write_text( + record_row("sympy-1.14.0.dist-info/METADATA", metadata.read_bytes()) + + record_row("../../bin/isympy", executable.read_bytes()) + + record_row("../../share/man/man1/isympy.1", man_page.read_bytes()) + + "sympy-1.14.0.dist-info/RECORD,,\n", + encoding="utf-8", + ) + + manifest = resolver.installed_file_manifest( + target, [{"name": "sympy", "version": "1.14.0"}] + ) + self.assertEqual( + [item["path"] for item in manifest["files"]], + [ + "bin/isympy", + "share/man/man1/isympy.1", + "sympy-1.14.0.dist-info/METADATA", + "sympy-1.14.0.dist-info/RECORD", + ], + ) + good_record = record.read_text(encoding="utf-8") + for unsafe_path in ("../../etc/passwd", "../../share/../../outside"): + with self.subTest(unsafe_path=unsafe_path): + record.write_text( + good_record.replace( + "../../share/man/man1/isympy.1", unsafe_path + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "escapes its target"): + resolver.installed_file_manifest( + target, [{"name": "sympy", "version": "1.14.0"}] + ) + record.write_text(good_record, encoding="utf-8") + def test_install_mode_stages_once_and_validates_report_before_writing_lock(self): fixture = report() commands = [] @@ -187,6 +321,7 @@ def fake_run(command, **_kwargs): with tempfile.TemporaryDirectory() as directory: output = pathlib.Path(directory) / "manifest" target = pathlib.Path(directory) / "staged packages" + progress_file = output / "download-progress.json" with ( patch.object( resolver.sys, @@ -200,6 +335,8 @@ def fake_run(command, **_kwargs): "--install", "--target", str(target), + "--progress-file", + str(progress_file), "--output", str(output), ], @@ -208,6 +345,8 @@ def fake_run(command, **_kwargs): ): resolver.main() self.assertEqual(len(commands), 1) + self.assertEqual(commands[0][3], "--_pumas-pip-progress-worker") + self.assertEqual(commands[0][4], str(progress_file)) self.assertEqual(commands[0][commands[0].index("--target") + 1], str(target)) self.assertTrue(target.is_dir()) self.assertEqual( From 2689868b74ed36d04bd9989c01329505dd1d78ba Mon Sep 17 00:00:00 2001 From: MrScripty Date: Sat, 26 Sep 2026 16:28:23 -0700 Subject: [PATCH 06/19] Allow copyable download sources across hosts --- .../execution-ledger.md | 37 ++++++++++-- .../plan.md | 11 ++-- .../upstream-version-manager-progress.md | 12 ++-- .../src/version_manager/installer/torch.rs | 50 ++++++++--------- .../crates/pumas-core/src/network/activity.rs | 44 +++++++++++++-- torch-server/resolve_runtime.py | 28 +++++----- torch-server/tests/test_resolve_runtime.py | 56 ++++++++++++++++--- 7 files changed, 169 insertions(+), 69 deletions(-) diff --git a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md index b37fc832..2743767c 100644 --- a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md +++ b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md @@ -1,5 +1,32 @@ # Execution Ledger: Cross-Platform Torch Runtime Management +## 2026-09-26 — Copy direct download sources across hosts + +- The earlier Torch telemetry host allowlist was too restrictive for the + requested copyable source. Copyable progress URLs now accept any HTTPS host, + port, and path, with no wheel-extension or official-host requirement. URLs + containing credentials, a query, fragment, whitespace/control character, or + more than 2,048 bytes are withheld while download bytes and speed remain + visible. The general activity registry accepts similarly safe direct HTTP(S) + URLs, including local-network sources. +- This is a display-safety rule, not an upstream trust decision. Torch package + provenance, official artifact URLs, and hash validation are unchanged. Signed + or authenticated URLs with query credentials stay hidden because copying + their query could disclose access tokens. +- Focused verification passed: 55 resolver tests and Ruff, 2 Torch source + handoff tests, 2 shared registry source tests, formatting, and + `git diff --check`. The full `pumas-library` crate run returned 1,373 passed, + 66 failed, and 6 ignored; failures were outside the changed source tests in + API, network, and runtime areas subject to this environment's socket/temp + directory restrictions. +- Rebuilt the local Linux v0.7.0 packages and passed artifact checks plus + extracted resource/backend `/health` smoke tests. AppImage SHA-256 + `19de13f4e429dd931196900e43028752e3900095546fa4a6d13dde5a9c3a6f51`; + Debian package SHA-256 + `4cfc5735786d4677614ae5cc1604b866902b785fc910f506b099a0a7c0b3ff77`. + The package is local and unpublished; broader OS-level network sampling and + DownloadManager/Hugging Face producer adapters remain open. + ## 2026-09-26 — Torch 2.14.0 RECORD validation repair and network activity - Diagnosed `launcher-data/logs/install-v2.14.0-1790458099378.log`: Torch @@ -17,10 +44,12 @@ - Added the reusable platform-neutral network activity registry and connected Torch pip downloads plus the installer's existing download callback. It tracks concurrent operation/transfer identity, cumulative payload bytes, - expected bytes, rate, state, and measurement coverage. Allowed source URLs are - copyable; unapproved URLs remain hidden while their byte rate is still - reported. Generic DownloadManager/Hugging Face adapters and OS-level - supplemental providers remain open; X8 is partial. + expected bytes, rate, state, and measurement coverage. Copyable direct URLs + accept any HTTPS host/path without embedded credentials, query, or fragment; + potentially secret-bearing URLs still report byte rate without exposing the + URL. Copyable status does not certify artifact provenance; official package + hash validation stays separate. Generic DownloadManager/Hugging Face adapters + and OS-level supplemental providers remain open; X8 is partial. - Verification passed: 54 resolver tests, Ruff, 231 app-manager tests, 725 frontend tests, frontend typecheck and production build, Rust formatting, desktop RPC `/health`, artifact checks, extracted AppImage/deb resource and diff --git a/docs/plans/torch-cross-platform-runtime-management/plan.md b/docs/plans/torch-cross-platform-runtime-management/plan.md index a2679602..57ef1200 100644 --- a/docs/plans/torch-cross-platform-runtime-management/plan.md +++ b/docs/plans/torch-cross-platform-runtime-management/plan.md @@ -355,9 +355,12 @@ resolver, manager, desktop, and platform runtime owners. payload and OS wire counters. - The first adapter is Torch package installation. The existing installer download callback and the pip wheel worker feed the same registry. The - desktop progress view shows the measured payload rate and a copyable URL only - for sources on Pumas' approved artifact allowlist; other sources still report - byte rate without exposing an unapproved link. + desktop progress view shows the measured payload rate and a copyable direct + URL for any HTTP(S) host when the URL has no embedded credentials, query, or + fragment. Other sources still report byte rate without exposing a potentially + secret-bearing URL. Copyable source status is a display-safety check, not + provenance approval; official package origin and hash validation remain + separate install requirements. - Keep OS process monitoring optional. It may add partial wire-byte observations under the same operation/transfer model, but permission-dependent OS counters cannot identify a repository or artifact and must not be presented as the @@ -379,7 +382,7 @@ resolver, manager, desktop, and platform runtime owners. | X5 | The actual install attempt retains the upstream distribution version, build, official wheel URL/hash, Python provider artifact identity, interpreter fingerprint, and complete dependency report; validates installed package names/versions and the exact staged file manifest before publication | Historical run `36223106097` resolved 25 hashed CPU/Core artifacts through the previous preview path on all targets; current direct-install resolver and Rust provenance tests cover post-install checks. Native packaged direct-install acceptance is pending. | partial | | X6 | Installed versions can be inspected, explicitly selected, started with health/protocol checks, and stopped by their owned generation; cancellation, timeout, RPC shutdown, and failed cleanup do not leak a resolver, installer, sidecar, interpreter provisioner, or unregistered runtime | Run `36223106097` passed second-session restart, fresh CPU operation, probe revalidation, protocol 3 sidecar trial/stop, and graceful shutdown on Linux, Windows, and macOS (see X2 reports); broader cancellation/timeout/failure cleanup remains | partial | | X7 | The same manager choices and lifecycle are reachable through packaged desktop controls and the existing RPC API; the user never has to choose Python, and the desktop offers no unsupported adapter | In the historical Linux v0.7.0 AppImage UI install, the first sampled state at 9 seconds showed the current phase in the header, indeterminate bars, Cancel, and readable controls under the previous package-resolution flow. A separate interaction/readability follow-up observed immediate artifact-check status but did not install again. The direct-install local AppImage/deb are now built and pass extracted-resource/backend health checks; source/UI tests cover local selection and visible install state. Manual packaged install remains pending. Packaged Windows/macOS installation remains unverified. | partial | -| X8 | Network activity from each integrated producer uses the shared operation/transfer registry with explicit payload-versus-wire basis, coverage, lifecycle, and source; UI rates are derived from cumulative samples and artifact links are validated | Torch pip and installer progress use the registry; copyable allowlisted wheel URLs and active/idle speed are covered by Rust and frontend tests. Generic `DownloadManager` and Hugging Face adapters, OS telemetry providers, and cross-platform permission behavior remain open. | partial | +| X8 | Network activity from each integrated producer uses the shared operation/transfer registry with explicit payload-versus-wire basis, coverage, lifecycle, and source; UI rates are derived from cumulative samples and source URLs are safely copyable | Torch pip and installer progress use the registry; arbitrary HTTP(S) hosts and direct paths without credentials or query/fragment are covered by Rust and Python tests. Generic `DownloadManager` and Hugging Face adapters, OS telemetry providers, and cross-platform permission behavior remain open. | partial | Plan acceptance is `pending` until every required row is satisfied on its declared native environment. Cross-compilation, Linux simulation, packaging, diff --git a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md index 50f3a939..00e430fc 100644 --- a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md +++ b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md @@ -35,11 +35,13 @@ install, and this repair provides no new CUDA, Tuldok image, Windows, or macOS runtime evidence. The same work adds a shared network activity registry used by Torch wheel -downloads and installer progress. It exposes payload rates across approved and -unapproved sources, while only approved HTTPS artifact URLs are copyable. -Generic DownloadManager and Hugging Face producers, OS-level supplementary -sampling, and native cross-platform permissions remain unimplemented; network -monitoring acceptance is partial. +downloads and installer progress. Any direct HTTPS source host and path is +copyable unless the URL contains embedded credentials, a query, or a fragment; +those potentially secret-bearing sources still expose payload rates without +displaying the URL. Copyable means safe to display, not officially sourced or +hash-validated. Generic DownloadManager and Hugging Face producers, OS-level +supplementary sampling, and native cross-platform permissions remain +unimplemented; network monitoring acceptance is partial. ## 2026-09-26 — Replace the long Torch check with the real install attempt diff --git a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs index 1659831c..31a08698 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs @@ -26,31 +26,23 @@ struct TorchDownloadProgress { speed_bytes_per_sec: Option, } -fn trusted_torch_download_source(source: &str) -> bool { - if source.len() > MAX_TORCH_DOWNLOAD_SOURCE_BYTES { +fn safe_torch_download_source(source: &str) -> bool { + if source.len() > MAX_TORCH_DOWNLOAD_SOURCE_BYTES + || source + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { return false; } let Ok(url) = reqwest::Url::parse(source) else { return false; }; - let official_torch = matches!( - url.host_str(), - Some("download.pytorch.org" | "download-r2.pytorch.org") - ) && url.path().starts_with("/whl/"); - let official_pypi = - url.host_str() == Some("files.pythonhosted.org") && url.path().starts_with("/packages/"); - let official_nunchaku = url.host_str() == Some("github.com") - && url - .path() - .starts_with("/nunchux-ai/nunchaku/releases/download/"); url.scheme() == "https" + && url.host().is_some() && url.username().is_empty() && url.password().is_none() - && matches!(url.port(), None | Some(443)) && url.query().is_none() && url.fragment().is_none() - && url.path().to_ascii_lowercase().ends_with(".whl") - && (official_torch || official_pypi || official_nunchaku) } fn read_torch_download_progress(path: &Path) -> Option { @@ -63,7 +55,7 @@ fn read_torch_download_progress(path: &Path) -> Option { if progress .source_url .as_deref() - .is_some_and(|source| !trusted_torch_download_source(source)) + .is_some_and(|source| !safe_torch_download_source(source)) || progress .speed_bytes_per_sec .is_some_and(|speed| !speed.is_finite() || speed < 0.0) @@ -2716,34 +2708,38 @@ mod download_progress_tests { use super::*; #[test] - fn progress_source_only_accepts_official_https_wheel_hosts() { + fn progress_source_accepts_any_safe_https_host_and_path() { for source in [ "https://download.pytorch.org/whl/cu134/torch-2.14.0.whl", "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0.whl", "https://files.pythonhosted.org/packages/ab/pkg-1.0.whl", "https://github.com/nunchux-ai/nunchaku/releases/download/v1.2.0/nunchaku-1.2.0.whl", + "https://mirror.example.net:8443/releases/model.safetensors", ] { - assert!(trusted_torch_download_source(source), "{source}"); + assert!(safe_torch_download_source(source), "{source}"); } for source in [ "http://files.pythonhosted.org/packages/pkg-1.0.whl", "https://user:secret@files.pythonhosted.org/packages/pkg-1.0.whl", "https://files.pythonhosted.org/packages/pkg-1.0.whl?token=secret", - "https://example.com/packages/pkg-1.0.whl", - "https://files.pythonhosted.org/packages/pkg-1.0.tar.gz", + "https://files.pythonhosted.org/packages/pkg-1.0.whl?", + "https://files.pythonhosted.org/packages/pkg-1.0.whl#download", + "https://files.pythonhosted.org/packages/pkg-1.0.whl#", + "https://mirror.example.net/releases/model file.bin", + &format!("https://mirror.example.net/{}", "a".repeat(2048)), ] { - assert!(!trusted_torch_download_source(source), "{source}"); + assert!(!safe_torch_download_source(source), "{source}"); } } #[test] - fn progress_file_rejects_untrusted_sources_and_bad_rates() { + fn progress_file_rejects_secret_sources_and_bad_rates() { let workspace = tempfile::tempdir().unwrap(); let path = workspace.path().join("download-progress.json"); std::fs::write( &path, serde_json::json!({ - "source_url":"https://example.com/torch.whl", + "source_url":"https://example.com/torch.whl?token=secret", "active":true, "downloaded_bytes":1, "total_bytes":2, @@ -2757,7 +2753,7 @@ mod download_progress_tests { std::fs::write( &path, serde_json::json!({ - "source_url":"https://files.pythonhosted.org/packages/torch.whl", + "source_url":"https://mirror.example.net:8443/releases/model.safetensors", "active":true, "downloaded_bytes":3, "total_bytes":2, @@ -2771,7 +2767,7 @@ mod download_progress_tests { std::fs::write( &path, serde_json::json!({ - "source_url":"https://files.pythonhosted.org/packages/torch.whl", + "source_url":"https://mirror.example.net:8443/releases/model.safetensors", "active":true, "downloaded_bytes":1, "total_bytes":2, @@ -2783,7 +2779,9 @@ mod download_progress_tests { assert_eq!( read_torch_download_progress(&path), Some(TorchDownloadProgress { - source_url: Some("https://files.pythonhosted.org/packages/torch.whl".into()), + source_url: Some( + "https://mirror.example.net:8443/releases/model.safetensors".into(), + ), active: true, downloaded_bytes: 1, total_bytes: Some(2), diff --git a/rust/crates/pumas-core/src/network/activity.rs b/rust/crates/pumas-core/src/network/activity.rs index d27b4d47..4cb96f6a 100644 --- a/rust/crates/pumas-core/src/network/activity.rs +++ b/rust/crates/pumas-core/src/network/activity.rs @@ -48,8 +48,9 @@ pub enum MeasurementCoverage { Unsupported, } -/// Safe display/copy metadata. URLs retain only their origin, omitting credentials, -/// path, query, and fragment. Labels are caller-provided and must contain no secrets. +/// Safe display/copy metadata. Direct HTTP(S) URLs retain their path and port but +/// are omitted if they contain credentials, a query, or a fragment. Labels are +/// caller-provided and must contain no secrets. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CopyableSource { url: Option, @@ -59,11 +60,24 @@ pub struct CopyableSource { impl CopyableSource { pub fn new(url: Option<&str>, label: Option<&str>) -> Self { let url = url.and_then(|raw| { + if raw.len() > 4096 + || raw + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return None; + } let parsed = Url::parse(raw).ok()?; - if !matches!(parsed.scheme(), "http" | "https") || parsed.host().is_none() { + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { return None; } - Some(parsed.origin().ascii_serialization()) + Some(parsed.to_string()) }); let label = label .map(|raw| { @@ -412,12 +426,30 @@ mod tests { } #[test] - fn copyable_source_omits_sensitive_url_components() { + fn copyable_source_preserves_any_safe_direct_http_source() { + let source = CopyableSource::new( + Some("https://mirror.example.net:8443/releases/model.safetensors"), + Some("model artifact"), + ); + assert_eq!( + source.url(), + Some("https://mirror.example.net:8443/releases/model.safetensors") + ); + + let local_source = CopyableSource::new(Some("http://192.168.1.4:8080/models/flux"), None); + assert_eq!( + local_source.url(), + Some("http://192.168.1.4:8080/models/flux") + ); + } + + #[test] + fn copyable_source_rejects_credentials_and_token_bearing_components() { let source = CopyableSource::new( Some("https://user:password@example.com/private/token?key=secret#fragment"), Some(" source\nlabel "), ); - assert_eq!(source.url(), Some("https://example.com")); + assert_eq!(source.url(), None); assert_eq!(source.label(), Some("sourcelabel")); } } diff --git a/torch-server/resolve_runtime.py b/torch-server/resolve_runtime.py index 7b615483..de152beb 100644 --- a/torch-server/resolve_runtime.py +++ b/torch-server/resolve_runtime.py @@ -32,33 +32,31 @@ def copyable_download_source(url: str) -> str | None: - """Keep progress links limited to the official HTTPS wheel hosts.""" - parsed = urlparse(url) + """Allow any direct HTTPS source without embedded credentials or query secrets.""" + if len(url.encode("utf-8")) > 2048 or any( + character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F + for character in url + ): + return None try: + parsed = urlparse(url) port = parsed.port + hostname = parsed.hostname except ValueError: return None if ( parsed.scheme != "https" + or not hostname or parsed.username is not None or parsed.password is not None - or port not in (None, 443) + or (port is not None and not 1 <= port <= 65535) or parsed.query or parsed.fragment - or not parsed.path.lower().endswith(".whl") + or "?" in url.split("#", 1)[0] + or "#" in url ): return None - if parsed.hostname == "files.pythonhosted.org" and parsed.path.startswith("/packages/"): - return url - if parsed.hostname in {"download.pytorch.org", "download-r2.pytorch.org"} and parsed.path.startswith( - "/whl/" - ): - return url - if parsed.hostname == "github.com" and parsed.path.startswith( - "/nunchux-ai/nunchaku/releases/download/" - ): - return url - return None + return url def _write_download_progress( diff --git a/torch-server/tests/test_resolve_runtime.py b/torch-server/tests/test_resolve_runtime.py index 6c28ff32..2acc2e16 100644 --- a/torch-server/tests/test_resolve_runtime.py +++ b/torch-server/tests/test_resolve_runtime.py @@ -70,33 +70,39 @@ def report( class ResolverTests(unittest.TestCase): - def test_copyable_download_sources_are_limited_to_official_wheel_urls(self): - trusted = ( + def test_copyable_download_sources_accept_any_safe_https_host_and_path(self): + safe = ( "https://files.pythonhosted.org/packages/ab/pkg-1.0-py3-none-any.whl", "https://download.pytorch.org/whl/cu134/torch-2.14.0.whl", "https://download-r2.pytorch.org/whl/cpu/torch-2.14.0.whl", "https://github.com/nunchux-ai/nunchaku/releases/download/v1.2.0/nunchaku-1.2.0.whl", + "https://mirror.example.net:8443/releases/model.safetensors", ) rejected = ( "http://files.pythonhosted.org/packages/pkg-1.0.whl", "https://user:secret@files.pythonhosted.org/packages/pkg-1.0.whl", "https://files.pythonhosted.org/packages/pkg-1.0.whl?token=secret", - "https://example.com/packages/pkg-1.0.whl", - "https://github.com/other/package/releases/download/v1/pkg-1.0.whl", - "https://files.pythonhosted.org/packages/pkg-1.0.tar.gz", + "https://files.pythonhosted.org/packages/pkg-1.0.whl?", + "https://files.pythonhosted.org/packages/pkg-1.0.whl#download", + "https://files.pythonhosted.org/packages/pkg-1.0.whl#", + "https://mirror.example.net/releases/model file.bin", + f"https://mirror.example.net/{'a' * 2048}", + "https://mirror.example.net:99999/releases/model.safetensors", + "https:///packages/pkg-1.0.whl", ) - for source in trusted: + for source in safe: with self.subTest(source=source): self.assertEqual(resolver.copyable_download_source(source), source) for source in rejected: with self.subTest(source=source): self.assertIsNone(resolver.copyable_download_source(source)) - def test_progress_worker_tracks_unlisted_downloads_without_exposing_their_urls(self): + def test_progress_worker_tracks_any_safe_https_download_source(self): pip_download = importlib.import_module("pip._internal.network.download") pip_cli = importlib.import_module("pip._internal.cli.main") response = object() - link = SimpleNamespace(url_without_fragment="https://example.com/private.whl") + source = "https://cdn.example.net/releases/package.whl" + link = SimpleNamespace(url_without_fragment=source) def prepare_download(response, link, progress_bar): del response, link, progress_bar @@ -118,11 +124,43 @@ def fake_pip_main(_arguments): ) progress = json.loads(progress_path.read_text(encoding="utf-8")) - self.assertIsNone(progress["source_url"]) + self.assertEqual(progress["source_url"], source) self.assertFalse(progress["active"]) self.assertEqual(progress["downloaded_bytes"], 4) self.assertEqual(progress["total_bytes"], 4) + def test_progress_worker_does_not_expose_secret_bearing_download_urls(self): + pip_download = importlib.import_module("pip._internal.network.download") + pip_cli = importlib.import_module("pip._internal.cli.main") + response = object() + link = SimpleNamespace( + url_without_fragment="https://cdn.example.net/private.whl?token=secret" + ) + + def prepare_download(response, link, progress_bar): + del response, link, progress_bar + return iter((b"ab", b"cd")) + + def fake_pip_main(_arguments): + return list(pip_download._prepare_download(response, link, None)) and 0 + + with tempfile.TemporaryDirectory() as directory: + progress_path = pathlib.Path(directory) / "download-progress.json" + with ( + patch.object(pip_cli, "main", side_effect=fake_pip_main), + patch.object(pip_download, "_prepare_download", prepare_download), + patch.object(pip_download, "is_from_cache", return_value=False), + patch.object(pip_download, "_get_http_response_size", return_value=4), + ): + self.assertEqual( + resolver.run_pip_progress_worker(progress_path, ["install", "pkg"]), 0 + ) + + progress = json.loads(progress_path.read_text(encoding="utf-8")) + self.assertIsNone(progress["source_url"]) + self.assertFalse(progress["active"]) + self.assertEqual(progress["downloaded_bytes"], 4) + def test_download_progress_reports_measured_rate_and_final_byte_count(self): timestamps = iter((0.0, 0.5, 1.0)) writes = [] From 837882146b550c0fd123a5e7c2d45d6794393fa9 Mon Sep 17 00:00:00 2001 From: MrScripty Date: Sat, 26 Sep 2026 16:55:39 -0700 Subject: [PATCH 07/19] Show Torch download speed during installs --- .../execution-ledger.md | 26 +++++++++++ .../upstream-version-manager-progress.md | 27 ++++++++++++ frontend/src/components/Header.test.tsx | 25 +++++++++++ frontend/src/components/HeaderStatus.ts | 18 +++++++- .../components/ProgressDetailsView.test.tsx | 42 ++++++++++++++++++ .../src/components/ProgressDetailsView.tsx | 12 ++++- torch-server/tests/test_resolve_runtime.py | 44 +++++++++++++++++++ 7 files changed, 190 insertions(+), 4 deletions(-) diff --git a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md index 2743767c..9320808d 100644 --- a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md +++ b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md @@ -1,5 +1,31 @@ # Execution Ledger: Cross-Platform Torch Runtime Management +## 2026-09-26 — Show Torch transfer status and rate during installation + +- Diagnosed the latest `install-v2.14.0-1790465718491.log`: the selected + `2.14.0+cu134` package could not be resolved, so that attempt failed before + wheel downloads began and had no transfer speed to report. Separately, a + worker-level chunk-interval test confirmed that an active pip wheel transfer + publishes a positive measured rate. +- Fixed two UI gaps: the progress dialog now keeps the network row visible while + an active transfer waits for its first sample, and the Torch-only header now + displays both “measuring speed” and positive measured rates. During package + resolution the dialog explains “Waiting for package transfer…” so it does not + imply that artifacts are already downloading. +- Verification passed: 729 frontend tests, frontend typecheck and production + build, 56 Torch resolver tests, Ruff, Linux artifact checks, extracted + AppImage/deb resource checks and backend `/health` smoke tests, and + `git diff --check`. The package smoke required local loopback permission; + Electron packaging required access to its GitHub helper. +- Rebuilt local Linux v0.7.0 packages: AppImage SHA-256 + `a16cd9e3a1d5cc30060c36e041fd7a4cab79f3b915ff33a449204e835369c982`; + Debian package SHA-256 + `cb51f204379d68405696fa9ae30356559f8f996234b7960088eabb078a5db68e`. + The packages are local and unpublished, so the toolbar-linked release is + unchanged. No fresh Torch installation or image-generation run was made; + the cu134 package-resolution failure is separate from this progress-display + repair. + ## 2026-09-26 — Copy direct download sources across hosts - The earlier Torch telemetry host allowlist was too restrictive for the diff --git a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md index 00e430fc..aed4dff7 100644 --- a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md +++ b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md @@ -7,6 +7,33 @@ inventory, not a completed Pumas desktop acceptance. The prior evidence below uses a separate, exact Torch 2.10 tuple and does not qualify a newly resolved upstream release. +## 2026-09-26 — Torch download speed visibility repair + +The latest reported log, `install-v2.14.0-1790465718491.log`, selected +`2.14.0+cu134` but pip could not find that package, so the attempt stopped +before downloading wheel files. It therefore had no transfer speed to display. +A worker test with a real delay between pip chunks confirmed that the backend +does publish a positive rate during an active wheel transfer. + +The missing speed was also a UI issue. The progress dialog hid its network row +until the first rate sample or source URL arrived, and the Torch-only header +discarded rates when there was no concurrent model download. The dialog now +shows “Waiting for package transfer…” while packages are being resolved and +“Measuring speed…” while an active transfer awaits its first sample. Once a +sample arrives, the Torch-only header displays the measured rate. Regression +tests cover the pending, pre-sample, and measured-rate states. + +Verification passed: all 729 frontend tests, frontend typecheck and production +build, 56 Torch resolver tests, Ruff, Linux artifact validation, and extracted +AppImage/deb resource and backend `/health` smoke tests. The local Linux v0.7.0 +AppImage SHA-256 is +`a16cd9e3a1d5cc30060c36e041fd7a4cab79f3b915ff33a449204e835369c982`; the deb +SHA-256 is +`cb51f204379d68405696fa9ae30356559f8f996234b7960088eabb078a5db68e`. These +packages are unpublished and do not update the toolbar-linked release. No new +Torch installation or Tuldok image run was performed. The cu134 resolution +failure is separate from this display repair. + ## 2026-09-26 — Torch 2.14.0 staged RECORD failure and repaired local package The reported install log shows that Torch 2.14.0+cu132 and its dependencies diff --git a/frontend/src/components/Header.test.tsx b/frontend/src/components/Header.test.tsx index 431a8a3f..c5f675df 100644 --- a/frontend/src/components/Header.test.tsx +++ b/frontend/src/components/Header.test.tsx @@ -144,6 +144,31 @@ describe('Header Component', () => { expect(screen.queryByText(/25% complete/)).not.toBeInTheDocument(); }); + it('shows that runtime download speed is being measured before the first sample', () => { + render(
); + + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('Downloading 1 runtime'); + expect(status).toHaveTextContent('measuring speed'); + }); + + it('shows a measured Torch download rate without a concurrent model transfer', () => { + render(
); + + expect(screen.getByRole('status')).toHaveTextContent('12.0 MB/s'); + }); + it('shows the pending and current Torch installation phase in the header', () => { const { rerender } = render(
); const pendingStatus = screen.getByRole('status'); diff --git a/frontend/src/components/HeaderStatus.ts b/frontend/src/components/HeaderStatus.ts index 006f66ef..28568b38 100644 --- a/frontend/src/components/HeaderStatus.ts +++ b/frontend/src/components/HeaderStatus.ts @@ -10,6 +10,7 @@ export interface InstallationProgress { stage_progress: number; overall_progress: number; current_item: string | null; + download_active?: boolean; download_speed: number | null; eta_seconds: number | null; total_size: number | null; @@ -76,11 +77,17 @@ function getCombinedDownloadStatus({ ? installationProgress.download_speed : 0; const totalSpeed = modelSpeed + runtimeSpeed; + const runtimeDownloadExpected = installationProgress?.download_active === true || + installationProgress?.stage === 'download'; const speedInfo = totalSpeed > 0 ? ` @ ${formatSpeed(totalSpeed)}` : ''; const hasDownloadActivity = Boolean(activeModelDownload?.status === 'downloading') || installationProgress?.stage === 'download' || + runtimeDownloadExpected || totalSpeed > 0; + const visibleSpeedInfo = totalSpeed > 0 + ? speedInfo + : runtimeDownloadExpected ? ' · measuring speed…' : ''; const activityVerb = hasDownloadActivity ? 'Downloading' : installationProgress?.stage === 'resolving' ? 'Preparing' : 'Installing'; @@ -88,7 +95,7 @@ function getCombinedDownloadStatus({ return { icon: Download, spinning: installationProgress?.stage === 'resolving', - text: `${activityVerb} ${joinDownloadParts(parts)}${speedInfo}`, + text: `${activityVerb} ${joinDownloadParts(parts)}${visibleSpeedInfo}`, }; } @@ -195,7 +202,14 @@ export function getHeaderStatusInfo({ text: `${modelStatus.text} · ${appId === 'torch' ? 'Torch ' : ''}runtime: ${activity.phase}`, }; } - if (appId === 'torch' && activity.active && activeModelDownload && combinedDownloadStatus) { + const runtimeDownloadActive = installationProgress?.download_active === true || + installationProgress?.stage === 'download' || + (installationProgress?.download_speed ?? 0) > 0; + const modelDownloadActive = activeModelDownload?.status === 'downloading'; + if ( + appId === 'torch' && activity.active && installationProgress && combinedDownloadStatus && + (runtimeDownloadActive || modelDownloadActive) + ) { return { ...combinedDownloadStatus, text: `${combinedDownloadStatus.text} · Torch: ${activity.phase}`, diff --git a/frontend/src/components/ProgressDetailsView.test.tsx b/frontend/src/components/ProgressDetailsView.test.tsx index e330454d..1586eba8 100644 --- a/frontend/src/components/ProgressDetailsView.test.tsx +++ b/frontend/src/components/ProgressDetailsView.test.tsx @@ -117,6 +117,48 @@ describe('ProgressDetailsView', () => { } }); + it('keeps the network rate visible while an active transfer awaits its first sample', () => { + render(); + + expect(screen.getByText('Network download')).toBeInTheDocument(); + expect(screen.getByText('Measuring speed…')).toBeInTheDocument(); + }); + + it('explains that package resolution has not started an artifact transfer yet', () => { + render(); + + expect(screen.getByText('Network download')).toBeInTheDocument(); + expect(screen.getByText('Waiting for package transfer…')).toBeInTheDocument(); + }); + it('renders dependency progress details and routes detail actions', () => { const onBackToList = vi.fn(); const onToggleCompletedItems = vi.fn(); diff --git a/frontend/src/components/ProgressDetailsView.tsx b/frontend/src/components/ProgressDetailsView.tsx index 21eefe5d..7ccbb3c3 100644 --- a/frontend/src/components/ProgressDetailsView.tsx +++ b/frontend/src/components/ProgressDetailsView.tsx @@ -175,7 +175,15 @@ export function ProgressDetailsView({ const downloadActive = progress.download_active ?? progress.download_speed !== null; const downloadStatus = progress.download_speed !== null ? formatSpeed(progress.download_speed) - : downloadActive ? 'Waiting…' : 'Idle'; + : downloadActive || progress.stage === 'download' + ? 'Measuring speed…' + : appId === 'torch' && progress.stage === 'resolving' && !progress.completed_at + ? 'Waiting for package transfer…' + : 'Idle'; + const awaitingPackageTransfer = + appId === 'torch' && progress.stage === 'resolving' && !progress.completed_at; + const showNetworkDownload = downloadActive || progress.stage === 'download' || + progress.download_speed !== null || Boolean(progress.download_source_url) || awaitingPackageTransfer; return (
@@ -255,7 +263,7 @@ export function ProgressDetailsView({
{/* Stage-specific Stats */} - {(progress.download_speed !== null || progress.download_source_url) && ( + {showNetworkDownload && (
diff --git a/torch-server/tests/test_resolve_runtime.py b/torch-server/tests/test_resolve_runtime.py index 2acc2e16..ed1e6862 100644 --- a/torch-server/tests/test_resolve_runtime.py +++ b/torch-server/tests/test_resolve_runtime.py @@ -129,6 +129,50 @@ def fake_pip_main(_arguments): self.assertEqual(progress["downloaded_bytes"], 4) self.assertEqual(progress["total_bytes"], 4) + def test_progress_worker_reports_speed_during_a_real_chunk_interval(self): + pip_download = importlib.import_module("pip._internal.network.download") + pip_cli = importlib.import_module("pip._internal.cli.main") + response = object() + source = "https://cdn.example.net/releases/package.whl" + link = SimpleNamespace(url_without_fragment=source) + progress_writes = [] + write_progress = resolver._write_download_progress + + def prepare_download(response, link, progress_bar): + del response, link, progress_bar + yield b"ab" + time.sleep(resolver.DOWNLOAD_PROGRESS_INTERVAL_SECONDS + 0.05) + yield b"cd" + + def fake_pip_main(_arguments): + return list(pip_download._prepare_download(response, link, None)) and 0 + + def capture_progress(*values): + progress_writes.append(values) + write_progress(*values) + + with tempfile.TemporaryDirectory() as directory: + progress_path = pathlib.Path(directory) / "download-progress.json" + with ( + patch.object(pip_cli, "main", side_effect=fake_pip_main), + patch.object(pip_download, "_prepare_download", prepare_download), + patch.object(pip_download, "is_from_cache", return_value=False), + patch.object(pip_download, "_get_http_response_size", return_value=4), + patch.object(resolver, "_write_download_progress", side_effect=capture_progress), + ): + self.assertEqual( + resolver.run_pip_progress_worker(progress_path, ["install", "pkg"]), 0 + ) + + measured = [ + sample + for sample in progress_writes + if sample[2] and sample[5] is not None + ] + self.assertTrue(measured) + self.assertEqual(measured[-1][1], source) + self.assertGreater(measured[-1][5], 0) + def test_progress_worker_does_not_expose_secret_bearing_download_urls(self): pip_download = importlib.import_module("pip._internal.network.download") pip_cli = importlib.import_module("pip._internal.cli.main") From 47e36d07ef987d0872f9b015c61e3ca24fc12546 Mon Sep 17 00:00:00 2001 From: MrScripty Date: Sat, 26 Sep 2026 17:56:33 -0700 Subject: [PATCH 08/19] Fix Torch install download speed reporting --- .../execution-ledger.md | 36 +++ .../upstream-version-manager-progress.md | 33 +++ electron/src/generated/desktop-contract.ts | 4 +- .../desktop-contract.validators.d.ts | 2 +- .../generated/desktop-contract.validators.js | 261 +++++++++--------- electron/tests/preload-rpc-contract.test.mjs | 2 +- frontend/src/components/Header.test.tsx | 35 +++ frontend/src/components/HeaderStatus.ts | 22 +- .../components/InstallDialogContent.test.tsx | 19 ++ .../src/components/InstallDialogContent.tsx | 17 +- .../components/ProgressDetailsView.test.tsx | 26 +- .../src/components/ProgressDetailsView.tsx | 20 +- .../components/TorchInstallPreview.test.tsx | 2 +- frontend/src/generated/desktop-contract.ts | 4 +- .../desktop-contract.validators.d.ts | 2 +- .../generated/desktop-contract.validators.js | 261 +++++++++--------- .../installationProgressTracking.test.ts | 50 +++- .../src/hooks/installationProgressTracking.ts | 12 +- .../src/hooks/useInstallationManager.test.ts | 2 + frontend/src/types/versions.ts | 1 + .../src/utils/installActivityPresentation.ts | 4 +- .../installationDownloadPresentation.test.ts | 53 ++++ .../utils/installationDownloadPresentation.ts | 60 ++++ .../src/version_manager/installer/torch.rs | 35 ++- .../src/version_manager/progress.rs | 21 +- rust/crates/pumas-core/src/models/version.rs | 2 + rust/crates/pumas-rpc/src/contract.rs | 18 +- torch-server/resolve_runtime.py | 71 +++-- torch-server/tests/test_resolve_runtime.py | 92 ++++-- 29 files changed, 826 insertions(+), 341 deletions(-) create mode 100644 frontend/src/utils/installationDownloadPresentation.test.ts create mode 100644 frontend/src/utils/installationDownloadPresentation.ts diff --git a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md index 9320808d..ef79e99f 100644 --- a/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md +++ b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md @@ -1,5 +1,41 @@ # Execution Ledger: Cross-Platform Torch Runtime Management +## 2026-09-26 — Repair pip rate collection across versions and keep it visible + +- The latest `install-v2.14.0-1790467476710.log` failed to resolve the + selected `2.14.0+cu134` package before any wheel transfer, so that attempt + had no numeric rate to display. A separate code defect also meant successful + transfers could be unmeasured: managed pip 26.2.1 has no + `_prepare_download`, and pip 24 names its first legacy parameter `resp`, + which the previous strict signature check rejected. +- The worker now wraps pip's imported `response_chunks` stream when present and + falls back to legacy `_prepare_download` without parameter-name assumptions. + It publishes explicit monitoring availability when neither path is usable. + A real throttled 2 MiB wheel through managed CPython 3.13.15 / pip 26.2.1 + produced two positive sidecar samples and pip reported 2.5 MB/s; the wheel + installed and its module was verified. The older pip path is covered by + tests, but was not run as a live pip installation. +- During this pip phase, Pumas keeps the install banner, progress details, and + header network status visible. Zero-progress Torch dependency work stays + indeterminate instead of displaying a frozen overall 95%; queued model + activity no longer hides Torch speed, and inactive transfers do not retain a + synthesized rate. Copyable source URLs remain filtered for credentials, + query strings, fragments, and unsafe forms. +- Verification passed: 741 frontend tests, frontend typecheck/build, 58 Torch + resolver tests and Ruff, 231 app-manager tests, 262 RPC unit tests plus 17 + RPC integration tests, 180 Electron tests (1 skipped), desktop-contract + check, Rust formatting, Linux artifact validation, extracted AppImage/deb + resource checks and backend `/health` smoke tests. Astra High reviewed the + change and the three focused repairs without finding a remaining blocker. +- Rebuilt local Linux v0.7.0 packages. AppImage SHA-256 + `a656a3937335e1b45ef30c25f7a133c728520347cfdc50d5a1539d0e9a493135`; deb + SHA-256 `e33d418fc848e83e3c256cc07e1d87fab80f6243c1ece1bd4cedcf5e834f7c35`. + These are local, unpublished packages; the toolbar-linked release is + unchanged. No fresh external Torch install or Tuldok image run was made. + Managed Python/bootstrap downloads and non-pip producers remain outside this + rate collector, so this is package-transfer monitoring rather than complete + Pumas process-network accounting. + ## 2026-09-26 — Show Torch transfer status and rate during installation - Diagnosed the latest `install-v2.14.0-1790465718491.log`: the selected diff --git a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md index aed4dff7..0e94ad9d 100644 --- a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md +++ b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md @@ -7,6 +7,39 @@ inventory, not a completed Pumas desktop acceptance. The prior evidence below uses a separate, exact Torch 2.10 tuple and does not qualify a newly resolved upstream release. +## 2026-09-26 — Repair pip rate collection across versions and keep it visible + +The latest `install-v2.14.0-1790467476710.log` selected `2.14.0+cu134` but +failed package resolution before wheel transfer. It had no rate to report. The +previous collector also missed otherwise successful downloads: the managed +pip 26.2.1 release removed `_prepare_download`, and pip 24's legacy helper +uses `resp` rather than the strictly required `response` parameter name. + +The worker now instruments pip's imported `response_chunks` stream and keeps a +fallback for older `_prepare_download` versions. If neither API exists, it +reports monitoring unavailable. A real throttled 2 MiB wheel installed through +the managed CPython 3.13.15 / pip 26.2.1 path; pip reported 2.5 MB/s and the +sidecar recorded two positive-rate samples. The legacy pip path has mocked +regression coverage, not a live install result. + +The install dialog shows the network status during package resolution and +installation, and the header keeps it visible beside queued model activity. +Zero-progress dependency work remains indeterminate (so the overall 95% does +not look frozen), and explicitly inactive transfers no longer show a stale +sampled rate. Astra High's read-only review approved these repairs. Verification +passed: 741 frontend tests, typecheck/build, 58 resolver tests and Ruff, 231 +app-manager tests, 262 RPC unit tests, 17 RPC integration tests, 180 Electron +tests (1 skipped), contract generation check, Rust formatting, artifact checks, +and packaged Linux RPC `/health` smokes. + +The rebuilt local Linux v0.7.0 AppImage SHA-256 is +`a656a3937335e1b45ef30c25f7a133c728520347cfdc50d5a1539d0e9a493135`; the deb +SHA-256 is `e33d418fc848e83e3c256cc07e1d87fab80f6243c1ece1bd4cedcf5e834f7c35`. +The packages are unpublished, so the toolbar-linked release is unchanged. No +fresh Torch installation or Tuldok generation was performed. This collector +measures pip package transfers; managed Python/bootstrap and other Pumas +network producers remain uncovered. + ## 2026-09-26 — Torch download speed visibility repair The latest reported log, `install-v2.14.0-1790465718491.log`, selected diff --git a/electron/src/generated/desktop-contract.ts b/electron/src/generated/desktop-contract.ts index 414d4d01..b016cdd7 100644 --- a/electron/src/generated/desktop-contract.ts +++ b/electron/src/generated/desktop-contract.ts @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 2e85e476d688e677d4ce7673ae8e67a61e8b1967493de71750a64e5c00f73e00. DO NOT EDIT. import { validateAvailableVersionsOutcome, validateBackendStatusOutcome, validateCancelInstallationOutcome, validateCatalogSearchOutcome, validateCheckVersionDependenciesOutcome, validateCheckVersionDependenciesParams, validateConversionCancelledOutcome, validateConversionEnvironmentOutcome, validateConversionListOutcome, validateConversionProgressResponse, validateConversionSetupStartedOutcome, validateConversionSetupStatusOutcome, validateConversionStartedOutcome, validateDownloadIdParams, validateDownloadListOutcome, validateDownloadMutationOutcome, validateDownloadStartedOutcome, validateDownloadStatusOutcome, validateFindTorchAlternativesParams, validateGetBackendSetupParams, validateGetHfDownloadDetailsParams, validateGetReleaseDependenciesOutcome, validateGetReleaseDependenciesParams, validateGetTorchPreviewReportParams, validateGetTorchReleaseOptionsParams, validateGetTorchRuntimeProbeParams, validateGithubCacheStatusOutcome, validateHfDownloadDetailsOutcome, validateInferenceSettingsOutcome, validateInstallVersionDependenciesOutcome, validateInstallVersionDependenciesParams, validateInstallVersionOutcome, validateInstallVersionParams, validateInstallationProgressOutcome, validateInstalledVersionsOutcome, validateLibraryModelMetadataOutcome, validateLinkHealthOutcome, validateModelIndexRefreshOutcome, validateModelsOutcome, validatePartialDownloadOutcome, validatePreviewTorchRuntimeParams, validatePublicError, validateRecoverDownloadParams, validateRemoveVersionOutcome, validateRouterProfileSyncStatus, validateRuntimeLaunchOutcome, validateRuntimeLaunchParams, validateRuntimeRunningOutcome, validateRuntimeStopOutcome, validateSearchCatalogParams, validateSelectedVersionOutcome, validateSetDefaultVersionOutcome, validateSetDefaultVersionParams, validateStartBackendSetupParams, validateStartConversionSetupParams, validateStopRuntimeProfileGenerationOutcome, validateStopRuntimeProfileGenerationParams, validateSuccessOutcome, validateSupportedQuantTypesOutcome, validateSwitchVersionOutcome, validateSwitchVersionParams, validateTorchDriverAvailability, validateTorchGpuVendor, validateTorchReleaseCombination, validateTorchReleaseDriverStatus, validateTorchReleaseOptionsOutcome, validateTorchReleaseOptionsStatus, validateTorchReleaseRecommendation, validateTorchRuntimePreview, validateTorchRuntimePreviewArtifact, validateTorchRuntimePreviewOutcome, validateTorchRuntimePreviewQualification, validateTorchRuntimePreviewRejectionReason, validateTrialTorchRuntimeOutcome, validateTrialTorchRuntimeParams, validateUpdateInferenceSettingsOutcome, validateUpdateInferenceSettingsParams, validateUpdateModelNotesOutcome, validateUpdateModelNotesParams, validateValidateInstallationsOutcome, validateVersionInfoOutcome, validateVersionStatusOutcome } from './desktop-contract.validators.js'; export type AvailableVersionsOutcome = (AvailableVersionsSuccess) | (AvailableVersionsRateLimited); export type AvailableVersionsRateLimited = { "error": string; "rate_limited": true; "retry_after_secs": number | null; "success": false }; @@ -91,7 +91,7 @@ export type RemoveVersionOutcome = { "success": boolean }; export type RouterCatalogState = "current" | "pending" | "uncertain"; export type RouterObservationState = "connecting" | "current" | "unavailable"; export type RouterProfileSyncStatus = ({ "catalog_state": "current" | "pending"; "generation": number; "last_error": string | null; "observation_state": RouterObservationState; "pending_model_ids": ReadonlyArray; "profile_id": string }) | ({ "catalog_state": "uncertain"; "generation": number; "last_error": string | null; "observation_state": "unavailable"; "pending_model_ids": ReadonlyArray; "profile_id": string }); -export type RuntimeInstallationProgress = { "completedAt": string | null; "completedDependencies": number; "completedItems": ReadonlyArray; "currentItem": string | null; "dependencyCount": number | null; "downloadActive": boolean; "downloadSourceUrl": string | null; "downloadSpeed": number | null; "downloadedBytes": number; "error": string | null; "etaSeconds": number | null; "logPath": string | null; "overallProgress": number | null; "stage": RuntimeInstallationStage; "stageProgress": number | null; "startedAt": string; "success": boolean | null; "tag": string; "totalSize": number | null }; +export type RuntimeInstallationProgress = { "completedAt": string | null; "completedDependencies": number; "completedItems": ReadonlyArray; "currentItem": string | null; "dependencyCount": number | null; "downloadActive": boolean; "downloadMeasurementAvailable": boolean | null; "downloadSourceUrl": string | null; "downloadSpeed": number | null; "downloadedBytes": number; "error": string | null; "etaSeconds": number | null; "logPath": string | null; "overallProgress": number | null; "stage": RuntimeInstallationStage; "stageProgress": number | null; "startedAt": string; "success": boolean | null; "tag": string; "totalSize": number | null }; export type RuntimeInstallationProgressItem = { "completedAt": string; "name": string; "size": number | null; "type": string }; export type RuntimeInstallationStage = "resolving" | "download" | "extract" | "venv" | "dependencies" | "setup"; export type RuntimeLaunchOutcome = { "error"?: string; "log_path"?: string; "ready"?: boolean; "success": boolean }; diff --git a/electron/src/generated/desktop-contract.validators.d.ts b/electron/src/generated/desktop-contract.validators.d.ts index 886c729d..da27c16e 100644 --- a/electron/src/generated/desktop-contract.validators.d.ts +++ b/electron/src/generated/desktop-contract.validators.d.ts @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 2e85e476d688e677d4ce7673ae8e67a61e8b1967493de71750a64e5c00f73e00. DO NOT EDIT. export declare function validateAvailableVersionsOutcome(value: unknown): boolean; export declare function validateBackendStatusOutcome(value: unknown): boolean; export declare function validateCancelInstallationOutcome(value: unknown): boolean; diff --git a/electron/src/generated/desktop-contract.validators.js b/electron/src/generated/desktop-contract.validators.js index 1ffe18e9..2a1c81b0 100644 --- a/electron/src/generated/desktop-contract.validators.js +++ b/electron/src/generated/desktop-contract.validators.js @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 2e85e476d688e677d4ce7673ae8e67a61e8b1967493de71750a64e5c00f73e00. DO NOT EDIT. var __getOwnPropNames = Object.getOwnPropertyNames; var __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; @@ -9138,7 +9138,7 @@ function validate66(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } var validateInstallationProgressOutcome = validate74; -var schema82 = { "additionalProperties": false, "properties": { "completedAt": { "type": ["string", "null"] }, "completedDependencies": { "maximum": 4294967295, "minimum": 0, "type": "integer" }, "completedItems": { "items": { "$ref": "#/definitions/RuntimeInstallationProgressItem" }, "type": "array" }, "currentItem": { "type": ["string", "null"] }, "dependencyCount": { "maximum": 4294967295, "minimum": 0, "type": ["integer", "null"] }, "downloadActive": { "type": "boolean" }, "downloadSourceUrl": { "type": ["string", "null"] }, "downloadSpeed": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "downloadedBytes": { "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, "error": { "type": ["string", "null"] }, "etaSeconds": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "logPath": { "type": ["string", "null"] }, "overallProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "stage": { "$ref": "#/definitions/RuntimeInstallationStage" }, "stageProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "startedAt": { "type": "string" }, "success": { "type": ["boolean", "null"] }, "tag": { "type": "string" }, "totalSize": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] } }, "required": ["tag", "startedAt", "stage", "stageProgress", "overallProgress", "currentItem", "downloadSourceUrl", "downloadActive", "downloadSpeed", "etaSeconds", "totalSize", "downloadedBytes", "dependencyCount", "completedDependencies", "completedItems", "error", "completedAt", "success", "logPath"], "type": "object" }; +var schema82 = { "additionalProperties": false, "properties": { "completedAt": { "type": ["string", "null"] }, "completedDependencies": { "maximum": 4294967295, "minimum": 0, "type": "integer" }, "completedItems": { "items": { "$ref": "#/definitions/RuntimeInstallationProgressItem" }, "type": "array" }, "currentItem": { "type": ["string", "null"] }, "dependencyCount": { "maximum": 4294967295, "minimum": 0, "type": ["integer", "null"] }, "downloadActive": { "type": "boolean" }, "downloadMeasurementAvailable": { "type": ["boolean", "null"] }, "downloadSourceUrl": { "type": ["string", "null"] }, "downloadSpeed": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "downloadedBytes": { "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, "error": { "type": ["string", "null"] }, "etaSeconds": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "logPath": { "type": ["string", "null"] }, "overallProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "stage": { "$ref": "#/definitions/RuntimeInstallationStage" }, "stageProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "startedAt": { "type": "string" }, "success": { "type": ["boolean", "null"] }, "tag": { "type": "string" }, "totalSize": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] } }, "required": ["tag", "startedAt", "stage", "stageProgress", "overallProgress", "currentItem", "downloadSourceUrl", "downloadActive", "downloadMeasurementAvailable", "downloadSpeed", "etaSeconds", "totalSize", "downloadedBytes", "dependencyCount", "completedDependencies", "completedItems", "error", "completedAt", "success", "logPath"], "type": "object" }; var schema83 = { "additionalProperties": false, "properties": { "completedAt": { "type": "string" }, "name": { "type": "string" }, "size": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] }, "type": { "type": "string" } }, "required": ["name", "type", "size", "completedAt"], "type": "object" }; var schema84 = { "enum": ["resolving", "download", "extract", "venv", "dependencies", "setup"], "type": "string" }; function validate75(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { @@ -9147,7 +9147,7 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; - if (data.tag === void 0 && (missing0 = "tag") || data.startedAt === void 0 && (missing0 = "startedAt") || data.stage === void 0 && (missing0 = "stage") || data.stageProgress === void 0 && (missing0 = "stageProgress") || data.overallProgress === void 0 && (missing0 = "overallProgress") || data.currentItem === void 0 && (missing0 = "currentItem") || data.downloadSourceUrl === void 0 && (missing0 = "downloadSourceUrl") || data.downloadActive === void 0 && (missing0 = "downloadActive") || data.downloadSpeed === void 0 && (missing0 = "downloadSpeed") || data.etaSeconds === void 0 && (missing0 = "etaSeconds") || data.totalSize === void 0 && (missing0 = "totalSize") || data.downloadedBytes === void 0 && (missing0 = "downloadedBytes") || data.dependencyCount === void 0 && (missing0 = "dependencyCount") || data.completedDependencies === void 0 && (missing0 = "completedDependencies") || data.completedItems === void 0 && (missing0 = "completedItems") || data.error === void 0 && (missing0 = "error") || data.completedAt === void 0 && (missing0 = "completedAt") || data.success === void 0 && (missing0 = "success") || data.logPath === void 0 && (missing0 = "logPath")) { + if (data.tag === void 0 && (missing0 = "tag") || data.startedAt === void 0 && (missing0 = "startedAt") || data.stage === void 0 && (missing0 = "stage") || data.stageProgress === void 0 && (missing0 = "stageProgress") || data.overallProgress === void 0 && (missing0 = "overallProgress") || data.currentItem === void 0 && (missing0 = "currentItem") || data.downloadSourceUrl === void 0 && (missing0 = "downloadSourceUrl") || data.downloadActive === void 0 && (missing0 = "downloadActive") || data.downloadMeasurementAvailable === void 0 && (missing0 = "downloadMeasurementAvailable") || data.downloadSpeed === void 0 && (missing0 = "downloadSpeed") || data.etaSeconds === void 0 && (missing0 = "etaSeconds") || data.totalSize === void 0 && (missing0 = "totalSize") || data.downloadedBytes === void 0 && (missing0 = "downloadedBytes") || data.dependencyCount === void 0 && (missing0 = "dependencyCount") || data.completedDependencies === void 0 && (missing0 = "completedDependencies") || data.completedItems === void 0 && (missing0 = "completedItems") || data.error === void 0 && (missing0 = "error") || data.completedAt === void 0 && (missing0 = "completedAt") || data.success === void 0 && (missing0 = "success") || data.logPath === void 0 && (missing0 = "logPath")) { validate75.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { @@ -9354,11 +9354,11 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.downloadSourceUrl !== void 0) { - let data11 = data.downloadSourceUrl; + if (data.downloadMeasurementAvailable !== void 0) { + let data11 = data.downloadMeasurementAvailable; const _errs26 = errors; - if (typeof data11 !== "string" && data11 !== null) { - validate75.errors = [{ instancePath: instancePath + "/downloadSourceUrl", schemaPath: "#/properties/downloadSourceUrl/type", keyword: "type", params: { type: schema82.properties.downloadSourceUrl.type }, message: "must be string,null" }]; + if (typeof data11 !== "boolean" && data11 !== null) { + validate75.errors = [{ instancePath: instancePath + "/downloadMeasurementAvailable", schemaPath: "#/properties/downloadMeasurementAvailable/type", keyword: "type", params: { type: schema82.properties.downloadMeasurementAvailable.type }, message: "must be boolean,null" }]; return false; } var valid0 = _errs26 === errors; @@ -9366,46 +9366,33 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.downloadSpeed !== void 0) { - let data12 = data.downloadSpeed; + if (data.downloadSourceUrl !== void 0) { + let data12 = data.downloadSourceUrl; const _errs28 = errors; - if (!(typeof data12 == "number" && isFinite(data12)) && data12 !== null) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/type", keyword: "type", params: { type: schema82.properties.downloadSpeed.type }, message: "must be number,null" }]; + if (typeof data12 !== "string" && data12 !== null) { + validate75.errors = [{ instancePath: instancePath + "/downloadSourceUrl", schemaPath: "#/properties/downloadSourceUrl/type", keyword: "type", params: { type: schema82.properties.downloadSourceUrl.type }, message: "must be string,null" }]; return false; } - if (errors === _errs28) { - if (typeof data12 == "number" && isFinite(data12)) { - if (data12 > 17976931348623157e292 || isNaN(data12)) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; - return false; - } else { - if (data12 < -17976931348623157e292 || isNaN(data12)) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; - return false; - } - } - } - } var valid0 = _errs28 === errors; } else { var valid0 = true; } if (valid0) { - if (data.downloadedBytes !== void 0) { - let data13 = data.downloadedBytes; + if (data.downloadSpeed !== void 0) { + let data13 = data.downloadSpeed; const _errs30 = errors; - if (!(typeof data13 == "number" && (!(data13 % 1) && !isNaN(data13)) && isFinite(data13))) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; + if (!(typeof data13 == "number" && isFinite(data13)) && data13 !== null) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/type", keyword: "type", params: { type: schema82.properties.downloadSpeed.type }, message: "must be number,null" }]; return false; } if (errors === _errs30) { if (typeof data13 == "number" && isFinite(data13)) { - if (data13 > 9007199254740991 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; + if (data13 > 17976931348623157e292 || isNaN(data13)) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; return false; } else { - if (data13 < 0 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + if (data13 < -17976931348623157e292 || isNaN(data13)) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; return false; } } @@ -9416,137 +9403,150 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.error !== void 0) { - let data14 = data.error; + if (data.downloadedBytes !== void 0) { + let data14 = data.downloadedBytes; const _errs32 = errors; - if (typeof data14 !== "string" && data14 !== null) { - validate75.errors = [{ instancePath: instancePath + "/error", schemaPath: "#/properties/error/type", keyword: "type", params: { type: schema82.properties.error.type }, message: "must be string,null" }]; + if (!(typeof data14 == "number" && (!(data14 % 1) && !isNaN(data14)) && isFinite(data14))) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; return false; } + if (errors === _errs32) { + if (typeof data14 == "number" && isFinite(data14)) { + if (data14 > 9007199254740991 || isNaN(data14)) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; + return false; + } else { + if (data14 < 0 || isNaN(data14)) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + return false; + } + } + } + } var valid0 = _errs32 === errors; } else { var valid0 = true; } if (valid0) { - if (data.etaSeconds !== void 0) { - let data15 = data.etaSeconds; + if (data.error !== void 0) { + let data15 = data.error; const _errs34 = errors; - if (!(typeof data15 == "number" && isFinite(data15)) && data15 !== null) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/type", keyword: "type", params: { type: schema82.properties.etaSeconds.type }, message: "must be number,null" }]; + if (typeof data15 !== "string" && data15 !== null) { + validate75.errors = [{ instancePath: instancePath + "/error", schemaPath: "#/properties/error/type", keyword: "type", params: { type: schema82.properties.error.type }, message: "must be string,null" }]; return false; } - if (errors === _errs34) { - if (typeof data15 == "number" && isFinite(data15)) { - if (data15 > 17976931348623157e292 || isNaN(data15)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; - return false; - } else { - if (data15 < -17976931348623157e292 || isNaN(data15)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; - return false; - } - } - } - } var valid0 = _errs34 === errors; } else { var valid0 = true; } if (valid0) { - if (data.logPath !== void 0) { - let data16 = data.logPath; + if (data.etaSeconds !== void 0) { + let data16 = data.etaSeconds; const _errs36 = errors; - if (typeof data16 !== "string" && data16 !== null) { - validate75.errors = [{ instancePath: instancePath + "/logPath", schemaPath: "#/properties/logPath/type", keyword: "type", params: { type: schema82.properties.logPath.type }, message: "must be string,null" }]; + if (!(typeof data16 == "number" && isFinite(data16)) && data16 !== null) { + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/type", keyword: "type", params: { type: schema82.properties.etaSeconds.type }, message: "must be number,null" }]; return false; } + if (errors === _errs36) { + if (typeof data16 == "number" && isFinite(data16)) { + if (data16 > 17976931348623157e292 || isNaN(data16)) { + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; + return false; + } else { + if (data16 < -17976931348623157e292 || isNaN(data16)) { + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; + return false; + } + } + } + } var valid0 = _errs36 === errors; } else { var valid0 = true; } if (valid0) { - if (data.overallProgress !== void 0) { - let data17 = data.overallProgress; + if (data.logPath !== void 0) { + let data17 = data.logPath; const _errs38 = errors; - if (!(typeof data17 == "number" && isFinite(data17)) && data17 !== null) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/type", keyword: "type", params: { type: schema82.properties.overallProgress.type }, message: "must be number,null" }]; + if (typeof data17 !== "string" && data17 !== null) { + validate75.errors = [{ instancePath: instancePath + "/logPath", schemaPath: "#/properties/logPath/type", keyword: "type", params: { type: schema82.properties.logPath.type }, message: "must be string,null" }]; return false; } - if (errors === _errs38) { - if (typeof data17 == "number" && isFinite(data17)) { - if (data17 > 34028234663852886e22 || isNaN(data17)) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; - return false; - } else { - if (data17 < -34028234663852886e22 || isNaN(data17)) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; - return false; - } - } - } - } var valid0 = _errs38 === errors; } else { var valid0 = true; } if (valid0) { - if (data.stage !== void 0) { - let data18 = data.stage; + if (data.overallProgress !== void 0) { + let data18 = data.overallProgress; const _errs40 = errors; - if (typeof data18 !== "string") { - validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + if (!(typeof data18 == "number" && isFinite(data18)) && data18 !== null) { + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/type", keyword: "type", params: { type: schema82.properties.overallProgress.type }, message: "must be number,null" }]; return false; } - if (!(data18 === "resolving" || data18 === "download" || data18 === "extract" || data18 === "venv" || data18 === "dependencies" || data18 === "setup")) { - validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/enum", keyword: "enum", params: { allowedValues: schema84.enum }, message: "must be equal to one of the allowed values" }]; - return false; + if (errors === _errs40) { + if (typeof data18 == "number" && isFinite(data18)) { + if (data18 > 34028234663852886e22 || isNaN(data18)) { + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; + return false; + } else { + if (data18 < -34028234663852886e22 || isNaN(data18)) { + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; + return false; + } + } + } } var valid0 = _errs40 === errors; } else { var valid0 = true; } if (valid0) { - if (data.stageProgress !== void 0) { - let data19 = data.stageProgress; - const _errs43 = errors; - if (!(typeof data19 == "number" && isFinite(data19)) && data19 !== null) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/type", keyword: "type", params: { type: schema82.properties.stageProgress.type }, message: "must be number,null" }]; + if (data.stage !== void 0) { + let data19 = data.stage; + const _errs42 = errors; + if (typeof data19 !== "string") { + validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } - if (errors === _errs43) { - if (typeof data19 == "number" && isFinite(data19)) { - if (data19 > 34028234663852886e22 || isNaN(data19)) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; - return false; - } else { - if (data19 < -34028234663852886e22 || isNaN(data19)) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; - return false; - } - } - } + if (!(data19 === "resolving" || data19 === "download" || data19 === "extract" || data19 === "venv" || data19 === "dependencies" || data19 === "setup")) { + validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/enum", keyword: "enum", params: { allowedValues: schema84.enum }, message: "must be equal to one of the allowed values" }]; + return false; } - var valid0 = _errs43 === errors; + var valid0 = _errs42 === errors; } else { var valid0 = true; } if (valid0) { - if (data.startedAt !== void 0) { + if (data.stageProgress !== void 0) { + let data20 = data.stageProgress; const _errs45 = errors; - if (typeof data.startedAt !== "string") { - validate75.errors = [{ instancePath: instancePath + "/startedAt", schemaPath: "#/properties/startedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + if (!(typeof data20 == "number" && isFinite(data20)) && data20 !== null) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/type", keyword: "type", params: { type: schema82.properties.stageProgress.type }, message: "must be number,null" }]; return false; } + if (errors === _errs45) { + if (typeof data20 == "number" && isFinite(data20)) { + if (data20 > 34028234663852886e22 || isNaN(data20)) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; + return false; + } else { + if (data20 < -34028234663852886e22 || isNaN(data20)) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; + return false; + } + } + } + } var valid0 = _errs45 === errors; } else { var valid0 = true; } if (valid0) { - if (data.success !== void 0) { - let data21 = data.success; + if (data.startedAt !== void 0) { const _errs47 = errors; - if (typeof data21 !== "boolean" && data21 !== null) { - validate75.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: schema82.properties.success.type }, message: "must be boolean,null" }]; + if (typeof data.startedAt !== "string") { + validate75.errors = [{ instancePath: instancePath + "/startedAt", schemaPath: "#/properties/startedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs47 === errors; @@ -9554,10 +9554,11 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.tag !== void 0) { + if (data.success !== void 0) { + let data22 = data.success; const _errs49 = errors; - if (typeof data.tag !== "string") { - validate75.errors = [{ instancePath: instancePath + "/tag", schemaPath: "#/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + if (typeof data22 !== "boolean" && data22 !== null) { + validate75.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: schema82.properties.success.type }, message: "must be boolean,null" }]; return false; } var valid0 = _errs49 === errors; @@ -9565,29 +9566,41 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.totalSize !== void 0) { - let data23 = data.totalSize; + if (data.tag !== void 0) { const _errs51 = errors; - if (!(typeof data23 == "number" && (!(data23 % 1) && !isNaN(data23)) && isFinite(data23)) && data23 !== null) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/type", keyword: "type", params: { type: schema82.properties.totalSize.type }, message: "must be integer,null" }]; + if (typeof data.tag !== "string") { + validate75.errors = [{ instancePath: instancePath + "/tag", schemaPath: "#/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } - if (errors === _errs51) { - if (typeof data23 == "number" && isFinite(data23)) { - if (data23 > 9007199254740991 || isNaN(data23)) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; - return false; - } else { - if (data23 < 0 || isNaN(data23)) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + var valid0 = _errs51 === errors; + } else { + var valid0 = true; + } + if (valid0) { + if (data.totalSize !== void 0) { + let data24 = data.totalSize; + const _errs53 = errors; + if (!(typeof data24 == "number" && (!(data24 % 1) && !isNaN(data24)) && isFinite(data24)) && data24 !== null) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/type", keyword: "type", params: { type: schema82.properties.totalSize.type }, message: "must be integer,null" }]; + return false; + } + if (errors === _errs53) { + if (typeof data24 == "number" && isFinite(data24)) { + if (data24 > 9007199254740991 || isNaN(data24)) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; return false; + } else { + if (data24 < 0 || isNaN(data24)) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + return false; + } } } } + var valid0 = _errs53 === errors; + } else { + var valid0 = true; } - var valid0 = _errs51 === errors; - } else { - var valid0 = true; } } } diff --git a/electron/tests/preload-rpc-contract.test.mjs b/electron/tests/preload-rpc-contract.test.mjs index 3f104d08..c2fff832 100644 --- a/electron/tests/preload-rpc-contract.test.mjs +++ b/electron/tests/preload-rpc-contract.test.mjs @@ -460,7 +460,7 @@ test('installation progress rejects malformed replies and preserves nullable cam const valid = { tag: ' vλ.1 ', startedAt: 'started', stage: 'dependencies', stageProgress: 125.5, overallProgress: 107.25, currentItem: 'torch', downloadSourceUrl: null, - downloadActive: false, downloadSpeed: 42.5, + downloadActive: false, downloadMeasurementAvailable: true, downloadSpeed: 42.5, etaSeconds: 0.5, totalSize: 1024, downloadedBytes: 512, dependencyCount: 2, completedDependencies: 1, completedItems: [{ name: 'torch', type: 'package', size: null, completedAt: 'done' }], diff --git a/frontend/src/components/Header.test.tsx b/frontend/src/components/Header.test.tsx index c5f675df..4ade9b6c 100644 --- a/frontend/src/components/Header.test.tsx +++ b/frontend/src/components/Header.test.tsx @@ -169,6 +169,18 @@ describe('Header Component', () => { expect(screen.getByRole('status')).toHaveTextContent('12.0 MB/s'); }); + it('keeps Torch package-transfer status visible while pip resolves or uses cached files', () => { + render(
); + + expect(screen.getByRole('status')).toHaveTextContent('no package transfer active'); + }); + it('shows the pending and current Torch installation phase in the header', () => { const { rerender } = render(
); const pendingStatus = screen.getByRole('status'); @@ -234,6 +246,29 @@ describe('Header Component', () => { expect(screen.getByText(/Downloading 1 model & 1 runtime/)).toHaveTextContent('Starting installation…'); }); + it('shows Torch download speed alongside a queued model download', () => { + render(
); + + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('Queued model download'); + expect(status).toHaveTextContent('Torch download: 12.0 MB/s'); + expect(status).toHaveTextContent('Torch runtime: Installing official Torch packages'); + }); + it.each([ ['queued', 'Queued model download'], ['pausing', 'Pausing model download'], diff --git a/frontend/src/components/HeaderStatus.ts b/frontend/src/components/HeaderStatus.ts index 28568b38..df3f38dd 100644 --- a/frontend/src/components/HeaderStatus.ts +++ b/frontend/src/components/HeaderStatus.ts @@ -2,6 +2,7 @@ import { Clock, Database, Download, RefreshCw, WifiOff, type LucideIcon } from ' import type { ActiveModelDownload } from '../hooks/useActiveModelDownload'; import { formatBytes, formatSpeed } from '../utils/formatters'; import { getInstallActivityPresentation } from '../utils/installActivityPresentation'; +import { getInstallationDownloadPresentation } from '../utils/installationDownloadPresentation'; export interface InstallationProgress { tag: string; @@ -11,6 +12,7 @@ export interface InstallationProgress { overall_progress: number; current_item: string | null; download_active?: boolean; + download_measurement_available?: boolean | null; download_speed: number | null; eta_seconds: number | null; total_size: number | null; @@ -48,11 +50,13 @@ function joinDownloadParts(parts: string[]): string { } function getCombinedDownloadStatus({ + appId, activeModelDownload, activeModelDownloadCount, installationProgress, runtimePending = false, }: { + appId?: string | null; activeModelDownload?: ActiveModelDownload | null; activeModelDownloadCount: number; installationProgress?: InstallationProgress | null; @@ -77,6 +81,7 @@ function getCombinedDownloadStatus({ ? installationProgress.download_speed : 0; const totalSpeed = modelSpeed + runtimeSpeed; + const downloadPresentation = getInstallationDownloadPresentation(appId, installationProgress); const runtimeDownloadExpected = installationProgress?.download_active === true || installationProgress?.stage === 'download'; const speedInfo = totalSpeed > 0 ? ` @ ${formatSpeed(totalSpeed)}` : ''; @@ -84,10 +89,11 @@ function getCombinedDownloadStatus({ Boolean(activeModelDownload?.status === 'downloading') || installationProgress?.stage === 'download' || runtimeDownloadExpected || - totalSpeed > 0; + totalSpeed > 0 || + downloadPresentation?.kind === 'measuring'; const visibleSpeedInfo = totalSpeed > 0 ? speedInfo - : runtimeDownloadExpected ? ' · measuring speed…' : ''; + : downloadPresentation ? ` · ${downloadPresentation.label.toLowerCase()}` : ''; const activityVerb = hasDownloadActivity ? 'Downloading' : installationProgress?.stage === 'resolving' ? 'Preparing' : 'Installing'; @@ -190,6 +196,7 @@ export function getHeaderStatusInfo({ }): HeaderStatusInfo { const activity = getInstallActivityPresentation({ appId, installingTag, progress: installationProgress }); const combinedDownloadStatus = getCombinedDownloadStatus({ + appId, activeModelDownload, activeModelDownloadCount, installationProgress, @@ -197,14 +204,21 @@ export function getHeaderStatusInfo({ }); if (activity.active && activeModelDownload && activeModelDownload.status !== 'downloading') { const modelStatus = getActiveDownloadStatus(activeModelDownload, activeModelDownloadCount); + const downloadPresentation = appId === 'torch' + ? getInstallationDownloadPresentation(appId, installationProgress) + : null; + const downloadStatus = downloadPresentation + ? ` · Torch download: ${downloadPresentation.label}` + : ''; return { ...modelStatus, - text: `${modelStatus.text} · ${appId === 'torch' ? 'Torch ' : ''}runtime: ${activity.phase}`, + text: `${modelStatus.text}${downloadStatus} · ${appId === 'torch' ? 'Torch ' : ''}runtime: ${activity.phase}`, }; } const runtimeDownloadActive = installationProgress?.download_active === true || installationProgress?.stage === 'download' || - (installationProgress?.download_speed ?? 0) > 0; + (installationProgress?.download_speed ?? 0) > 0 || + getInstallationDownloadPresentation(appId, installationProgress) !== null; const modelDownloadActive = activeModelDownload?.status === 'downloading'; if ( appId === 'torch' && activity.active && installationProgress && combinedDownloadStatus && diff --git a/frontend/src/components/InstallDialogContent.test.tsx b/frontend/src/components/InstallDialogContent.test.tsx index baa72f57..a607b846 100644 --- a/frontend/src/components/InstallDialogContent.test.tsx +++ b/frontend/src/components/InstallDialogContent.test.tsx @@ -105,6 +105,25 @@ describe('InstallDialogContent', () => { ); }); + it('shows measured download speed in the visible Torch status banner', () => { + renderInstallDialogContent({ + appId: 'torch', + installingVersion: 'v2.14.0', + progress: { + ...progress, + tag: 'v2.14.0', + stage: 'dependencies', + current_item: 'Installing official Torch packages', + download_active: true, + download_measurement_available: true, + download_speed: 12 * 1024 * 1024, + error: null, + }, + }); + + expect(screen.getByRole('status')).toHaveTextContent('Network download · 12.0 MB/s'); + }); + it('renders notices and routes install, release-link, and remove-error actions', async () => { const removeError = new Error('remove failed'); const { props } = renderInstallDialogContent({ diff --git a/frontend/src/components/InstallDialogContent.tsx b/frontend/src/components/InstallDialogContent.tsx index 3ac0fbac..6b81e07b 100644 --- a/frontend/src/components/InstallDialogContent.tsx +++ b/frontend/src/components/InstallDialogContent.tsx @@ -5,6 +5,7 @@ import { ProgressDetailsView } from './ProgressDetailsView'; import { VersionListItem } from './VersionListItem'; import type { TorchInstalledConfig } from '../types/torch-install'; import { getInstallActivityPresentation } from '../utils/installActivityPresentation'; +import { getInstallationDownloadPresentation } from '../utils/installationDownloadPresentation'; interface InstallDialogContentProps { appId?: string; @@ -73,6 +74,7 @@ export function InstallDialogContent({ }: InstallDialogContentProps) { const activity = getInstallActivityPresentation({ appId, installingTag: installingVersion, progress }); const torchTag = installingVersion ?? progress?.tag; + const downloadPresentation = getInstallationDownloadPresentation(appId, progress); return (
@@ -93,10 +95,17 @@ export function InstallDialogContent({ {activity.active && (appId === 'torch' || !progress) && (
- - +
+ + + {appId === 'torch' && downloadPresentation && ( + + Network download · {downloadPresentation.label} + + )} +
)} diff --git a/frontend/src/components/ProgressDetailsView.test.tsx b/frontend/src/components/ProgressDetailsView.test.tsx index 1586eba8..7f9afa0d 100644 --- a/frontend/src/components/ProgressDetailsView.test.tsx +++ b/frontend/src/components/ProgressDetailsView.test.tsx @@ -57,6 +57,30 @@ describe('ProgressDetailsView', () => { expect(screen.getByRole('progressbar', { name: 'Final Setup progress' })).not.toHaveAttribute('aria-valuenow'); }); + it('keeps the long Torch dependency stage indeterminate at zero stage progress', () => { + render(); + expect(screen.getByText('Working…')).toBeInTheDocument(); + expect(screen.getByText('Installing official Torch packages')).toBeInTheDocument(); + expect(screen.queryByText('95%')).not.toBeInTheDocument(); + expect(screen.getByRole('progressbar', { name: 'Overall installation progress' })).not.toHaveAttribute('aria-valuenow'); + expect(screen.getByRole('progressbar', { name: 'Installing Dependencies progress' })).not.toHaveAttribute('aria-valuenow'); + }); + it('shows measured Torch setup progress when the stage reports real movement', () => { render( { installingVersion="v2.14.0" showCompletedItems={false} onToggleCompletedItems={vi.fn()} onBackToList={vi.fn()} onOpenLogPath={vi.fn()} />); - expect(screen.getByText('Idle')).toBeInTheDocument(); + expect(screen.getByText('No package transfer active')).toBeInTheDocument(); } finally { vi.unstubAllGlobals(); } diff --git a/frontend/src/components/ProgressDetailsView.tsx b/frontend/src/components/ProgressDetailsView.tsx index 7ccbb3c3..e0535904 100644 --- a/frontend/src/components/ProgressDetailsView.tsx +++ b/frontend/src/components/ProgressDetailsView.tsx @@ -24,8 +24,9 @@ import { RefreshCw, } from 'lucide-react'; import type { InstallationProgress } from '../hooks/useVersions'; -import { formatBytes, formatSpeed } from '../utils/formatters'; +import { formatBytes } from '../utils/formatters'; import { formatElapsedTime } from '../utils/installationFormatters'; +import { getInstallationDownloadPresentation } from '../utils/installationDownloadPresentation'; import { IconButton } from './ui'; import { getInstallActivityPresentation } from '../utils/installActivityPresentation'; @@ -172,18 +173,7 @@ export function ProgressDetailsView({ : outcome?.kind === 'cancelled' ? 'text-[hsl(var(--accent-warning))]' : 'text-[hsl(var(--accent-error))]'; - const downloadActive = progress.download_active ?? progress.download_speed !== null; - const downloadStatus = progress.download_speed !== null - ? formatSpeed(progress.download_speed) - : downloadActive || progress.stage === 'download' - ? 'Measuring speed…' - : appId === 'torch' && progress.stage === 'resolving' && !progress.completed_at - ? 'Waiting for package transfer…' - : 'Idle'; - const awaitingPackageTransfer = - appId === 'torch' && progress.stage === 'resolving' && !progress.completed_at; - const showNetworkDownload = downloadActive || progress.stage === 'download' || - progress.download_speed !== null || Boolean(progress.download_source_url) || awaitingPackageTransfer; + const downloadPresentation = getInstallationDownloadPresentation(appId, progress); return (
@@ -263,7 +253,7 @@ export function ProgressDetailsView({
{/* Stage-specific Stats */} - {showNetworkDownload && ( + {downloadPresentation && (
@@ -271,7 +261,7 @@ export function ProgressDetailsView({ Network download
- {downloadStatus} + {downloadPresentation.label}
{progress.download_source_url && } diff --git a/frontend/src/components/TorchInstallPreview.test.tsx b/frontend/src/components/TorchInstallPreview.test.tsx index 9d93d96f..8c084ee7 100644 --- a/frontend/src/components/TorchInstallPreview.test.tsx +++ b/frontend/src/components/TorchInstallPreview.test.tsx @@ -57,7 +57,7 @@ describe('TorchInstallPreview', () => { tag: 'v2.14.0', startedAt: '2026-09-26T00:00:00Z', stage: 'resolving', stageProgress: 0, overallProgress: 0, currentItem: 'Preparing managed Python and resolving Torch packages', - downloadSourceUrl: null, downloadActive: false, + downloadSourceUrl: null, downloadActive: false, downloadMeasurementAvailable: null, downloadSpeed: null, etaSeconds: null, totalSize: null, downloadedBytes: 0, dependencyCount: null, completedDependencies: 0, completedItems: [], error: null, completedAt: null, success: null, logPath: null, diff --git a/frontend/src/generated/desktop-contract.ts b/frontend/src/generated/desktop-contract.ts index 414d4d01..b016cdd7 100644 --- a/frontend/src/generated/desktop-contract.ts +++ b/frontend/src/generated/desktop-contract.ts @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 2e85e476d688e677d4ce7673ae8e67a61e8b1967493de71750a64e5c00f73e00. DO NOT EDIT. import { validateAvailableVersionsOutcome, validateBackendStatusOutcome, validateCancelInstallationOutcome, validateCatalogSearchOutcome, validateCheckVersionDependenciesOutcome, validateCheckVersionDependenciesParams, validateConversionCancelledOutcome, validateConversionEnvironmentOutcome, validateConversionListOutcome, validateConversionProgressResponse, validateConversionSetupStartedOutcome, validateConversionSetupStatusOutcome, validateConversionStartedOutcome, validateDownloadIdParams, validateDownloadListOutcome, validateDownloadMutationOutcome, validateDownloadStartedOutcome, validateDownloadStatusOutcome, validateFindTorchAlternativesParams, validateGetBackendSetupParams, validateGetHfDownloadDetailsParams, validateGetReleaseDependenciesOutcome, validateGetReleaseDependenciesParams, validateGetTorchPreviewReportParams, validateGetTorchReleaseOptionsParams, validateGetTorchRuntimeProbeParams, validateGithubCacheStatusOutcome, validateHfDownloadDetailsOutcome, validateInferenceSettingsOutcome, validateInstallVersionDependenciesOutcome, validateInstallVersionDependenciesParams, validateInstallVersionOutcome, validateInstallVersionParams, validateInstallationProgressOutcome, validateInstalledVersionsOutcome, validateLibraryModelMetadataOutcome, validateLinkHealthOutcome, validateModelIndexRefreshOutcome, validateModelsOutcome, validatePartialDownloadOutcome, validatePreviewTorchRuntimeParams, validatePublicError, validateRecoverDownloadParams, validateRemoveVersionOutcome, validateRouterProfileSyncStatus, validateRuntimeLaunchOutcome, validateRuntimeLaunchParams, validateRuntimeRunningOutcome, validateRuntimeStopOutcome, validateSearchCatalogParams, validateSelectedVersionOutcome, validateSetDefaultVersionOutcome, validateSetDefaultVersionParams, validateStartBackendSetupParams, validateStartConversionSetupParams, validateStopRuntimeProfileGenerationOutcome, validateStopRuntimeProfileGenerationParams, validateSuccessOutcome, validateSupportedQuantTypesOutcome, validateSwitchVersionOutcome, validateSwitchVersionParams, validateTorchDriverAvailability, validateTorchGpuVendor, validateTorchReleaseCombination, validateTorchReleaseDriverStatus, validateTorchReleaseOptionsOutcome, validateTorchReleaseOptionsStatus, validateTorchReleaseRecommendation, validateTorchRuntimePreview, validateTorchRuntimePreviewArtifact, validateTorchRuntimePreviewOutcome, validateTorchRuntimePreviewQualification, validateTorchRuntimePreviewRejectionReason, validateTrialTorchRuntimeOutcome, validateTrialTorchRuntimeParams, validateUpdateInferenceSettingsOutcome, validateUpdateInferenceSettingsParams, validateUpdateModelNotesOutcome, validateUpdateModelNotesParams, validateValidateInstallationsOutcome, validateVersionInfoOutcome, validateVersionStatusOutcome } from './desktop-contract.validators.js'; export type AvailableVersionsOutcome = (AvailableVersionsSuccess) | (AvailableVersionsRateLimited); export type AvailableVersionsRateLimited = { "error": string; "rate_limited": true; "retry_after_secs": number | null; "success": false }; @@ -91,7 +91,7 @@ export type RemoveVersionOutcome = { "success": boolean }; export type RouterCatalogState = "current" | "pending" | "uncertain"; export type RouterObservationState = "connecting" | "current" | "unavailable"; export type RouterProfileSyncStatus = ({ "catalog_state": "current" | "pending"; "generation": number; "last_error": string | null; "observation_state": RouterObservationState; "pending_model_ids": ReadonlyArray; "profile_id": string }) | ({ "catalog_state": "uncertain"; "generation": number; "last_error": string | null; "observation_state": "unavailable"; "pending_model_ids": ReadonlyArray; "profile_id": string }); -export type RuntimeInstallationProgress = { "completedAt": string | null; "completedDependencies": number; "completedItems": ReadonlyArray; "currentItem": string | null; "dependencyCount": number | null; "downloadActive": boolean; "downloadSourceUrl": string | null; "downloadSpeed": number | null; "downloadedBytes": number; "error": string | null; "etaSeconds": number | null; "logPath": string | null; "overallProgress": number | null; "stage": RuntimeInstallationStage; "stageProgress": number | null; "startedAt": string; "success": boolean | null; "tag": string; "totalSize": number | null }; +export type RuntimeInstallationProgress = { "completedAt": string | null; "completedDependencies": number; "completedItems": ReadonlyArray; "currentItem": string | null; "dependencyCount": number | null; "downloadActive": boolean; "downloadMeasurementAvailable": boolean | null; "downloadSourceUrl": string | null; "downloadSpeed": number | null; "downloadedBytes": number; "error": string | null; "etaSeconds": number | null; "logPath": string | null; "overallProgress": number | null; "stage": RuntimeInstallationStage; "stageProgress": number | null; "startedAt": string; "success": boolean | null; "tag": string; "totalSize": number | null }; export type RuntimeInstallationProgressItem = { "completedAt": string; "name": string; "size": number | null; "type": string }; export type RuntimeInstallationStage = "resolving" | "download" | "extract" | "venv" | "dependencies" | "setup"; export type RuntimeLaunchOutcome = { "error"?: string; "log_path"?: string; "ready"?: boolean; "success": boolean }; diff --git a/frontend/src/generated/desktop-contract.validators.d.ts b/frontend/src/generated/desktop-contract.validators.d.ts index 886c729d..da27c16e 100644 --- a/frontend/src/generated/desktop-contract.validators.d.ts +++ b/frontend/src/generated/desktop-contract.validators.d.ts @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 2e85e476d688e677d4ce7673ae8e67a61e8b1967493de71750a64e5c00f73e00. DO NOT EDIT. export declare function validateAvailableVersionsOutcome(value: unknown): boolean; export declare function validateBackendStatusOutcome(value: unknown): boolean; export declare function validateCancelInstallationOutcome(value: unknown): boolean; diff --git a/frontend/src/generated/desktop-contract.validators.js b/frontend/src/generated/desktop-contract.validators.js index 1ffe18e9..2a1c81b0 100644 --- a/frontend/src/generated/desktop-contract.validators.js +++ b/frontend/src/generated/desktop-contract.validators.js @@ -1,4 +1,4 @@ -// Generated from pumas-rpc contract.rs; SHA256 63bff8476c2fbaa2b622044c1d466ae3b621696673491825fe14e850cae9a2cf. DO NOT EDIT. +// Generated from pumas-rpc contract.rs; SHA256 2e85e476d688e677d4ce7673ae8e67a61e8b1967493de71750a64e5c00f73e00. DO NOT EDIT. var __getOwnPropNames = Object.getOwnPropertyNames; var __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; @@ -9138,7 +9138,7 @@ function validate66(data, { instancePath = "", parentData, parentDataProperty, r return errors === 0; } var validateInstallationProgressOutcome = validate74; -var schema82 = { "additionalProperties": false, "properties": { "completedAt": { "type": ["string", "null"] }, "completedDependencies": { "maximum": 4294967295, "minimum": 0, "type": "integer" }, "completedItems": { "items": { "$ref": "#/definitions/RuntimeInstallationProgressItem" }, "type": "array" }, "currentItem": { "type": ["string", "null"] }, "dependencyCount": { "maximum": 4294967295, "minimum": 0, "type": ["integer", "null"] }, "downloadActive": { "type": "boolean" }, "downloadSourceUrl": { "type": ["string", "null"] }, "downloadSpeed": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "downloadedBytes": { "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, "error": { "type": ["string", "null"] }, "etaSeconds": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "logPath": { "type": ["string", "null"] }, "overallProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "stage": { "$ref": "#/definitions/RuntimeInstallationStage" }, "stageProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "startedAt": { "type": "string" }, "success": { "type": ["boolean", "null"] }, "tag": { "type": "string" }, "totalSize": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] } }, "required": ["tag", "startedAt", "stage", "stageProgress", "overallProgress", "currentItem", "downloadSourceUrl", "downloadActive", "downloadSpeed", "etaSeconds", "totalSize", "downloadedBytes", "dependencyCount", "completedDependencies", "completedItems", "error", "completedAt", "success", "logPath"], "type": "object" }; +var schema82 = { "additionalProperties": false, "properties": { "completedAt": { "type": ["string", "null"] }, "completedDependencies": { "maximum": 4294967295, "minimum": 0, "type": "integer" }, "completedItems": { "items": { "$ref": "#/definitions/RuntimeInstallationProgressItem" }, "type": "array" }, "currentItem": { "type": ["string", "null"] }, "dependencyCount": { "maximum": 4294967295, "minimum": 0, "type": ["integer", "null"] }, "downloadActive": { "type": "boolean" }, "downloadMeasurementAvailable": { "type": ["boolean", "null"] }, "downloadSourceUrl": { "type": ["string", "null"] }, "downloadSpeed": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "downloadedBytes": { "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, "error": { "type": ["string", "null"] }, "etaSeconds": { "maximum": 17976931348623157e292, "minimum": -17976931348623157e292, "type": ["number", "null"] }, "logPath": { "type": ["string", "null"] }, "overallProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "stage": { "$ref": "#/definitions/RuntimeInstallationStage" }, "stageProgress": { "maximum": 34028234663852886e22, "minimum": -34028234663852886e22, "type": ["number", "null"] }, "startedAt": { "type": "string" }, "success": { "type": ["boolean", "null"] }, "tag": { "type": "string" }, "totalSize": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] } }, "required": ["tag", "startedAt", "stage", "stageProgress", "overallProgress", "currentItem", "downloadSourceUrl", "downloadActive", "downloadMeasurementAvailable", "downloadSpeed", "etaSeconds", "totalSize", "downloadedBytes", "dependencyCount", "completedDependencies", "completedItems", "error", "completedAt", "success", "logPath"], "type": "object" }; var schema83 = { "additionalProperties": false, "properties": { "completedAt": { "type": "string" }, "name": { "type": "string" }, "size": { "maximum": 9007199254740991, "minimum": 0, "type": ["integer", "null"] }, "type": { "type": "string" } }, "required": ["name", "type", "size", "completedAt"], "type": "object" }; var schema84 = { "enum": ["resolving", "download", "extract", "venv", "dependencies", "setup"], "type": "string" }; function validate75(data, { instancePath = "", parentData, parentDataProperty, rootData = data } = {}) { @@ -9147,7 +9147,7 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r if (errors === 0) { if (data && typeof data == "object" && !Array.isArray(data)) { let missing0; - if (data.tag === void 0 && (missing0 = "tag") || data.startedAt === void 0 && (missing0 = "startedAt") || data.stage === void 0 && (missing0 = "stage") || data.stageProgress === void 0 && (missing0 = "stageProgress") || data.overallProgress === void 0 && (missing0 = "overallProgress") || data.currentItem === void 0 && (missing0 = "currentItem") || data.downloadSourceUrl === void 0 && (missing0 = "downloadSourceUrl") || data.downloadActive === void 0 && (missing0 = "downloadActive") || data.downloadSpeed === void 0 && (missing0 = "downloadSpeed") || data.etaSeconds === void 0 && (missing0 = "etaSeconds") || data.totalSize === void 0 && (missing0 = "totalSize") || data.downloadedBytes === void 0 && (missing0 = "downloadedBytes") || data.dependencyCount === void 0 && (missing0 = "dependencyCount") || data.completedDependencies === void 0 && (missing0 = "completedDependencies") || data.completedItems === void 0 && (missing0 = "completedItems") || data.error === void 0 && (missing0 = "error") || data.completedAt === void 0 && (missing0 = "completedAt") || data.success === void 0 && (missing0 = "success") || data.logPath === void 0 && (missing0 = "logPath")) { + if (data.tag === void 0 && (missing0 = "tag") || data.startedAt === void 0 && (missing0 = "startedAt") || data.stage === void 0 && (missing0 = "stage") || data.stageProgress === void 0 && (missing0 = "stageProgress") || data.overallProgress === void 0 && (missing0 = "overallProgress") || data.currentItem === void 0 && (missing0 = "currentItem") || data.downloadSourceUrl === void 0 && (missing0 = "downloadSourceUrl") || data.downloadActive === void 0 && (missing0 = "downloadActive") || data.downloadMeasurementAvailable === void 0 && (missing0 = "downloadMeasurementAvailable") || data.downloadSpeed === void 0 && (missing0 = "downloadSpeed") || data.etaSeconds === void 0 && (missing0 = "etaSeconds") || data.totalSize === void 0 && (missing0 = "totalSize") || data.downloadedBytes === void 0 && (missing0 = "downloadedBytes") || data.dependencyCount === void 0 && (missing0 = "dependencyCount") || data.completedDependencies === void 0 && (missing0 = "completedDependencies") || data.completedItems === void 0 && (missing0 = "completedItems") || data.error === void 0 && (missing0 = "error") || data.completedAt === void 0 && (missing0 = "completedAt") || data.success === void 0 && (missing0 = "success") || data.logPath === void 0 && (missing0 = "logPath")) { validate75.errors = [{ instancePath, schemaPath: "#/required", keyword: "required", params: { missingProperty: missing0 }, message: "must have required property '" + missing0 + "'" }]; return false; } else { @@ -9354,11 +9354,11 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.downloadSourceUrl !== void 0) { - let data11 = data.downloadSourceUrl; + if (data.downloadMeasurementAvailable !== void 0) { + let data11 = data.downloadMeasurementAvailable; const _errs26 = errors; - if (typeof data11 !== "string" && data11 !== null) { - validate75.errors = [{ instancePath: instancePath + "/downloadSourceUrl", schemaPath: "#/properties/downloadSourceUrl/type", keyword: "type", params: { type: schema82.properties.downloadSourceUrl.type }, message: "must be string,null" }]; + if (typeof data11 !== "boolean" && data11 !== null) { + validate75.errors = [{ instancePath: instancePath + "/downloadMeasurementAvailable", schemaPath: "#/properties/downloadMeasurementAvailable/type", keyword: "type", params: { type: schema82.properties.downloadMeasurementAvailable.type }, message: "must be boolean,null" }]; return false; } var valid0 = _errs26 === errors; @@ -9366,46 +9366,33 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.downloadSpeed !== void 0) { - let data12 = data.downloadSpeed; + if (data.downloadSourceUrl !== void 0) { + let data12 = data.downloadSourceUrl; const _errs28 = errors; - if (!(typeof data12 == "number" && isFinite(data12)) && data12 !== null) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/type", keyword: "type", params: { type: schema82.properties.downloadSpeed.type }, message: "must be number,null" }]; + if (typeof data12 !== "string" && data12 !== null) { + validate75.errors = [{ instancePath: instancePath + "/downloadSourceUrl", schemaPath: "#/properties/downloadSourceUrl/type", keyword: "type", params: { type: schema82.properties.downloadSourceUrl.type }, message: "must be string,null" }]; return false; } - if (errors === _errs28) { - if (typeof data12 == "number" && isFinite(data12)) { - if (data12 > 17976931348623157e292 || isNaN(data12)) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; - return false; - } else { - if (data12 < -17976931348623157e292 || isNaN(data12)) { - validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; - return false; - } - } - } - } var valid0 = _errs28 === errors; } else { var valid0 = true; } if (valid0) { - if (data.downloadedBytes !== void 0) { - let data13 = data.downloadedBytes; + if (data.downloadSpeed !== void 0) { + let data13 = data.downloadSpeed; const _errs30 = errors; - if (!(typeof data13 == "number" && (!(data13 % 1) && !isNaN(data13)) && isFinite(data13))) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; + if (!(typeof data13 == "number" && isFinite(data13)) && data13 !== null) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/type", keyword: "type", params: { type: schema82.properties.downloadSpeed.type }, message: "must be number,null" }]; return false; } if (errors === _errs30) { if (typeof data13 == "number" && isFinite(data13)) { - if (data13 > 9007199254740991 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; + if (data13 > 17976931348623157e292 || isNaN(data13)) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; return false; } else { - if (data13 < 0 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + if (data13 < -17976931348623157e292 || isNaN(data13)) { + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; return false; } } @@ -9416,137 +9403,150 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.error !== void 0) { - let data14 = data.error; + if (data.downloadedBytes !== void 0) { + let data14 = data.downloadedBytes; const _errs32 = errors; - if (typeof data14 !== "string" && data14 !== null) { - validate75.errors = [{ instancePath: instancePath + "/error", schemaPath: "#/properties/error/type", keyword: "type", params: { type: schema82.properties.error.type }, message: "must be string,null" }]; + if (!(typeof data14 == "number" && (!(data14 % 1) && !isNaN(data14)) && isFinite(data14))) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/type", keyword: "type", params: { type: "integer" }, message: "must be integer" }]; return false; } + if (errors === _errs32) { + if (typeof data14 == "number" && isFinite(data14)) { + if (data14 > 9007199254740991 || isNaN(data14)) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; + return false; + } else { + if (data14 < 0 || isNaN(data14)) { + validate75.errors = [{ instancePath: instancePath + "/downloadedBytes", schemaPath: "#/properties/downloadedBytes/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + return false; + } + } + } + } var valid0 = _errs32 === errors; } else { var valid0 = true; } if (valid0) { - if (data.etaSeconds !== void 0) { - let data15 = data.etaSeconds; + if (data.error !== void 0) { + let data15 = data.error; const _errs34 = errors; - if (!(typeof data15 == "number" && isFinite(data15)) && data15 !== null) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/type", keyword: "type", params: { type: schema82.properties.etaSeconds.type }, message: "must be number,null" }]; + if (typeof data15 !== "string" && data15 !== null) { + validate75.errors = [{ instancePath: instancePath + "/error", schemaPath: "#/properties/error/type", keyword: "type", params: { type: schema82.properties.error.type }, message: "must be string,null" }]; return false; } - if (errors === _errs34) { - if (typeof data15 == "number" && isFinite(data15)) { - if (data15 > 17976931348623157e292 || isNaN(data15)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; - return false; - } else { - if (data15 < -17976931348623157e292 || isNaN(data15)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; - return false; - } - } - } - } var valid0 = _errs34 === errors; } else { var valid0 = true; } if (valid0) { - if (data.logPath !== void 0) { - let data16 = data.logPath; + if (data.etaSeconds !== void 0) { + let data16 = data.etaSeconds; const _errs36 = errors; - if (typeof data16 !== "string" && data16 !== null) { - validate75.errors = [{ instancePath: instancePath + "/logPath", schemaPath: "#/properties/logPath/type", keyword: "type", params: { type: schema82.properties.logPath.type }, message: "must be string,null" }]; + if (!(typeof data16 == "number" && isFinite(data16)) && data16 !== null) { + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/type", keyword: "type", params: { type: schema82.properties.etaSeconds.type }, message: "must be number,null" }]; return false; } + if (errors === _errs36) { + if (typeof data16 == "number" && isFinite(data16)) { + if (data16 > 17976931348623157e292 || isNaN(data16)) { + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; + return false; + } else { + if (data16 < -17976931348623157e292 || isNaN(data16)) { + validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; + return false; + } + } + } + } var valid0 = _errs36 === errors; } else { var valid0 = true; } if (valid0) { - if (data.overallProgress !== void 0) { - let data17 = data.overallProgress; + if (data.logPath !== void 0) { + let data17 = data.logPath; const _errs38 = errors; - if (!(typeof data17 == "number" && isFinite(data17)) && data17 !== null) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/type", keyword: "type", params: { type: schema82.properties.overallProgress.type }, message: "must be number,null" }]; + if (typeof data17 !== "string" && data17 !== null) { + validate75.errors = [{ instancePath: instancePath + "/logPath", schemaPath: "#/properties/logPath/type", keyword: "type", params: { type: schema82.properties.logPath.type }, message: "must be string,null" }]; return false; } - if (errors === _errs38) { - if (typeof data17 == "number" && isFinite(data17)) { - if (data17 > 34028234663852886e22 || isNaN(data17)) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; - return false; - } else { - if (data17 < -34028234663852886e22 || isNaN(data17)) { - validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; - return false; - } - } - } - } var valid0 = _errs38 === errors; } else { var valid0 = true; } if (valid0) { - if (data.stage !== void 0) { - let data18 = data.stage; + if (data.overallProgress !== void 0) { + let data18 = data.overallProgress; const _errs40 = errors; - if (typeof data18 !== "string") { - validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + if (!(typeof data18 == "number" && isFinite(data18)) && data18 !== null) { + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/type", keyword: "type", params: { type: schema82.properties.overallProgress.type }, message: "must be number,null" }]; return false; } - if (!(data18 === "resolving" || data18 === "download" || data18 === "extract" || data18 === "venv" || data18 === "dependencies" || data18 === "setup")) { - validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/enum", keyword: "enum", params: { allowedValues: schema84.enum }, message: "must be equal to one of the allowed values" }]; - return false; + if (errors === _errs40) { + if (typeof data18 == "number" && isFinite(data18)) { + if (data18 > 34028234663852886e22 || isNaN(data18)) { + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; + return false; + } else { + if (data18 < -34028234663852886e22 || isNaN(data18)) { + validate75.errors = [{ instancePath: instancePath + "/overallProgress", schemaPath: "#/properties/overallProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; + return false; + } + } + } } var valid0 = _errs40 === errors; } else { var valid0 = true; } if (valid0) { - if (data.stageProgress !== void 0) { - let data19 = data.stageProgress; - const _errs43 = errors; - if (!(typeof data19 == "number" && isFinite(data19)) && data19 !== null) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/type", keyword: "type", params: { type: schema82.properties.stageProgress.type }, message: "must be number,null" }]; + if (data.stage !== void 0) { + let data19 = data.stage; + const _errs42 = errors; + if (typeof data19 !== "string") { + validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } - if (errors === _errs43) { - if (typeof data19 == "number" && isFinite(data19)) { - if (data19 > 34028234663852886e22 || isNaN(data19)) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; - return false; - } else { - if (data19 < -34028234663852886e22 || isNaN(data19)) { - validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; - return false; - } - } - } + if (!(data19 === "resolving" || data19 === "download" || data19 === "extract" || data19 === "venv" || data19 === "dependencies" || data19 === "setup")) { + validate75.errors = [{ instancePath: instancePath + "/stage", schemaPath: "#/definitions/RuntimeInstallationStage/enum", keyword: "enum", params: { allowedValues: schema84.enum }, message: "must be equal to one of the allowed values" }]; + return false; } - var valid0 = _errs43 === errors; + var valid0 = _errs42 === errors; } else { var valid0 = true; } if (valid0) { - if (data.startedAt !== void 0) { + if (data.stageProgress !== void 0) { + let data20 = data.stageProgress; const _errs45 = errors; - if (typeof data.startedAt !== "string") { - validate75.errors = [{ instancePath: instancePath + "/startedAt", schemaPath: "#/properties/startedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + if (!(typeof data20 == "number" && isFinite(data20)) && data20 !== null) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/type", keyword: "type", params: { type: schema82.properties.stageProgress.type }, message: "must be number,null" }]; return false; } + if (errors === _errs45) { + if (typeof data20 == "number" && isFinite(data20)) { + if (data20 > 34028234663852886e22 || isNaN(data20)) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/maximum", keyword: "maximum", params: { comparison: "<=", limit: 34028234663852886e22 }, message: "must be <= 3.4028234663852886e+38" }]; + return false; + } else { + if (data20 < -34028234663852886e22 || isNaN(data20)) { + validate75.errors = [{ instancePath: instancePath + "/stageProgress", schemaPath: "#/properties/stageProgress/minimum", keyword: "minimum", params: { comparison: ">=", limit: -34028234663852886e22 }, message: "must be >= -3.4028234663852886e+38" }]; + return false; + } + } + } + } var valid0 = _errs45 === errors; } else { var valid0 = true; } if (valid0) { - if (data.success !== void 0) { - let data21 = data.success; + if (data.startedAt !== void 0) { const _errs47 = errors; - if (typeof data21 !== "boolean" && data21 !== null) { - validate75.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: schema82.properties.success.type }, message: "must be boolean,null" }]; + if (typeof data.startedAt !== "string") { + validate75.errors = [{ instancePath: instancePath + "/startedAt", schemaPath: "#/properties/startedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } var valid0 = _errs47 === errors; @@ -9554,10 +9554,11 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.tag !== void 0) { + if (data.success !== void 0) { + let data22 = data.success; const _errs49 = errors; - if (typeof data.tag !== "string") { - validate75.errors = [{ instancePath: instancePath + "/tag", schemaPath: "#/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; + if (typeof data22 !== "boolean" && data22 !== null) { + validate75.errors = [{ instancePath: instancePath + "/success", schemaPath: "#/properties/success/type", keyword: "type", params: { type: schema82.properties.success.type }, message: "must be boolean,null" }]; return false; } var valid0 = _errs49 === errors; @@ -9565,29 +9566,41 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.totalSize !== void 0) { - let data23 = data.totalSize; + if (data.tag !== void 0) { const _errs51 = errors; - if (!(typeof data23 == "number" && (!(data23 % 1) && !isNaN(data23)) && isFinite(data23)) && data23 !== null) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/type", keyword: "type", params: { type: schema82.properties.totalSize.type }, message: "must be integer,null" }]; + if (typeof data.tag !== "string") { + validate75.errors = [{ instancePath: instancePath + "/tag", schemaPath: "#/properties/tag/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; return false; } - if (errors === _errs51) { - if (typeof data23 == "number" && isFinite(data23)) { - if (data23 > 9007199254740991 || isNaN(data23)) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; - return false; - } else { - if (data23 < 0 || isNaN(data23)) { - validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + var valid0 = _errs51 === errors; + } else { + var valid0 = true; + } + if (valid0) { + if (data.totalSize !== void 0) { + let data24 = data.totalSize; + const _errs53 = errors; + if (!(typeof data24 == "number" && (!(data24 % 1) && !isNaN(data24)) && isFinite(data24)) && data24 !== null) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/type", keyword: "type", params: { type: schema82.properties.totalSize.type }, message: "must be integer,null" }]; + return false; + } + if (errors === _errs53) { + if (typeof data24 == "number" && isFinite(data24)) { + if (data24 > 9007199254740991 || isNaN(data24)) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/maximum", keyword: "maximum", params: { comparison: "<=", limit: 9007199254740991 }, message: "must be <= 9007199254740991" }]; return false; + } else { + if (data24 < 0 || isNaN(data24)) { + validate75.errors = [{ instancePath: instancePath + "/totalSize", schemaPath: "#/properties/totalSize/minimum", keyword: "minimum", params: { comparison: ">=", limit: 0 }, message: "must be >= 0" }]; + return false; + } } } } + var valid0 = _errs53 === errors; + } else { + var valid0 = true; } - var valid0 = _errs51 === errors; - } else { - var valid0 = true; } } } diff --git a/frontend/src/hooks/installationProgressTracking.test.ts b/frontend/src/hooks/installationProgressTracking.test.ts index b9ac8f10..fbd6b46b 100644 --- a/frontend/src/hooks/installationProgressTracking.test.ts +++ b/frontend/src/hooks/installationProgressTracking.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { RuntimeInstallationProgress } from '../generated/desktop-contract'; -import { projectInstallationProgress } from './installationProgressTracking'; +import { createNetworkStatusState } from '../utils/networkStatusMonitor'; +import { normalizeInstallationProgress, projectInstallationProgress } from './installationProgressTracking'; describe('projectInstallationProgress', () => { it('projects the validated camelCase wire into the established UI model', () => { @@ -13,6 +14,7 @@ describe('projectInstallationProgress', () => { currentItem: 'torch', downloadSourceUrl: 'https://download.pytorch.org/whl/cu134/torch-2.14.0.whl', downloadActive: true, + downloadMeasurementAvailable: true, downloadSpeed: 42.5, etaSeconds: 0.5, totalSize: 1024, @@ -35,6 +37,7 @@ describe('projectInstallationProgress', () => { current_item: 'torch', download_source_url: 'https://download.pytorch.org/whl/cu134/torch-2.14.0.whl', download_active: true, + download_measurement_available: true, download_speed: 42.5, eta_seconds: 0.5, total_size: 1024, @@ -54,7 +57,7 @@ describe('projectInstallationProgress', () => { tag: '', startedAt: '', stage: 'download', downloadedBytes: 0, completedDependencies: 0, completedItems: [], stageProgress: null, overallProgress: null, currentItem: null, downloadSpeed: null, - downloadSourceUrl: null, downloadActive: false, + downloadSourceUrl: null, downloadActive: false, downloadMeasurementAvailable: null, etaSeconds: null, totalSize: null, dependencyCount: null, error: null, completedAt: null, success: null, logPath: null, }; @@ -62,7 +65,48 @@ describe('projectInstallationProgress', () => { expect(projectInstallationProgress(wire)).toMatchObject({ tag: '', started_at: '', stage: 'download', stage_progress: 0, overall_progress: 0, downloaded_bytes: 0, completed_dependencies: 0, - completed_items: [], download_active: false, completed_at: undefined, success: undefined, + completed_items: [], download_active: false, download_measurement_available: null, + completed_at: undefined, success: undefined, }); }); + + it('clears the sampled fallback rate when an unattributed transfer becomes inactive', () => { + const trackerState = { + lastDownloadTag: null, + lastStage: null, + lastDownloadSourceUrl: null, + networkState: createNetworkStatusState(), + }; + const progress = { + tag: 'v2.14.0', + started_at: 'started', + stage: 'dependencies' as const, + stage_progress: 0, + overall_progress: 95, + current_item: 'Installing official Torch packages', + download_source_url: null, + download_active: true, + download_measurement_available: true, + download_speed: null, + eta_seconds: null, + total_size: null, + downloaded_bytes: 1024, + dependency_count: 1, + completed_dependencies: 0, + completed_items: [], + error: null, + }; + + const first = normalizeInstallationProgress(progress, [], trackerState, 1000); + expect(first.adjustedProgress.download_speed).toBeNull(); + const duringTransfer = normalizeInstallationProgress( + { ...progress, downloaded_bytes: 2048 }, [], trackerState, 2000, + ); + expect(duringTransfer.adjustedProgress.download_speed).toBe(1024); + + const afterTransfer = normalizeInstallationProgress( + { ...progress, download_active: false, downloaded_bytes: 2048 }, [], trackerState, 3000, + ); + expect(afterTransfer.adjustedProgress.download_speed).toBeNull(); + }); }); diff --git a/frontend/src/hooks/installationProgressTracking.ts b/frontend/src/hooks/installationProgressTracking.ts index 50187de2..4ef99349 100644 --- a/frontend/src/hooks/installationProgressTracking.ts +++ b/frontend/src/hooks/installationProgressTracking.ts @@ -40,6 +40,7 @@ export function projectInstallationProgress( current_item: progress.currentItem, download_source_url: progress.downloadSourceUrl, download_active: progress.downloadActive, + download_measurement_available: progress.downloadMeasurementAvailable, download_speed: progress.downloadSpeed, eta_seconds: progress.etaSeconds, total_size: progress.totalSize, @@ -150,6 +151,10 @@ export function normalizeInstallationProgress( const averageSpeed = computeAverageSpeed(trackerState.networkState.downloadSamples); const expectedTotal = computeExpectedTotal(progress, availableVersions); const etaSeconds = computeEtaSeconds(progress, averageSpeed, expectedTotal); + const transferActive = progress.download_active ?? Boolean( + progress.download_source_url || progress.stage === 'download' + ); + const canReportSpeed = transferActive && progress.download_measurement_available !== false; const adjustedProgress: InstallationProgress = { tag: progress.tag || '', @@ -160,9 +165,10 @@ export function normalizeInstallationProgress( current_item: progress.current_item || null, download_source_url: progress.download_source_url ?? null, download_active: progress.download_active ?? false, - download_speed: progress.download_source_url - ? progress.download_speed - : progress.download_speed ?? (averageSpeed > 0 ? averageSpeed : null), + download_measurement_available: progress.download_measurement_available ?? null, + download_speed: canReportSpeed + ? progress.download_speed ?? (averageSpeed > 0 ? averageSpeed : null) + : null, eta_seconds: etaSeconds, total_size: expectedTotal ?? progress.total_size ?? null, downloaded_bytes: downloadedBytes, diff --git a/frontend/src/hooks/useInstallationManager.test.ts b/frontend/src/hooks/useInstallationManager.test.ts index 7e523026..da3aae18 100644 --- a/frontend/src/hooks/useInstallationManager.test.ts +++ b/frontend/src/hooks/useInstallationManager.test.ts @@ -105,6 +105,7 @@ const activeProgress: InstallationProgress = { current_item: 'archive.zip', download_source_url: null, download_active: true, + download_measurement_available: null, download_speed: 1024, eta_seconds: 30, total_size: 4096, @@ -124,6 +125,7 @@ const activeWireProgress: RuntimeInstallationProgress = { currentItem: activeProgress.current_item, downloadSourceUrl: activeProgress.download_source_url ?? null, downloadActive: activeProgress.download_active ?? false, + downloadMeasurementAvailable: activeProgress.download_measurement_available ?? null, downloadSpeed: activeProgress.download_speed, etaSeconds: activeProgress.eta_seconds, totalSize: activeProgress.total_size, diff --git a/frontend/src/types/versions.ts b/frontend/src/types/versions.ts index ca968060..cbe21632 100644 --- a/frontend/src/types/versions.ts +++ b/frontend/src/types/versions.ts @@ -31,6 +31,7 @@ export interface InstallationProgress { current_item: string | null; download_source_url?: string | null; download_active?: boolean; + download_measurement_available?: boolean | null; download_speed: number | null; eta_seconds: number | null; total_size: number | null; diff --git a/frontend/src/utils/installActivityPresentation.ts b/frontend/src/utils/installActivityPresentation.ts index 8e468998..f089d222 100644 --- a/frontend/src/utils/installActivityPresentation.ts +++ b/frontend/src/utils/installActivityPresentation.ts @@ -28,7 +28,9 @@ export function getInstallActivityPresentation({ const terminal = Boolean(currentProgress?.completed_at || currentProgress?.error); const active = Boolean((installingTag || currentProgress?.tag) && !terminal); const indeterminate = active && (!currentProgress || ( - appId === 'torch' && (currentProgress.stage === 'resolving' || currentProgress.stage === 'setup') && currentProgress.stage_progress <= 0 + appId === 'torch' && + (currentProgress.stage === 'resolving' || currentProgress.stage === 'dependencies' || currentProgress.stage === 'setup') && + currentProgress.stage_progress <= 0 )); const stageLabel = currentProgress ? STAGE_LABELS[currentProgress.stage] : null; diff --git a/frontend/src/utils/installationDownloadPresentation.test.ts b/frontend/src/utils/installationDownloadPresentation.test.ts new file mode 100644 index 00000000..c72130b2 --- /dev/null +++ b/frontend/src/utils/installationDownloadPresentation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { getInstallationDownloadPresentation } from './installationDownloadPresentation'; + +describe('getInstallationDownloadPresentation', () => { + it('shows a measured rate while the Torch install has an active transfer', () => { + expect(getInstallationDownloadPresentation('torch', { + stage: 'dependencies', download_active: true, download_speed: 12 * 1024 * 1024, + })).toEqual({ kind: 'measured', label: '12.0 MB/s' }); + }); + + it('says it is measuring while an active transfer awaits its first sample', () => { + expect(getInstallationDownloadPresentation('torch', { + stage: 'dependencies', download_active: true, download_speed: null, + })).toEqual({ kind: 'measuring', label: 'Measuring speed…' }); + }); + + it('distinguishes a capable monitor with no current package transfer', () => { + expect(getInstallationDownloadPresentation('torch', { + stage: 'dependencies', download_active: false, download_speed: null, + download_measurement_available: true, + })).toEqual({ kind: 'waiting', label: 'No package transfer active' }); + }); + + it('reports unsupported telemetry instead of silently omitting the status', () => { + expect(getInstallationDownloadPresentation('torch', { + stage: 'dependencies', download_active: false, download_speed: null, + download_measurement_available: false, + })).toEqual({ kind: 'unavailable', label: 'Speed monitoring unavailable' }); + }); + + it('does not label an inactive transfer with its last sampled speed', () => { + expect(getInstallationDownloadPresentation('torch', { + stage: 'dependencies', download_active: false, download_speed: 12 * 1024 * 1024, + download_measurement_available: true, + })).toEqual({ kind: 'waiting', label: 'No package transfer active' }); + }); + + it('prioritizes unavailable monitoring over stale speed or active state', () => { + expect(getInstallationDownloadPresentation('torch', { + stage: 'dependencies', download_active: true, download_speed: 12 * 1024 * 1024, + download_measurement_available: false, + })).toEqual({ kind: 'unavailable', label: 'Speed monitoring unavailable' }); + }); + + it('does not show completed or unrelated installation stages as downloads', () => { + expect(getInstallationDownloadPresentation('torch', { + stage: 'dependencies', download_speed: 12 * 1024 * 1024, completed_at: 'done', + })).toBeNull(); + expect(getInstallationDownloadPresentation('ollama', { + stage: 'dependencies', download_speed: null, download_active: false, + })).toBeNull(); + }); +}); diff --git a/frontend/src/utils/installationDownloadPresentation.ts b/frontend/src/utils/installationDownloadPresentation.ts new file mode 100644 index 00000000..a65337f7 --- /dev/null +++ b/frontend/src/utils/installationDownloadPresentation.ts @@ -0,0 +1,60 @@ +import { formatSpeed } from './formatters'; + +interface InstallationDownloadSnapshot { + stage: string; + download_active?: boolean; + download_measurement_available?: boolean | null; + download_speed?: number | null; + download_source_url?: string | null; + completed_at?: string; +} + +export type InstallationDownloadPresentation = { + kind: 'measured' | 'measuring' | 'waiting' | 'unavailable'; + label: string; +}; + +export function getInstallationDownloadPresentation( + appId: string | null | undefined, + progress: InstallationDownloadSnapshot | null | undefined, +): InstallationDownloadPresentation | null { + if (!progress || progress.completed_at) { + return null; + } + + if ( + progress.download_measurement_available !== false && + progress.download_active !== false && + progress.download_speed !== null && + progress.download_speed !== undefined + ) { + return { kind: 'measured', label: formatSpeed(progress.download_speed) }; + } + + if (progress.download_measurement_available === false) { + return { kind: 'unavailable', label: 'Speed monitoring unavailable' }; + } + + if (progress.download_active || progress.stage === 'download') { + return { kind: 'measuring', label: 'Measuring speed…' }; + } + + if (progress.download_source_url) { + return { kind: 'waiting', label: 'No package transfer active' }; + } + + if (appId === 'torch' && progress.stage === 'dependencies') { + return { + kind: 'waiting', + label: progress.download_measurement_available === true + ? 'No package transfer active' + : 'Starting download monitor…', + }; + } + + if (appId === 'torch' && progress.stage === 'resolving') { + return { kind: 'waiting', label: 'Waiting for package transfer…' }; + } + + return null; +} diff --git a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs index 31a08698..69089d55 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs @@ -24,6 +24,8 @@ struct TorchDownloadProgress { downloaded_bytes: u64, total_bytes: Option, speed_bytes_per_sec: Option, + #[serde(default)] + measurement_available: bool, } fn safe_torch_download_source(source: &str) -> bool { @@ -2609,13 +2611,25 @@ impl VersionInstaller { .env("TEMP", &scratch); } self.progress_tracker.write().await.update_stage( - InstallationStage::Setup, + if download_progress_path.is_some() { + InstallationStage::Dependencies + } else { + InstallationStage::Setup + }, 0.0, Some(stage), ); - let _ = progress_tx.try_send(ProgressUpdate::Setup { - message: stage.to_string(), - }); + let progress_update = if download_progress_path.is_some() { + ProgressUpdate::StageChanged { + stage: InstallationStage::Dependencies, + message: stage.to_string(), + } + } else { + ProgressUpdate::Setup { + message: stage.to_string(), + } + }; + let _ = progress_tx.try_send(progress_update); let log = std::fs::OpenOptions::new() .create(true) .append(true) @@ -2650,6 +2664,7 @@ impl VersionInstaller { download_progress.downloaded_bytes, download_progress.total_bytes, download_progress.speed_bytes_per_sec, + download_progress.measurement_available, ); last_download_progress = Some(download_progress); last_download_progress_change_at = Some(tokio::time::Instant::now()); @@ -2669,6 +2684,7 @@ impl VersionInstaller { progress.downloaded_bytes, progress.total_bytes, None, + progress.measurement_available, ); download_speed_expired = true; } @@ -2743,7 +2759,8 @@ mod download_progress_tests { "active":true, "downloaded_bytes":1, "total_bytes":2, - "speed_bytes_per_sec":1.0 + "speed_bytes_per_sec":1.0, + "measurement_available":true }) .to_string(), ) @@ -2771,7 +2788,8 @@ mod download_progress_tests { "active":true, "downloaded_bytes":1, "total_bytes":2, - "speed_bytes_per_sec":10.0 + "speed_bytes_per_sec":10.0, + "measurement_available":true }) .to_string(), ) @@ -2786,6 +2804,7 @@ mod download_progress_tests { downloaded_bytes: 1, total_bytes: Some(2), speed_bytes_per_sec: Some(10.0), + measurement_available: true, }) ); @@ -2796,7 +2815,8 @@ mod download_progress_tests { "active":true, "downloaded_bytes":1, "total_bytes":2, - "speed_bytes_per_sec":1.0 + "speed_bytes_per_sec":1.0, + "measurement_available":true }) .to_string(), ) @@ -2809,6 +2829,7 @@ mod download_progress_tests { downloaded_bytes: 1, total_bytes: Some(2), speed_bytes_per_sec: Some(1.0), + measurement_available: true, }) ); } diff --git a/rust/crates/pumas-app-manager/src/version_manager/progress.rs b/rust/crates/pumas-app-manager/src/version_manager/progress.rs index 956c04a6..08896556 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/progress.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/progress.rs @@ -122,6 +122,8 @@ struct InstallationProgressState { download_source_url: Option, #[serde(default)] download_active: bool, + #[serde(default)] + download_measurement_available: Option, download_speed: Option, eta_seconds: Option, total_size: Option, @@ -202,6 +204,7 @@ impl InstallationProgressTracker { current_item: None, download_source_url: None, download_active: false, + download_measurement_available: None, download_speed: None, eta_seconds: None, total_size, @@ -238,6 +241,7 @@ impl InstallationProgressTracker { state.current_item = current_item.map(String::from); state.download_source_url = None; state.download_active = false; + state.download_measurement_available = None; state.download_speed = None; state.overall_progress = self.calculate_overall_progress(state); } @@ -267,6 +271,7 @@ impl InstallationProgressTracker { state.downloaded_bytes = downloaded_bytes; state.download_source_url = None; state.download_active = active; + state.download_measurement_available = Some(true); if let Some(total) = total_bytes { state.total_size = Some(total); state.stage_progress = (downloaded_bytes as f32 / total as f32) * 100.0; @@ -295,6 +300,7 @@ impl InstallationProgressTracker { downloaded_bytes: u64, total_bytes: Option, speed_bytes_per_sec: Option, + measurement_available: bool, ) { let transfer_key = source_url.map_or_else( || "torch-pip-download".to_owned(), @@ -313,6 +319,7 @@ impl InstallationProgressTracker { if let Some(ref mut state) = *guard { state.download_source_url = source_url.map(str::to_owned); state.download_active = active; + state.download_measurement_available = Some(measurement_available); state.downloaded_bytes = downloaded_bytes; state.total_size = total_bytes; state.download_speed = active @@ -491,6 +498,7 @@ impl InstallationProgressTracker { state.completed_at = Some(chrono::Utc::now().to_rfc3339()); state.success = Some(success); state.download_active = false; + state.download_measurement_available = None; state.download_speed = None; if success { state.stage_progress = 100.0; @@ -514,6 +522,7 @@ impl InstallationProgressTracker { current_item: state.current_item.clone(), download_source_url: state.download_source_url.clone(), download_active: state.download_active, + download_measurement_available: state.download_measurement_available, download_speed: state.download_speed, eta_seconds: state.eta_seconds, total_size: state.total_size, @@ -670,6 +679,7 @@ impl InstallationProgressTracker { current_item: state.current_item.clone(), download_source_url: state.download_source_url.clone(), download_active: state.download_active, + download_measurement_available: state.download_measurement_available, download_speed: state.download_speed, eta_seconds: state.eta_seconds, total_size: state.total_size, @@ -774,6 +784,7 @@ mod tests { 1024, Some(4096), Some(512.0), + true, ); let state = tracker.get_current_state().unwrap(); @@ -787,6 +798,7 @@ mod tests { assert_eq!(state.downloaded_bytes, Some(1024)); assert_eq!(state.total_size, Some(4096)); assert_eq!(state.download_speed, Some(512.0)); + assert_eq!(state.download_measurement_available, Some(true)); } #[test] @@ -800,6 +812,7 @@ mod tests { 20, Some(40), Some(10.0), + true, ); tracker.update_stage( InstallationStage::Setup, @@ -811,6 +824,7 @@ mod tests { assert_eq!(state.download_source_url, None); assert!(!state.download_active); assert_eq!(state.download_speed, None); + assert_eq!(state.download_measurement_available, None); } #[test] @@ -824,6 +838,7 @@ mod tests { 20, Some(40), Some(10.0), + true, ); tracker.update_network_transfer( Some("https://files.pythonhosted.org/packages/pkg-1.0.whl"), @@ -831,6 +846,7 @@ mod tests { 40, Some(40), None, + true, ); let transfer = tracker.get_current_state().unwrap(); assert!(!transfer.download_active); @@ -842,6 +858,7 @@ mod tests { 20, Some(40), Some(10.0), + true, ); tracker.complete_installation(false); let terminal = tracker.get_current_state().unwrap(); @@ -854,8 +871,8 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let mut tracker = InstallationProgressTracker::new(temp_dir.path().to_path_buf()); tracker.start_installation("v2.14.0", None, None, None); - tracker.update_network_transfer(None, true, 0, Some(40), None); - tracker.update_network_transfer(None, true, 40, Some(40), None); + tracker.update_network_transfer(None, true, 0, Some(40), None, true); + tracker.update_network_transfer(None, true, 40, Some(40), None, true); let state = tracker.get_current_state().unwrap(); assert_eq!(state.download_source_url, None); diff --git a/rust/crates/pumas-core/src/models/version.rs b/rust/crates/pumas-core/src/models/version.rs index 9c911b8b..49a686cf 100644 --- a/rust/crates/pumas-core/src/models/version.rs +++ b/rust/crates/pumas-core/src/models/version.rs @@ -133,6 +133,8 @@ pub struct InstallationProgress { #[serde(default)] pub download_active: bool, #[serde(default)] + pub download_measurement_available: Option, + #[serde(default)] pub download_speed: Option, #[serde(default)] pub eta_seconds: Option, diff --git a/rust/crates/pumas-rpc/src/contract.rs b/rust/crates/pumas-rpc/src/contract.rs index c295689a..bb678257 100644 --- a/rust/crates/pumas-rpc/src/contract.rs +++ b/rust/crates/pumas-rpc/src/contract.rs @@ -1726,6 +1726,7 @@ pub(crate) struct RuntimeInstallationProgress { current_item: Option, download_source_url: Option, download_active: bool, + download_measurement_available: Option, download_speed: Option, eta_seconds: Option, total_size: Option, @@ -1807,6 +1808,7 @@ impl InstallationProgressOutcome { current_item: value.current_item, download_source_url: value.download_source_url, download_active: value.download_active, + download_measurement_available: value.download_measurement_available, download_speed: value.download_speed.filter(|value| value.is_finite()), eta_seconds: value.eta_seconds.filter(|value| value.is_finite()), total_size: value.total_size, @@ -1846,6 +1848,7 @@ fn installation_progress_fixture() -> pumas_library::models::InstallationProgres current_item: Some("torch".into()), download_source_url: Some("https://download.pytorch.org/whl/cu134/torch-2.14.0.whl".into()), download_active: true, + download_measurement_available: Some(true), download_speed: Some(42.5), eta_seconds: Some(0.5), total_size: Some(1024), @@ -1891,7 +1894,7 @@ mod installation_progress_contract_tests { #[test] fn installation_progress_preserves_literal_wire_and_null() { let value = installation_progress_fixture(); - let expected = serde_json::json!({"tag":" vλ.1 ","startedAt":"started","stage":"dependencies","stageProgress":125.5,"overallProgress":107.25,"currentItem":"torch","downloadSourceUrl":"https://download.pytorch.org/whl/cu134/torch-2.14.0.whl","downloadActive":true,"downloadSpeed":42.5,"etaSeconds":0.5,"totalSize":1024,"downloadedBytes":512,"dependencyCount":2,"completedDependencies":1,"completedItems":[{"name":"torch","type":"package","size":null,"completedAt":"item done"}],"error":null,"completedAt":null,"success":null,"logPath":"runtime/install.log"}); + let expected = serde_json::json!({"tag":" vλ.1 ","startedAt":"started","stage":"dependencies","stageProgress":125.5,"overallProgress":107.25,"currentItem":"torch","downloadSourceUrl":"https://download.pytorch.org/whl/cu134/torch-2.14.0.whl","downloadActive":true,"downloadMeasurementAvailable":true,"downloadSpeed":42.5,"etaSeconds":0.5,"totalSize":1024,"downloadedBytes":512,"dependencyCount":2,"completedDependencies":1,"completedItems":[{"name":"torch","type":"package","size":null,"completedAt":"item done"}],"error":null,"completedAt":null,"success":null,"logPath":"runtime/install.log"}); assert_eq!(serde_json::to_value(&value).unwrap(), expected); let outcome = InstallationProgressOutcome::new(Some(value)).unwrap(); assert_eq!(serde_json::to_value(&outcome).unwrap(), expected); @@ -1916,11 +1919,14 @@ mod installation_progress_contract_tests { serde_json::to_value(InstallationProgressOutcome::new(Some(value)).unwrap()).unwrap(); assert_eq!(encoded["stage"], "resolving"); let schema = desktop_contract_schema().unwrap(); - assert!(schema["schemas"]["RuntimeInstallationStage"]["enum"] - .as_array() - .unwrap() - .iter() - .any(|stage| stage == "resolving")); + assert!( + schema["schemas"]["InstallationProgressOutcome"]["definitions"] + ["RuntimeInstallationStage"]["enum"] + .as_array() + .unwrap() + .iter() + .any(|stage| stage == "resolving") + ); } #[test] diff --git a/torch-server/resolve_runtime.py b/torch-server/resolve_runtime.py index de152beb..d619b260 100644 --- a/torch-server/resolve_runtime.py +++ b/torch-server/resolve_runtime.py @@ -11,7 +11,6 @@ from concurrent.futures import ThreadPoolExecutor, wait import hashlib from html.parser import HTMLParser -import inspect import json import os import platform @@ -66,6 +65,7 @@ def _write_download_progress( downloaded_bytes: int, total_bytes: int | None, speed_bytes_per_sec: float | None, + measurement_available: bool = True, ) -> None: """Atomically publish a private, best-effort progress snapshot.""" payload = { @@ -74,6 +74,7 @@ def _write_download_progress( "downloaded_bytes": downloaded_bytes, "total_bytes": total_bytes, "speed_bytes_per_sec": speed_bytes_per_sec, + "measurement_available": measurement_available, } temporary = progress_path.with_name( f".{progress_path.name}.{os.getpid()}.{threading.get_ident()}.tmp" @@ -125,41 +126,71 @@ def run_pip_progress_worker(progress_path: Path, pip_arguments: list[str]) -> in try: from pip._internal.cli.main import main as pip_main except ImportError: + _write_download_progress(progress_path, None, False, 0, None, None, False) return subprocess.run( [sys.executable, "-I", "-m", "pip", *pip_arguments], check=False ).returncode try: from pip._internal.network import download as pip_download - original_prepare_download = getattr(pip_download, "_prepare_download", None) - signature = inspect.signature(original_prepare_download) - except (ImportError, TypeError, ValueError): - return pip_main(pip_arguments) - if original_prepare_download is None or tuple(signature.parameters) != ( - "response", - "link", - "progress_bar", - ): + except ImportError: + _write_download_progress(progress_path, None, False, 0, None, None, False) return pip_main(pip_arguments) - def prepare_download(response, link, progress_bar): - chunks = original_prepare_download(response, link, progress_bar) + def track_response(response, chunks): try: if pip_download.is_from_cache(response): return chunks - source_url = copyable_download_source(link.url_without_fragment) + except (AttributeError, TypeError, ValueError): + pass + + try: + source_url = copyable_download_source(response.url) + except (AttributeError, TypeError, ValueError): + source_url = None + try: total_bytes = pip_download._get_http_response_size(response) - return _track_download_chunks(chunks, source_url, total_bytes, progress_path) - except Exception: - # Progress is best-effort: an unexpected pip helper/API change must - # not turn an otherwise valid installation into a failure. - return chunks + except (AttributeError, TypeError, ValueError): + total_bytes = None + return _track_download_chunks(chunks, source_url, total_bytes, progress_path) + + original_response_chunks = getattr(pip_download, "response_chunks", None) + original_prepare_download = getattr(pip_download, "_prepare_download", None) + if callable(original_response_chunks): + + def response_chunks(response, *args, **kwargs): + return track_response( + response, + original_response_chunks(response, *args, **kwargs), + ) + + pip_download.response_chunks = response_chunks + + def restore_hook(): + pip_download.response_chunks = original_response_chunks + + elif callable(original_prepare_download): + + def prepare_download(response, *args, **kwargs): + return track_response( + response, + original_prepare_download(response, *args, **kwargs), + ) + + pip_download._prepare_download = prepare_download + + def restore_hook(): + pip_download._prepare_download = original_prepare_download + + else: + _write_download_progress(progress_path, None, False, 0, None, None, False) + return pip_main(pip_arguments) - pip_download._prepare_download = prepare_download + _write_download_progress(progress_path, None, False, 0, None, None, True) try: return pip_main(pip_arguments) finally: - pip_download._prepare_download = original_prepare_download + restore_hook() def staged_file_digest(path: Path) -> tuple[bytes, int]: diff --git a/torch-server/tests/test_resolve_runtime.py b/torch-server/tests/test_resolve_runtime.py index ed1e6862..20a93dad 100644 --- a/torch-server/tests/test_resolve_runtime.py +++ b/torch-server/tests/test_resolve_runtime.py @@ -100,22 +100,21 @@ def test_copyable_download_sources_accept_any_safe_https_host_and_path(self): def test_progress_worker_tracks_any_safe_https_download_source(self): pip_download = importlib.import_module("pip._internal.network.download") pip_cli = importlib.import_module("pip._internal.cli.main") - response = object() source = "https://cdn.example.net/releases/package.whl" - link = SimpleNamespace(url_without_fragment=source) + response = SimpleNamespace(url=source, headers={"Content-Length": "4"}) - def prepare_download(response, link, progress_bar): - del response, link, progress_bar + def response_chunks(_response, chunk_size=10): + del chunk_size return iter((b"ab", b"cd")) def fake_pip_main(_arguments): - return list(pip_download._prepare_download(response, link, None)) and 0 + return list(pip_download.response_chunks(response, chunk_size=2)) and 0 with tempfile.TemporaryDirectory() as directory: progress_path = pathlib.Path(directory) / "download-progress.json" with ( patch.object(pip_cli, "main", side_effect=fake_pip_main), - patch.object(pip_download, "_prepare_download", prepare_download), + patch.object(pip_download, "response_chunks", response_chunks), patch.object(pip_download, "is_from_cache", return_value=False), patch.object(pip_download, "_get_http_response_size", return_value=4), ): @@ -128,24 +127,24 @@ def fake_pip_main(_arguments): self.assertFalse(progress["active"]) self.assertEqual(progress["downloaded_bytes"], 4) self.assertEqual(progress["total_bytes"], 4) + self.assertTrue(progress["measurement_available"]) def test_progress_worker_reports_speed_during_a_real_chunk_interval(self): pip_download = importlib.import_module("pip._internal.network.download") pip_cli = importlib.import_module("pip._internal.cli.main") - response = object() source = "https://cdn.example.net/releases/package.whl" - link = SimpleNamespace(url_without_fragment=source) + response = SimpleNamespace(url=source, headers={"Content-Length": "4"}) progress_writes = [] write_progress = resolver._write_download_progress - def prepare_download(response, link, progress_bar): - del response, link, progress_bar + def response_chunks(_response, chunk_size=10): + del chunk_size yield b"ab" time.sleep(resolver.DOWNLOAD_PROGRESS_INTERVAL_SECONDS + 0.05) yield b"cd" def fake_pip_main(_arguments): - return list(pip_download._prepare_download(response, link, None)) and 0 + return list(pip_download.response_chunks(response, chunk_size=2)) and 0 def capture_progress(*values): progress_writes.append(values) @@ -155,7 +154,8 @@ def capture_progress(*values): progress_path = pathlib.Path(directory) / "download-progress.json" with ( patch.object(pip_cli, "main", side_effect=fake_pip_main), - patch.object(pip_download, "_prepare_download", prepare_download), + patch.object(pip_download, "_prepare_download", None, create=True), + patch.object(pip_download, "response_chunks", response_chunks), patch.object(pip_download, "is_from_cache", return_value=False), patch.object(pip_download, "_get_http_response_size", return_value=4), patch.object(resolver, "_write_download_progress", side_effect=capture_progress), @@ -173,16 +173,15 @@ def capture_progress(*values): self.assertEqual(measured[-1][1], source) self.assertGreater(measured[-1][5], 0) - def test_progress_worker_does_not_expose_secret_bearing_download_urls(self): + def test_progress_worker_falls_back_to_legacy_prepare_download(self): pip_download = importlib.import_module("pip._internal.network.download") pip_cli = importlib.import_module("pip._internal.cli.main") - response = object() - link = SimpleNamespace( - url_without_fragment="https://cdn.example.net/private.whl?token=secret" - ) + source = "https://cdn.example.net/releases/package.whl" + response = SimpleNamespace(url=source, headers={"Content-Length": "4"}) + link = SimpleNamespace(url_without_fragment=source) - def prepare_download(response, link, progress_bar): - del response, link, progress_bar + def prepare_download(resp, link, progress_bar): + del resp, link, progress_bar return iter((b"ab", b"cd")) def fake_pip_main(_arguments): @@ -192,6 +191,7 @@ def fake_pip_main(_arguments): progress_path = pathlib.Path(directory) / "download-progress.json" with ( patch.object(pip_cli, "main", side_effect=fake_pip_main), + patch.object(pip_download, "response_chunks", None), patch.object(pip_download, "_prepare_download", prepare_download), patch.object(pip_download, "is_from_cache", return_value=False), patch.object(pip_download, "_get_http_response_size", return_value=4), @@ -200,10 +200,64 @@ def fake_pip_main(_arguments): resolver.run_pip_progress_worker(progress_path, ["install", "pkg"]), 0 ) + progress = json.loads(progress_path.read_text(encoding="utf-8")) + self.assertEqual(progress["source_url"], source) + self.assertFalse(progress["active"]) + self.assertEqual(progress["downloaded_bytes"], 4) + self.assertTrue(progress["measurement_available"]) + + def test_progress_worker_reports_when_pip_streaming_api_is_unavailable(self): + pip_download = importlib.import_module("pip._internal.network.download") + pip_cli = importlib.import_module("pip._internal.cli.main") + + with tempfile.TemporaryDirectory() as directory: + progress_path = pathlib.Path(directory) / "download-progress.json" + with ( + patch.object(pip_cli, "main", return_value=0), + patch.object(pip_download, "response_chunks", None), + patch.object(pip_download, "_prepare_download", None, create=True), + ): + self.assertEqual( + resolver.run_pip_progress_worker(progress_path, ["install", "pkg"]), 0 + ) + + progress = json.loads(progress_path.read_text(encoding="utf-8")) + self.assertFalse(progress["measurement_available"]) + self.assertFalse(progress["active"]) + self.assertIsNone(progress["speed_bytes_per_sec"]) + + def test_progress_worker_does_not_expose_secret_bearing_download_urls(self): + pip_download = importlib.import_module("pip._internal.network.download") + pip_cli = importlib.import_module("pip._internal.cli.main") + response = SimpleNamespace( + url="https://cdn.example.net/private.whl?token=secret", + headers={"Content-Length": "4"}, + ) + + def response_chunks(_response, chunk_size=10): + del chunk_size + return iter((b"ab", b"cd")) + + def fake_pip_main(_arguments): + return list(pip_download.response_chunks(response, chunk_size=2)) and 0 + + with tempfile.TemporaryDirectory() as directory: + progress_path = pathlib.Path(directory) / "download-progress.json" + with ( + patch.object(pip_cli, "main", side_effect=fake_pip_main), + patch.object(pip_download, "response_chunks", response_chunks), + patch.object(pip_download, "is_from_cache", return_value=False), + patch.object(pip_download, "_get_http_response_size", return_value=4), + ): + self.assertEqual( + resolver.run_pip_progress_worker(progress_path, ["install", "pkg"]), 0 + ) + progress = json.loads(progress_path.read_text(encoding="utf-8")) self.assertIsNone(progress["source_url"]) self.assertFalse(progress["active"]) self.assertEqual(progress["downloaded_bytes"], 4) + self.assertTrue(progress["measurement_available"]) def test_download_progress_reports_measured_rate_and_final_byte_count(self): timestamps = iter((0.0, 0.5, 1.0)) From b68f3fce1aa0be29e8a141e349ec6e4ae28356fd Mon Sep 17 00:00:00 2001 From: MrScripty Date: Sat, 26 Sep 2026 18:38:51 -0700 Subject: [PATCH 09/19] Record bounded Torch speed transfer verification A real managed pip 26 worker emitted positive rates during a throttled 2 MiB transfer; the reported Torch install used cached wheels. Fix the legacy pip test fixture to create the API removed in pip 26. --- .../upstream-version-manager-progress.md | 24 +++++++++++++++++++ torch-server/tests/test_resolve_runtime.py | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md index 0e94ad9d..32b7cd26 100644 --- a/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md +++ b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md @@ -40,6 +40,30 @@ fresh Torch installation or Tuldok generation was performed. This collector measures pip package transfers; managed Python/bootstrap and other Pumas network producers remain uncovered. +## 2026-09-26 — Bounded live transfer and packaged code check + +The latest successful `install-v2.14.0-1790470772611.log` used cached wheels, +including Torch, and therefore offered no network transfer rate to display. +To check the uncached collector without reinstalling Torch, a 2 MiB local wheel +was served in 32 KiB chunks with 40 ms spacing and installed through the real +managed CPython 3.14.7 / pip 26.2.1 progress worker. Polling its progress file +captured 11 distinct snapshots, including eight active snapshots with positive +rates (first about 936 kB/s). The final snapshot cleared the rate. The local +HTTP source was intentionally omitted from the copyable URL field by the +HTTPS-only source rule; byte and rate measurements remained available. + +The local AppImage (`a656a3937335e1b45ef30c25f7a133c728520347cfdc50d5a1539d0e9a493135`) +contains a frontend bundle byte-identical to the current built bundle and a +backend containing the `response_chunks` pip collector. Focused checks passed: +52 frontend presentation tests, 58 resolver tests, 11 Rust progress tests, +and six RPC installation-progress tests. +The resolver's legacy-pip test fixture was corrected to create the removed +`_prepare_download` attribute when running against pip 26. This evidence +confirms the live worker and packaged files, while the earlier Playwright check +remains a mocked-renderer acceptance. It does not establish a simultaneous +real-transfer, packaged-RPC, renderer observation; no new release build is +required for these findings. + ## 2026-09-26 — Torch download speed visibility repair The latest reported log, `install-v2.14.0-1790465718491.log`, selected diff --git a/torch-server/tests/test_resolve_runtime.py b/torch-server/tests/test_resolve_runtime.py index 20a93dad..c1cb9527 100644 --- a/torch-server/tests/test_resolve_runtime.py +++ b/torch-server/tests/test_resolve_runtime.py @@ -192,7 +192,7 @@ def fake_pip_main(_arguments): with ( patch.object(pip_cli, "main", side_effect=fake_pip_main), patch.object(pip_download, "response_chunks", None), - patch.object(pip_download, "_prepare_download", prepare_download), + patch.object(pip_download, "_prepare_download", prepare_download, create=True), patch.object(pip_download, "is_from_cache", return_value=False), patch.object(pip_download, "_get_http_response_size", return_value=4), ): From f650fff71e575d2c84067a391341d462fae5c0db Mon Sep 17 00:00:00 2001 From: MrScripty Date: Tue, 29 Sep 2026 07:13:44 -0700 Subject: [PATCH 10/19] fix(torch): restore install-flow CI gates --- .../src/version_manager/installer.rs | 2 +- .../src/version_manager/installer/torch.rs | 116 +++++++++--------- .../src/version_manager/mod.rs | 2 +- .../src/version_manager/progress.rs | 2 +- rust/crates/pumas-rpc/src/contract.rs | 21 ++-- torch-server/resolve_runtime.py | 3 +- torch-server/tests/test_resolve_runtime.py | 10 +- 7 files changed, 75 insertions(+), 81 deletions(-) diff --git a/rust/crates/pumas-app-manager/src/version_manager/installer.rs b/rust/crates/pumas-app-manager/src/version_manager/installer.rs index cc0b3a80..2bb6e9e8 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/installer.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/installer.rs @@ -20,7 +20,7 @@ pub(crate) struct TorchInstallPlan { } pub(crate) enum TorchInstallInput { Selection(super::torch_preview::TorchInstallSelection), - Resolved(TorchInstallPlan), + Resolved(Box), } #[cfg(test)] pub(crate) use torch::TorchPublicationPause; diff --git a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs index 69089d55..cf9b9baa 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs @@ -1413,16 +1413,31 @@ struct DirectTorchResolution { artifacts: Vec, } +struct DirectTorchSelection<'a> { + version: &'a str, + build: &'a str, + minor: &'a str, + adapter: &'a str, + python: &'a Path, +} + +struct TorchAttemptContext<'a> { + staging: &'a std::sync::Arc, + log_path: &'a Path, + progress_tx: &'a mpsc::Sender, +} + fn validate_direct_torch_report( resolution: &DirectTorchResolution, report: &serde_json::Value, requirements: &str, - version: &str, - build: &str, - minor: &str, - adapter: &str, - python: &Path, + selection: &DirectTorchSelection<'_>, ) -> Result<()> { + let version = selection.version; + let build = selection.build; + let minor = selection.minor; + let adapter = selection.adapter; + let python = selection.python; let expected_torch = if cfg!(target_os = "macos") && build == "cpu" { version.to_owned() } else { @@ -1508,8 +1523,8 @@ fn validate_direct_torch_report( )); } } - if artifact.name == "torchvision" { - if adapter == "none" + if artifact.name == "torchvision" + && (adapter == "none" || !(artifact.version.ends_with(&format!("+{build}")) || (cfg!(target_os = "macos") && build == "cpu" @@ -1517,12 +1532,11 @@ fn validate_direct_torch_report( && artifact .version .bytes() - .all(|byte| byte.is_ascii_digit() || byte == b'.'))) - { - return Err(failed( - "Torchvision build differs from selected Torch build", - )); - } + .all(|byte| byte.is_ascii_digit() || byte == b'.')))) + { + return Err(failed( + "Torchvision build differs from selected Torch build", + )); } let item = &entries[index]; let report_name = item["metadata"]["name"] @@ -1703,27 +1717,14 @@ fn validate_and_move_direct_torch_packages( resolution: &DirectTorchResolution, report: &serde_json::Value, requirements: &str, - version: &str, - build: &str, - minor: &str, - adapter: &str, - python: &Path, + selection: &DirectTorchSelection<'_>, target: &Path, runtime: &Path, manifest: &StagedFilesManifest, ) -> Result<()> { - validate_direct_torch_report( - resolution, - report, - requirements, - version, - build, - minor, - adapter, - python, - )?; + validate_direct_torch_report(resolution, report, requirements, selection)?; validate_staged_files(target, manifest)?; - move_verified_packages(target, runtime, minor) + move_verified_packages(target, runtime, selection.minor) } impl VersionInstaller { @@ -1812,9 +1813,11 @@ impl VersionInstaller { &build, minor, &python_root, - staging, - log_path, - progress_tx, + &TorchAttemptContext { + staging, + log_path, + progress_tx, + }, ) .await?; match attempt { @@ -1844,10 +1847,11 @@ impl VersionInstaller { build: &str, minor: &str, python_root: &Path, - staging: &std::sync::Arc, - log_path: &Path, - progress_tx: &mpsc::Sender, + context: &TorchAttemptContext<'_>, ) -> Result { + let staging = context.staging; + let log_path = context.log_path; + let progress_tx = context.progress_tx; let managed_python = self .run_provider_with_cancel(async { super::super::managed_python::ensure_managed_torch_interpreter( @@ -1954,11 +1958,13 @@ impl VersionInstaller { &resolution, &report, &requirements, - version, - build, - minor, - &selection.adapter, - &python, + &DirectTorchSelection { + version, + build, + minor, + adapter: &selection.adapter, + python: &python, + }, &packages, &runtime, &manifest, @@ -2151,7 +2157,7 @@ impl VersionInstaller { )); } let plan = match input.as_ref() { - Some(TorchInstallInput::Resolved(plan)) => Some(plan), + Some(TorchInstallInput::Resolved(plan)) => Some(plan.as_ref()), _ => None, }; let selection = match input.as_ref() { @@ -2263,15 +2269,7 @@ impl VersionInstaller { Some(log_path.to_string_lossy().as_ref()), ); let result = self - .stage_torch_runtime( - tag, - recipe, - plan, - selection, - &staging, - &log_path, - &progress_tx, - ) + .stage_torch_runtime(recipe, plan, selection, &staging, &log_path, &progress_tx) .await; #[cfg(test)] if result.is_ok() && self.torch_stage_override.is_some() { @@ -2378,7 +2376,6 @@ impl VersionInstaller { async fn stage_torch_runtime( &self, - _tag: &str, recipe_spec: Option<&TorchRuntimeRecipe>, plan: Option<&TorchInstallPlan>, selection: Option<&super::super::torch_preview::TorchInstallSelection>, @@ -2906,10 +2903,15 @@ mod managed_python_provenance_tests { .collect::>() .join("\n"); let python = Path::new("/staged/venv/python"); + let selection = DirectTorchSelection { + version: "2.14.0", + build: "cpu", + minor: "3.12", + adapter: "none", + python, + }; let check = |resolution: &DirectTorchResolution, report: &serde_json::Value, lock: &str| { - validate_direct_torch_report( - resolution, report, lock, "2.14.0", "cpu", "3.12", "none", python, - ) + validate_direct_torch_report(resolution, report, lock, &selection) }; assert!(check(&resolution, &report, &requirements).is_ok()); resolution.artifacts[0].url = "https://example.com/torch.whl".into(); @@ -2941,11 +2943,7 @@ mod managed_python_provenance_tests { &resolution, &report, lock, - "2.14.0", - "cpu", - "3.12", - "none", - python, + &selection, &target, &runtime, &manifest, diff --git a/rust/crates/pumas-app-manager/src/version_manager/mod.rs b/rust/crates/pumas-app-manager/src/version_manager/mod.rs index 50a0a0a7..4e07e284 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/mod.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/mod.rs @@ -910,7 +910,7 @@ impl VersionManager { manager.torch_cleanup.clone(), ) .await?; - Some(installer::TorchInstallInput::Resolved(plan)) + Some(installer::TorchInstallInput::Resolved(Box::new(plan))) } Some(selection) => Some(installer::TorchInstallInput::Selection(selection)), None => None, diff --git a/rust/crates/pumas-app-manager/src/version_manager/progress.rs b/rust/crates/pumas-app-manager/src/version_manager/progress.rs index 08896556..5fbe72eb 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/progress.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/progress.rs @@ -256,7 +256,7 @@ impl InstallationProgressTracker { total_bytes: Option, speed_bytes_per_sec: Option, ) { - let active = total_bytes.map_or(true, |total| downloaded_bytes < total); + let active = total_bytes.is_none_or(|total| downloaded_bytes < total); let measured_speed = self .record_network_activity( "installer-download", diff --git a/rust/crates/pumas-rpc/src/contract.rs b/rust/crates/pumas-rpc/src/contract.rs index bb678257..e8e727b9 100644 --- a/rust/crates/pumas-rpc/src/contract.rs +++ b/rust/crates/pumas-rpc/src/contract.rs @@ -1918,15 +1918,18 @@ mod installation_progress_contract_tests { let encoded = serde_json::to_value(InstallationProgressOutcome::new(Some(value)).unwrap()).unwrap(); assert_eq!(encoded["stage"], "resolving"); - let schema = desktop_contract_schema().unwrap(); - assert!( - schema["schemas"]["InstallationProgressOutcome"]["definitions"] - ["RuntimeInstallationStage"]["enum"] - .as_array() - .unwrap() - .iter() - .any(|stage| stage == "resolving") - ); + #[cfg(feature = "export-contract")] + { + let schema = desktop_contract_schema().unwrap(); + assert!( + schema["schemas"]["InstallationProgressOutcome"]["definitions"] + ["RuntimeInstallationStage"]["enum"] + .as_array() + .unwrap() + .iter() + .any(|stage| stage == "resolving") + ); + } } #[test] diff --git a/torch-server/resolve_runtime.py b/torch-server/resolve_runtime.py index d619b260..0333400d 100644 --- a/torch-server/resolve_runtime.py +++ b/torch-server/resolve_runtime.py @@ -33,8 +33,7 @@ def copyable_download_source(url: str) -> str | None: """Allow any direct HTTPS source without embedded credentials or query secrets.""" if len(url.encode("utf-8")) > 2048 or any( - character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F - for character in url + character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F for character in url ): return None try: diff --git a/torch-server/tests/test_resolve_runtime.py b/torch-server/tests/test_resolve_runtime.py index c1cb9527..0385d8f1 100644 --- a/torch-server/tests/test_resolve_runtime.py +++ b/torch-server/tests/test_resolve_runtime.py @@ -164,11 +164,7 @@ def capture_progress(*values): resolver.run_pip_progress_worker(progress_path, ["install", "pkg"]), 0 ) - measured = [ - sample - for sample in progress_writes - if sample[2] and sample[5] is not None - ] + measured = [sample for sample in progress_writes if sample[2] and sample[5] is not None] self.assertTrue(measured) self.assertEqual(measured[-1][1], source) self.assertGreater(measured[-1][5], 0) @@ -407,9 +403,7 @@ def record_row(name, content): for unsafe_path in ("../../etc/passwd", "../../share/../../outside"): with self.subTest(unsafe_path=unsafe_path): record.write_text( - good_record.replace( - "../../share/man/man1/isympy.1", unsafe_path - ), + good_record.replace("../../share/man/man1/isympy.1", unsafe_path), encoding="utf-8", ) with self.assertRaisesRegex(ValueError, "escapes its target"): From c302dda86340d87284e9789654248c2d06cda3a3 Mon Sep 17 00:00:00 2001 From: MrScripty Date: Tue, 29 Sep 2026 07:20:22 -0700 Subject: [PATCH 11/19] test(torch): pin Linux install expectations across hosts --- torch-server/tests/test_resolve_runtime.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/torch-server/tests/test_resolve_runtime.py b/torch-server/tests/test_resolve_runtime.py index 0385d8f1..06f38251 100644 --- a/torch-server/tests/test_resolve_runtime.py +++ b/torch-server/tests/test_resolve_runtime.py @@ -471,6 +471,7 @@ def fake_run(command, **_kwargs): str(output), ], ), + patch.object(resolver, "native_target", return_value="linux"), patch.object(resolver.subprocess, "run", side_effect=fake_run), ): resolver.main() @@ -591,6 +592,7 @@ def test_install_mode_reports_pip_failure_without_publishing_a_lock(self): str(output), ], ), + patch.object(resolver, "native_target", return_value="linux"), patch.object(resolver.subprocess, "run", return_value=failed), redirect_stderr(StringIO()), ): From 524ed1c28f9b97034e7b5bfe26a17651de38344c Mon Sep 17 00:00:00 2001 From: MrScripty Date: Tue, 29 Sep 2026 07:24:39 -0700 Subject: [PATCH 12/19] test(torch): accept direct install flow in native RPC gate --- .../torch-managed-python-acceptance.py | 169 +++++++++++++++--- 1 file changed, 145 insertions(+), 24 deletions(-) diff --git a/scripts/release/torch-managed-python-acceptance.py b/scripts/release/torch-managed-python-acceptance.py index 35f30f56..538a9a37 100644 --- a/scripts/release/torch-managed-python-acceptance.py +++ b/scripts/release/torch-managed-python-acceptance.py @@ -300,6 +300,86 @@ def test_top_level_error_is_visible_before_bulky_release_options(self) -> None: self.assertLessEqual(len(message), len("release options failed: ") + 1200) +class InstallSelectionFixture(unittest.TestCase): + def test_advisory_options_and_ready_token_start_install_without_premature_resolution(self): + calls = [] + + def fake_rpc(base, method, params, timeout): + calls.append((base, method, params, timeout)) + if method == "get_torch_release_options": + return { + "tag": TAG, + "status": "inconclusive", + "completeScan": False, + "combinations": [], + } + return { + "status": "ready", + "preview": { + "tag": TAG, + "build": "cpu", + "python": "auto", + "adapter": "none", + "qualification": "unverified", + "artifacts": [], + "previewId": "selection-token", + "expiresInSeconds": 300, + }, + } + + preview = request_install_selection("http://127.0.0.1:1", rpc_call=fake_rpc) + self.assertEqual(preview["previewId"], "selection-token") + self.assertEqual( + [call[1] for call in calls], ["get_torch_release_options", "preview_torch_runtime"] + ) + self.assertEqual( + calls[1][2], {"tag": TAG, "build": "cpu", "python": "auto", "adapter": "none"} + ) + + def test_old_resolved_preview_cannot_stand_in_for_a_ready_selection(self): + def fake_rpc(_base, method, _params, timeout): + if method == "get_torch_release_options": + return { + "tag": TAG, + "status": "inconclusive", + "completeScan": False, + "combinations": [], + } + return {"status": "resolved", "preview": {"previewId": "old-token"}} + + with self.assertRaisesRegex(RuntimeError, "Torch selection failed"): + request_install_selection("http://127.0.0.1:1", rpc_call=fake_rpc) + + def test_installed_resolution_counts_only_validated_persisted_artifacts(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + runtime = root / "torch-versions" / TAG + runtime.mkdir(parents=True) + resolution = { + "release": TAG.removeprefix("v"), + "build": "cpu", + "adapter": "none", + "python": "3.14", + "artifacts": [ + { + "name": "torch", + "version": "2.14.0", + "url": "https://download.pytorch.org/whl/cpu/torch.whl", + "sha256": "a" * 64, + } + ], + } + path = runtime / "resolution.json" + path.write_text(json.dumps(resolution), encoding="utf-8") + self.assertEqual( + installed_resolution_evidence(root, "python3.14"), {"artifact_count": 1} + ) + resolution["artifacts"][0]["sha256"] = "invalid" + path.write_text(json.dumps(resolution), encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "installed Torch resolution failed"): + installed_resolution_evidence(root, "python3.14") + + class VersionPreflightFixture(unittest.TestCase): @staticmethod def listing(tag: str = TAG) -> dict: @@ -863,7 +943,7 @@ def fake_rpc(_base, method, params=None, timeout=120): ) as evidence, ): restart = exercise_restart("http://second", Path("/launcher"), first) - evidence.assert_called_once_with(Path("/launcher"), {"python": "python3.14"}) + evidence.assert_called_once_with(Path("/launcher"), "python3.14") cpu_operation.assert_called_once_with(Path("/launcher")) self.assertEqual(restart["active_version"], TAG) self.assertEqual(restart["profile_id"], PROFILE_ID) @@ -2318,7 +2398,7 @@ def wait_for_install(base: str, deadline_seconds: int = 2700) -> dict: raise RuntimeError(f"Torch installation timed out: {json.dumps(last)[:1200]}") -def managed_python_evidence(root: Path, preview: dict) -> dict: +def managed_python_evidence(root: Path, expected_python: str | None) -> dict: recipe_path = root / "torch-versions" / TAG / "runtime.json" with recipe_path.open(encoding="utf-8") as recipe_file: recipe = json.load(recipe_file) @@ -2345,7 +2425,10 @@ def managed_python_evidence(root: Path, preview: dict) -> dict: distribution.get("implementation") == "CPython" and isinstance(python_version, str) and re.fullmatch(r"\d+\.\d+\.\d+", python_version) is not None - and "python" + ".".join(python_version.split(".")[:2]) == preview["python"] + and ( + expected_python is None + or "python" + ".".join(python_version.split(".")[:2]) == expected_python + ) and isinstance(target, str) and bool(target) and isinstance(source_url, str) @@ -2886,45 +2969,80 @@ def tail(value) -> str: raise -def exercise(base: str, root: Path, state: dict) -> dict: - preflight_torch_release(base, state) - options = rpc( +def request_install_selection(base: str, *, rpc_call=None) -> dict: + """Confirm the advisory response and obtain an install-time selection token.""" + if rpc_call is None: + rpc_call = rpc + options = rpc_call( base, "get_torch_release_options", {"tag": TAG}, timeout=RELEASE_OPTIONS_RPC_TIMEOUT_SECONDS, ) require( - options.get("tag") == TAG and options.get("completeScan") is True, - "release options", - options, - ) - require( - options.get("status") == "matches" - and any(item.get("build") == "cpu" for item in options.get("combinations", [])), - "CPU release combination", + options.get("tag") == TAG + and options.get("status") == "inconclusive" + and options.get("completeScan") is False + and options.get("combinations") == [], + "advisory release options", options, ) - preview_result = rpc( + preview_result = rpc_call( base, "preview_torch_runtime", {"tag": TAG, "build": "cpu", "python": "auto", "adapter": "none"}, timeout=900, ) - require(preview_result.get("status") == "resolved", "Torch preview", preview_result) + require(preview_result.get("status") == "ready", "Torch selection", preview_result) preview = preview_result["preview"] require( preview.get("tag") == TAG and preview.get("build") == "cpu" and preview.get("adapter") == "none" - and preview.get("python", "").startswith("python3.") - and preview.get("qualification") in {"qualified", "unverified"} + and preview.get("python") == "auto" + and preview.get("qualification") == "unverified" + and preview.get("artifacts") == [] and isinstance(preview.get("previewId"), str) - and bool(preview["previewId"]), - "qualified managed Python preview", + and bool(preview["previewId"]) + and type(preview.get("expiresInSeconds")) is int + and preview["expiresInSeconds"] > 0, + "ready Torch selection", preview, ) + return preview + + +def installed_resolution_evidence(root: Path, python: str) -> dict: + resolution_path = root / "torch-versions" / TAG / "resolution.json" + with resolution_path.open(encoding="utf-8") as resolution_file: + resolution = json.load(resolution_file) + artifacts = resolution.get("artifacts") + require( + resolution.get("release") == TAG.removeprefix("v") + and resolution.get("build") == "cpu" + and resolution.get("adapter") == "none" + and resolution.get("python") == python.removeprefix("python") + and isinstance(artifacts, list) + and bool(artifacts) + and all( + isinstance(item, dict) + and isinstance(item.get("name"), str) + and isinstance(item.get("version"), str) + and isinstance(item.get("url"), str) + and isinstance(item.get("sha256"), str) + and re.fullmatch(r"[0-9a-fA-F]{64}", item["sha256"]) is not None + for item in artifacts + ), + "installed Torch resolution", + resolution, + ) + return {"artifact_count": len(artifacts)} + + +def exercise(base: str, root: Path, state: dict) -> dict: + preflight_torch_release(base, state) + preview = request_install_selection(base) started = rpc( base, "install_version", @@ -2933,7 +3051,9 @@ def exercise(base: str, root: Path, state: dict) -> dict: require(started.get("success") is True, "installation start", started) wait_for_install(base) state["install_succeeded"] = True - python_evidence = managed_python_evidence(root, preview) + python_evidence = managed_python_evidence(root, None) + selected_python = "python" + ".".join(python_evidence["cpython_version"].split(".")[:2]) + resolution_evidence = installed_resolution_evidence(root, selected_python) probe = rpc(base, "get_torch_runtime_probe", {"tag": TAG}, timeout=60) capabilities = probe.get("capabilities", {}) @@ -2992,9 +3112,9 @@ def exercise(base: str, root: Path, state: dict) -> dict: "version_preflight": state["version_preflight"], "release": TAG, "build": "cpu", - "python": preview["python"], + "python": selected_python, **python_evidence, - "artifact_count": len(preview["artifacts"]), + **resolution_evidence, "platform": sys.platform, "architecture": platform.machine(), "probe": "passed", @@ -3027,7 +3147,7 @@ def exercise_restart(base: str, root: Path, first: dict) -> dict: "persisted managed CPU profile after restart", snapshot, ) - python_evidence = managed_python_evidence(root, {"python": first["python"]}) + python_evidence = managed_python_evidence(root, first["python"]) identity_keys = ( "cpython_version", "provider", @@ -3182,6 +3302,7 @@ def main() -> None: for fixture in ( WindowsCanonicalPathFixture, FailureSummaryFixture, + InstallSelectionFixture, VersionPreflightFixture, EvidenceCollectionFixture, LauncherRootFinalizationFixture, From c083568d28d8c700433cc68ecf88c6c2727cf88e Mon Sep 17 00:00:00 2001 From: MrScripty Date: Tue, 29 Sep 2026 07:27:23 -0700 Subject: [PATCH 13/19] chore(attribution): record updated Torch acceptance source --- docs/release-attribution/0.7.0/inventory.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-attribution/0.7.0/inventory.json b/docs/release-attribution/0.7.0/inventory.json index be441f57..b6cad420 100644 --- a/docs/release-attribution/0.7.0/inventory.json +++ b/docs/release-attribution/0.7.0/inventory.json @@ -6703,7 +6703,7 @@ "rust/crates/pumas-rpc/Cargo.toml": "b51fc102e08bf9ba72cbec544cd8b933171031078b1ba273b289a6f4c5cf981a", "scripts/release/artifact-plan.json": "d5e4a2700728ba61cabca36d727efd477d716f3b50cc90fa9ce7f5e7959c7b0c", "scripts/release/licenses/managed-python-sources.json": "eabda4e2a25a92cfa8610865e79d11a50836930f53098efe89dd90778c4212ca", - "scripts/release/torch-managed-python-acceptance.py": "130b1e3d98032943bed55831228172f5c6fc29e08ea0377373dee8a1284d3166" + "scripts/release/torch-managed-python-acceptance.py": "1f176a1b2bd18f1cd9fcc63822852d30db05655a608579b42cd5fb7732e67aa8" }, "notices_sha256": "b1631dab130001c76f70f116c2099bae0b87b8ba67d2f110c2f5bae7e465ef89" } From 84cf89d61cb057e20389845277033c173b54d22b Mon Sep 17 00:00:00 2001 From: MrScripty Date: Tue, 29 Sep 2026 07:35:35 -0700 Subject: [PATCH 14/19] test(torch): retain bounded installer diagnostics in native gate --- docs/release-attribution/0.7.0/inventory.json | 2 +- .../torch-managed-python-acceptance.py | 44 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/docs/release-attribution/0.7.0/inventory.json b/docs/release-attribution/0.7.0/inventory.json index b6cad420..42018e2d 100644 --- a/docs/release-attribution/0.7.0/inventory.json +++ b/docs/release-attribution/0.7.0/inventory.json @@ -6703,7 +6703,7 @@ "rust/crates/pumas-rpc/Cargo.toml": "b51fc102e08bf9ba72cbec544cd8b933171031078b1ba273b289a6f4c5cf981a", "scripts/release/artifact-plan.json": "d5e4a2700728ba61cabca36d727efd477d716f3b50cc90fa9ce7f5e7959c7b0c", "scripts/release/licenses/managed-python-sources.json": "eabda4e2a25a92cfa8610865e79d11a50836930f53098efe89dd90778c4212ca", - "scripts/release/torch-managed-python-acceptance.py": "1f176a1b2bd18f1cd9fcc63822852d30db05655a608579b42cd5fb7732e67aa8" + "scripts/release/torch-managed-python-acceptance.py": "db007665ed3af158932f7d05ce69347d860cdbc6f82c49863e9ee3f4ebea251b" }, "notices_sha256": "b1631dab130001c76f70f116c2099bae0b87b8ba67d2f110c2f5bae7e465ef89" } diff --git a/scripts/release/torch-managed-python-acceptance.py b/scripts/release/torch-managed-python-acceptance.py index 538a9a37..0b34f311 100644 --- a/scripts/release/torch-managed-python-acceptance.py +++ b/scripts/release/torch-managed-python-acceptance.py @@ -379,6 +379,25 @@ def test_installed_resolution_counts_only_validated_persisted_artifacts(self): with self.assertRaisesRegex(RuntimeError, "installed Torch resolution failed"): installed_resolution_evidence(root, "python3.14") + def test_install_failure_log_capture_is_bounded_to_launcher_logs(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "launcher" + logs = root / "launcher-data" / "logs" + logs.mkdir(parents=True) + log = logs / "install.log" + log.write_text( + "earlier\n" + "x" * 20000 + "\nInvalid wheel resolution: bad record\n", + encoding="utf-8", + ) + capture_install_failure_log(root, {"logPath": str(log)}) + retained = (root / "install-failure-tail.txt").read_bytes() + self.assertLessEqual(len(retained), 16384) + self.assertIn(b"Invalid wheel resolution: bad record", retained) + outside = Path(directory) / "outside.log" + outside.write_text("private", encoding="utf-8") + capture_install_failure_log(root, {"logPath": str(outside)}) + self.assertEqual((root / "install-failure-tail.txt").read_bytes(), retained) + class VersionPreflightFixture(unittest.TestCase): @staticmethod @@ -2365,6 +2384,9 @@ def collect_evidence(root: Path, output: Path, result: dict, *, install_succeede cpu_failure = root / "restart-cpu-operation-failure.json" if cpu_failure.is_file(): shutil.copyfile(cpu_failure, output / cpu_failure.name) + install_failure = root / "install-failure-tail.txt" + if install_failure.is_file(): + shutil.copyfile(install_failure, output / install_failure.name) if install_succeeded: runtime = root / "torch-versions" / TAG for name in INSTALL_REPORTS: @@ -2382,7 +2404,24 @@ def finalize_launcher_root(root: Path, *, cleanup_safe: bool) -> None: shutil.rmtree(root) -def wait_for_install(base: str, deadline_seconds: int = 2700) -> dict: +def capture_install_failure_log(root: Path, progress: dict) -> None: + """Retain a bounded tail only from the launcher's own installation logs.""" + raw_path = progress.get("logPath") + if not isinstance(raw_path, str): + return + try: + candidate = Path(raw_path).resolve(strict=True) + logs = (root / "launcher-data" / "logs").resolve(strict=True) + if not candidate.is_file() or not path_is_within(candidate, logs, windows=native_windows()): + return + with candidate.open("rb") as log: + log.seek(max(0, candidate.stat().st_size - 16384)) + (root / "install-failure-tail.txt").write_bytes(log.read(16384)) + except OSError: + pass + + +def wait_for_install(base: str, root: Path, deadline_seconds: int = 2700) -> dict: deadline = time.monotonic() + deadline_seconds last = None while time.monotonic() < deadline: @@ -2391,6 +2430,7 @@ def wait_for_install(base: str, deadline_seconds: int = 2700) -> dict: last = progress require(progress.get("tag") == TAG, "installation tag", progress) if progress.get("success") is False or progress.get("error"): + capture_install_failure_log(root, progress) raise RuntimeError(f"Torch installation failed: {json.dumps(progress)[:1200]}") if progress.get("success") is True and progress.get("completedAt"): return progress @@ -3049,7 +3089,7 @@ def exercise(base: str, root: Path, state: dict) -> dict: {"appId": "torch", "tag": TAG, "previewId": preview["previewId"]}, ) require(started.get("success") is True, "installation start", started) - wait_for_install(base) + wait_for_install(base, root) state["install_succeeded"] = True python_evidence = managed_python_evidence(root, None) selected_python = "python" + ".".join(python_evidence["cpython_version"].split(".")[:2]) From 0f4c1c552752f2db80634363f17f805c9adde929 Mon Sep 17 00:00:00 2001 From: MrScripty Date: Tue, 29 Sep 2026 07:52:13 -0700 Subject: [PATCH 15/19] fix: accept verified macOS CPU Torch wheels from PyPI --- torch-server/resolve_runtime.py | 35 ++++++++++++++++--- torch-server/tests/test_resolve_runtime.py | 40 ++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/torch-server/resolve_runtime.py b/torch-server/resolve_runtime.py index 0333400d..b6630f5b 100644 --- a/torch-server/resolve_runtime.py +++ b/torch-server/resolve_runtime.py @@ -21,7 +21,11 @@ import time from pathlib import Path from pip._vendor.packaging import tags as packaging_tags -from pip._vendor.packaging.utils import canonicalize_name +from pip._vendor.packaging.utils import ( + InvalidWheelFilename, + canonicalize_name, + parse_wheel_filename, +) from pip._vendor.packaging.version import Version, InvalidVersion from urllib.parse import unquote, urljoin, urlparse, urlunparse from urllib.request import HTTPRedirectHandler, Request, build_opener @@ -938,7 +942,9 @@ def official_torch_url(url: str, build: str) -> bool: ) -def trusted_wheel_url(url: str, build: str, name: str, adapter: str) -> bool: +def trusted_wheel_url( + url: str, build: str, name: str, adapter: str, target: str, version: str +) -> bool: parsed = urlparse(url) if ( parsed.scheme != "https" @@ -952,7 +958,28 @@ def trusted_wheel_url(url: str, build: str, name: str, adapter: str) -> bool: ): return False if name in {"torch", "torchvision"}: - return official_torch_url(url, build) + if official_torch_url(url, build): + return True + # PyTorch publishes plain-version macOS CPU wheels on PyPI. Match the + # report identity and this interpreter's wheel tags before trusting it. + if target != "macos" or build != "cpu" or not parsed.path.startswith("/packages/"): + return False + if parsed.hostname != "files.pythonhosted.org": + return False + try: + wheel_name, wheel_version, _, wheel_tags = parse_wheel_filename( + unquote(parsed.path.rsplit("/", 1)[-1]) + ) + return ( + wheel_name == canonicalize_name(name) + and wheel_version == Version(version) + and any( + tag in wheel_tags and tag.platform.startswith("macosx_") + for tag in packaging_tags.sys_tags() + ) + ) + except (InvalidWheelFilename, InvalidVersion): + return False if parsed.hostname == "files.pythonhosted.org" and parsed.path.startswith("/packages/"): return True if parsed.hostname in {"download.pytorch.org", "download-r2.pytorch.org"}: @@ -1016,7 +1043,7 @@ def requirements_from_report( digest = info.get("archive_info", {}).get("hashes", {}).get("sha256") if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", digest): raise ValueError(f"No SHA-256 provenance for {name}") - if not trusted_wheel_url(url, build, name, adapter): + if not trusted_wheel_url(url, build, name, adapter, target, item["metadata"]["version"]): raise ValueError(f"Untrusted or non-binary artifact for {name}: {url}") if name == "torchvision": vision_version = item["metadata"]["version"] diff --git a/torch-server/tests/test_resolve_runtime.py b/torch-server/tests/test_resolve_runtime.py index 06f38251..ebf7c2ec 100644 --- a/torch-server/tests/test_resolve_runtime.py +++ b/torch-server/tests/test_resolve_runtime.py @@ -1496,6 +1496,46 @@ def test_wrong_build_or_untrusted_origin_is_rejected(self): with self.assertRaises(ValueError): resolver.requirements_from_report(fixture, "2.10.0", "cpu") + def test_macos_cpu_accepts_pypi_torch_wheels_with_matching_identity_and_tags(self): + torch_url = ( + "https://files.pythonhosted.org/packages/ee/90/" + "torch-2.14.0-cp314-cp314-macosx_14_0_arm64.whl" + ) + vision_url = ( + "https://files.pythonhosted.org/packages/aa/bb/" + "torchvision-0.29.0-cp314-cp314-macosx_14_0_arm64.whl" + ) + fixture = report(version="2.14.0+cpu", adapter="flux2") + fixture["install"][0]["metadata"]["version"] = "2.14.0" + fixture["install"][0]["download_info"]["url"] = torch_url + vision = next( + item for item in fixture["install"] if item["metadata"]["name"] == "torchvision" + ) + vision["metadata"]["version"] = "0.29.0" + vision["download_info"]["url"] = vision_url + mac_tag = resolver.packaging_tags.Tag("cp314", "cp314", "macosx_14_0_arm64") + with ( + patch.object(resolver.sys, "platform", "darwin"), + patch.object(resolver.platform, "machine", return_value="arm64"), + patch.object(resolver.sys, "version_info", SimpleNamespace(major=3, minor=14)), + patch.object(resolver.packaging_tags, "sys_tags", return_value=[mac_tag]), + ): + requirements, _ = resolver.requirements_from_report(fixture, "2.14.0", "cpu", "flux2") + self.assertTrue(any(torch_url in requirement for requirement in requirements)) + self.assertTrue(any(vision_url in requirement for requirement in requirements)) + for bad_url in ( + torch_url.replace("torch-2.14.0", "torch-2.13.0"), + torch_url.replace("torch-2.14.0", "other-2.14.0"), + torch_url.replace("macosx_14_0_arm64", "manylinux_2_28_x86_64"), + torch_url.replace("files.pythonhosted.org", "files.pythonhosted.org.evil.test"), + ): + with self.subTest(bad_url=bad_url), self.assertRaisesRegex(ValueError, "Untrusted"): + fixture["install"][0]["download_info"]["url"] = bad_url + resolver.requirements_from_report(fixture, "2.14.0", "cpu", "flux2") + fixture["install"][0]["download_info"]["url"] = torch_url + with self.assertRaises(ValueError): + resolver.requirements_from_report(fixture, "2.14.0", "cpu", "flux2") + def test_torchvision_wrong_build_is_rejected(self): fixture = report(adapter="flux2") fixture["install"][6]["metadata"]["version"] = "0.25.0+cu130" From 30ebc03663e22890b579bad2b5b0b170eccc99dc Mon Sep 17 00:00:00 2001 From: MrScripty Date: Tue, 29 Sep 2026 08:04:59 -0700 Subject: [PATCH 16/19] fix: trust verified macOS CPU PyPI wheels in Torch report --- .../src/version_manager/installer/torch.rs | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs index cf9b9baa..3841ae95 100644 --- a/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs +++ b/rust/crates/pumas-app-manager/src/version_manager/installer/torch.rs @@ -1427,6 +1427,43 @@ struct TorchAttemptContext<'a> { progress_tx: &'a mpsc::Sender, } +fn trusted_macos_pypi_torch_wheel( + url: &reqwest::Url, + name: &str, + version: &str, + macos_cpu: bool, +) -> bool { + if !macos_cpu + || !matches!(name, "torch" | "torchvision") + || url.host_str() != Some("files.pythonhosted.org") + || !url.path().starts_with("/packages/") + || version.contains('+') + { + return false; + } + let Some(filename) = url.path().rsplit('/').next() else { + return false; + }; + let prefix = format!("{name}-{version}-"); + let Some(tags) = filename + .strip_prefix(&prefix) + .and_then(|value| value.strip_suffix(".whl")) + else { + return false; + }; + let mut parts = tags.split('-'); + let (Some(python), Some(abi), Some(platform), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return false; + }; + !python.is_empty() + && !abi.is_empty() + && platform.split('.').any(|tag| { + tag.starts_with("macosx_") && (tag.ends_with("_arm64") || tag.ends_with("_universal2")) + }) +} + fn validate_direct_torch_report( resolution: &DirectTorchResolution, report: &serde_json::Value, @@ -1500,10 +1537,16 @@ fn validate_direct_torch_report( && url.fragment().is_none() && url.path().to_ascii_lowercase().ends_with(".whl") && if artifact.name == "torch" || artifact.name == "torchvision" { - matches!( + (matches!( url.host_str(), Some("download.pytorch.org" | "download-r2.pytorch.org") - ) && url.path().starts_with(&format!("/whl/{build}/")) + ) && url.path().starts_with(&format!("/whl/{build}/"))) + || trusted_macos_pypi_torch_wheel( + &url, + &artifact.name, + &artifact.version, + cfg!(target_os = "macos") && build == "cpu", + ) } else { (url.host_str() == Some("files.pythonhosted.org") && url.path().starts_with("/packages/")) @@ -2836,6 +2879,29 @@ mod download_progress_tests { mod managed_python_provenance_tests { use super::*; + #[test] + fn macos_pypi_torch_wheels_require_matching_identity_and_platform() { + let source = "https://files.pythonhosted.org/packages/ee/90/torch-2.14.0-cp314-cp314-macosx_14_0_arm64.whl"; + let url = reqwest::Url::parse(source).unwrap(); + assert!(trusted_macos_pypi_torch_wheel( + &url, "torch", "2.14.0", true + )); + assert!(!trusted_macos_pypi_torch_wheel( + &url, "torch", "2.14.0", false + )); + for altered in [ + source.replace("torch-2.14.0", "torch-2.13.0"), + source.replace("torch-2.14.0", "other-2.14.0"), + source.replace("macosx_14_0_arm64", "manylinux_2_28_x86_64"), + source.replace("files.pythonhosted.org", "files.pythonhosted.org.evil.test"), + ] { + let altered = reqwest::Url::parse(&altered).unwrap(); + assert!(!trusted_macos_pypi_torch_wheel( + &altered, "torch", "2.14.0", true + )); + } + } + #[test] fn direct_install_report_requires_trusted_hash_locked_wheels_before_probe() { let torch_version = if cfg!(target_os = "macos") { From cc1487a9cb5cda979a69ceaaa4b9b067d91d2a2b Mon Sep 17 00:00:00 2001 From: MrScripty Date: Tue, 29 Sep 2026 08:15:48 -0700 Subject: [PATCH 17/19] ci: authenticate bounded native evidence release lookup --- .github/workflows/build.yml | 2 + docs/release-attribution/0.7.0/inventory.json | 2 +- .../torch-managed-python-acceptance.py | 53 ++++++++++++++++--- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ede037a..64a954fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -254,6 +254,8 @@ jobs: - name: Build native RPC for Torch acceptance run: cargo build --locked --manifest-path rust/Cargo.toml -p pumas-rpc - name: Accept v2.14 CPU/Core via native RPC (${{ runner.os }}) + env: + PUMAS_RELEASE_API_TOKEN: ${{ github.token }} run: python scripts/release/torch-managed-python-acceptance.py rust/target/debug --evidence-dir torch-native-evidence - name: Upload native Torch acceptance evidence if: always() diff --git a/docs/release-attribution/0.7.0/inventory.json b/docs/release-attribution/0.7.0/inventory.json index 42018e2d..c6bc7c2b 100644 --- a/docs/release-attribution/0.7.0/inventory.json +++ b/docs/release-attribution/0.7.0/inventory.json @@ -6703,7 +6703,7 @@ "rust/crates/pumas-rpc/Cargo.toml": "b51fc102e08bf9ba72cbec544cd8b933171031078b1ba273b289a6f4c5cf981a", "scripts/release/artifact-plan.json": "d5e4a2700728ba61cabca36d727efd477d716f3b50cc90fa9ce7f5e7959c7b0c", "scripts/release/licenses/managed-python-sources.json": "eabda4e2a25a92cfa8610865e79d11a50836930f53098efe89dd90778c4212ca", - "scripts/release/torch-managed-python-acceptance.py": "db007665ed3af158932f7d05ce69347d860cdbc6f82c49863e9ee3f4ebea251b" + "scripts/release/torch-managed-python-acceptance.py": "d18cbf09bebecbe3e8bee1f009d88d6eb3cf838aa7df0eec398a05ef5d777c4f" }, "notices_sha256": "b1631dab130001c76f70f116c2099bae0b87b8ba67d2f110c2f5bae7e465ef89" } diff --git a/scripts/release/torch-managed-python-acceptance.py b/scripts/release/torch-managed-python-acceptance.py index 0b34f311..3c4809c6 100644 --- a/scripts/release/torch-managed-python-acceptance.py +++ b/scripts/release/torch-managed-python-acceptance.py @@ -64,10 +64,29 @@ def redirect_request(self, request, fp, code, msg, headers, newurl): DOWNLOAD_HTTP = urllib.request.build_opener(HTTPSOnlyRedirectHandler()) +class GitHubApiRedirectHandler(HTTPSOnlyRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + target = urlsplit(newurl) + require( + target.scheme == "https" and target.netloc == "api.github.com", + "GitHub API same-origin redirect", + newurl, + ) + return super().redirect_request(request, fp, code, msg, headers, newurl) + + +GITHUB_API_HTTP = urllib.request.build_opener(GitHubApiRedirectHandler()) + + def backend_environment(root: Path) -> dict[str, str]: """Remove ambient interpreter selectors before launching the backend.""" blocked = ("PYTHON", "PIP_", "UV_", "VIRTUAL_ENV", "CONDA", "PYENV", "PDM_") - env = {key: value for key, value in os.environ.items() if not key.upper().startswith(blocked)} + env = { + key: value + for key, value in os.environ.items() + if not key.upper().startswith(blocked) + and key.upper() not in {"PUMAS_RELEASE_API_TOKEN", "GITHUB_TOKEN"} + } env["PATH"] = ( str(Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32") if os.name == "nt" @@ -1676,6 +1695,27 @@ def test_download_file_is_private_and_http_redirect_is_rejected(self) -> None: request, None, 302, "Found", {}, "http://example.invalid/asset" ) + def test_release_api_token_stays_out_of_backend_and_off_other_origins(self) -> None: + class Response(io.BytesIO): + def geturl(self) -> str: + return "https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/20260901" + + def read1(self, size: int) -> bytes: + return self.read(size) + + with ( + patch.dict(os.environ, {"PUMAS_RELEASE_API_TOKEN": "fixture-secret"}), + patch.object(GITHUB_API_HTTP, "open", return_value=Response(b"{}")) as opened, + ): + self.assertEqual(worker_fetch_release(), b"{}") + request = opened.call_args.args[0] + self.assertEqual(request.get_header("Authorization"), "Bearer fixture-secret") + self.assertNotIn("PUMAS_RELEASE_API_TOKEN", backend_environment(Path("/tmp"))) + with self.assertRaises(RuntimeError): + GitHubApiRedirectHandler().redirect_request( + request, None, 302, "Found", {}, "https://example.invalid/release" + ) + def test_body_read_uses_remaining_deadline_for_socket_timeout(self) -> None: class Socket: timeout = None @@ -2025,12 +2065,13 @@ def worker_fetch_release() -> bytes: "https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/" + REVIEWED_PBS_RELEASE ) - request = urllib.request.Request( - url, - headers={"Accept": "application/vnd.github+json", "User-Agent": "Pumas-release-acceptance"}, - ) + headers = {"Accept": "application/vnd.github+json", "User-Agent": "Pumas-release-acceptance"} + token = os.environ.get("PUMAS_RELEASE_API_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) deadline = time.monotonic() + 60 - with DOWNLOAD_HTTP.open( + with GITHUB_API_HTTP.open( request, timeout=min(30, max(0.001, deadline - time.monotonic())) ) as response: require( From 818a49c18b2d8100f83e08ef2d911db9109301a2 Mon Sep 17 00:00:00 2001 From: MrScripty Date: Tue, 29 Sep 2026 08:22:11 -0700 Subject: [PATCH 18/19] test: wait for install options in B2 fallback case --- frontend/src/components/TorchInstallPreview.test.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/TorchInstallPreview.test.tsx b/frontend/src/components/TorchInstallPreview.test.tsx index 8c084ee7..b53b9677 100644 --- a/frontend/src/components/TorchInstallPreview.test.tsx +++ b/frontend/src/components/TorchInstallPreview.test.tsx @@ -109,10 +109,17 @@ describe('TorchInstallPreview', () => { }); it('falls back to automatic build when the quick options omit defaultBuild', async () => { - getOptions.mockResolvedValue({ ...options, defaultBuild: undefined }); + let resolveOptions!: (value: TorchRuntimeOptions) => void; + getOptions.mockImplementation(() => new Promise((resolve) => { + resolveOptions = resolve; + })); const onInstall = vi.fn(); render(); - fireEvent.click(await screen.findByRole('button', { name: 'Install Torch' })); + expect(screen.getByRole('button', { name: 'Install Torch' })).toBeDisabled(); + await act(async () => { resolveOptions({ ...options, defaultBuild: undefined }); }); + const installButton = screen.getByRole('button', { name: 'Install Torch' }); + expect(installButton).toBeEnabled(); + fireEvent.click(installButton); await waitFor(() => expect(onInstall).toHaveBeenCalledWith('selection-token')); expect(getSelection).toHaveBeenCalledWith({ tag: 'v2.14.0', build: 'auto', python: 'auto', adapter: 'none' }); }); From 64d00951fc02092dea3a1205a7c16758e19810ba Mon Sep 17 00:00:00 2001 From: MrScripty Date: Tue, 29 Sep 2026 11:07:26 -0700 Subject: [PATCH 19/19] fix(torch-ui): retain supported build profiles and the fixed preset --- .../components/TorchInstallPreview.test.tsx | 48 ++++++++++++++++++- .../src/components/TorchInstallPreview.tsx | 46 +++++++++++++++--- 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/TorchInstallPreview.test.tsx b/frontend/src/components/TorchInstallPreview.test.tsx index b53b9677..c8a6a246 100644 --- a/frontend/src/components/TorchInstallPreview.test.tsx +++ b/frontend/src/components/TorchInstallPreview.test.tsx @@ -74,7 +74,7 @@ describe('TorchInstallPreview', () => { expect(getReleaseOptions).not.toHaveBeenCalled(); expect(getSelection).not.toHaveBeenCalled(); expect(screen.queryByRole('button', { name: 'Check selected combination' })).not.toBeInTheDocument(); - expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.getByText('Advanced setup').closest('details')).not.toHaveAttribute('open'); expect(screen.queryByText(/exact artifacts resolved/i)).not.toBeInTheDocument(); }); @@ -124,6 +124,52 @@ describe('TorchInstallPreview', () => { expect(getSelection).toHaveBeenCalledWith({ tag: 'v2.14.0', build: 'auto', python: 'auto', adapter: 'none' }); }); + it('preserves an explicit CUDA build and Linux image dependencies without pre-install discovery', async () => { + getOptions.mockResolvedValue({ ...options, adapters: ['none', 'flux2'] }); + const onInstall = vi.fn(); + render(); + fireEvent.click(await screen.findByText('Advanced setup')); + fireEvent.change(screen.getByRole('combobox', { name: 'Torch build' }), { target: { value: 'cu130' } }); + fireEvent.change(screen.getByRole('combobox', { name: 'Dependency profile' }), { target: { value: 'flux2' } }); + fireEvent.click(screen.getByRole('button', { name: 'Install Torch' })); + await waitFor(() => expect(onInstall).toHaveBeenCalledWith('selection-token')); + expect(getSelection).toHaveBeenCalledWith({ tag: 'v2.14.0', build: 'cu130', python: 'auto', adapter: 'flux2' }); + expect(getReleaseOptions).not.toHaveBeenCalled(); + }); + + it('keeps the historical fixed preset distinct and submits its exact interpreter tuple', async () => { + getOptions.mockResolvedValue({ ...options, bundledPresetAvailable: true }); + const onInstall = vi.fn(); + render(); + const presetButton = await screen.findByRole('button', { name: 'Fixed v2.9.1 preset' }); + expect(presetButton).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(presetButton); + expect(screen.getByText(/This tuple has historical qualification/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Install Torch' })); + await waitFor(() => expect(onInstall).toHaveBeenCalledWith('selection-token')); + expect(getSelection).toHaveBeenCalledWith({ tag: 'v2.9.1', build: 'cu130', python: 'python3.12', adapter: 'bundled' }); + expect(presetButton).toBeDisabled(); + }); + + it('does not expose platform-unavailable adapters or a preset for another release', async () => { + getOptions.mockResolvedValue({ ...options, bundledPresetAvailable: true }); + render(); + await screen.findByText('Advanced setup'); + expect(screen.queryByRole('option', { name: 'Pumas FLUX.2 image dependencies' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Fixed v2.9.1 preset' })).not.toBeInTheDocument(); + }); + + it('rejects a ready token that changes the requested preset Python', async () => { + getOptions.mockResolvedValue({ ...options, bundledPresetAvailable: true }); + const onInstall = vi.fn(); + getSelection.mockImplementation(async (request) => readySelection({ ...request, python: 'auto' })); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Fixed v2.9.1 preset' })); + fireEvent.click(screen.getByRole('button', { name: 'Install Torch' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Torch selection could not be confirmed'); + expect(onInstall).not.toHaveBeenCalled(); + }); + it('does not install a rejected or mismatched selection', async () => { const onInstall = vi.fn(); getSelection.mockResolvedValueOnce({ status: 'rejected', reason: 'unsupported', message: 'Unavailable build' }); diff --git a/frontend/src/components/TorchInstallPreview.tsx b/frontend/src/components/TorchInstallPreview.tsx index 00766cd2..e7b153a7 100644 --- a/frontend/src/components/TorchInstallPreview.tsx +++ b/frontend/src/components/TorchInstallPreview.tsx @@ -25,6 +25,9 @@ function errorText(error: unknown): string { export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPreviewProps) { const [runtimeOptions, setRuntimeOptions] = useState(null); const [loadingOptions, setLoadingOptions] = useState(true); + const [buildChoice, setBuildChoice] = useState('auto'); + const [adapterChoice, setAdapterChoice] = useState('none'); + const [fixedPreset, setFixedPreset] = useState(false); const [starting, setStarting] = useState(false); const [error, setError] = useState(null); const [attempt, setAttempt] = useState(0); @@ -36,10 +39,16 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev setRuntimeOptions(null); setLoadingOptions(true); setStarting(false); + setFixedPreset(false); + setBuildChoice('auto'); + setAdapterChoice('none'); setError(null); void api.get_torch_runtime_options().then((options) => { - if (active) setRuntimeOptions(options); + if (active) { + setRuntimeOptions(options); + setBuildChoice(options.defaultBuild || 'auto'); + } }).catch((cause: unknown) => { if (active) setError(`Torch runtime choices unavailable: ${errorText(cause)}`); }).finally(() => { @@ -55,7 +64,9 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev const startInstallation = async () => { if (!runtimeOptions || starting) return; const currentRequest = ++requestNumber.current; - const build = runtimeOptions.defaultBuild || 'auto'; + const build = fixedPreset ? runtimeOptions.preset.build : buildChoice; + const python = fixedPreset ? runtimeOptions.preset.python : 'auto'; + const adapter = fixedPreset ? runtimeOptions.preset.adapter : adapterChoice; setStarting(true); setError(null); @@ -63,8 +74,8 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev const outcome: TorchRuntimePreviewOutcome = await api.preview_torch_runtime({ tag, build, - python: 'auto', - adapter: 'none', + python, + adapter, }); if (requestNumber.current !== currentRequest) return; if (outcome.status === 'rejected') { @@ -79,7 +90,7 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev } const selection = outcome.preview; if (!selection.previewId || selection.expiresInSeconds <= 0 || selection.tag !== tag - || selection.build !== build || selection.adapter !== 'none') { + || selection.build !== build || selection.python !== python || selection.adapter !== adapter) { setError('Torch selection could not be confirmed. Try again.'); setStarting(false); return; @@ -102,7 +113,7 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev

Install Torch {tag}

- Pumas will choose the build and a private Python version for this computer. Core Torch dependencies are included. + Pumas can choose the build and a private Python version for this computer. Core Torch dependencies are included by default.

Package and Python resolution, wheel downloads, and installation happen within the install task after you confirm below. Installation does not select or start this version. @@ -112,6 +123,29 @@ export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPrev

{loadingOptions &&

} + {runtimeOptions && <> + {runtimeOptions.bundledPresetAvailable && tag === runtimeOptions.preset.tag &&
+ + +
} + {fixedPreset ?

Fixed preset: {runtimeOptions.preset.build} · {runtimeOptions.preset.python} · bundled image dependencies. This tuple has historical qualification; this installation is still validated before publication.

:
+ Advanced setup +
+ + +
+

These are platform-supported choices. Release and package compatibility are checked during installation. Pumas image dependencies are separate from upstream Torch.

+
} + } {starting &&

} {error &&

{error}

} {error && !runtimeOptions && !loadingOptions && }