diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98aba5b1a..383603c36 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -259,6 +259,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/plans/torch-cross-platform-runtime-management/execution-ledger.md b/docs/plans/torch-cross-platform-runtime-management/execution-ledger.md index 34a486412..ef79e99f7 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,182 @@ # 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 + `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 + 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 + 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. 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 + `/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 + direct-install source. `stage-rpc.py`, the packaged RPC `/health` smoke, + Electron build, Linux artifact checks, and extracted AppImage/deb backend + `/health` smokes passed. The smoke scripts needed local loopback permission; + both backend and extracted package checks passed when allowed. +- Torch package resolution now runs inside the visible, cancellable install + task. The normal Core path does not wait for release metadata, a release-wide + wheel scan, or a pre-install dry run. Optional release-options requests return + immediately as unchecked; the install itself resolves official packages. +- The candidate AppImage SHA-256 is + `2beab4b5fb0a4d3d7482f7e8040f7f3a01c934cac5e936b7b8d53ffe3f268b6d`; the deb + SHA-256 is + `57792a65941ffa2c6a7abc6e839e383c6866ee09989790ec9e432dd772c3c794`. + These local files replace the repository's prior local 0.7.0 candidates, but + do not update the public toolbar-linked release. +- No Torch install was run through this candidate. Its artifact and backend + health checks are not Torch-install acceptance; manual packaged Linux install + and packaged Windows/macOS acceptance remain pending. + +## 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 @@ -565,3 +742,88 @@ 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. + +## 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/docs/plans/torch-cross-platform-runtime-management/plan.md b/docs/plans/torch-cross-platform-runtime-management/plan.md index 1b17bff69..57ef1200f 100644 --- a/docs/plans/torch-cross-platform-runtime-management/plan.md +++ b/docs/plans/torch-cross-platform-runtime-management/plan.md @@ -24,30 +24,58 @@ 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. - -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. +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. + +**Direct-install policy (2026-09-26):** The owner rejected a multi-minute +compatibility check before installation. The normal Core Torch flow admits the +install from a local selection token, without refetching GitHub release +metadata, scanning every wheel channel, or running `pip --dry-run`. The visible, +cancellable install task provisions managed Python and makes a real binary-only +pip install into private staging. Linux automatic CUDA selection tries at most +four driver-compatible channels, then CPU; Windows and macOS start with CPU. +Only conclusive missing-wheel/dependency outcomes permit a bounded candidate +retry. Network, integrity, and ambiguous errors stop the actual attempt. The +staged report, installed package identities, wheel hashes, and exact file +manifest are checked before staged Python is run or the runtime is published. +Any optional release-options request now returns immediately as unchecked; the +installer resolves availability during the user's install attempt. The older +AppImage install report below predates this direct-install change and does not +accept it. A rebuilt local package now passes artifact and extracted-backend +health checks; manual packaged installation and native direct-install +acceptance remain pending. + +Earlier local Linux v0.7.0 AppImage and deb candidates were rebuilt with the +generated CPython notices and Torch install feedback/readability repair, before +the direct-install change. +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 last pre-direct-install local +AppImage SHA-256 is +`34a5ffc88f5f42aa03bd9ed72ead37e5ca385838037247b74186a67d9d128aaf`; the deb +SHA-256 is +`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 candidate +built before the direct-install change; 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), and this evidence predates the +direct-install path described below. 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,25 +109,53 @@ 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. - -**Next slice:** Exercise the Torch discovery/install controls through the Linux -Electron UI and 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. +**Direct-install follow-up:** The current source replaces the pre-install +Torch artifact scan and release-metadata refetch with a local selection token +and a real staged pip install. Linux `build=auto` now makes up to four ordered +driver-compatible CUDA install attempts before CPU fallback. Automatic Python +fallback is limited to conclusive wheel/dependency failures. pip report and +staged-file provenance checks run before package files move into the runtime. +The existing Linux AppImage install report above uses the earlier path, so it +does not accept this change. Focused source checks cover local admission, +installer retries, staging integrity, cancellation, and progress. The rebuilt +local package passes artifact and extracted-backend health checks, but a +packaged direct-install run remains pending. The fixed bundled adapter preset +continues to use its separate retained-lock resolution path. + +**Remaining gates:** Exercise the direct-install flow through the local Linux +package, then run packaged Windows/macOS Torch installation and provider +cancellation/tamper/retry acceptance. CUDA/device acceleration and v2.14.0 +image/Tuldok generation are untested. Local AppImage/deb candidates do not +update the public toolbar-linked package. + +**Next slice:** Verify the rebuilt Linux direct-install candidate, then run +packaged install acceptance on Windows and macOS and 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. ## Objective and scope Extend the existing Torch release manager so users can discover a stable -upstream Torch release, let Pumas provision the newest stable CPython that -matches an exact official wheel and resolves the complete selected dependency -profile, install and inspect it, explicitly select it, and start/stop its -managed sidecar through the existing RPC and desktop flows. The host does not -need Python on `PATH`, and the desktop does not require a Python selection. +upstream Torch release, let Pumas provision a compatible stable CPython, install +the selected package profile, inspect it, explicitly select it, and start/stop +its managed sidecar through the existing RPC and desktop flows. The host does +not need Python on `PATH`, and the desktop does not require a Python selection. + +The normal install path must not run a full wheel scan, remote release metadata +fetch, or `pip --dry-run` as a gate. Local selection and host probing are +bounded to a few seconds; if a quick probe is unavailable or inconclusive, +installation still proceeds with the safe automatic choice. The UI admits the +install immediately and enters a visible, cancellable task. Pumas provisions its +managed Python and runs a real binary-only `pip install --report` into an +unpublished staging environment. pip's dependency resolution and any wheel +downloads happen inside that actual install attempt. Pumas validates the +resulting report's artifact URLs, hashes, package names and versions, interpreter +identity, and exact staged file manifest before it starts staged Python or +publishes the runtime. A failed attempt is removed from staging and reported to +the user. Automatic Python/build retries are bounded and allowed only after a +conclusive unsupported-wheel/dependency result; network, integrity, timeout, +and ambiguous failures stop without downgrading. “Any release” continues to mean an upstream stable tag for which an exact host, stable standard-CPython wheel, and complete dependency resolution exist. It does @@ -128,16 +184,15 @@ recorded exact tuples. The target triples and artifacts match the current release contract in [`artifact-plan.json`](../../../scripts/release/artifact-plan.json#L58). Pumas owns a private CPython installation depot. Candidate stable CPython minor -versions come from the exact official Torch wheel tags and the pinned Python -distribution catalog in the pinned uv release; do not maintain a Pumas upper -version allowlist or treat prerelease/free-threaded tags as standard CPython. -CPython 3.10 is Pumas' runtime minimum; there is no configured upper minor cap. -Rank candidates numerically from newest to oldest, validate each installed -interpreter's observed target and `sys_tags()`, and retain the first complete -dependency resolution. A proven missing wheel or dependency incompatibility can -advance to the next candidate. Network failure, timeout, provisioning failure, -integrity failure, or incomplete scans are inconclusive and cannot justify a -lower Python version. The user never has to install or choose Python. +versions come from the pinned Python distribution catalog in the pinned uv +release; do not maintain a Pumas upper-version allowlist or treat +prerelease/free-threaded tags as standard CPython. CPython 3.10 is Pumas' runtime +minimum; there is no configured upper minor cap. Rank candidates numerically +from newest to oldest, validate each installed interpreter's observed target +and `sys_tags()`, and attempt the actual staged install. A proven missing wheel +or dependency incompatibility can advance to the next candidate. Network +failure, timeout, provisioning failure, or integrity failure stops the install +without downgrading Python. The user never has to install or choose Python. The app-manager downloads and bootstraps uv 0.12.18 for the shipped Linux x86_64 GNU, Windows x86_64 MSVC, and macOS arm64 targets from versioned official archives. It @@ -145,14 +200,13 @@ verifies each archive against the exact SHA-256 in the provider report before extracting or executing it. uv's Python distribution catalog is embedded and frozen per uv release; its exact binary pin therefore pins that catalog too. The manager enumerates that catalog on the native host, filters to stable -standard CPython candidates, and sorts them newest-first. One managed -interpreter acts as a bootstrap for native wheel discovery; the resolver -synthesizes standard CPython tags for the catalog candidates, so discovery does -not install every Python minor. Preview provisions real candidates in bounded -newest-first order and runs complete dependency resolution under each candidate. -An exact absent wheel or definite dependency incompatibility permits trying the -next candidate. Network, provider, timeout, integrity, or incomplete-scan -outcomes stop as inconclusive. uv +standard CPython candidates, and sorts them newest-first. The normal install +path provisions candidates in bounded newest-first order and invokes pip's +binary-only install into a fresh unpublished target for each attempt. An exact +absent wheel or definite dependency incompatibility permits trying the next +candidate. Network, provider, timeout, integrity, or ambiguous outcomes stop the +attempt. The quick selection token does not install Python or inspect package +indexes. uv provisions Python Build Standalone CPython releases; Pumas must retain the selected distribution URL, version, target/build, pinned uv/catalog identity, and a fingerprint of the installed interpreter. uv verifies the CPython archive @@ -170,10 +224,11 @@ official Torch 2.14.0 CPU index publishes Windows wheels named upstream distribution version. Do not manufacture a `+cpu` local version for the macOS artifact. -Exact package availability is determined from official indexes and the -provisioned interpreter's `packaging.tags.sys_tags()`, not a Pumas Torch release -allowlist or a guessed OS floor. Full preview remains required before install; -an exact wheel match alone never claims dependency compatibility. On macOS, +Exact package availability is determined by the install attempt against official +indexes and the provisioned interpreter's `packaging.tags.sys_tags()`, not a +Pumas Torch release allowlist or a guessed OS floor. A selection preview is +local and immediate; package availability and dependency compatibility are +learned during the staged install. On macOS, MPS availability is probed from the installed Torch runtime and host. MPS does not establish model/adapter support. CUDA recommendation on Windows remains CPU unless a Windows-specific driver rule and required evidence are added to @@ -198,8 +253,8 @@ every release or platform. ## Ownership and design - `pumas-app-manager` owns release choices, CPython candidate ranking and - provisioning, retained interpreter leases/previews, staged Torch installation, - identity, selection, and cleanup admission. Python artifacts live in an + provisioning, selection tokens, staged Torch installation, identity, + selection, and cleanup admission. Python artifacts live in an immutable Pumas-managed depot; uv's disposable download cache is separate. - The app-manager invokes only the exact-hash-pinned uv binary that it has downloaded into its private bootstrap directory and verified. It clears @@ -209,16 +264,17 @@ every release or platform. - A selected Python distribution's source catalog identity, exact CPython version, native target/build, download URL, uv/catalog identity, executable fingerprint, and observed `sys_tags()` remain attached to its retained - preview and installed runtime record. uv verifies the Python archive with the + install attempt and installed runtime record. uv verifies the Python archive with the digest from its embedded catalog. Existing installations keep their original base interpreter; managed-runtime cleanup may not delete an interpreter referenced by a preview, install, or installed runtime. Automatic pruning is out of scope until reference-safe removal is implemented. -- `torch-server/resolve_runtime.py` continues to own pip dry-run resolution, - wheel-tag matching, official wheel provenance, and hash reporting. Its - protocol must carry the upstream distribution version and exact build as - separate validated facts. +- `torch-server/resolve_runtime.py` owns binary-only pip resolution and install + into the unpublished target, wheel-tag matching, official wheel provenance, + and hash reporting. Its protocol carries the upstream distribution version + and exact build as separate validated facts. Its report is checked before + staged Python is started or the runtime is published. - `pumas-core::platform::paths` owns OS-native venv executable paths. - `pumas-core::runtime_profiles::process_owner` owns the persistent generation-specific sidecar process lifecycle. Platform process primitives @@ -252,8 +308,8 @@ resolver, manager, desktop, and platform runtime owners. retained interpreter; hardware/driver evidence; wheel URLs and hashes; staged directory and cleanup; process generation and cancellation. These remain explicit fields/outcomes at the manager boundaries. -3. **Caller knowledge:** RPC/desktop clients consume manager choices, retained - preview identifiers, interpreter identity, and lifecycle status. They do not +3. **Caller knowledge:** RPC/desktop clients consume manager choices, local + selection identifiers, interpreter identity, and lifecycle status. They do not parse platform paths, wheel filenames, drivers, or process IDs to decide support. 4. **Representative changes:** a new supported host must update target facts, @@ -262,8 +318,9 @@ resolver, manager, desktop, and platform runtime owners. A new upstream wheel tag remains local to official-tag resolution and its tests. A new image adapter does not change this runtime-management contract. 5. **Stable interfaces:** exact artifact and host facts cross the resolver to - manager; the retained resolution crosses preview to installer; process - owners expose generation-scoped lifecycle results. OS path strings, + manager; the post-install report and verified staged-file manifest cross + the resolver to installer before publication; process owners expose + generation-scoped lifecycle results. OS path strings, display text, or ambient command lookup do not act as artifact identity. 6. **Independent verification:** resolver tags can be fixture-tested; host detection, Python provisioning, and venv paths require clean-host native @@ -289,17 +346,43 @@ 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 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 + 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 | | --- | --- | --- | --- | | X1 | Linux behavior and existing accepted v2.9.0 install/v2.10 Tuldok evidence remain accurately scoped and pass relevant regression gates | Existing Linux manager/resolver/RPC/frontend suites; retained historical evidence references | pending | | X2 | Each shipped target provisions a private stable standard CPython 3.10 or newer without a host Python dependency or global PATH/registry changes; artifact integrity, target identity, cache separation, cancellation, and retention are verified | Linux, Windows, and macOS v2.14.0 RPC installs provisioned CPython 3.14.7 and matched persisted interpreter identity across sessions ([Linux](reports/v2.14.0-linux-cpu-rpc-restart-acceptance/README.md), [Windows](reports/v2.14.0-windows-cpu-rpc-restart-acceptance/README.md), [macOS](reports/v2.14.0-macos-cpu-rpc-restart-acceptance/README.md)). Artifact/source hashes are retained and all three full-archive license supersets are included in generated attribution; cancellation and cache separation remain | partial | -| X3 | Candidate versions come from exact official standard-CPython wheel tags and the pinned stable provider catalog; the manager tries highest to lowest, selects the newest complete compatible resolution, and never treats network/provisioning failure as a reason to downgrade | CPython 3.14 preference when fully resolved; definite 3.14 dependency incompatibility falls to 3.13; prerelease/free-threaded/wrong-target and pre-3.10 artifact rejection; incomplete scan/provider failures remain typed inconclusive | pending | -| 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 | +| X3 | Candidate versions come from the pinned stable provider catalog; the manager tries highest to lowest through actual staged installs and never treats network/provisioning failure as a reason to downgrade | CPython 3.14 preference when fully resolved; definite 3.14 dependency incompatibility falls to 3.13; prerelease/free-threaded/wrong-target and pre-3.10 artifact rejection; direct install retry and failure classification tests | partial | +| X4 | Windows x64 and macOS arm64 installation selects only official binary wheels matching the managed interpreter's native tags; CPU is automatic, CUDA remains an explicit Windows choice, and MPS is checked as a runtime capability | Native wheel fixtures and wrong-target rejection passed; prior manual RPC run `36223106097` resolved exact official CPU wheel tags on Linux, Windows, and macOS arm64 through the preview flow. Direct-install native acceptance, CUDA execution, and macOS MPS acceleration remain untested. | partial | +| 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 | 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 | 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 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, @@ -318,9 +401,10 @@ and unit fixtures do not substitute for the Windows/macOS runtime claims. ### M1 — Native resolver, manager, and process lifecycle -- **Goal:** Implement the support contract through discovery, exact preview, - isolated install, identity, selection, sidecar startup/stop, and existing - RPC/desktop routes while preserving Linux. +- **Goal:** Implement the support contract through stable release discovery, + local selection, direct staged install, post-install identity verification, + selection, sidecar startup/stop, and existing RPC/desktop routes while + preserving Linux. - **Write sets:** `torch-server/resolve_runtime.py` and `torch-server/tests/test_resolve_runtime.py`; `rust/crates/pumas-app-manager/src/version_manager/torch_alternatives.rs`; @@ -358,9 +442,11 @@ and unit fixtures do not substitute for the Windows/macOS runtime claims. - **Re-plan trigger:** A native target requires a contract/schema change, process primitive outside the existing ownership boundary, or a non-CPU runtime claim unsupported by official evidence. -- **State:** Implemented; v2.14.0 CPU/Core RPC install and restart passed on - Linux, Windows, and macOS. CUDA and MPS behavior remain outside this accepted - runtime tuple. +- **State:** Cross-platform runtime implementation is present; v2.14.0 CPU/Core + RPC install and restart passed on Linux, Windows, and macOS through the prior + preview flow. The direct-install source path is implemented and under focused + verification; packaged acceptance of that path is pending. CUDA and MPS + behavior remain outside the accepted runtime tuple. ### M1b — Managed stable CPython @@ -400,18 +486,22 @@ 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 - Keep a single exact platform/interpreter/wheel authority. Do not make the renderer or a second manually maintained table authoritative for support. -- Preserve hash-locked previews, staging/publication ordering, explicit - selection/defaulting, cleanup draining, owned generation stop, loopback - exposure, and typed inconclusive outcomes. +- Preserve isolated staging/publication ordering, official-source and + post-install hash/file verification, explicit selection/defaulting, cleanup + draining, owned generation stop, loopback exposure, and typed install errors. + Dynamic Torch installs no longer require a pre-install hash-locked preview; + the generated requirements file records the completed install for provenance + and repeatability, but is not the former `pip --require-hashes` gate. The + fixed bundled preset remains a separate lock-based path. - Preview identifiers remain cryptographically random on every target. Use the already locked `getrandom 0.3.4` at the app-manager boundary instead of the Linux-only `/dev/urandom` path; RNG failure is a typed error, never a weak 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 000000000..60e0767af --- /dev/null +++ b/docs/plans/torch-cross-platform-runtime-management/reports/v2.14.0-linux-appimage-cu132-ui-acceptance/README.md @@ -0,0 +1,56 @@ +# 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. + +## 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/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 000000000..7d75e0950 --- /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-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 000000000..49d35b132 --- /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/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md b/docs/plans/torch-diffusion-serving/reports/upstream-version-manager-progress.md index a21e688eb..90e6eaa0c 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,157 @@ 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 — 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 +`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 +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. 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 + +The owner rejected the previous multi-minute compatibility check. For the +normal Core Torch desktop/API path, selection now creates a local one-use token; +installation does not refetch GitHub release metadata, scan every build/Python +combination, or run `pip --dry-run` before it starts. The install task becomes +visible and cancellable, provisions Pumas-managed CPython, and runs a real +binary-only pip install into private staging. Package resolution and large +wheel downloads are part of that installation task, rather than a separate +check whose results are discarded before downloading again. + +Linux automatic selection makes a bounded sequence of up to four +driver-compatible CUDA install attempts, followed by CPU. Windows and macOS use +CPU automatically. Python candidates come from the pinned uv catalog in +newest-first order. Only conclusive missing-wheel/dependency errors permit the +next candidate; network, integrity, timeout, and ambiguous failures stop. Each +successful pip report is checked against official artifact sources and hashes, +staged distribution names/versions, the interpreter/host identity, and a full +installed-file manifest before staged Python runs or files are published. The +generated requirements lock records the result for provenance and repeatable +future installs; dynamic installs do not run pip with the old pre-install +`--require-hashes` preview lock. + +Focused Rust/Python tests and read-only security/lifecycle reviews cover this +source path. The existing AppImage UI install report below used the previous +preview flow and is not acceptance for this change. A rebuilt local Linux +AppImage/deb now passes artifact checks and extracted-backend `/health` smoke; +manual Torch installation through that package remains pending. The fixed +`v2.9.1` bundled adapter remains a separate pinned recipe; image/Tuldok support +claims are unchanged. + ## Scope handoff authorized 2026-09-24 The repository owner selected the broad release-management scope after reviewing @@ -17,16 +168,20 @@ an error without a partial list presented as complete. Installation is offered f an exact official CPU, CUDA, or ROCm binary wheel plus fully resolved dependencies on Linux x86_64 with an already installed CPython 3.10–3.13. This records the original Linux-only scope and is superseded for the in-progress install flow by -the 2026-09-25 managed-CPython authorization below. The current implementation -provisions stable native CPython 3.10+ from a pinned provider and chooses the -newest candidate whose exact official wheel and complete dependencies resolve. -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 +the 2026-09-25 managed-CPython authorization below. The normal path provisions +stable native CPython 3.10+ from a pinned provider. The previous implementation +chose the newest candidate through an exact wheel/dependency preview; the +direct-install path and its narrower acceptance are recorded above. +On the previous runtime source at `21041697`, Torch 2.14.0 CPU/Core RPC +install/restart and native QA passed 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 earlier Linux v0.7.0 +AppImage UI install/progress flow passed locally under the preview-based path; +it does not validate the direct-install change. 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 @@ -83,11 +238,13 @@ The install flow no longer requires host-installed Python or a Python choice. PyTorch wheels do not declare one Python version for an entire release: each official wheel declares its compatible CPython ABI and platform tags. Pumas uses its pinned provider catalog to provision stable native CPython candidates -from newest to oldest, then retains the first interpreter whose exact selected -Torch wheel and complete dependency profile resolve. CPython 3.10 is the -minimum; no upper minor cap is configured. A newer candidate is skipped only -after a definite wheel or dependency incompatibility. Provider, network, and -incomplete-scan failures remain inconclusive. See the +from newest to oldest. Under the previous preview-based path, the manager +retained the first interpreter whose exact selected Torch wheel and complete +dependency profile resolved. The normal install now learns that compatibility +from each real staged pip install. CPython 3.10 is the minimum; no upper minor +cap is configured. A newer candidate is skipped only after a definite wheel or +dependency incompatibility. Provider, network, and incomplete-scan failures +remain inconclusive. See the [managed Python provider report](../../torch-cross-platform-runtime-management/reports/managed-python-provider.md). The manager now defaults every target to Core Torch (`none`). Linux exposes @@ -649,17 +806,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/docs/release-attribution/0.7.0/inventory.json b/docs/release-attribution/0.7.0/inventory.json index bf17ea2cf..d8722aed5 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": "1ddb8f7ff8433750a49e855c2392eb701a0616619ef2d241e3f4159d454b8ac9" + "scripts/release/torch-managed-python-acceptance.py": "b0486387627d6ae98b4b6533adcceed93e93ebf868385626f3b13f1fb949893b" }, "notices_sha256": "b1631dab130001c76f70f116c2099bae0b87b8ba67d2f110c2f5bae7e465ef89" } diff --git a/electron/src/generated/desktop-contract.ts b/electron/src/generated/desktop-contract.ts index ae8ff956d..b016cdd74 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 67dd940c814b877de830883b563b559875d539d93a9158aba6cb812ffba3419a. 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,9 +91,9 @@ 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; "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 = "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/electron/src/generated/desktop-contract.validators.d.ts b/electron/src/generated/desktop-contract.validators.d.ts index 8a0ca8711..da27c16ed 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 67dd940c814b877de830883b563b559875d539d93a9158aba6cb812ffba3419a. 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 abfafbec0..2a1c81b0b 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 67dd940c814b877de830883b563b559875d539d93a9158aba6cb812ffba3419a. 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,16 +9138,16 @@ 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" }, "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": ["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; 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.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 { @@ -9343,61 +9343,34 @@ 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.downloadMeasurementAvailable !== void 0) { + let data11 = data.downloadMeasurementAvailable; 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 !== "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; } - 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.downloadSourceUrl !== void 0) { + let data12 = data.downloadSourceUrl; 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" }]; + validate75.errors = [{ instancePath: instancePath + "/downloadSourceUrl", schemaPath: "#/properties/downloadSourceUrl/type", keyword: "type", params: { type: schema82.properties.downloadSourceUrl.type }, message: "must be string,null" }]; return false; } var valid0 = _errs28 === errors; @@ -9405,21 +9378,21 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.etaSeconds !== void 0) { - let data13 = data.etaSeconds; + if (data.downloadSpeed !== void 0) { + let data13 = data.downloadSpeed; 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" }]; + 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 > 17976931348623157e292 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; + 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 < -17976931348623157e292 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; return false; } } @@ -9430,141 +9403,206 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.logPath !== void 0) { - let data14 = data.logPath; + if (data.downloadedBytes !== void 0) { + let data14 = data.downloadedBytes; 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" }]; + 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.overallProgress !== void 0) { - let data15 = data.overallProgress; + if (data.error !== void 0) { + let data15 = data.error; 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" }]; + 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 > 34028234663852886e22 || isNaN(data15)) { - 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 (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" }]; - return false; - } - } - } - } var valid0 = _errs34 === errors; } else { var valid0 = true; } if (valid0) { - if (data.stage !== void 0) { - let data16 = data.stage; + if (data.etaSeconds !== void 0) { + let data16 = data.etaSeconds; 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" }]; + 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 (!(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; + 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.stageProgress !== void 0) { - let data17 = data.stageProgress; - const _errs39 = 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" }]; + if (data.logPath !== void 0) { + let data17 = data.logPath; + const _errs38 = errors; + 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 === _errs39) { - 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" }]; - 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" }]; - 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.overallProgress !== void 0) { + let data18 = data.overallProgress; + const _errs40 = errors; + 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; } - var valid0 = _errs41 === errors; + 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.success !== void 0) { - let data19 = data.success; - 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 (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; } - var valid0 = _errs43 === errors; + 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 = _errs42 === errors; } else { var valid0 = true; } if (valid0) { - if (data.tag !== void 0) { + if (data.stageProgress !== void 0) { + let data20 = data.stageProgress; 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 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.totalSize !== void 0) { - let data21 = data.totalSize; + if (data.startedAt !== void 0) { 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 data.startedAt !== "string") { + validate75.errors = [{ instancePath: instancePath + "/startedAt", schemaPath: "#/properties/startedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; 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.success !== void 0) { + let data22 = data.success; + const _errs49 = errors; + 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; + } else { + var valid0 = true; + } + if (valid0) { + if (data.tag !== void 0) { + const _errs51 = 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; - } 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" }]; + } + 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 = _errs47 === errors; - } else { - var valid0 = true; } } } @@ -16558,8 +16596,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 +16631,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 +16642,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 +16654,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 +16686,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 +16854,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 +16917,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 +16926,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 +16938,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 +16950,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 +16962,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 +16973,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 +16984,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 +16996,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 +17020,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 +17031,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 +17042,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 +17053,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 +17073,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 +17242,7 @@ function validate139(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err10); } errors++; - validate139.errors = vErrors; + validate140.errors = vErrors; return false; } else { errors = _errs0; @@ -17130,24 +17254,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 +17280,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 +17292,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 +17307,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 +17466,7 @@ function validate144(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err8); } errors++; - validate144.errors = vErrors; + validate145.errors = vErrors; return false; } else { errors = _errs0; @@ -17354,10 +17478,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 +17489,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 +17520,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 +17550,7 @@ function validate143(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err2); } errors++; - validate143.errors = vErrors; + validate144.errors = vErrors; return false; } else { errors = _errs3; @@ -17447,17 +17571,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 +17596,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 +17620,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 +17652,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 +17680,7 @@ function validate142(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err1); } errors++; - validate142.errors = vErrors; + validate143.errors = vErrors; return false; } else { errors = _errs3; @@ -17575,8 +17699,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 +17712,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 +17723,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 +17734,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 +17746,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 +17765,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 +17829,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 +17904,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 +17995,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 +18102,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 +18177,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 +18268,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 +18335,7 @@ function validate141(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err30); } errors++; - validate141.errors = vErrors; + validate142.errors = vErrors; return false; } else { errors = _errs0; @@ -18223,11 +18347,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 +18568,7 @@ function validate154(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err14); } errors++; - validate154.errors = vErrors; + validate155.errors = vErrors; return false; } else { errors = _errs0; @@ -18456,12 +18580,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 +18900,7 @@ function validate155(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err20); } errors++; - validate155.errors = vErrors; + validate156.errors = vErrors; return false; } else { errors = _errs0; @@ -18788,24 +18912,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 +18945,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 +18954,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 +18973,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 +18982,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 +18995,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 +19020,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 +19055,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 +19070,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 +19081,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 +19092,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 +19104,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 +19117,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 +19132,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 +19168,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 +19190,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 +19199,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 +19218,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 +19227,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 +19239,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 +19251,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 +19262,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 +19292,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 +19304,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 +19316,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 +19344,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 +19354,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 +19368,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 +19396,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 +19409,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 +19424,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/electron/tests/preload-rpc-contract.test.mjs b/electron/tests/preload-rpc-contract.test.mjs index 69bf98324..c2fff832a 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, 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/App.tsx b/frontend/src/App.tsx index 20bf4714a..f07e5832b 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 7346a2e64..601d82f80 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 1ac6fd15f..4ade9b6c8 100644 --- a/frontend/src/components/Header.test.tsx +++ b/frontend/src/components/Header.test.tsx @@ -144,6 +144,147 @@ 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('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'); + expect(pendingStatus).toHaveTextContent('Installing Torch v2.14.0 · Starting installation…'); + expect(pendingStatus).toHaveClass('text-xs', 'font-medium', 'text-[hsl(var(--text-primary))]'); + 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 download status when Torch resolution overlaps a model transfer', () => { + render(
); + expect(screen.getByText(/Downloading 1 model & 1 runtime/)).toHaveTextContent('4.0 MB/s'); + expect(screen.getByText(/Downloading 1 model & 1 runtime/)).toHaveTextContent('Preparing Torch packages'); + }); + + 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('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'], + ['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 d1c53e0d7..07f268f44 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 21fe373a7..cf35d8b24 100644 --- a/frontend/src/components/HeaderControls.tsx +++ b/frontend/src/components/HeaderControls.tsx @@ -46,9 +46,9 @@ export function HeaderStatusBadge({ status }: { status: HeaderStatusInfo }) { return (
-
+
- {status.text} + {status.text}
); diff --git a/frontend/src/components/HeaderStatus.ts b/frontend/src/components/HeaderStatus.ts index e36d438a3..df3f38ddc 100644 --- a/frontend/src/components/HeaderStatus.ts +++ b/frontend/src/components/HeaderStatus.ts @@ -1,14 +1,18 @@ 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'; +import { getInstallationDownloadPresentation } from '../utils/installationDownloadPresentation'; 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; + download_active?: boolean; + download_measurement_available?: boolean | null; download_speed: number | null; eta_seconds: number | null; total_size: number | null; @@ -46,15 +50,19 @@ function joinDownloadParts(parts: string[]): string { } function getCombinedDownloadStatus({ + appId, activeModelDownload, activeModelDownloadCount, installationProgress, + runtimePending = false, }: { + appId?: string | null; 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; @@ -73,16 +81,27 @@ 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)}` : ''; const hasDownloadActivity = Boolean(activeModelDownload?.status === 'downloading') || installationProgress?.stage === 'download' || - totalSpeed > 0; + runtimeDownloadExpected || + totalSpeed > 0 || + downloadPresentation?.kind === 'measuring'; + const visibleSpeedInfo = totalSpeed > 0 + ? speedInfo + : downloadPresentation ? ` · ${downloadPresentation.label.toLowerCase()}` : ''; + const activityVerb = hasDownloadActivity + ? 'Downloading' + : installationProgress?.stage === 'resolving' ? 'Preparing' : 'Installing'; return { icon: Download, - spinning: false, - text: `${hasDownloadActivity ? 'Downloading' : 'Installing'} ${joinDownloadParts(parts)}${speedInfo}`, + spinning: installationProgress?.stage === 'resolving', + text: `${activityVerb} ${joinDownloadParts(parts)}${visibleSpeedInfo}`, }; } @@ -159,23 +178,72 @@ 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({ + appId, activeModelDownload, activeModelDownloadCount, installationProgress, + runtimePending: activity.active, }); + 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}${downloadStatus} · ${appId === 'torch' ? 'Torch ' : ''}runtime: ${activity.phase}`, + }; + } + const runtimeDownloadActive = installationProgress?.download_active === true || + installationProgress?.stage === 'download' || + (installationProgress?.download_speed ?? 0) > 0 || + getInstallationDownloadPresentation(appId, installationProgress) !== null; + const modelDownloadActive = activeModelDownload?.status === 'downloading'; + if ( + appId === 'torch' && activity.active && installationProgress && combinedDownloadStatus && + (runtimeDownloadActive || modelDownloadActive) + ) { + 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 42543fdb2..7a20e6906 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.test.tsx b/frontend/src/components/InstallDialogContent.test.tsx index fa04dcfe4..a607b846b 100644 --- a/frontend/src/components/InstallDialogContent.test.tsx +++ b/frontend/src/components/InstallDialogContent.test.tsx @@ -94,6 +94,36 @@ 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('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 ce5f291d0..6b81e07bf 100644 --- a/frontend/src/components/InstallDialogContent.tsx +++ b/frontend/src/components/InstallDialogContent.tsx @@ -4,6 +4,8 @@ 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'; +import { getInstallationDownloadPresentation } from '../utils/installationDownloadPresentation'; interface InstallDialogContentProps { appId?: string; @@ -70,6 +72,9 @@ export function InstallDialogContent({ onInspectTorchProbe, onReportRemoveError, }: InstallDialogContentProps) { + const activity = getInstallActivityPresentation({ appId, installingTag: installingVersion, progress }); + const torchTag = installingVersion ?? progress?.tag; + const downloadPresentation = getInstallationDownloadPresentation(appId, progress); return (
@@ -88,6 +93,23 @@ export function InstallDialogContent({ )} + {activity.active && (appId === 'torch' || !progress) && ( +
+
+ + + {appId === 'torch' && downloadPresentation && ( + + Network download · {downloadPresentation.label} + + )} +
+ +
+ )} + {isRateLimited && ( { + it('shows Torch package resolution as indeterminate without a false completion percentage', () => { + render(); + expect(screen.getByText('Preparing Torch Packages')).toBeInTheDocument(); + expect(screen.getByText('Preparing managed Python and resolving Torch packages')).toBeInTheDocument(); + expect(screen.queryByText('0%')).not.toBeInTheDocument(); + expect(screen.getByRole('progressbar', { name: 'Overall installation progress' })).not.toHaveAttribute('aria-valuenow'); + expect(screen.getByRole('progressbar', { name: 'Preparing Torch Packages progress' })).not.toHaveAttribute('aria-valuenow'); + }); + + 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('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(); + 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('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('No package transfer active')).toBeInTheDocument(); + } finally { + vi.unstubAllGlobals(); + } + }); + + 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 639f3b2d5..e05359046 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, @@ -18,13 +21,17 @@ import { ChevronUp, AlertCircle, FileText, + 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'; const STAGE_LABELS = { + resolving: 'Preparing Torch Packages', download: 'Downloading', extract: 'Extracting', venv: 'Creating Environment', @@ -33,6 +40,7 @@ const STAGE_LABELS = { }; const STAGE_ICONS = { + resolving: RefreshCw, download: Download, extract: FolderArchive, venv: Settings, @@ -88,7 +96,50 @@ 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; installingVersion: string | null; showCompletedItems: boolean; @@ -98,6 +149,7 @@ interface ProgressDetailsViewProps { } export function ProgressDetailsView({ + appId, progress, installingVersion, showCompletedItems, @@ -108,6 +160,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' @@ -120,6 +173,7 @@ export function ProgressDetailsView({ : outcome?.kind === 'cancelled' ? 'text-[hsl(var(--accent-warning))]' : 'text-[hsl(var(--accent-error))]'; + const downloadPresentation = getInstallationDownloadPresentation(appId, progress); return (
@@ -140,22 +194,22 @@ export function ProgressDetailsView({
Overall Progress - {overallProgress}% + {activity.indeterminate ? 'Working…' : `${overallProgress}%`}
- : + />}
@@ -171,7 +225,7 @@ export function ProgressDetailsView({ {STAGE_LABELS[progress.stage]} - {stageProgress}% + {activity.indeterminate ? 'In progress' : `${stageProgress}%`}
{progress.current_item && ( @@ -184,30 +238,33 @@ 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" > - : + />}
{/* Stage-specific Stats */} - {progress.download_speed !== null && ( + {downloadPresentation && (
-
- - Speed +
+
+ + Network download +
+ + {downloadPresentation.label} +
- - {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 000000000..89ff79165 --- /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 6be7ac258..148a34d78 100644 --- a/frontend/src/components/StatusFooter.tsx +++ b/frontend/src/components/StatusFooter.tsx @@ -8,7 +8,7 @@ const logger = getLogger('StatusFooter'); 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; @@ -79,18 +79,32 @@ export const StatusFooter: React.FC = ({ }; } + if (installationProgress.stage === 'resolving') { + return { + icon: RefreshCw, + text: installationProgress.current_item || 'Preparing Torch packages', + color: 'text-accent-info', + bgColor: 'bg-[hsl(var(--accent-info)/0.1)]', + spinning: true + }; + } + // Other installation stages (extract, venv, setup) const stageNames = { + resolving: 'Preparing Torch packages', extract: 'Extracting', venv: 'Creating environment', setup: 'Finalizing setup' }; 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/TorchDesktopProjection.test.tsx b/frontend/src/components/TorchDesktopProjection.test.tsx index 063a8e5cf..407bcacb0 100644 --- a/frontend/src/components/TorchDesktopProjection.test.tsx +++ b/frontend/src/components/TorchDesktopProjection.test.tsx @@ -73,41 +73,26 @@ function Harness({ installVersion, switchVersion, refreshAll }: { afterEach(() => vi.unstubAllGlobals()); describe('Torch desktop projection', () => { - it('keeps installed versions visible through release discovery failure and runs the reviewed release through explicit selection and an owned startup trial', async () => { + it('keeps installed versions visible through release discovery failure and starts a quick Torch install before an owned startup trial', async () => { const installVersion = vi.fn().mockResolvedValue(true); const switchVersion = vi.fn().mockResolvedValue(true); const refreshAll = vi.fn().mockRejectedValue(new Error('release discovery unavailable')); const getOptions = vi.fn().mockResolvedValue({ - builds: ['cpu', 'cu130'], + builds: ['cpu', 'cu130'], defaultBuild: 'auto', pythons: [{ id: 'python3.12', label: 'Python 3.12' }], adapters: ['none', 'flux2'], - bundledPresetAvailable: true, defaultAdapter: 'flux2', + bundledPresetAvailable: true, defaultAdapter: 'none', preset: { tag: installedTag, build: 'cu130', python: 'python3.12', adapter: 'bundled' }, installed: [ { tag: installedTag, build: 'cu130', python: 'python3.12', adapter: 'bundled', qualification: 'qualified' }, { tag: releaseTag, build: 'cpu', python: 'python3.12', adapter: 'flux2', qualification: 'unverified' }, ], }); - const getReleaseOptions = vi.fn().mockResolvedValue({ - tag: releaseTag, - status: 'matches', - completeScan: true, - checkedChannels: ['cpu', 'cu130'], - combinations: [ - { build: 'cpu', python: 'python3.12', wheelUrl: 'https://download.pytorch.org/whl/cpu/torch.whl' }, - { build: 'cu130', python: 'python3.12', wheelUrl: 'https://download.pytorch.org/whl/cu130/torch.whl' }, - ], - issues: [], - detectedGpuVendors: [], - driverStatus: { nvidia: 'not_present', amd: 'not_present' }, - recommended: { build: 'cpu', python: 'python3.12' }, - recommendationNote: null, - }); + const getReleaseOptions = vi.fn(); const preview = vi.fn().mockResolvedValue({ - status: 'resolved', preview: { - previewId, expiresInSeconds: 300, tag: releaseTag, build: 'cpu', python: 'python3.12', adapter: 'flux2', - qualification: 'unverified', - artifacts: [{ name: 'torch', version: '2.10.0', url: 'https://download.pytorch.org/whl/cpu/torch.whl', sha256: 'abc123' }], + status: 'ready', preview: { + previewId, expiresInSeconds: 300, tag: releaseTag, build: 'auto', python: 'auto', adapter: 'none', + qualification: 'unverified', artifacts: [], }, }); const probe = vi.fn().mockResolvedValue({ @@ -147,12 +132,12 @@ describe('Torch desktop projection', () => { if (!installButton) throw new TypeError('Expected install button'); fireEvent.click(installButton); expect(installVersion).not.toHaveBeenCalled(); - expect(await screen.findByText('Pumas image dependencies are selected by Pumas for its FLUX.2 path; they are not part of upstream Torch.')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(screen.getByText('SHA-256: abc123')).toBeInTheDocument()); - expect(preview).toHaveBeenCalledWith({ tag: releaseTag, build: 'cpu', python: 'auto', adapter: 'flux2' }); - expect(screen.getByText('Selected Python: python3.12')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Install reviewed artifacts' })); + expect(await screen.findByText(/Package and Python resolution, wheel downloads, and installation happen within the install task/)).toBeInTheDocument(); + const confirmInstall = screen.getByRole('button', { name: 'Install Torch' }); + await waitFor(() => expect(confirmInstall).toBeEnabled()); + expect(getReleaseOptions).not.toHaveBeenCalled(); + fireEvent.click(confirmInstall); + expect(preview).toHaveBeenCalledWith({ tag: releaseTag, build: 'auto', python: 'auto', adapter: 'none' }); await waitFor(() => expect(installVersion).toHaveBeenCalledWith(releaseTag, previewId)); expect(switchVersion).not.toHaveBeenCalled(); diff --git a/frontend/src/components/TorchInstallPreview.test.tsx b/frontend/src/components/TorchInstallPreview.test.tsx index 90f21e5bf..c8a6a246b 100644 --- a/frontend/src/components/TorchInstallPreview.test.tsx +++ b/frontend/src/components/TorchInstallPreview.test.tsx @@ -1,392 +1,198 @@ -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TorchInstallPreview } from './TorchInstallPreview'; -import type { - TorchAlternativesOutcome, - TorchReleaseOptionsOutcome, - TorchRuntimeOptions, - TorchRuntimePreviewOutcome, - TorchRuntimePreviewRequest, -} from '../types/torch-install'; +import { + decodeInstallationProgressOutcome, + decodeTorchRuntimePreviewOutcome, +} from '../generated/desktop-contract'; +import type { TorchReleaseOptionsOutcome, TorchRuntimeOptions, TorchRuntimePreviewOutcome, TorchRuntimePreviewRequest } from '../types/torch-install'; +type ReadySelection = Extract; + +const getOptions = vi.fn<() => Promise>(); const getReleaseOptions = vi.fn<(tag: string) => Promise>(); -const getPresetOptions = vi.fn<() => Promise>(); -const getPreview = vi.fn<(request: TorchRuntimePreviewRequest) => Promise>(); -const getAlternatives = vi.fn<(tag: string, build: string, python: string) => Promise>(); +const getSelection = vi.fn<(request: TorchRuntimePreviewRequest) => Promise>(); vi.mock('../api/adapter', () => ({ api: { + get_torch_runtime_options: () => getOptions(), get_torch_release_options: (tag: string) => getReleaseOptions(tag), - get_torch_runtime_options: () => getPresetOptions(), - preview_torch_runtime: (request: TorchRuntimePreviewRequest) => getPreview(request), - find_torch_alternatives: (tag: string, build: string, python: string) => getAlternatives(tag, build, python), + preview_torch_runtime: (request: TorchRuntimePreviewRequest) => getSelection(request), }, })); -const release: TorchReleaseOptionsOutcome = { - tag: 'v2.14.0', status: 'matches', completeScan: true, - checkedChannels: ['cpu', 'cu130'], - combinations: [ - { build: 'cpu', python: 'python3.11', wheelUrl: 'https://download.pytorch.org/whl/cpu/torch.whl' }, - { build: 'cu130', python: 'python3.12', wheelUrl: 'https://download.pytorch.org/whl/cu130/torch.whl' }, - ], - issues: [], detectedGpuVendors: ['nvidia', 'intel'], - driverStatus: { nvidia: 'available', amd: 'not_present' }, - recommended: { build: 'cu130', python: 'python3.12' }, recommendationNote: null, -}; -const preset: TorchRuntimeOptions = { - builds: ['cpu', 'cu130', 'cu134', 'rocm'], - pythons: [{ id: 'python3.12', label: 'Python 3.12' }], - adapters: ['none', 'flux2'], installed: [], - bundledPresetAvailable: true, defaultAdapter: 'none', +const options: TorchRuntimeOptions = { + builds: ['cpu', 'cu130'], defaultBuild: 'auto', + pythons: [], adapters: ['none'], defaultAdapter: 'none', + bundledPresetAvailable: false, preset: { tag: 'v2.9.1', build: 'cu130', python: 'python3.12', adapter: 'bundled' }, + installed: [], }; -beforeEach(() => { - vi.clearAllMocks(); - getReleaseOptions.mockResolvedValue(release); - getPresetOptions.mockResolvedValue(preset); - getPreview.mockImplementation(async (request) => ({ - status: 'resolved', +function readySelection(request: TorchRuntimePreviewRequest): ReadySelection { + return { + status: 'ready', preview: { - ...request, previewId: 'review-1', expiresInSeconds: 300, - python: request.python === 'auto' ? (request.build === 'cpu' ? 'python3.11' : 'python3.12') : request.python, - qualification: request.adapter === 'bundled' ? 'qualified' : 'unverified', - artifacts: [{ name: 'torch', version: '2.14.0', url: 'https://download.pytorch.org/torch.whl', sha256: 'abc123' }], + ...request, previewId: 'selection-token', expiresInSeconds: 300, + qualification: 'unverified', artifacts: [], }, - })); + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + getOptions.mockResolvedValue(options); + getSelection.mockImplementation(async (request) => readySelection(request)); }); describe('TorchInstallPreview', () => { - it('retries runtime choices independently of a successful discovery and clears the error', async () => { - getPresetOptions.mockRejectedValueOnce(new Error('choices offline')); - render(); - expect(await screen.findByRole('alert')).toHaveTextContent('Torch runtime choices unavailable: choices offline'); - expect(screen.getByRole('alert')).not.toHaveTextContent('Probe inconclusive'); - fireEvent.click(screen.getByRole('button', { name: 'Retry Torch choices' })); - expect(await screen.findByText(/Recommended setup: cu130/)).toBeInTheDocument(); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(getPresetOptions).toHaveBeenCalledTimes(2); - }); - - it('uses the manager recommendation with core Torch and no required profile selectors', async () => { - const onInstall = vi.fn(); - render(); - - expect(await screen.findByText(/Recommended setup: cu130 · Python selected automatically · Core runtime only/)).toBeInTheDocument(); - expect(getReleaseOptions).toHaveBeenCalledWith('v2.14.0'); - expect(getPresetOptions).toHaveBeenCalledOnce(); - expect(screen.getByText('Advanced setup').closest('details')).not.toHaveAttribute('open'); - expect(screen.getByText(/Pumas installs a private Python version that matches the selected Torch release and its dependencies/i)).toBeInTheDocument(); - expect(screen.queryByRole('combobox', { name: 'Installed Python' })).not.toBeInTheDocument(); - expect(screen.queryByText(/install Python 3\.10–3\.13/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/selected by Pumas for its FLUX.2 path/)).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(getPreview).toHaveBeenCalledWith({ tag: 'v2.14.0', build: 'cu130', python: 'auto', adapter: 'none' })); - expect(await screen.findByText('SHA-256: abc123')).toBeInTheDocument(); - expect(screen.getByText('Selected Python: python3.12')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Install reviewed artifacts' })); - expect(onInstall).toHaveBeenCalledWith('review-1'); + it('accepts the ready token through the generated desktop RPC decoder', () => { + expect(decodeTorchRuntimePreviewOutcome(readySelection({ + tag: 'v2.14.0', build: 'auto', python: 'auto', adapter: 'none', + }))).toMatchObject({ status: 'valid', value: { status: 'ready' } }); }); - it('limits advanced build overrides to this release’s returned combinations', async () => { - render(); - fireEvent.click(await screen.findByText('Advanced setup')); - const build = screen.getByRole('combobox', { name: 'Torch build' }); - expect(within(build).getAllByRole('option').map((option) => option.textContent)).toEqual(['Choose a build', 'cpu', 'cu130']); - expect(within(build).queryByRole('option', { name: /cu134|rocm/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('combobox', { name: 'Installed Python' })).not.toBeInTheDocument(); - fireEvent.change(build, { target: { value: 'cpu' } }); - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(getPreview).toHaveBeenCalledWith({ tag: 'v2.14.0', build: 'cpu', python: 'auto', adapter: 'none' })); + it('accepts the resolving progress stage through the generated desktop RPC decoder', () => { + expect(decodeInstallationProgressOutcome({ + 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, downloadMeasurementAvailable: null, + downloadSpeed: null, etaSeconds: null, totalSize: null, downloadedBytes: 0, + dependencyCount: null, completedDependencies: 0, completedItems: [], + error: null, completedAt: null, success: null, logPath: null, + })).toMatchObject({ status: 'valid', value: { stage: 'resolving' } }); }); - it('requires a deliberate advanced selection when no recommendation exists for a GPU-only release', async () => { - getReleaseOptions.mockResolvedValue({ - ...release, - checkedChannels: ['cu132'], - combinations: [{ build: 'cu132', python: 'python3.12', wheelUrl: 'https://download.pytorch.org/whl/cu132/torch.whl' }], - recommended: null, - recommendationNote: 'No GPU driver could be verified.', - }); + it('loads local choices without release discovery or a separate package check', async () => { render(); - expect(await screen.findByText(/No setup was recommended. Open Advanced setup/)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Check selected combination' })).toBeDisabled(); - expect(getPreview).not.toHaveBeenCalled(); - fireEvent.click(screen.getByText('Advanced setup')); - const build = screen.getByRole('combobox', { name: 'Torch build' }); - expect(build).toHaveValue(''); - fireEvent.change(build, { target: { value: 'cu132' } }); - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(getPreview).toHaveBeenCalledWith({ tag: 'v2.14.0', build: 'cu132', python: 'auto', adapter: 'none' })); + expect(screen.getByRole('status')).toHaveTextContent('Loading Torch choices…'); + expect(screen.getByRole('button', { name: 'Install Torch' })).toBeDisabled(); + expect(screen.getByText(/Package and Python resolution, wheel downloads, and installation happen within the install task/)).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Install Torch' })).toBeEnabled(); + expect(getReleaseOptions).not.toHaveBeenCalled(); + expect(getSelection).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: 'Check selected combination' })).not.toBeInTheDocument(); + expect(screen.getByText('Advanced setup').closest('details')).not.toHaveAttribute('open'); + expect(screen.queryByText(/exact artifacts resolved/i)).not.toBeInTheDocument(); }); - it('shows the manager’s driver caveat alongside a CPU recommendation', async () => { + it.each(['inconclusive', 'none'] as const)('allows installation when release discovery would be %s', async (status) => { getReleaseOptions.mockResolvedValue({ - ...release, - driverStatus: { ...release.driverStatus, nvidia: 'unavailable' }, - recommended: { build: 'cpu', python: 'python3.11' }, - recommendationNote: 'NVIDIA hardware was detected, but its driver could not be verified. CPU is recommended.', + tag: 'v2.14.0', status, completeScan: false, checkedChannels: [], combinations: [], + issues: [], detectedGpuVendors: [], driverStatus: { nvidia: 'not_present', amd: 'not_present' }, + recommended: null, recommendationNote: null, }); - render(); - expect(await screen.findByText(/Recommended setup: cpu · Python selected automatically/)).toBeInTheDocument(); - expect(screen.getByText(/driver could not be verified. CPU is recommended/)).toBeInTheDocument(); - }); - - it('keeps Pumas FLUX.2 dependencies as an explicit advanced choice', async () => { - render(); - fireEvent.click(await screen.findByText('Advanced setup')); - fireEvent.change(screen.getByRole('combobox', { name: 'Dependency profile' }), { target: { value: 'flux2' } }); - expect(screen.getByText(/Recommended setup: cu130 · Python selected automatically · Pumas image dependencies/)).toBeInTheDocument(); - expect(screen.getByText(/selected by Pumas for its FLUX.2 path; they are not part of upstream Torch/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(getPreview).toHaveBeenCalledWith({ tag: 'v2.14.0', build: 'cu130', python: 'auto', adapter: 'flux2' })); - }); - - it('does not imply an incomplete discovery found no wheels', async () => { - getReleaseOptions.mockResolvedValue({ ...release, status: 'inconclusive', completeScan: false, combinations: [], recommended: null, issues: ['index timed out'] }); - render(); - expect(await screen.findByText(/Release discovery was inconclusive. Some official wheels may exist/)).toBeInTheDocument(); - expect(screen.getByText('index timed out')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Check selected combination' })).toBeDisabled(); - expect(screen.queryByText(/No official wheel matches were found/)).not.toBeInTheDocument(); - }); - - it('does not require a host Python selection before checking wheels', async () => { - getPresetOptions.mockResolvedValue({ ...preset, pythons: [], bundledPresetAvailable: false }); - render(); - - const check = await screen.findByRole('button', { name: 'Check selected combination' }); - expect(check).toBeEnabled(); - fireEvent.click(screen.getByText('Advanced setup')); - expect(screen.queryByRole('combobox', { name: 'Installed Python' })).not.toBeInTheDocument(); - expect(screen.queryByText(/Install.*CPython 3\.10–3\.13/i)).not.toBeInTheDocument(); - fireEvent.click(check); - await waitFor(() => expect(getPreview).toHaveBeenCalledWith({ tag: 'v2.14.0', build: 'cu130', python: 'auto', adapter: 'none' })); - }); - - it('keeps the qualified v2.9.1 bundled preset and an unverified upstream choice distinct', async () => { - getReleaseOptions.mockResolvedValue({ ...release, tag: 'v2.9.1' }); const onInstall = vi.fn(); - render(); - const qualified = await screen.findByRole('button', { name: 'Qualified fixed preset' }); - expect(qualified).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByRole('button', { name: 'Other official wheels (unverified)' })).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByText('Advanced setup').closest('details')).not.toHaveAttribute('open'); - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(getPreview).toHaveBeenCalledWith({ tag: 'v2.9.1', build: 'cu130', python: 'auto', adapter: 'none' })); - expect(screen.getByRole('button', { name: 'Install reviewed artifacts' })).toBeEnabled(); - - fireEvent.click(qualified); - 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' })); - fireEvent.click(screen.getByRole('button', { name: 'Install fixed preset' })); - expect(onInstall).toHaveBeenCalledWith('review-1'); + render(); - fireEvent.click(screen.getByRole('button', { name: 'Other official wheels (unverified)' })); - expect(screen.getByRole('button', { name: 'Install reviewed artifacts' })).toBeDisabled(); - expect(screen.queryByText(/Pumas image dependencies are selected by Pumas/)).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(getPreview).toHaveBeenLastCalledWith({ tag: 'v2.9.1', build: 'cu130', python: 'auto', adapter: 'none' })); - expect(await screen.findByText(/Artifact resolution is unverified/)).toBeInTheDocument(); + fireEvent.click(await screen.findByRole('button', { name: 'Install Torch' })); + await waitFor(() => expect(onInstall).toHaveBeenCalledWith('selection-token')); + expect(getReleaseOptions).not.toHaveBeenCalled(); + expect(getSelection).toHaveBeenCalledWith({ tag: 'v2.14.0', build: 'auto', python: 'auto', adapter: 'none' }); }); - it('keeps the fixed preset available when upstream discovery fails', async () => { - getReleaseOptions.mockResolvedValue({ ...release, tag: 'v2.9.1' }); - getReleaseOptions.mockRejectedValueOnce(new Error('index timed out')); + it('shows immediate pending status and starts the install with the selection token', async () => { + let resolveSelection!: (value: TorchRuntimePreviewOutcome) => void; + getSelection.mockImplementationOnce(() => new Promise((resolve) => { resolveSelection = resolve; })); const onInstall = vi.fn(); - render(); - - expect(await screen.findByRole('alert')).toHaveTextContent('Release discovery inconclusive: index timed out'); - const qualified = screen.getByRole('button', { name: 'Qualified fixed preset' }); - expect(qualified).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByRole('button', { name: 'Other official wheels (unverified)' })).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getByRole('button', { name: 'Check selected combination' })).toBeDisabled(); - - fireEvent.click(qualified); - expect(screen.getByText(/Recommended setup: cu130 · python3.12 · bundled image dependencies \(fixed\)/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(getPreview).toHaveBeenCalledWith({ tag: 'v2.9.1', build: 'cu130', python: 'python3.12', adapter: 'bundled' })); - fireEvent.click(screen.getByRole('button', { name: 'Install fixed preset' })); - expect(onInstall).toHaveBeenCalledWith('review-1'); + render(); - fireEvent.click(screen.getByRole('button', { name: 'Other official wheels (unverified)' })); - expect(screen.getByRole('alert')).toHaveTextContent('Release discovery inconclusive: index timed out'); - fireEvent.click(screen.getByRole('button', { name: 'Retry release discovery' })); - expect(await screen.findByText(/Recommended setup: cu130 · Python selected automatically · Core runtime only/)).toBeInTheDocument(); - expect(getReleaseOptions).toHaveBeenCalledTimes(2); + fireEvent.click(await screen.findByRole('button', { name: 'Install Torch' })); + expect(screen.getByRole('status')).toHaveTextContent('Starting installation…'); + expect(screen.getByRole('button', { name: 'Starting installation…' })).toBeDisabled(); + expect(onInstall).not.toHaveBeenCalled(); + await act(async () => { resolveSelection(readySelection({ tag: 'v2.14.0', build: 'auto', python: 'auto', adapter: 'none' })); }); + expect(onInstall).toHaveBeenCalledOnce(); + expect(onInstall).toHaveBeenCalledWith('selection-token'); }); - it('offers the fixed preset while upstream discovery is still pending', async () => { - getReleaseOptions.mockImplementation(() => new Promise(() => { - // Keep the upstream scan pending throughout this test. + it('falls back to automatic build when the quick options omit defaultBuild', async () => { + let resolveOptions!: (value: TorchRuntimeOptions) => void; + getOptions.mockImplementation(() => new Promise((resolve) => { + resolveOptions = resolve; })); - render(); - - const qualified = await screen.findByRole('button', { name: 'Qualified fixed preset' }); - expect(qualified).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByText('Discovering official wheels…')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Check selected combination' })).toBeDisabled(); - - fireEvent.click(qualified); - expect(screen.queryByText('Discovering official wheels…')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(getPreview).toHaveBeenCalledWith({ tag: 'v2.9.1', build: 'cu130', python: 'python3.12', adapter: 'bundled' })); - expect(screen.getByRole('button', { name: 'Install fixed preset' })).toBeEnabled(); - - fireEvent.click(screen.getByRole('button', { name: 'Other official wheels (unverified)' })); - expect(screen.getByText('Discovering official wheels…')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Check selected combination' })).toBeDisabled(); + const onInstall = vi.fn(); + render(); + 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' }); }); - it('keeps the fixed preset available after an inconclusive upstream scan', async () => { - getReleaseOptions.mockResolvedValue({ - ...release, tag: 'v2.9.1', status: 'inconclusive', completeScan: false, - combinations: [], recommended: null, issues: ['index timed out'], - }); - render(); - - expect(await screen.findByText(/Release discovery was inconclusive. Some official wheels may exist/)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Check selected combination' })).toBeDisabled(); - fireEvent.click(screen.getByRole('button', { name: 'Qualified fixed preset' })); - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(getPreview).toHaveBeenCalledWith({ tag: 'v2.9.1', build: 'cu130', python: 'python3.12', adapter: 'bundled' })); - expect(screen.getByRole('button', { name: 'Install fixed preset' })).toBeEnabled(); + 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('skips the bundled v2.9.1 preset when the manager says it is unavailable', async () => { - getPresetOptions.mockResolvedValue({ ...preset, bundledPresetAvailable: false, defaultAdapter: 'none', adapters: ['none'] }); - getReleaseOptions.mockResolvedValue({ - ...release, tag: 'v2.9.1', checkedChannels: ['cpu'], - combinations: [{ build: 'cpu', python: 'python3.12', wheelUrl: 'https://download.pytorch.org/whl/cpu/torch.whl' }], - recommended: { build: 'cpu', python: 'python3.12' }, - }); + it('keeps the historical fixed preset distinct and submits its exact interpreter tuple', async () => { + getOptions.mockResolvedValue({ ...options, bundledPresetAvailable: true }); const onInstall = vi.fn(); render(); - - expect(await screen.findByText(/Recommended setup: cpu · Python selected automatically · Core runtime only/)).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Qualified fixed preset' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Other official wheels (unverified)' })).not.toBeInTheDocument(); - expect(screen.queryByText(/bundled image dependencies \(fixed\)/)).not.toBeInTheDocument(); - expect(screen.queryByText(/install Python 3\.10–3\.13/i)).not.toBeInTheDocument(); - fireEvent.click(screen.getByText('Advanced setup')); - const profile = screen.getByRole('combobox', { name: 'Dependency profile' }); - expect(within(profile).getAllByRole('option').map((option) => option.textContent)).toEqual(['Core runtime only']); - fireEvent.click(screen.getByRole('button', { name: 'Check selected combination' })); - await waitFor(() => expect(getPreview).toHaveBeenCalledWith({ tag: 'v2.9.1', build: 'cpu', python: 'auto', adapter: 'none' })); - fireEvent.click(screen.getByRole('button', { name: 'Install reviewed artifacts' })); - expect(onInstall).toHaveBeenCalledWith('review-1'); - }); - - it('leaves the upstream v2.9.1 choice unset when the manager has no recommendation', async () => { - getReleaseOptions.mockResolvedValue({ - ...release, tag: 'v2.9.1', recommended: null, - checkedChannels: ['cu132'], - combinations: [{ build: 'cu132', python: 'python3.12', wheelUrl: 'https://download.pytorch.org/whl/cu132/torch.whl' }], - }); - render(); - fireEvent.click(await screen.findByRole('button', { name: 'Other official wheels (unverified)' })); - expect(await screen.findByText(/No setup was recommended. Open Advanced setup/)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Check selected combination' })).toBeDisabled(); - expect(getPreview).not.toHaveBeenCalled(); - }); - - it('keeps rejected previews from installing and classifies validation by enum', async () => { - getPreview.mockResolvedValue({ status: 'rejected', reason: 'validation_failed', message: 'Unsupported combination: missing hash' }); + 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(); - fireEvent.click(await screen.findByRole('button', { name: 'Check selected combination' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Resolved wheel report failed validation: Unsupported combination: missing hash'); - expect(screen.queryByRole('button', { name: 'Find compatible alternatives' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Install reviewed artifacts' })).toBeDisabled(); + 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('offers official wheel alternatives only after an unsupported preview and rechecks the chosen match', async () => { - getPreview.mockResolvedValueOnce({ status: 'rejected', reason: 'unsupported', message: 'selected dependencies have no matching wheel' }); - getPreview.mockImplementationOnce(async (request) => ({ - status: 'resolved', - preview: { ...request, python: 'python3.11', previewId: 'review-cpu', expiresInSeconds: 300, qualification: 'unverified', artifacts: [] }, - })); - getAlternatives.mockResolvedValue({ - selectedTag: 'v2.14.0', selectedBuild: 'cu130', selectedPython: 'python3.12', - status: 'matches', incomplete: true, dependenciesNotChecked: true, - checkedBuilds: ['cu130', 'cpu'], issues: [], - matches: [{ tag: 'v2.14.0', build: 'cpu', python: 'python3.11', wheelUrl: 'https://download.pytorch.org/whl/cpu/torch.whl', sha256: 'a'.repeat(64) }], - }); + it('rejects a ready token that changes the requested preset Python', async () => { + getOptions.mockResolvedValue({ ...options, bundledPresetAvailable: true }); const onInstall = vi.fn(); - render(); - - fireEvent.click(await screen.findByRole('button', { name: 'Check selected combination' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Unsupported combination'); - expect(screen.getByRole('button', { name: 'Install reviewed artifacts' })).toBeDisabled(); - fireEvent.click(screen.getByRole('button', { name: 'Find compatible alternatives' })); - expect(await screen.findByText('Official Torch wheel matches only')).toBeInTheDocument(); - expect(screen.getByText(/Other dependencies have not been checked/)).toBeInTheDocument(); - expect(getAlternatives).toHaveBeenCalledWith('v2.14.0', 'cu130', 'python3.12'); - fireEvent.click(screen.getByRole('button', { name: 'Preview v2.14.0 · cpu · python3.11' })); - await waitFor(() => expect(getPreview).toHaveBeenLastCalledWith({ tag: 'v2.14.0', build: 'cpu', python: 'python3.11', adapter: 'none' })); - expect(await screen.findByText('Exact artifacts resolved')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Install reviewed artifacts' })); - expect(onInstall).toHaveBeenCalledWith('review-cpu'); + 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('shows and previews only host-eligible alternatives from the release combinations', async () => { - getReleaseOptions.mockResolvedValue({ - ...release, - checkedChannels: ['cpu'], - combinations: [{ build: 'cpu', python: 'python3.11', wheelUrl: 'https://download.pytorch.org/whl/cpu/torch.whl' }], - recommended: { build: 'cpu', python: 'python3.11' }, - }); - getPreview.mockResolvedValueOnce({ status: 'rejected', reason: 'unsupported', message: 'dependency unavailable' }); - getPreview.mockImplementationOnce(async (request) => ({ - status: 'resolved', - preview: { ...request, python: 'python3.11', previewId: 'eligible-cpu', expiresInSeconds: 300, qualification: 'unverified', artifacts: [] }, - })); - getAlternatives.mockResolvedValue({ - selectedTag: 'v2.14.0', selectedBuild: 'cpu', selectedPython: 'python3.11', - status: 'matches', incomplete: true, dependenciesNotChecked: true, - checkedBuilds: ['cpu', 'cu130', 'rocm6.4'], issues: [], - matches: [ - { tag: 'v2.14.0', build: 'cpu', python: 'python3.11' }, - { tag: 'v2.14.0', build: 'cu130', python: 'python3.11' }, - { tag: 'v2.14.0', build: 'rocm6.4', python: 'python3.11' }, - { tag: 'v2.14.0', build: 'cpu', python: 'python3.12' }, - { tag: 'v2.13.0', build: 'cpu', python: 'python3.11' }, - ], - }); - render(); + it('does not install a rejected or mismatched selection', async () => { + const onInstall = vi.fn(); + getSelection.mockResolvedValueOnce({ status: 'rejected', reason: 'unsupported', message: 'Unavailable build' }); + const { rerender } = render(); + fireEvent.click(await screen.findByRole('button', { name: 'Install Torch' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Unsupported selection: Unavailable build'); + expect(onInstall).not.toHaveBeenCalled(); - fireEvent.click(await screen.findByRole('button', { name: 'Check selected combination' })); - fireEvent.click(await screen.findByRole('button', { name: 'Find compatible alternatives' })); - expect(await screen.findByText('Checked builds: cpu')).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: /^Preview v/ })).toHaveLength(1); - expect(screen.queryByRole('button', { name: /Preview .*cu130|Preview .*rocm6\.4|Preview v2\.13\.0/ })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Preview v2.14.0 · cpu · python3.11' })); - await waitFor(() => expect(getPreview).toHaveBeenCalledTimes(2)); - expect(getPreview).toHaveBeenLastCalledWith({ tag: 'v2.14.0', build: 'cpu', python: 'python3.11', adapter: 'none' }); + getSelection.mockResolvedValueOnce(readySelection({ tag: 'wrong-tag', build: 'auto', python: 'auto', adapter: 'none' })); + fireEvent.click(screen.getByRole('button', { name: 'Install Torch' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Torch selection could not be confirmed'); + expect(onInstall).not.toHaveBeenCalled(); + rerender(); + await waitFor(() => expect(getOptions).toHaveBeenCalledTimes(2)); }); - it('keeps a long artifact list and its install action inside the scrollable preview region', 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) => ({ - name: `wheel-${index}`, version: '1.0', - url: `https://download.pytorch.org/whl/cu130/wheel-${index}.whl`, - sha256: 'a'.repeat(64), - })), - }, - })); - 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' })); + it('does not treat a legacy resolved artifact preview as a quick selection token', async () => { + const onInstall = vi.fn(); + const ready = readySelection({ tag: 'v2.14.0', build: 'auto', python: 'auto', adapter: 'none' }); + getSelection.mockResolvedValueOnce({ status: 'resolved', preview: ready.preview }); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Install Torch' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Torch selection could not be confirmed'); + expect(onInstall).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/TorchInstallPreview.tsx b/frontend/src/components/TorchInstallPreview.tsx index fc4dd933b..e7b153a72 100644 --- a/frontend/src/components/TorchInstallPreview.tsx +++ b/frontend/src/components/TorchInstallPreview.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { ArrowLeft, Loader2 } from 'lucide-react'; import { api } from '../api/adapter'; -import type { TorchAlternativeMatch, TorchAlternativesOutcome, TorchReleaseOptionsOutcome, TorchRuntimeOptions, TorchRuntimePreview, TorchRuntimePreviewOutcome, TorchRuntimePreviewRejectReason } from '../types/torch-install'; +import type { TorchRuntimeOptions, TorchRuntimePreviewOutcome, TorchRuntimePreviewRejectReason } from '../types/torch-install'; interface TorchInstallPreviewProps { tag: string; @@ -9,318 +9,155 @@ interface TorchInstallPreviewProps { onInstall: (previewId: string) => void; } -interface CheckedCombination { - key: string; - label: string; - status: 'resolved' | TorchRuntimePreviewRejectReason; -} - function reasonLabel(reason: TorchRuntimePreviewRejectReason): string { switch (reason) { - case 'unsupported': return 'Unsupported combination'; - case 'validation_failed': return 'Resolved wheel report failed validation'; + case 'unsupported': return 'Unsupported selection'; + case 'validation_failed': return 'Selection failed validation'; case 'network_inconclusive': return 'Network inconclusive'; - case 'inconclusive': return 'Probe inconclusive'; + case 'inconclusive': return 'Selection inconclusive'; } } -function checkStatusLabel(status: CheckedCombination['status']): string { - if (status === 'resolved') return 'resolved'; - if (status === 'validation_failed') return 'failed validation'; - return reasonLabel(status).toLowerCase(); -} - function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } export function TorchInstallPreview({ tag, onBack, onInstall }: TorchInstallPreviewProps) { const [runtimeOptions, setRuntimeOptions] = useState(null); - const [releaseOptions, setReleaseOptions] = useState(null); - const [runtimeError, setRuntimeError] = useState(null); - const [releaseError, setReleaseError] = useState(null); - const [releaseAttempt, setReleaseAttempt] = useState(0); - const [build, setBuild] = useState(''); - const [adapter, setAdapter] = useState(''); - const [selectionMode, setSelectionMode] = useState<'preset' | 'upstream'>('upstream'); - const [preview, setPreview] = useState(null); - const [loadingRuntimeOptions, setLoadingRuntimeOptions] = useState(true); - const [loadingReleaseOptions, setLoadingReleaseOptions] = useState(true); - const [probing, setProbing] = useState(false); + 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 [rejection, setRejection] = useState | null>(null); - const [checks, setChecks] = useState([]); - const [alternatives, setAlternatives] = useState(null); - const [alternativesBusy, setAlternativesBusy] = useState(false); - const [alternativesError, setAlternativesError] = useState(null); + const [attempt, setAttempt] = useState(0); const requestNumber = useRef(0); - const selectionModeRef = useRef<'preset' | 'upstream'>('upstream'); useEffect(() => { let active = true; - setLoadingRuntimeOptions(true); - setLoadingReleaseOptions(true); + requestNumber.current += 1; setRuntimeOptions(null); - setRuntimeError(null); - setReleaseOptions(null); - setReleaseError(null); - setPreview(null); + setLoadingOptions(true); + setStarting(false); + setFixedPreset(false); + setBuildChoice('auto'); + setAdapterChoice('none'); setError(null); - setRejection(null); - setChecks([]); - setAlternatives(null); - setAlternativesError(null); - setAlternativesBusy(false); - selectionModeRef.current = 'upstream'; - setSelectionMode(selectionModeRef.current); - setBuild(''); - setAdapter(''); - const releaseRequest = api.get_torch_release_options(tag).then((result) => { - if (!active) return; - setReleaseOptions(result); - if (selectionModeRef.current === 'preset') return; - const choice = result.combinations.find((item) => item.build === result.recommended?.build && item.python === result.recommended.python); - setBuild(choice?.build ?? ''); - }).catch((cause: unknown) => { - if (active) setReleaseError(`Release discovery inconclusive: ${errorText(cause)}`); - }).finally(() => { - if (active) setLoadingReleaseOptions(false); - }); - const runtimeRequest = api.get_torch_runtime_options().then((result) => { - if (!active) return; - setRuntimeOptions(result); - setAdapter(result.defaultAdapter); + + void api.get_torch_runtime_options().then((options) => { + if (active) { + setRuntimeOptions(options); + setBuildChoice(options.defaultBuild || 'auto'); + } }).catch((cause: unknown) => { - if (active) setRuntimeError(`Torch runtime choices unavailable: ${errorText(cause)}`); + if (active) setError(`Torch runtime choices unavailable: ${errorText(cause)}`); }).finally(() => { - if (active) setLoadingRuntimeOptions(false); + if (active) setLoadingOptions(false); }); - void releaseRequest; - void runtimeRequest; + return () => { active = false; requestNumber.current += 1; }; - }, [tag, releaseAttempt]); - - const invalidatePreview = () => { - requestNumber.current += 1; - setPreview(null); - setProbing(false); - setError(null); - setRejection(null); - setAlternatives(null); - setAlternativesError(null); - setAlternativesBusy(false); - }; + }, [tag, attempt]); - const selectedPython = selectionMode === 'preset' ? runtimeOptions?.preset.python ?? '' : 'auto'; - const probe = async (selection = { build, python: selectedPython, adapter }) => { - if (!selection.build || !selection.python || probing) return; + const startInstallation = async () => { + if (!runtimeOptions || starting) return; const currentRequest = ++requestNumber.current; - const key = `${selection.build}|${selection.python}|${selection.adapter}`; - const label = `${selection.build} · ${selection.python === 'auto' ? 'Python selected automatically' : selection.python} · ${selection.adapter}`; - const recordCheck = (status: CheckedCombination['status']) => { - setChecks((previous) => [...previous.filter((item) => item.key !== key), { key, label, status }]); - }; - setPreview(null); + 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); - setRejection(null); - setProbing(true); + try { - const outcome = await api.preview_torch_runtime({ tag, ...selection }); - if (requestNumber.current === currentRequest) { - if (outcome.status === 'resolved') { - setPreview(outcome.preview); - recordCheck('resolved'); - } else { - setRejection(outcome); - recordCheck(outcome.reason); - } + const outcome: TorchRuntimePreviewOutcome = await api.preview_torch_runtime({ + tag, + build, + python, + adapter, + }); + if (requestNumber.current !== currentRequest) return; + if (outcome.status === 'rejected') { + setError(`${reasonLabel(outcome.reason)}: ${outcome.message}`); + setStarting(false); + return; + } + if (outcome.status !== 'ready') { + setError('Torch selection could not be confirmed. Try again.'); + setStarting(false); + return; + } + const selection = outcome.preview; + if (!selection.previewId || selection.expiresInSeconds <= 0 || selection.tag !== tag + || selection.build !== build || selection.python !== python || selection.adapter !== adapter) { + setError('Torch selection could not be confirmed. Try again.'); + setStarting(false); + return; } + onInstall(selection.previewId); } catch (cause) { if (requestNumber.current === currentRequest) { - const message = errorText(cause); - setError(message); - recordCheck('inconclusive'); + setError(`Could not start Torch installation: ${errorText(cause)}`); + setStarting(false); } - } finally { - if (requestNumber.current === currentRequest) setProbing(false); } }; - const findAlternatives = async () => { - if (rejection?.reason !== 'unsupported' || alternativesBusy) return; - const currentRequest = requestNumber.current; - setAlternativesBusy(true); - setAlternatives(null); - setAlternativesError(null); - try { - const discoveryPython = releaseOptions?.combinations.find((choice) => choice.build === build)?.python; - if (!discoveryPython) return; - const result = await api.find_torch_alternatives(tag, build, discoveryPython); - if (requestNumber.current === currentRequest) setAlternatives(result); - } catch (cause) { - if (requestNumber.current === currentRequest) setAlternativesError(errorText(cause)); - } finally { - if (requestNumber.current === currentRequest) setAlternativesBusy(false); - } - }; - - const isPresetRelease = tag === runtimeOptions?.preset.tag && runtimeOptions.bundledPresetAvailable; - const fixedPreset = isPresetRelease && selectionMode === 'preset'; - const chooseMode = (mode: 'preset' | 'upstream') => { - invalidatePreview(); - selectionModeRef.current = mode; - setSelectionMode(mode); - if (mode === 'preset') { - setBuild(runtimeOptions?.preset.build ?? ''); - setAdapter(runtimeOptions?.preset.adapter ?? ''); - } else { - const choice = releaseOptions?.combinations.find((item) => item.build === releaseOptions.recommended?.build && item.python === releaseOptions.recommended.python); - setBuild(choice?.build ?? ''); - setAdapter(runtimeOptions?.defaultAdapter ?? ''); - } - }; - const availableBuilds = [...new Set(releaseOptions?.combinations.map((choice) => choice.build) ?? [])]; - const availableAdapters = runtimeOptions?.adapters.filter((choice) => choice === 'none' || choice === 'flux2') ?? []; - const isEligibleAlternative = (match: TorchAlternativeMatch) => match.tag === tag - && releaseOptions?.tag === tag - && releaseOptions.combinations.some((choice) => choice.build === match.build && choice.python === match.python); - const eligibleAlternatives = alternatives?.matches.filter(isEligibleAlternative) ?? []; - const eligibleCheckedBuilds = alternatives?.checkedBuilds.filter((choice) => availableBuilds.includes(choice)) ?? []; - const loadingOptions = loadingRuntimeOptions || (!isPresetRelease && loadingReleaseOptions); - const canInstall = preview !== null && Boolean(preview.previewId) - && preview.tag === tag && preview.build === build - && Boolean(preview.python) && preview.python !== 'auto' - && (!fixedPreset || preview.python === runtimeOptions.preset.python) - && preview.adapter === adapter; - return ( -
- -
-

Review Torch {tag}

-

- {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}

)} - - - )} - {runtimeError &&
-

{runtimeError}

- -
} - {!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'}

-

Selected Python: {preview.python}

- {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} -
  • - ))}
} +
+

Install Torch {tag}

+

+ 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. +

+

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

- )} - + {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 && } +
+
+ +

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

+
); } diff --git a/frontend/src/components/VersionListItem.test.tsx b/frontend/src/components/VersionListItem.test.tsx index 3b557b73a..66674137d 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, @@ -143,6 +165,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 +197,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 d15045df1..e76e99ddb 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 d0ba99a2e..2c6e552d6 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,8 +184,14 @@ 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 (isInstalling) { + if (canCancel) { onCancel(); return; } @@ -199,23 +204,27 @@ 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, + isInstalling: canCancel, })} > {getButtonContent({ - displayState, + displayState: buttonDisplayState, isCancelHovered, isInstalled, - isInstalling, + isInstalling: canCancel, })} ); diff --git a/frontend/src/components/VersionListItemState.ts b/frontend/src/components/VersionListItemState.ts index 0bf175f70..6814b575f 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) 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 23a325098..8600802a3 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 e23a6e01e..96dd868a4 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 d4ce6db5c..2cb460190 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,23 +199,18 @@ 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'); + 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: [], }, }), @@ -215,10 +221,11 @@ describe('TorchPanel shared version controls', () => { }); fireEvent.click(installButton); expect(actions.installVersion).not.toHaveBeenCalled(); - 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); + expect(screen.getByRole('region', { name: `Torch installation preview for ${candidateTag}` })).toHaveClass('text-[hsl(var(--text-primary))]'); + 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' })) @@ -242,7 +249,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 df6328154..ca12cfa21 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 ( -
-
+
+
-

+

Create a Torch runtime profile, then use Serve on the image model. {' '}{gatewayUrl ? 'Copy the Pumas gateway URL above into Tuldok to generate images.' diff --git a/frontend/src/components/app-panels/VersionManagementPanel.tsx b/frontend/src/components/app-panels/VersionManagementPanel.tsx index 51f548890..68d463dd3 100644 --- a/frontend/src/components/app-panels/VersionManagementPanel.tsx +++ b/frontend/src/components/app-panels/VersionManagementPanel.tsx @@ -144,6 +144,7 @@ export function VersionManagementPanel({ return (

; "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; "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 = "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 8a0ca8711..da27c16ed 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 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 abfafbec0..2a1c81b0b 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 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,16 +9138,16 @@ 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" }, "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": ["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; 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.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 { @@ -9343,61 +9343,34 @@ 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.downloadMeasurementAvailable !== void 0) { + let data11 = data.downloadMeasurementAvailable; 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 !== "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; } - 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.downloadSourceUrl !== void 0) { + let data12 = data.downloadSourceUrl; 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" }]; + validate75.errors = [{ instancePath: instancePath + "/downloadSourceUrl", schemaPath: "#/properties/downloadSourceUrl/type", keyword: "type", params: { type: schema82.properties.downloadSourceUrl.type }, message: "must be string,null" }]; return false; } var valid0 = _errs28 === errors; @@ -9405,21 +9378,21 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.etaSeconds !== void 0) { - let data13 = data.etaSeconds; + if (data.downloadSpeed !== void 0) { + let data13 = data.downloadSpeed; 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" }]; + 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 > 17976931348623157e292 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/maximum", keyword: "maximum", params: { comparison: "<=", limit: 17976931348623157e292 }, message: "must be <= 1.7976931348623157e+308" }]; + 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 < -17976931348623157e292 || isNaN(data13)) { - validate75.errors = [{ instancePath: instancePath + "/etaSeconds", schemaPath: "#/properties/etaSeconds/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; + validate75.errors = [{ instancePath: instancePath + "/downloadSpeed", schemaPath: "#/properties/downloadSpeed/minimum", keyword: "minimum", params: { comparison: ">=", limit: -17976931348623157e292 }, message: "must be >= -1.7976931348623157e+308" }]; return false; } } @@ -9430,141 +9403,206 @@ function validate75(data, { instancePath = "", parentData, parentDataProperty, r var valid0 = true; } if (valid0) { - if (data.logPath !== void 0) { - let data14 = data.logPath; + if (data.downloadedBytes !== void 0) { + let data14 = data.downloadedBytes; 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" }]; + 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.overallProgress !== void 0) { - let data15 = data.overallProgress; + if (data.error !== void 0) { + let data15 = data.error; 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" }]; + 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 > 34028234663852886e22 || isNaN(data15)) { - 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 (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" }]; - return false; - } - } - } - } var valid0 = _errs34 === errors; } else { var valid0 = true; } if (valid0) { - if (data.stage !== void 0) { - let data16 = data.stage; + if (data.etaSeconds !== void 0) { + let data16 = data.etaSeconds; 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" }]; + 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 (!(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; + 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.stageProgress !== void 0) { - let data17 = data.stageProgress; - const _errs39 = 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" }]; + if (data.logPath !== void 0) { + let data17 = data.logPath; + const _errs38 = errors; + 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 === _errs39) { - 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" }]; - 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" }]; - 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.overallProgress !== void 0) { + let data18 = data.overallProgress; + const _errs40 = errors; + 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; } - var valid0 = _errs41 === errors; + 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.success !== void 0) { - let data19 = data.success; - 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 (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; } - var valid0 = _errs43 === errors; + 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 = _errs42 === errors; } else { var valid0 = true; } if (valid0) { - if (data.tag !== void 0) { + if (data.stageProgress !== void 0) { + let data20 = data.stageProgress; 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 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.totalSize !== void 0) { - let data21 = data.totalSize; + if (data.startedAt !== void 0) { 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 data.startedAt !== "string") { + validate75.errors = [{ instancePath: instancePath + "/startedAt", schemaPath: "#/properties/startedAt/type", keyword: "type", params: { type: "string" }, message: "must be string" }]; 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.success !== void 0) { + let data22 = data.success; + const _errs49 = errors; + 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; + } else { + var valid0 = true; + } + if (valid0) { + if (data.tag !== void 0) { + const _errs51 = 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; - } 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" }]; + } + 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 = _errs47 === errors; - } else { - var valid0 = true; } } } @@ -16558,8 +16596,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 +16631,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 +16642,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 +16654,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 +16686,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 +16854,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 +16917,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 +16926,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 +16938,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 +16950,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 +16962,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 +16973,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 +16984,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 +16996,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 +17020,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 +17031,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 +17042,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 +17053,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 +17073,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 +17242,7 @@ function validate139(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err10); } errors++; - validate139.errors = vErrors; + validate140.errors = vErrors; return false; } else { errors = _errs0; @@ -17130,24 +17254,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 +17280,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 +17292,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 +17307,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 +17466,7 @@ function validate144(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err8); } errors++; - validate144.errors = vErrors; + validate145.errors = vErrors; return false; } else { errors = _errs0; @@ -17354,10 +17478,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 +17489,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 +17520,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 +17550,7 @@ function validate143(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err2); } errors++; - validate143.errors = vErrors; + validate144.errors = vErrors; return false; } else { errors = _errs3; @@ -17447,17 +17571,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 +17596,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 +17620,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 +17652,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 +17680,7 @@ function validate142(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err1); } errors++; - validate142.errors = vErrors; + validate143.errors = vErrors; return false; } else { errors = _errs3; @@ -17575,8 +17699,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 +17712,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 +17723,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 +17734,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 +17746,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 +17765,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 +17829,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 +17904,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 +17995,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 +18102,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 +18177,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 +18268,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 +18335,7 @@ function validate141(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err30); } errors++; - validate141.errors = vErrors; + validate142.errors = vErrors; return false; } else { errors = _errs0; @@ -18223,11 +18347,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 +18568,7 @@ function validate154(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err14); } errors++; - validate154.errors = vErrors; + validate155.errors = vErrors; return false; } else { errors = _errs0; @@ -18456,12 +18580,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 +18900,7 @@ function validate155(data, { instancePath = "", parentData, parentDataProperty, vErrors.push(err20); } errors++; - validate155.errors = vErrors; + validate156.errors = vErrors; return false; } else { errors = _errs0; @@ -18788,24 +18912,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 +18945,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 +18954,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 +18973,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 +18982,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 +18995,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 +19020,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 +19055,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 +19070,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 +19081,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 +19092,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 +19104,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 +19117,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 +19132,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 +19168,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 +19190,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 +19199,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 +19218,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 +19227,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 +19239,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 +19251,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 +19262,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 +19292,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 +19304,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 +19316,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 +19344,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 +19354,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 +19368,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 +19396,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 +19409,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 +19424,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/hooks/installationProgressTracking.test.ts b/frontend/src/hooks/installationProgressTracking.test.ts index 282648221..fbd6b46b1 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', () => { @@ -11,6 +12,9 @@ describe('projectInstallationProgress', () => { 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, @@ -31,6 +35,9 @@ 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_measurement_available: true, download_speed: 42.5, eta_seconds: 0.5, total_size: 1024, @@ -50,6 +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, downloadMeasurementAvailable: null, etaSeconds: null, totalSize: null, dependencyCount: null, error: null, completedAt: null, success: null, logPath: null, }; @@ -57,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: [], 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 654a4a631..4ef99349a 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,9 @@ 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_measurement_available: progress.downloadMeasurementAvailable, download_speed: progress.downloadSpeed, eta_seconds: progress.etaSeconds, total_size: progress.totalSize, @@ -106,6 +111,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 +122,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( @@ -140,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 || '', @@ -148,7 +163,12 @@ 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_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 14c7d3751..da3aae18d 100644 --- a/frontend/src/hooks/useInstallationManager.test.ts +++ b/frontend/src/hooks/useInstallationManager.test.ts @@ -103,6 +103,9 @@ const activeProgress: InstallationProgress = { stage_progress: 50, overall_progress: 25, current_item: 'archive.zip', + download_source_url: null, + download_active: true, + download_measurement_available: null, download_speed: 1024, eta_seconds: 30, total_size: 4096, @@ -120,6 +123,9 @@ 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, + downloadMeasurementAvailable: activeProgress.download_measurement_available ?? null, 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 257fda3ef..7d92ab0a9 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/torch-install.ts b/frontend/src/types/torch-install.ts index d455de3c6..3374516ce 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 13d7e326f..cbe216320 100644 --- a/frontend/src/types/versions.ts +++ b/frontend/src/types/versions.ts @@ -25,10 +25,13 @@ 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; + 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 new file mode 100644 index 000000000..f089d222a --- /dev/null +++ b/frontend/src/utils/installActivityPresentation.ts @@ -0,0 +1,42 @@ +import type { InstallationProgress } from '../types/versions'; + +const STAGE_LABELS: Record = { + resolving: 'Preparing Torch packages', + 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 === 'resolving' || currentProgress.stage === 'dependencies' || 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/frontend/src/utils/installationDownloadPresentation.test.ts b/frontend/src/utils/installationDownloadPresentation.test.ts new file mode 100644 index 000000000..c72130b2a --- /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 000000000..a65337f71 --- /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.rs b/rust/crates/pumas-app-manager/src/version_manager/installer.rs index 1dfc65b4e..5a3803607 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(Box), +} #[cfg(test)] pub(crate) use torch::TorchPublicationPause; @@ -497,16 +501,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, @@ -515,7 +519,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 72218e469..3841ae951 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,66 @@ 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, + #[serde(default)] + measurement_available: bool, +} + +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; + }; + url.scheme() == "https" + && url.host().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() +} + +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| !safe_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)] @@ -210,6 +268,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 +586,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(); @@ -1072,27 +1259,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)?; @@ -1109,7 +1303,753 @@ 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, +} + +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 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, + requirements: &str, + 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 { + 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}/"))) + || 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/")) + || (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" + && (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, + selection: &DirectTorchSelection<'_>, + target: &Path, + runtime: &Path, + manifest: &StagedFilesManifest, +) -> Result<()> { + validate_direct_torch_report(resolution, report, requirements, selection)?; + validate_staged_files(target, manifest)?; + move_verified_packages(target, runtime, selection.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, + &TorchAttemptContext { + 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, + 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( + 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 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 + .arg("-I") + .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") + .arg(&resolver_output) + .arg("--cache-dir") + .arg(pip_cache); + let status = self + .run_runtime_command_status_with_download_progress( + install, + log_path, + &format!( + "Resolving and installing official Torch {build} wheels for Python {minor}" + ), + progress_tx, + Some(staging.clone()), + Some(&download_progress_path), + ) + .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, + &DirectTorchSelection { + version, + build, + minor, + adapter: &selection.adapter, + python: &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, @@ -1169,8 +2109,14 @@ impl VersionInstaller { ) .map_err(PumasError::from)?; let python = pumas_library::platform::paths::venv_python(&runtime); - let mut install = Command::new(pumas_library::platform::paths::venv_pip(&runtime)); + let pip_cache = super::super::torch_workspace::managed_pip_cache_dir(&self.launcher_root)?; + 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", @@ -1182,15 +2128,22 @@ impl VersionInstaller { ]) .arg(runtime.join("requirements.txt")) .arg("--cache-dir") - .arg(self.launcher_root.join("launcher-data/cache/pip")); - self.run_runtime_command( - install, - log_path, - "Installing resolved wheel artifacts", - progress_tx, - Some(staging.clone()), - ) - .await?; + .arg(pip_cache); + 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( @@ -1209,7 +2162,7 @@ impl VersionInstaller { tag: &str, release: &GitHubRelease, progress_tx: mpsc::Sender, - plan: Option, + input: Option, ) -> Result<()> { let _attempt = self .torch_attempt_lock @@ -1217,7 +2170,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 @@ -1228,7 +2181,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"), @@ -1246,16 +2199,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.as_ref()), + _ => 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 @@ -1320,7 +2282,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( @@ -1350,14 +2312,7 @@ impl VersionInstaller { Some(log_path.to_string_lossy().as_ref()), ); let result = self - .stage_torch_runtime( - tag, - recipe, - plan.as_ref(), - &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() { @@ -1414,7 +2369,7 @@ impl VersionInstaller { &destination, &progress_tx, staging.clone(), - plan.as_ref() + plan .map(|plan| format!("Python {}", plan.managed_python.version)), ) .await; @@ -1464,9 +2419,9 @@ impl VersionInstaller { async fn stage_torch_runtime( &self, - _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, @@ -1480,6 +2435,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(); @@ -1560,8 +2520,14 @@ impl VersionInstaller { ) .await?; let python = pumas_library::platform::paths::venv_python(&runtime); - let mut install = Command::new(pumas_library::platform::paths::venv_pip(&runtime)); + let pip_cache = super::super::torch_workspace::managed_pip_cache_dir(&self.launcher_root)?; + 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", @@ -1572,15 +2538,22 @@ impl VersionInstaller { ]) .arg(runtime.join("requirements.txt")) .arg("--cache-dir") - .arg(self.launcher_root.join("launcher-data/cache/pip")); - self.run_runtime_command( - install, - log_path, - "Installing locked runtime dependencies", - progress_tx, - Some(staging.clone()), - ) - .await?; + .arg(pip_cache); + 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")) @@ -1623,21 +2596,80 @@ 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, + 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 { + 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, + 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) @@ -1656,10 +2688,48 @@ 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, + download_progress.measurement_available, + ); + 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, + progress.measurement_available, + ); + download_speed_expired = true; + } + } + } if cancelled || timed_out || !matches!(&observed, Ok(None)) { drop(child); self.torch_cleanup @@ -1670,6 +2740,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!( @@ -1679,23 +2752,350 @@ 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; } } } +#[cfg(test)] +mod download_progress_tests { + use super::*; + + #[test] + 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!(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://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!(!safe_torch_download_source(source), "{source}"); + } + } + + #[test] + 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?token=secret", + "active":true, + "downloaded_bytes":1, + "total_bytes":2, + "speed_bytes_per_sec":1.0, + "measurement_available":true + }) + .to_string(), + ) + .unwrap(); + assert!(read_torch_download_progress(&path).is_none()); + + std::fs::write( + &path, + serde_json::json!({ + "source_url":"https://mirror.example.net:8443/releases/model.safetensors", + "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://mirror.example.net:8443/releases/model.safetensors", + "active":true, + "downloaded_bytes":1, + "total_bytes":2, + "speed_bytes_per_sec":10.0, + "measurement_available":true + }) + .to_string(), + ) + .unwrap(); + assert_eq!( + read_torch_download_progress(&path), + Some(TorchDownloadProgress { + source_url: Some( + "https://mirror.example.net:8443/releases/model.safetensors".into(), + ), + active: true, + downloaded_bytes: 1, + total_bytes: Some(2), + speed_bytes_per_sec: Some(10.0), + measurement_available: true, + }) + ); + + std::fs::write( + &path, + serde_json::json!({ + "source_url":null, + "active":true, + "downloaded_bytes":1, + "total_bytes":2, + "speed_bytes_per_sec":1.0, + "measurement_available":true + }) + .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), + measurement_available: true, + }) + ); + } +} + #[cfg(test)] 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") { + "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 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, &selection) + }; + 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, + &selection, + &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 c3738da54..80a25d27c 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; @@ -90,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. @@ -135,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)] @@ -273,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)] @@ -716,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; @@ -733,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(); @@ -746,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(), @@ -866,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(Box::new(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(); @@ -892,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 }, @@ -952,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(()); @@ -1220,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 = @@ -2083,20 +2367,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 a66632819..5fbe72eb9 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,12 @@ struct InstallationProgressState { stage_progress: f32, overall_progress: f32, current_item: Option, + #[serde(default)] + download_source_url: Option, + #[serde(default)] + download_active: bool, + #[serde(default)] + download_measurement_available: Option, download_speed: Option, eta_seconds: Option, total_size: Option, @@ -134,6 +147,7 @@ impl InstallationProgressTracker { cache_dir, state_filename: "installation-state.json".to_string(), state: Mutex::new(None), + network_activity: NetworkActivityRegistry::new(), } } @@ -180,6 +194,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 +202,9 @@ impl InstallationProgressTracker { stage_progress: 0.0, overall_progress: 0.0, current_item: None, + download_source_url: None, + download_active: false, + download_measurement_available: None, download_speed: None, eta_seconds: None, total_size, @@ -215,11 +233,16 @@ 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_measurement_available = None; + state.download_speed = None; state.overall_progress = self.calculate_overall_progress(state); } drop(guard); @@ -233,17 +256,30 @@ impl InstallationProgressTracker { total_bytes: Option, speed_bytes_per_sec: Option, ) { + let active = total_bytes.is_none_or(|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; + 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; } - 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 +292,99 @@ 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, + measurement_available: bool, + ) { + 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.download_measurement_available = Some(measurement_available); + 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 +492,14 @@ 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_measurement_available = None; + state.download_speed = None; if success { state.stage_progress = 100.0; state.overall_progress = 100.0; @@ -387,6 +520,9 @@ 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_measurement_available: state.download_measurement_available, download_speed: state.download_speed, eta_seconds: state.eta_seconds, total_size: state.total_size, @@ -449,7 +585,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(), @@ -540,6 +677,9 @@ 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_measurement_available: state.download_measurement_available, download_speed: state.download_speed, eta_seconds: state.eta_seconds, total_size: state.total_size, @@ -628,6 +768,119 @@ 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), + true, + ); + + 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)); + assert_eq!(state.download_measurement_available, Some(true)); + } + + #[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), + true, + ); + 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); + assert_eq!(state.download_measurement_available, 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), + true, + ); + tracker.update_network_transfer( + Some("https://files.pythonhosted.org/packages/pkg-1.0.whl"), + false, + 40, + Some(40), + None, + true, + ); + 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), + true, + ); + 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, 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); + 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-app-manager/src/version_manager/torch_alternatives.rs b/rust/crates/pumas-app-manager/src/version_manager/torch_alternatives.rs index f522418a5..1622f82eb 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')?) } @@ -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, @@ -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( @@ -1011,7 +1048,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 cb9ef79e3..2e33b7a0f 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,9 +62,13 @@ 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; +const TORCH_RESOLVER_TIMEOUT_MESSAGE: &str = + "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)] enum ResolverDiagnosticCode { @@ -363,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, @@ -387,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. @@ -626,6 +649,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"] { @@ -737,7 +816,11 @@ mod tests { "network_inconclusive", "Network access prevented a conclusive wheel resolution.", ), - (1, "inconclusive", "Wheel resolution did not complete conclusively."), + ( + 1, + "inconclusive", + "Pip could not complete dependency resolution. Wheel availability is inconclusive; check network or package-index access and retry.", + ), ] { let outcome = resolver_rejection(PreviewResolverRun::Exited( std::process::ExitStatus::from_raw(code << 8), @@ -762,10 +845,7 @@ mod tests { .unwrap(); assert_eq!(timeout["status"], "rejected"); assert_eq!(timeout["reason"], "inconclusive"); - assert_eq!( - timeout["message"], - TorchPreviewRejectionReason::Inconclusive.message() - ); + assert_eq!(timeout["message"], TORCH_RESOLVER_TIMEOUT_MESSAGE); } #[test] @@ -853,6 +933,7 @@ mod tests { let value = serde_json::to_value(rejected).unwrap(); assert_eq!(value["status"], "rejected"); assert_eq!(value["reason"], "inconclusive"); + assert_eq!(value["message"], TORCH_RESOLVER_TIMEOUT_MESSAGE); } #[tokio::test] @@ -878,7 +959,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()); @@ -888,7 +971,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( @@ -906,6 +989,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); @@ -1177,6 +1268,9 @@ impl TorchPreviewRejectionReason { #[derive(Clone, Debug, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum TorchPreviewOutcome { + Ready { + preview: TorchPreview, + }, Resolved { preview: TorchPreview, }, @@ -1199,6 +1293,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, @@ -1329,11 +1434,16 @@ fn resolver_rejection(run: PreviewResolverRun) -> Option { PreviewResolverRun::Exited(status) if status.success() => return None, PreviewResolverRun::Exited(status) => { let reason = TorchPreviewRejectionReason::from_exit_code(status.code()); - (reason, reason.message_for_exit_code(status.code())) + let message = if status.code() == Some(1) { + TORCH_RESOLVER_FAILURE_MESSAGE + } else { + reason.message_for_exit_code(status.code()) + }; + (reason, message) } PreviewResolverRun::TimedOut => ( TorchPreviewRejectionReason::Inconclusive, - TorchPreviewRejectionReason::Inconclusive.message(), + TORCH_RESOLVER_TIMEOUT_MESSAGE, ), }; Some(TorchPreviewOutcome::Rejected { reason, message }) @@ -1345,6 +1455,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")) @@ -1491,22 +1602,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() @@ -1529,7 +1626,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"}, @@ -1545,7 +1642,151 @@ impl VersionManager { adapter: &str, ) -> Result { if self.app_id != AppId::Torch - || !supported_torch_build(build) + || !(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( + &self, + tag: &str, + build: &str, + python: &str, + adapter: &str, + resolver_deadline: tokio::time::Instant, + ) -> Result { + if self.app_id != AppId::Torch + || !(build == "auto" || supported_torch_build(build)) || (!ADAPTERS.contains(&adapter) && adapter != "bundled") { return Err(failed("Invalid Torch preview selection")); @@ -1567,6 +1808,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, @@ -1613,9 +1855,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, @@ -1648,7 +1923,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( @@ -1679,10 +1960,11 @@ impl VersionManager { let outcome = self .preview_torch_runtime_with_interpreter( tag, - build, + &install_build, adapter, &managed, candidate.wheel.as_ref(), + resolver_deadline, ) .await?; if let Some(outcome) = search.record_attempt(outcome) { @@ -1699,6 +1981,7 @@ impl VersionManager { adapter: &str, managed_python: &super::managed_python::ManagedPythonIdentity, selected_wheel: Option<&TorchReleaseCombination>, + resolver_deadline: tokio::time::Instant, ) -> Result { let python = managed_python.python.as_str(); let interpreter = managed_python.executable.as_path(); @@ -1825,7 +2108,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, @@ -1834,6 +2117,7 @@ impl VersionManager { .await .map_err(PumasError::from)?; let mut command = Command::new(interpreter); + let cache_dir = super::torch_workspace::managed_pip_cache_dir(&self.launcher_root)?; command .arg(&resolver) .args([ @@ -1845,18 +2129,19 @@ impl VersionManager { adapter, "--output", ]) - .arg(workspace.path()); + .arg(workspace.path()) + .arg("--cache-dir") + .arg(cache_dir); if let Some(wheel) = selected_wheel { append_exact_wheel_args(&mut command, wheel); } self.torch_cleanup.drain_residual_child_slots().await?; - let run = run_preview_resolver( - command, - &workspace, - Duration::from_secs(180), - &self.torch_cleanup, - ) - .await?; + let remaining = resolver_deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Ok(resolver_rejection(PreviewResolverRun::TimedOut) + .expect("a timed out resolver must be rejected")); + } + let run = run_preview_resolver(command, &workspace, remaining, &self.torch_cleanup).await?; if matches!(&run, PreviewResolverRun::Exited(status) if status.code() == Some(3)) { log_resolver_rejection_diagnostic(workspace.path()); } 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 000000000..3f47e974c --- /dev/null +++ b/rust/crates/pumas-app-manager/src/version_manager/torch_workspace.rs @@ -0,0 +1,193 @@ +//! 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)) +} + +/// Return Pumas' persistent pip cache shared by artifact checks and installs. +pub(super) fn managed_pip_cache_dir(launcher_root: &Path) -> Result { + let root = fs::canonicalize(launcher_root) + .map_err(|err| PumasError::io_with_path(err, launcher_root))?; + let mut cache = root.clone(); + for component in ["launcher-data", "cache", "pip"] { + cache = checked_child_directory(&cache, component, &root)?; + } + Ok(cache) +} + +/// 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()); + } + + #[test] + fn pip_cache_is_persistent_and_managed() { + let root = tempfile::tempdir().unwrap(); + let expected = fs::canonicalize(root.path()) + .unwrap() + .join("launcher-data/cache/pip"); + + let first = managed_pip_cache_dir(root.path()).unwrap(); + let second = managed_pip_cache_dir(root.path()).unwrap(); + + assert_eq!(first, expected); + assert_eq!(second, expected); + assert!(first.is_dir()); + } + + #[cfg(unix)] + #[test] + fn escaping_pip_cache_symlink_is_rejected_without_touching_target() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let managed_cache = root.path().join("launcher-data/cache"); + fs::create_dir_all(&managed_cache).unwrap(); + let sentinel = outside.path().join("keep"); + fs::write(&sentinel, b"untouched").unwrap(); + symlink(outside.path(), managed_cache.join("pip")).unwrap(); + + assert!(managed_pip_cache_dir(root.path()).is_err()); + assert_eq!(fs::read(&sentinel).unwrap(), b"untouched"); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 1); + } + + #[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-core/src/models/version.rs b/rust/crates/pumas-core/src/models/version.rs index 545d19e4d..49a686cf4 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, @@ -126,6 +129,12 @@ 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_measurement_available: Option, + #[serde(default)] pub download_speed: Option, #[serde(default)] pub eta_seconds: Option, @@ -185,6 +194,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-core/src/network/activity.rs b/rust/crates/pumas-core/src/network/activity.rs new file mode 100644 index 000000000..4cb96f6a5 --- /dev/null +++ b/rust/crates/pumas-core/src/network/activity.rs @@ -0,0 +1,455 @@ +//! 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. 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, + label: Option, +} + +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() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return None; + } + Some(parsed.to_string()) + }); + 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_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(), None); + 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 818a57769..54face835 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 49d63ce3e..e8e727b9f 100644 --- a/rust/crates/pumas-rpc/src/contract.rs +++ b/rust/crates/pumas-rpc/src/contract.rs @@ -1724,6 +1724,9 @@ pub(crate) struct RuntimeInstallationProgress { stage_progress: Option, overall_progress: Option, current_item: Option, + download_source_url: Option, + download_active: bool, + download_measurement_available: Option, download_speed: Option, eta_seconds: Option, total_size: Option, @@ -1742,6 +1745,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 +1795,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, @@ -1801,6 +1806,9 @@ 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_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, @@ -1838,6 +1846,9 @@ 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_measurement_available: Some(true), download_speed: Some(42.5), eta_seconds: Some(0.5), total_size: Some(1024), @@ -1883,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","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); @@ -1899,6 +1910,28 @@ 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"); + #[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] fn installation_progress_preserves_terminal_and_nonfinite_wire() { for success in [false, true] { @@ -3985,6 +4018,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 +4122,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,32 +4166,13 @@ 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::Rejected { reason, .. } => { + 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 => { TorchRuntimePreviewRejectionReason::Unsupported @@ -4139,10 +4187,32 @@ 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." + | "Torch artifact checking timed out. Cached wheel downloads may be reused if you retry." + | "Pip could not complete dependency resolution. Wheel availability is inconclusive; check network or package-index access and retry." + ) => + { + message + } + _ => reason.message(), + }; + Self::Rejected { reason, message } } }) } @@ -4154,6 +4224,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(), }) @@ -4198,22 +4284,122 @@ 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_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 = 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")); + 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() { + use pumas_app_manager::version_manager::{ + TorchPreviewOutcome, TorchPreviewRejectionReason, + }; + + 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.", + ), + ( + TorchPreviewRejectionReason::Inconclusive, + "inconclusive", + "Torch artifact checking timed out. Cached wheel downloads may be reused if you retry.", + ), + ( + TorchPreviewRejectionReason::Inconclusive, + "inconclusive", + "Pip could not complete dependency resolution. Wheel availability is inconclusive; check network or package-index access and retry.", + ), + ] { + 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")] @@ -4260,6 +4446,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/scripts/release/torch-managed-python-acceptance.py b/scripts/release/torch-managed-python-acceptance.py index 09f7a7f2f..1160d0d8c 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" @@ -300,6 +319,105 @@ 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") + + 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 def listing(tag: str = TAG) -> dict: @@ -863,7 +981,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) @@ -1577,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 @@ -1926,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( @@ -2306,6 +2446,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: @@ -2323,7 +2466,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: @@ -2332,6 +2492,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 @@ -2339,7 +2500,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) @@ -2366,7 +2527,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) @@ -2907,54 +3071,91 @@ 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", {"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, 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", {}) @@ -3013,9 +3214,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", @@ -3048,7 +3249,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", @@ -3204,6 +3405,7 @@ def main() -> None: DeclaredLicensePathsFixture, WindowsCanonicalPathFixture, FailureSummaryFixture, + InstallSelectionFixture, VersionPreflightFixture, EvidenceCollectionFixture, LauncherRootFinalizationFixture, diff --git a/torch-server/resolve_runtime.py b/torch-server/resolve_runtime.py index e7c62cbec..b6630f5bc 100644 --- a/torch-server/resolve_runtime.py +++ b/torch-server/resolve_runtime.py @@ -1,24 +1,328 @@ -"""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 import sys +import threading import time from pathlib import Path from pip._vendor.packaging import tags as packaging_tags +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 CORE = ("fastapi", "uvicorn", "psutil", "pillow", "safetensors") +DOWNLOAD_PROGRESS_INTERVAL_SECONDS = 0.25 + + +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 + ): + 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 is not None and not 1 <= port <= 65535) + or parsed.query + or parsed.fragment + or "?" in url.split("#", 1)[0] + or "#" in url + ): + return None + return url + + +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, + measurement_available: bool = True, +) -> 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, + "measurement_available": measurement_available, + } + 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: + _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 + except ImportError: + _write_download_progress(progress_path, None, False, 0, None, None, False) + return pip_main(pip_arguments) + + def track_response(response, chunks): + try: + if pip_download.is_from_cache(response): + return chunks + 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) + 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) + + _write_download_progress(progress_path, None, False, 0, None, None, True) + try: + return pip_main(pip_arguments) + finally: + restore_hook() + + +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("../../"): + 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) + if ( + not relative + or path.is_absolute() + or "\\" in relative + or any(part in ("", ".", "..") for part in relative.split("/")) + ): + 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 + 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", @@ -638,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" @@ -652,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"}: @@ -716,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"] @@ -821,6 +1148,10 @@ 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("--progress-file", type=Path) parser.add_argument("--discover", action="store_true") parser.add_argument("--release-options", action="store_true") parser.add_argument("--selected-python") @@ -837,9 +1168,13 @@ def main() -> None: or args.build 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 + 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: @@ -861,11 +1196,21 @@ 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 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: 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: parser.error("Discovery requires --selected-python and at least one --interpreter") try: @@ -878,7 +1223,12 @@ def main() -> None: return if args.output is None: parser.error("Resolution requires --output") - if args.torch_wheel is None: + 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: target = native_target( @@ -888,10 +1238,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 ): @@ -899,6 +1252,16 @@ 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" torch_requirement = ( args.version @@ -912,7 +1275,9 @@ def main() -> None: "pip", "--isolated", "install", - "--dry-run", + "--cache-dir", + str(cache_dir), + *(["--target", str(args.target)] if args.install else ["--dry-run"]), "--report", str(report_path), "--ignore-installed", @@ -921,12 +1286,27 @@ 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, ] - 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: @@ -940,11 +1320,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( @@ -953,7 +1336,15 @@ 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__": + 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 8742fe217..ebf7c2ec7 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,538 @@ def report( class ResolverTests(unittest.TestCase): + 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://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 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_any_safe_https_download_source(self): + pip_download = importlib.import_module("pip._internal.network.download") + pip_cli = importlib.import_module("pip._internal.cli.main") + source = "https://cdn.example.net/releases/package.whl" + response = SimpleNamespace(url=source, 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.assertEqual(progress["source_url"], source) + 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") + source = "https://cdn.example.net/releases/package.whl" + response = SimpleNamespace(url=source, headers={"Content-Length": "4"}) + progress_writes = [] + write_progress = resolver._write_download_progress + + 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.response_chunks(response, chunk_size=2)) 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", 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), + ): + 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_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") + 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(resp, link, progress_bar): + del resp, 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, "response_chunks", None), + 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), + ): + self.assertEqual( + 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)) + 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) + 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_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 = [] + + 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" + progress_file = output / "download-progress.json" + with ( + patch.object( + resolver.sys, + "argv", + [ + "resolve_runtime.py", + "--version", + "2.10.0", + "--build", + "cpu", + "--install", + "--target", + str(target), + "--progress-file", + str(progress_file), + "--output", + str(output), + ], + ), + patch.object(resolver, "native_target", return_value="linux"), + patch.object(resolver.subprocess, "run", side_effect=fake_run), + ): + 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( + 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, "native_target", return_value="linux"), + 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"), @@ -443,6 +977,7 @@ def test_cli_pins_discovered_macos_wheel_and_resolves_dependencies(self): digest = "a" * 64 fixture = report(version="2.14.0", url=wheel) commands = [] + cache_dir = pathlib.Path(tempfile.gettempdir()) / "Pumas shared pip cache" def fake_run(command, **_kwargs): commands.append(command) @@ -452,6 +987,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 +1003,9 @@ def fake_run(command, **_kwargs): "--torch-sha256", digest, "--output", - directory, + str(output), + "--cache-dir", + str(cache_dir), ], ), patch.object(resolver.sys, "platform", "darwin"), @@ -484,11 +1022,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(cache_dir)) 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", ) @@ -957,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"