diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d3a7b3..6eab959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,10 +206,11 @@ jobs: if: >- needs.scope.outputs.run_suite == 'true' && matrix.python-version == '3.14' && - inputs.package_artifact_name != '' + (needs.scope.outputs.run_desktop == 'true' || + inputs.package_artifact_name != '') uses: actions/upload-artifact@v7 with: - name: ${{ inputs.package_artifact_name }} + name: ${{ inputs.package_artifact_name || 'vidxp-python-dist' }} path: dist/ if-no-files-found: error retention-days: 30 @@ -268,10 +269,13 @@ jobs: if: >- github.event_name == 'pull_request' && needs.scope.outputs.run_desktop == 'true' - needs: scope + needs: + - scope + - validate uses: ./.github/workflows/desktop.yml with: checkout_ref: ${{ github.sha }} + package_artifact_name: ${{ inputs.package_artifact_name || 'vidxp-python-dist' }} required: if: always() && github.event_name == 'pull_request' diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 0afcf30..e0c6d56 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -17,6 +17,11 @@ on: required: false default: false type: boolean + package_artifact_name: + description: Validated Python distribution to embed in every installer. + required: false + default: "" + type: string workflow_dispatch: inputs: checkout_ref: @@ -34,6 +39,11 @@ on: required: false default: false type: boolean + package_artifact_name: + description: Existing Python distribution artifact; otherwise build from checkout. + required: false + default: "" + type: string permissions: contents: read @@ -55,6 +65,30 @@ jobs: with: ref: ${{ inputs.checkout_ref || github.event.inputs.checkout_ref || github.sha }} + - uses: actions/download-artifact@v8 + if: inputs.package_artifact_name != '' + with: + name: ${{ inputs.package_artifact_name }} + path: dist/ + + - uses: actions/setup-python@v7 + if: inputs.package_artifact_name == '' + with: + python-version: "3.14" + + - name: Build the Python distribution for a standalone Desktop build + if: inputs.package_artifact_name == '' + shell: bash + run: | + python -m pip install -r utils/build-requirements.txt + bash utils/build_package.sh + + - name: Verify the embedded Python package input + shell: bash + run: | + [[ "$(find dist -maxdepth 1 -name '*.whl' | wc -l | tr -d ' ')" == "1" ]] + [[ "$(find dist -maxdepth 1 -name '*.tar.gz' | wc -l | tr -d ' ')" == "1" ]] + - name: Install Linux desktop build dependencies if: runner.os == 'Linux' run: | diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index d8bccb9..db5e50a 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -106,11 +106,14 @@ jobs: run_containers: false desktop: - needs: contract + needs: + - contract + - core uses: ./.github/workflows/desktop.yml with: artifact_retention_days: 30 checkout_ref: ${{ inputs.head_sha }} + package_artifact_name: vidxp-python-dist sign: true secrets: inherit diff --git a/README.md b/README.md index e79b8b7..7dcc62f 100644 --- a/README.md +++ b/README.md @@ -179,14 +179,18 @@ plugin packaging for additional ChatGPT surfaces will follow separately. First setup downloads only the models needed for the capabilities you select. VidXP shows the download size and destination before it starts. +The Desktop-managed Python runtime and its selected dependencies can use +approximately 3 GiB. + | Capability | Approximate model download | |---|---:| | Dialogue search | 2.64 GiB | | Scene search | 1.43 GiB | | Actor matching | 37 MiB | -Leave additional space for the VidXP runtime, indexes, source videos, and -exported results. +A full local Desktop setup with every search capability uses approximately +7.1 GiB. Leave additional temporary space during installation and for indexes, +source videos, and exported results. By default, the CLI and desktop app share the same VidXP data directory: diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 8af7082..3709052 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -9,6 +9,53 @@ fn main() { let expected = manifest["uv_version"] .as_str() .expect("runtime manifest must contain uv_version"); + let package_name = manifest["package_name"] + .as_str() + .expect("runtime manifest must contain package_name"); + let package_version = manifest["package_version"] + .as_str() + .expect("runtime manifest must contain package_version"); + let wheel_version = package_version.replace("-b.", "b").replace("-b", "b"); + let wheel_prefix = format!("{}-{wheel_version}-", package_name.replace('-', "_")); + let distribution_directory = Path::new("../..").join("dist"); + let wheels = std::fs::read_dir(&distribution_directory) + .unwrap_or_else(|error| { + panic!( + "{} is unavailable; build the Python distribution before Desktop: {error}", + distribution_directory.display() + ) + }) + .map(|entry| { + entry + .expect("the distribution directory must be readable") + .path() + }) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(&wheel_prefix) && name.ends_with(".whl")) + }) + .collect::>(); + assert_eq!( + wheels.len(), + 1, + "Desktop requires exactly one {package_name} {package_version} wheel in {}; found {}", + distribution_directory.display(), + wheels.len() + ); + let wheel = &wheels[0]; + let wheel_name = wheel + .file_name() + .and_then(|name| name.to_str()) + .expect("the runtime wheel name must be valid UTF-8"); + let wheel_bytes = std::fs::read(wheel).expect("the runtime wheel must be readable"); + let wheel_digest = + Sha256::digest(&wheel_bytes) + .iter() + .fold(String::with_capacity(64), |mut encoded, byte| { + write!(&mut encoded, "{byte:02x}").expect("writing to a string cannot fail"); + encoded + }); let target = std::env::var("TARGET").expect("Cargo must provide TARGET"); let suffix = if target.contains("windows") { ".exe" @@ -34,9 +81,22 @@ fn main() { expected ); - let constraints = - PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo must provide OUT_DIR")) - .join("runtime-constraints.txt"); + let output_directory = + PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo must provide OUT_DIR")); + let constraints = output_directory.join("runtime-constraints.txt"); + std::fs::write(output_directory.join("runtime-package.whl"), &wheel_bytes) + .expect("Cargo must be able to embed the runtime wheel"); + std::fs::write( + output_directory.join("runtime-package-name.txt"), + wheel_name, + ) + .expect("Cargo must be able to embed the runtime wheel name"); + std::fs::write( + output_directory.join("runtime-package-sha256.txt"), + &wheel_digest, + ) + .expect("Cargo must be able to embed the runtime wheel digest"); + manifest["package_wheel_sha256"] = serde_json::Value::String(wheel_digest); let project = Path::new("../.."); let export = std::process::Command::new(&sidecar) .args([ @@ -90,6 +150,7 @@ fn main() { .expect("Cargo must be able to write the embedded runtime manifest"); println!("cargo:rerun-if-changed=../../pyproject.toml"); println!("cargo:rerun-if-changed=../../uv.lock"); + println!("cargo:rerun-if-changed=../../dist"); println!("cargo:rerun-if-changed=../runtime-manifest.json"); let attributes = tauri_build::Attributes::new(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9ec8f40..1ab1967 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -18,7 +18,7 @@ use atomic_write_file::AtomicWriteFile; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tauri::{ - AppHandle, Manager, RunEvent, WindowEvent, + AppHandle, Emitter, Manager, RunEvent, WindowEvent, menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}, tray::TrayIconBuilder, }; @@ -44,6 +44,12 @@ const RUNTIME_MANIFEST_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/runtime-manifest.json")); const RUNTIME_CONSTRAINTS_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/runtime-constraints.txt")); +const RUNTIME_PACKAGE_WHEEL_BYTES: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/runtime-package.whl")); +const RUNTIME_PACKAGE_WHEEL_NAME: &str = + include_str!(concat!(env!("OUT_DIR"), "/runtime-package-name.txt")); +const RUNTIME_PACKAGE_WHEEL_SHA256: &str = + include_str!(concat!(env!("OUT_DIR"), "/runtime-package-sha256.txt")); const MODEL_CACHE_CATALOG_BYTES: &[u8] = include_bytes!("../../model-cache-catalog.json"); const PRODUCT_DATA_DIRECTORY_NAME: &str = "VidXP"; const RUNTIME_CONSTRAINTS_FILE_NAME: &str = "runtime-constraints.txt"; @@ -117,6 +123,70 @@ struct InstallTransitionResult { setup: target_profiles::TargetState, } +#[derive(Clone, Serialize)] +struct ManagedSetupProgress { + draft_id: String, + current: u8, + total: u8, + stage: String, + message: String, + model_message: Option, + model_current: Option, + model_total: Option, +} + +fn emit_managed_setup_progress( + app: &AppHandle, + draft_id: &str, + current: u8, + total: u8, + stage: &str, + message: &str, +) { + let _ = app.emit( + "managed-setup-progress", + ManagedSetupProgress { + draft_id: draft_id.into(), + current, + total, + stage: stage.into(), + message: message.into(), + model_message: None, + model_current: None, + model_total: None, + }, + ); +} + +fn emit_managed_model_progress( + app: &AppHandle, + draft_id: &str, + current: u8, + total: u8, + progress: &ManagedModelJobProgress, +) { + let _ = app.emit( + "managed-setup-progress", + ManagedSetupProgress { + draft_id: draft_id.into(), + current, + total, + stage: "models".into(), + message: "Verifying and downloading selected model files".into(), + model_message: Some(progress.message.clone()), + model_current: progress.current, + model_total: progress.total, + }, + ); +} + +#[derive(Deserialize)] +struct ManagedModelJobProgress { + message: String, + current: Option, + total: Option, +} + #[derive(Serialize)] struct RuntimeStatus { state: RuntimeState, @@ -1077,6 +1147,19 @@ fn package_specification_for_version( capabilities: &[String], surfaces: &[String], version: &str, +) -> String { + let extras = package_extras(manifest, capabilities, surfaces); + if extras.is_empty() { + format!("{}=={}", manifest.package_name, version) + } else { + format!("{}[{}]=={}", manifest.package_name, extras, version) + } +} + +fn package_extras( + manifest: &RuntimeManifest, + capabilities: &[String], + surfaces: &[String], ) -> String { let local_worker_selected = surfaces.iter().any(|name| name == "worker"); let extras: BTreeSet<_> = manifest @@ -1091,12 +1174,7 @@ fn package_specification_for_version( .map(|name| manifest.capabilities[name].extra.clone()), ) .collect(); - let extras = extras.into_iter().collect::>().join(","); - if extras.is_empty() { - format!("{}=={}", manifest.package_name, version) - } else { - format!("{}[{}]=={}", manifest.package_name, extras, version) - } + extras.into_iter().collect::>().join(",") } fn external_installation_arguments( @@ -1151,7 +1229,12 @@ fn base_package_specification(manifest: &RuntimeManifest) -> String { format!("{}=={}", manifest.package_name, manifest.package_version) } -fn package_acquisition_arguments(manifest: &RuntimeManifest, python: &Path) -> Vec { +fn package_acquisition_arguments( + manifest: &RuntimeManifest, + python: &Path, + wheel: &Path, +) -> Vec { + let wheel_directory = wheel.parent().unwrap_or_else(|| Path::new(".")); vec![ "pip".into(), "install".into(), @@ -1159,14 +1242,30 @@ fn package_acquisition_arguments(manifest: &RuntimeManifest, python: &Path) -> V python.to_string_lossy().into_owned(), "--no-config".into(), "--no-deps".into(), - "--default-index".into(), - manifest.dependency_index.clone(), - "--index-strategy".into(), - "first-index".into(), + "--no-index".into(), + "--find-links".into(), + wheel_directory.to_string_lossy().into_owned(), base_package_specification(manifest), ] } +fn stage_runtime_package_wheel(runtime: &Path) -> Result { + let wheel_name = Path::new(RUNTIME_PACKAGE_WHEEL_NAME); + if wheel_name.file_name().and_then(|name| name.to_str()) != Some(RUNTIME_PACKAGE_WHEEL_NAME) { + return Err("The embedded runtime wheel name is invalid.".into()); + } + let actual = hex::encode(Sha256::digest(RUNTIME_PACKAGE_WHEEL_BYTES)); + if actual != RUNTIME_PACKAGE_WHEEL_SHA256 { + return Err(format!( + "The embedded runtime wheel has digest {actual}; expected {RUNTIME_PACKAGE_WHEEL_SHA256}." + )); + } + let wheel = runtime.join(wheel_name); + fs::write(&wheel, RUNTIME_PACKAGE_WHEEL_BYTES) + .map_err(|error| format!("Could not stage the embedded VidXP package: {error}"))?; + Ok(wheel) +} + struct UvInvocation { arguments: Vec, working_directory: PathBuf, @@ -1199,6 +1298,8 @@ fn dependency_installation_invocation( manifest.dependency_index.clone(), "--index-strategy".into(), "first-index".into(), + "--find-links".into(), + ".".into(), "--constraints".into(), constraints_file_name.to_string_lossy().into_owned(), ]; @@ -1222,12 +1323,16 @@ fn capability_command_arguments( .map(|name| manifest.capabilities[name].modality.as_str()) .collect::>() .join(","); - vec![ + let mut arguments = vec![ operation.into(), "--json".into(), "--modalities".into(), modalities, - ] + ]; + if operation == "prepare" { + arguments.push("--yes".into()); + } + arguments } fn executable(runtime: &Path, name: &str) -> PathBuf { @@ -1942,10 +2047,39 @@ async fn supervised_output( } let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - let detail = if stderr.is_empty() { stdout } else { stderr }; + let detail = match (stdout.is_empty(), stderr.is_empty()) { + (false, false) => format!("{stdout}\n\nAdditional diagnostics:\n{stderr}"), + (false, true) => stdout, + (true, false) => stderr, + (true, true) => "The process did not return an error message.".into(), + }; Err(format!("{operation} failed ({}): {detail}", output.status)) } +fn watch_managed_model_progress( + app: &AppHandle, + draft_id: &str, + progress_path: &Path, + current: u8, + total: u8, + stop: &AtomicBool, +) { + let mut last_contents = None; + loop { + if let Ok(contents) = fs::read(progress_path) + && last_contents.as_deref() != Some(contents.as_slice()) + && let Ok(progress) = serde_json::from_slice(&contents) + { + emit_managed_model_progress(app, draft_id, current, total, &progress); + last_contents = Some(contents); + } + if stop.load(Ordering::Acquire) { + break; + } + thread::sleep(Duration::from_millis(100)); + } +} + async fn uv_output( app: &AppHandle, paths: &DesktopPaths, @@ -2683,17 +2817,26 @@ async fn install_runtime( .duration_since(UNIX_EPOCH) .map_err(|error| format!("The system clock is invalid: {error}"))? .as_nanos(); - let staging_name = format!(".staging-{profile_hash}-{timestamp}-{}", std::process::id()); - let staging = paths.runtimes.join(&staging_name); - let constraints = staging.join(RUNTIME_CONSTRAINTS_FILE_NAME); + let profile = format!("{profile_hash}-{timestamp}"); + let runtime = paths.runtimes.join(&profile); + let constraints = runtime.join(RUNTIME_CONSTRAINTS_FILE_NAME); + let progress_total = if request.prepare_models { 8 } else { 7 }; let install_result = async { + emit_managed_setup_progress( + &app, + &request.draft_id, + 2, + progress_total, + "python", + "Preparing an isolated Python runtime", + ); uv_output( &app, &paths, vec![ "venv".into(), - staging.to_string_lossy().into_owned(), + runtime.to_string_lossy().into_owned(), "--python".into(), manifest.python_version.clone(), "--managed-python".into(), @@ -2706,17 +2849,31 @@ async fn install_runtime( .await?; let constraints_path = constraints.clone(); - tauri::async_runtime::spawn_blocking(move || { + let wheel_runtime = runtime.clone(); + let runtime_wheel = tauri::async_runtime::spawn_blocking(move || { fs::write(&constraints_path, normalized_runtime_constraints().as_ref()) - .map_err(|error| format!("Could not write runtime constraints: {error}")) + .map_err(|error| format!("Could not write runtime constraints: {error}"))?; + stage_runtime_package_wheel(&wheel_runtime) }) .await .map_err(|error| format!("Runtime constraint staging stopped unexpectedly: {error}"))??; + emit_managed_setup_progress( + &app, + &request.draft_id, + 3, + progress_total, + "package", + "Acquiring the VidXP package", + ); uv_output( &app, &paths, - package_acquisition_arguments(&manifest, &executable(&staging, "python")), + package_acquisition_arguments( + &manifest, + &executable(&runtime, "python"), + &runtime_wheel, + ), None, cancellation.token(), "VidXP package acquisition", @@ -2727,10 +2884,18 @@ async fn install_runtime( &manifest, &capabilities, &surfaces, - &executable(&staging, "python"), + &executable(&runtime, "python"), &constraints, !cfg!(target_os = "macos"), )?; + emit_managed_setup_progress( + &app, + &request.draft_id, + 4, + progress_total, + "dependencies", + "Installing the selected search features", + ); uv_output( &app, &paths, @@ -2740,9 +2905,22 @@ async fn install_runtime( "VidXP package installation", ) .await?; + if let Err(error) = fs::remove_file(&runtime_wheel) { + log::warn!( + "Installed the embedded VidXP package, but could not remove its staged wheel: {error}" + ); + } + emit_managed_setup_progress( + &app, + &request.draft_id, + 5, + progress_total, + "media", + "Configuring FFmpeg and video codecs", + ); run_vidxp_supervised( - &staging, + &runtime, &paths, &[ "init".into(), @@ -2757,9 +2935,18 @@ async fn install_runtime( ) .await?; - let doctor_arguments = capability_command_arguments(&manifest, "doctor", &capabilities); + emit_managed_setup_progress( + &app, + &request.draft_id, + 6, + progress_total, + "validation", + "Validating installed packages and video tools", + ); + let mut doctor_arguments = capability_command_arguments(&manifest, "doctor", &capabilities); + doctor_arguments.push("--no-models".into()); run_vidxp_supervised( - &staging, + &runtime, &paths, &doctor_arguments, cancellation.token(), @@ -2768,48 +2955,87 @@ async fn install_runtime( .await?; if request.prepare_models { - let prepare_arguments = + emit_managed_setup_progress( + &app, + &request.draft_id, + 7, + progress_total, + "models", + "Verifying and downloading selected model files", + ); + let progress_path = runtime.join(".managed-model-progress.json"); + let mut prepare_arguments = capability_command_arguments(&manifest, "prepare", &capabilities); - let mut worker = state.worker_stop.register(staging.clone(), paths.clone())?; + prepare_arguments.push("--progress-file".into()); + prepare_arguments.push(progress_path.to_string_lossy().into_owned()); + let mut worker = state.worker_stop.register(runtime.clone(), paths.clone())?; + let preparation_app = app.clone(); + let preparation_draft_id = request.draft_id.clone(); + let monitor_stop = Arc::new(AtomicBool::new(false)); + let monitor_stop_worker = monitor_stop.clone(); + let progress_path_worker = progress_path.clone(); + let progress_monitor = thread::spawn(move || { + watch_managed_model_progress( + &preparation_app, + &preparation_draft_id, + &progress_path_worker, + 7, + progress_total, + &monitor_stop_worker, + ); + }); let preparation = run_vidxp_supervised( - &staging, + &runtime, &paths, &prepare_arguments, cancellation.token(), "VidXP model preparation", ) .await; + monitor_stop.store(true, Ordering::Release); + let monitor_result = progress_monitor.join(); + let _ = fs::remove_file(&progress_path); worker.stop_before(Instant::now() + Duration::from_secs(5)); preparation?; + monitor_result + .map_err(|_| "VidXP model progress stopped unexpectedly".to_owned())?; } Ok::<(), String>(()) } .await; if let Err(error) = install_result { - let failed_staging = staging.clone(); + let failed_runtime = runtime.clone(); let cleanup_error = tauri::async_runtime::spawn_blocking(move || { - if failed_staging.exists() { - fs::remove_dir_all(&failed_staging).err() + if failed_runtime.exists() { + fs::remove_dir_all(&failed_runtime).err() } else { None } }) .await - .map_err(|join| format!("{error}. Staged-runtime cleanup stopped unexpectedly: {join}"))?; + .map_err(|join| { + format!("{error}. Candidate-runtime cleanup stopped unexpectedly: {join}") + })?; return Err(match cleanup_error { Some(cleanup_error) => format!( - "{error}. The previous active runtime was not changed. VidXP could not remove the failed staged runtime at {}: {cleanup_error}", - staging.display() + "{error}. The previous active runtime was not changed. VidXP could not remove the failed candidate runtime at {}: {cleanup_error}", + runtime.display() ), None => format!( - "{error}. The previous active runtime was not changed, and the failed staged runtime was removed." + "{error}. The previous active runtime was not changed, and the failed candidate runtime was removed." ), }); } - let profile = format!("{profile_hash}-{timestamp}"); - let runtime = paths.runtimes.join(&profile); + emit_managed_setup_progress( + &app, + &request.draft_id, + progress_total, + progress_total, + "activation", + "Activating VidXP and cleaning up installation files", + ); let active = ActiveRuntime { schema_version: 2, manifest_sha256: manifest_digest(), @@ -2820,29 +3046,20 @@ async fn install_runtime( model_directory: paths.models.clone(), }; let activation_app = app.clone(); + let activation_cancellation = cancellation.token(); + let cache_paths = paths.clone(); let activation_paths = paths; let activation_manifest_version = manifest.desktop_version.clone(); let activation = tauri::async_runtime::spawn_blocking(move || { let previous_active_bytes = read_active_runtime_snapshot(&activation_paths)?; let previous_targets = target_profiles::current_state(&activation_app).map_err(|error| error.to_string())?; - if let Err(error) = fs::rename(&staging, &runtime) { - let cleanup = fs::remove_dir_all(&staging); - return Err(match cleanup { - Ok(()) => format!("Could not finalize the validated runtime: {error}"), - Err(cleanup) => format!( - "Could not finalize the validated runtime: {error}. The staging directory at {} could not be removed: {cleanup}", - staging.display() - ), - }); - } - let projection = managed_runtime_projection_for(&activation_paths, &active); let validated = validate_managed_projection( &activation_paths, &projection, &activation_manifest_version, - Some(&cancellation.token()), + Some(&activation_cancellation), ); let candidate_targets = match validated.and_then(|validated| { target_profiles::prepare_managed_activation( @@ -2936,6 +3153,18 @@ async fn install_runtime( }) .await .map_err(|error| format!("Managed activation stopped unexpectedly: {error}"))??; + if let Err(error) = uv_output( + &app, + &cache_paths, + vec!["cache".into(), "prune".into(), "--ci".into()], + None, + cancellation.token(), + "VidXP installation cache cleanup", + ) + .await + { + log::warn!("VidXP was activated, but its installation cache could not be pruned: {error}"); + } stop_ui_process(&state); stop_api_process(&state); transition.commit_draft(); @@ -4418,17 +4647,18 @@ mod tests { use super::{ ActivationJournal, ActivationRecovery, ActivationStage, ActiveRuntime, DesktopAction, DesktopActivation, DesktopCloseAction, DesktopState, DraftPhase, DraftRecord, - ManagedSetupDraft, RUNTIME_CONSTRAINTS_FILE_NAME, TargetTransitionCoordinator, - TransitionKind, UiProcessAction, WorkerStopSupervisor, action_for_activation, - activation_recovery, base_package_specification, capability_command_arguments, - claim_browser_open, clean_environment_from, close_action, configure_ui_service_command, - configured_runtime_status, dependency_installation_invocation, desktop_paths_from_roots, - display_command, external_installation_arguments, external_installation_version, - inventory_model_directory, manifest, manifest_digest, normalize_line_endings, - normalized_runtime_constraints, package_acquisition_arguments, package_specification, - read_active_runtime_snapshot, reconcile_managed_runtime_storage, required_encoder_missing, - restore_active_runtime, selected_capabilities, selected_surfaces, ui_process_action, - write_activation_journal, write_active_runtime, + ManagedSetupDraft, RUNTIME_CONSTRAINTS_FILE_NAME, RUNTIME_PACKAGE_WHEEL_NAME, + TargetTransitionCoordinator, TransitionKind, UiProcessAction, WorkerStopSupervisor, + action_for_activation, activation_recovery, base_package_specification, + capability_command_arguments, claim_browser_open, clean_environment_from, close_action, + configure_ui_service_command, configured_runtime_status, + dependency_installation_invocation, desktop_paths_from_roots, display_command, + external_installation_arguments, external_installation_version, inventory_model_directory, + manifest, manifest_digest, normalize_line_endings, normalized_runtime_constraints, + package_acquisition_arguments, package_specification, read_active_runtime_snapshot, + reconcile_managed_runtime_storage, required_encoder_missing, restore_active_runtime, + selected_capabilities, selected_surfaces, ui_process_action, write_activation_journal, + write_active_runtime, }; use std::{ ffi::OsStr, @@ -5048,12 +5278,13 @@ mod tests { } #[test] - fn package_and_dependencies_use_channel_specific_indexes() { + fn managed_install_uses_the_bundled_package_and_public_dependency_index() { let manifest = manifest().expect("manifest"); let python = Path::new("managed-python"); let constraints = Path::new("staging").join(RUNTIME_CONSTRAINTS_FILE_NAME); + let wheel = Path::new("staging").join(RUNTIME_PACKAGE_WHEEL_NAME); let selected_package_index = manifest.dependency_index.as_str(); - let acquisition = package_acquisition_arguments(&manifest, python); + let acquisition = package_acquisition_arguments(&manifest, python, &wheel); let dependency_installation = dependency_installation_invocation( &manifest, &["scene".into()], @@ -5068,8 +5299,18 @@ mod tests { assert_eq!(selected_package_index, "https://pypi.org/simple"); assert_eq!(manifest.dependency_index, "https://pypi.org/simple"); assert!(acquisition.iter().any(|item| item == "--no-deps")); + assert!(acquisition.iter().any(|item| item == "--no-index")); assert!( acquisition + .windows(2) + .any(|items| items == ["--find-links", "staging"]) + ); + assert_eq!( + acquisition.last(), + Some(&base_package_specification(&manifest)) + ); + assert!( + !acquisition .iter() .any(|item| item == selected_package_index) ); @@ -5085,6 +5326,15 @@ mod tests { .windows(2) .any(|items| items == ["--constraints", "runtime-constraints.txt"]) ); + assert!( + dependencies + .windows(2) + .any(|items| items == ["--find-links", "."]) + ); + assert_eq!( + dependencies.last(), + Some(&package_specification(&manifest, &["scene".into()], &[])) + ); assert_eq!( dependency_installation.working_directory, Path::new("staging") @@ -5102,7 +5352,6 @@ mod tests { .join("runtimes") .join("staging") .join(RUNTIME_CONSTRAINTS_FILE_NAME); - let invocation = dependency_installation_invocation( &manifest, &["scene".into()], @@ -5131,6 +5380,10 @@ mod tests { capability_command_arguments(&manifest, "doctor", &["dialogue".into(), "scene".into()]), ["doctor", "--json", "--modalities", "dialogue,scene"] ); + assert_eq!( + capability_command_arguments(&manifest, "prepare", &["scene".into()]), + ["prepare", "--json", "--modalities", "scene", "--yes"] + ); } #[test] diff --git a/desktop/src-tauri/src/target_profiles.rs b/desktop/src-tauri/src/target_profiles.rs index 183f985..6739e04 100644 --- a/desktop/src-tauri/src/target_profiles.rs +++ b/desktop/src-tauri/src/target_profiles.rs @@ -594,9 +594,14 @@ fn validate_executable_with( let request_id = challenge_for(&canonical)?; let output = run_probe(&canonical, desktop_version, &request_id)?; if !output.success { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); return Err(TargetError::new( TargetErrorCode::ProbeFailed, - "The selected executable rejected the VidXP compatibility probe.", + if detail.is_empty() { + "The selected executable rejected the VidXP compatibility probe.".into() + } else { + format!("The compatibility probe failed: {detail}") + }, )); } let document: ProbeDocument = serde_json::from_slice(&output.stdout).map_err(|_| { @@ -1705,18 +1710,16 @@ mod tests { .code, TargetErrorCode::ProbeTimeout ); - assert_eq!( - validate_executable_with(&executable, "0.4.0-b", |_, _, _| { - Ok(ProbeOutput { - success: false, - stdout: Vec::new(), - stderr: Vec::new(), - }) + let failed = validate_executable_with(&executable, "0.4.0-b", |_, _, _| { + Ok(ProbeOutput { + success: false, + stdout: Vec::new(), + stderr: b"embedded interpreter path is unavailable".to_vec(), }) - .expect_err("failed") - .code, - TargetErrorCode::ProbeFailed - ); + }) + .expect_err("failed"); + assert_eq!(failed.code, TargetErrorCode::ProbeFailed); + assert!(failed.message.contains("embedded interpreter path")); } #[test] diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index b29b02e..80d7276 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({ chooseLocalExecutable: vi.fn(), inspectLocalTarget: vi.fn(), activateLocalTarget: vi.fn(), selectTargetProfile: vi.fn(), deleteTargetProfile: vi.fn(), confirmForgetTarget: vi.fn(), beginManagedSetup: vi.fn(), cancelManagedSetup: vi.fn(), installMediaRuntime: vi.fn(), installRuntime: vi.fn(), - prepareManagedModels: vi.fn(), + prepareManagedModels: vi.fn(), onManagedSetupProgress: vi.fn(), runtimeManifest: vi.fn(), runtimeStatus: vi.fn(), launchUi: vi.fn(), chooseModelDirectory: vi.fn(), modelDirectoryInventory: vi.fn(), targetDoctor: vi.fn(), mcpClientConfig: vi.fn(), localServerStatus: vi.fn(), localWorkerStatus: vi.fn(), browserServiceStatus: vi.fn(), @@ -98,6 +98,7 @@ describe('desktop target lifecycle', () => { mocks.runtimeStatus.mockResolvedValue({ state: 'never_configured', ready: false, runtime_profile: null, package_version: '0.4.0', capabilities: [], surfaces: [], model_directory: 'C:\\Models', detail: 'No managed runtime yet.' }); mocks.modelDirectoryInventory.mockResolvedValue({ directory: 'C:\\Models', exists: false, readable: true, total_bytes: 0, file_count: 0, recognized_models: [], empty: true, verification_required: false, truncated: false, detail: 'Empty.' }); mocks.installMediaRuntime.mockResolvedValue({ ready: true }); + mocks.onManagedSetupProgress.mockResolvedValue(vi.fn()); mocks.installRuntime.mockResolvedValue({ install: { package_version: '0.4.0', capabilities: ['scene'], surfaces: ['worker', 'browser'], model_directory: 'C:\\Models', prepared: true }, setup: { profiles: [managedProfile], selected_profile_id: managedProfile.id, issues: [] }, @@ -447,6 +448,50 @@ describe('desktop target lifecycle', () => { expect(mocks.launchUi).not.toHaveBeenCalled(); }); + it('blocks setup interaction and reports managed installation stages', async () => { + const media = deferred<{ ready: boolean }>(); + mocks.installMediaRuntime.mockReturnValue(media.promise); + let reportProgress: ((progress: { draft_id: string; current: number; total: number; stage: string; message: string; model_message?: string; model_current?: number; model_total?: number }) => void) | undefined; + mocks.onManagedSetupProgress.mockImplementation(async (handler) => { + reportProgress = handler; + return vi.fn(); + }); + const user = userEvent.setup(); renderApp(); await enterManaged(user); + + await user.click(screen.getByRole('button', { name: 'Install VidXP' })); + + expect(screen.getByRole('dialog', { name: 'Setting up VidXP' })).toBeVisible(); + expect(screen.getByText('Step 1 of 8')).toBeVisible(); + expect(screen.getByText('Checking FFmpeg and required video codecs')).toBeVisible(); + reportProgress?.({ draft_id: 'draft-1', current: 4, total: 8, stage: 'dependencies', message: 'Installing the selected search features' }); + expect(await screen.findByText('Step 4 of 8')).toBeVisible(); + expect(screen.getByText('Installing the selected search features')).toBeVisible(); + reportProgress?.({ + draft_id: 'draft-1', + current: 7, + total: 8, + stage: 'models', + message: 'Verifying and downloading selected model files', + model_message: 'Preparing model artifacts.', + }); + expect(await screen.findByText('Preparing model artifacts.')).toBeVisible(); + reportProgress?.({ + draft_id: 'draft-1', + current: 7, + total: 8, + stage: 'models', + message: 'Verifying and downloading selected model files', + model_message: 'Downloading dialogue transcription model.', + model_current: 512 * 1024 * 1024, + model_total: 1024 * 1024 * 1024, + }); + expect(await screen.findByText('Downloading dialogue transcription model.')).toBeVisible(); + expect(screen.getByText('512.0 MiB of 1.00 GiB')).toBeVisible(); + expect(screen.getByRole('progressbar', { name: 'Current model download progress' })).toHaveAttribute('aria-valuenow', '50'); + + media.resolve({ ready: true }); + }); + it('coalesces duplicate managed Continue actions', async () => { const pending = deferred<{ id: string; previous_profile_id: null }>(); mocks.beginManagedSetup.mockReturnValue(pending.promise); diff --git a/desktop/src/components/ManagedSetup.tsx b/desktop/src/components/ManagedSetup.tsx index e00b747..c88b5f5 100644 --- a/desktop/src/components/ManagedSetup.tsx +++ b/desktop/src/components/ManagedSetup.tsx @@ -5,6 +5,8 @@ import { Checkbox, Group, Loader, + Modal, + Progress, Stack, Switch, Text, @@ -21,12 +23,14 @@ import { installRuntime, launchUi, modelDirectoryInventory, + onManagedSetupProgress, prepareManagedModels, runtimeManifest, runtimeStatus, type RuntimeManifest, type RuntimeStatus, type ModelDirectoryInventory, + type ManagedSetupProgress, type TargetSetupState, } from '../tauri'; import { useExclusiveOperation } from '../useAsyncAction'; @@ -51,6 +55,8 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o const [operation, setOperation] = useState('load'); const [message, setMessage] = useState('Loading VidXP options…'); const [failure, setFailure] = useState(null); + const [setupProgress, setSetupProgress] = useState(null); + const [setupElapsed, setSetupElapsed] = useState(0); const operations = useExclusiveOperation(); const initialLoad = useRef { + let active = true; + let stop: (() => void) | undefined; + void onManagedSetupProgress((progress) => { + if (active && progress.draft_id === draftId) setSetupProgress(progress); + }).then((unlisten) => { + if (active) stop = unlisten; + else unlisten(); + }); + return () => { + active = false; + stop?.(); + }; + }, [draftId]); + + useEffect(() => { + if (operation !== 'install') { + setSetupElapsed(0); + return undefined; + } + const started = Date.now(); + const timer = window.setInterval(() => setSetupElapsed(Math.floor((Date.now() - started) / 1000)), 1000); + return () => window.clearInterval(timer); + }, [operation]); + function toggleValue(value: string, checked: boolean, setter: (next: string[]) => void, current: string[]) { setter(checked ? [...current, value] : current.filter((item) => item !== value)); } @@ -173,6 +204,13 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o draft_id: draftId, }; setFailure(null); + setSetupProgress({ + draft_id: draftId, + current: 1, + total: captured.prepare_models ? 8 : 7, + stage: 'video-tools', + message: 'Checking FFmpeg and required video codecs', + }); try { setMessage('Checking FFmpeg and required codecs…'); await installMediaRuntime(draftId); @@ -196,6 +234,7 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o setFailure(errorMessage(error, 'Setup did not finish. Your previous VidXP installation is unchanged.')); } finally { settleOperation(operationId); + setSetupProgress(null); } } @@ -263,6 +302,8 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o const isBusy = operation !== null; const attentionTitle = /ffmpeg|ffprobe/i.test(message) ? 'Video tools need attention' : 'VidXP needs attention'; + const progressCurrent = setupProgress?.current ?? 1; + const progressTotal = setupProgress?.total ?? (prepareDuringInstall ? 8 : 7); function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; @@ -329,6 +370,9 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o
Downloaded model storageVidXP keeps the files needed by your selected search features here.{modelDirectory &&
Storage location{displayPath(modelDirectory)}
}
+ + The managed runtime can use approximately 3 GiB. Models add 37 MiB to 4.11 GiB depending on the selected search features. A full local setup uses approximately 7.1 GiB, plus temporary installation space, indexes, and videos. +
{operation === 'load' || operation === 'folder' || operation === 'reset' ? ( Checking cached model files… @@ -389,18 +433,60 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o {status?.ready && !displayedRuntimeSelected && Switch back to this installation before preparing models or opening VidXP.}
-
{isBusy && }{(isBusy || status?.ready) && message}
+
{isBusy && operation !== 'install' && }{(isBusy || status?.ready) && message}
{recoverableConfiguration ? ( - + ) : ( - + )}
+ undefined} + title="Setting up VidXP" + size="md" + closeOnClickOutside={false} + closeOnEscape={false} + withCloseButton={false} + > + + + Step {progressCurrent} of {progressTotal} + {setupElapsed}s elapsed + + + {setupProgress?.stage === 'models' + && setupProgress.model_message && ( + + + {setupProgress.model_message} + {setupProgress.model_current != null && setupProgress.model_total != null + ? + {formatBytes(setupProgress.model_current)} of {formatBytes(setupProgress.model_total)} + + : } + + {setupProgress.model_current != null && setupProgress.model_total != null && ( + + )} + + )} +
+ {setupProgress?.message ?? 'Starting managed setup'} + The existing installation remains active until every step has completed and the replacement passes validation. +
+
+
{failure && } ); diff --git a/desktop/src/tauri.test.ts b/desktop/src/tauri.test.ts index 1e79c45..8ce6466 100644 --- a/desktop/src/tauri.test.ts +++ b/desktop/src/tauri.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +const { invoke, listen } = vi.hoisted(() => ({ invoke: vi.fn(), listen: vi.fn() })); vi.mock('@tauri-apps/api/core', () => ({ invoke })); +vi.mock('@tauri-apps/api/event', () => ({ listen })); import { beginManagedSetup, @@ -12,6 +13,7 @@ import { browserServiceStatus, localServerStatus, localWorkerStatus, + onManagedSetupProgress, mcpClientConfig, recheckTargetState, startLocalServer, @@ -25,7 +27,10 @@ import { targetSetupState, } from './tauri'; -beforeEach(() => invoke.mockReset()); +beforeEach(() => { + invoke.mockReset(); + listen.mockReset(); +}); describe('displayPath', () => { it('prettifies extended Windows drive and UNC paths', () => { @@ -84,6 +89,20 @@ describe('desktop IPC adapter', () => { expect(invoke).toHaveBeenNthCalledWith(2, 'install_runtime', { request }); }); + it('maps managed setup progress events to their payload', async () => { + const stop = vi.fn(); + listen.mockResolvedValue(stop); + const handler = vi.fn(); + + await expect(onManagedSetupProgress(handler)).resolves.toBe(stop); + const listener = listen.mock.calls[0][1]; + const payload = { draft_id: 'draft-1', current: 3, total: 8, stage: 'package', message: 'Acquiring VidXP' }; + listener({ payload }); + + expect(listen).toHaveBeenCalledWith('managed-setup-progress', expect.any(Function)); + expect(handler).toHaveBeenCalledWith(payload); + }); + it('maps runtime health, MCP configuration, and service lifecycle commands', async () => { invoke.mockResolvedValue({}); diff --git a/desktop/src/tauri.ts b/desktop/src/tauri.ts index 558b8ee..123ac90 100644 --- a/desktop/src/tauri.ts +++ b/desktop/src/tauri.ts @@ -1,4 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; export type TargetKind = 'existing_local' | 'managed'; export type LifecycleOwnership = 'external' | 'desktop'; @@ -186,6 +187,17 @@ export interface InstallRuntimeRequest { draft_id: string; } +export interface ManagedSetupProgress { + draft_id: string; + current: number; + total: number; + stage: string; + message: string; + model_message?: string | null; + model_current?: number | null; + model_total?: number | null; +} + export interface InstallRuntimeResult { package_version: string; capabilities: string[]; @@ -348,6 +360,11 @@ export function installRuntime(request: InstallRuntimeRequest): Promise void, +): Promise<() => void> { + return listen('managed-setup-progress', (event) => handler(event.payload)); +} export function prepareManagedModels(draftId: string): Promise { return invoke('prepare_managed_models', { draftId }).then(normalizeState); } diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 803f2f9..b52260d 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -162,6 +162,7 @@ npm --prefix desktop ci npm --prefix desktop run model-catalog:check npm --prefix desktop run notices:check npm --prefix desktop run check +python -m build npm --prefix desktop run sidecar:windows cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml ``` diff --git a/docs/architecture/platform.md b/docs/architecture/platform.md index aecfdbf..275b7ec 100644 --- a/docs/architecture/platform.md +++ b/docs/architecture/platform.md @@ -1150,8 +1150,9 @@ uncancellable model thread inside the UI process. The Phase 11 adapter is a small Tauri v2 shell. Its first-run configuration selects capability extras, optional interfaces, model preparation, and model -storage, while the processing application is the exact published VidXP package -installed into a versioned uv-managed environment. When selected, the Streamlit +storage, while the processing application is the exact release VidXP wheel +embedded in the installer and installed into a versioned uv-managed +environment. When selected, the Streamlit adapter is the local human interface on a random loopback port; remote loopback content receives no Tauri IPC access. Runtime activation is atomic and a failed configuration retains the prior environment. Tauri owns the Streamlit process diff --git a/docs/desktop.md b/docs/desktop.md index c0f1d35..a755a3a 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -139,11 +139,13 @@ and **App integration service** adds the loopback API plus Streamable HTTP MCP through `server`. These package names stay out of the normal product flow. Model preparation can be deferred, and a native folder picker can select a model-cache directory before any model is downloaded. -The managed runtime acquires the exact VidXP package with dependency resolution -disabled, then resolves that package's selected extras. Beta and stable desktop -releases use production PyPI for both steps, so a pinned prerelease and its -normal dependencies come from one authoritative index. TestPyPI is used only -for package-only nightly validation and is never a desktop runtime source. +The managed runtime acquires the exact VidXP package from the wheel embedded in +the Desktop installer with dependency resolution disabled, then resolves that +local package's selected extras and constrained dependencies from production +PyPI. Release candidates embed the same already-smoke-tested wheel retained for +publication, so fresh managed setup can be validated before that version exists +on the public index. TestPyPI is used only for package-only nightly validation +and is never a desktop runtime source. The release contract classifies prerelease versions as beta and ordinary versions as stable, and the bundled manifest pins the matching Python runtime. This release does not include an automatic Desktop updater, so there is not yet diff --git a/docs/releasing.md b/docs/releasing.md index cddcddd..16618d7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -23,7 +23,8 @@ The candidate reuses the normal CI, desktop, and container workflows. It: 1. validates the exact Release Please head against the current target branch; 2. runs the full Python/provider suite and retains its tested wheel and sdist; -3. builds and tests all three desktop installers and retains them; +3. embeds that exact tested wheel into, builds, and tests all three desktop + installers, then retains them; 4. builds and smokes the three container targets once, pushes temporary candidate tags, and records their immutable digests; and 5. records a `release/candidate` commit status linked to the Actions run. @@ -68,9 +69,12 @@ filenames from the validated candidate and preserves the generated changelog below it. Re-running publication updates the same marked section instead of duplicating release notes. -Beta packages intentionally use real PyPI so the desktop-managed runtime can -resolve its pinned prerelease and normal dependencies from one index. TestPyPI -is reserved for unique nightly package validation. +Desktop-managed setup installs its pinned VidXP package from the exact candidate +wheel embedded in the installer, so an unpublished candidate can complete a +fresh setup before the release PR is merged. Beta and stable packages are still +published to real PyPI, and selected extras plus their normal dependencies +resolve from that production index. TestPyPI is reserved for unique nightly +package validation. Publication is resumable. An existing Python version must have the exact same filenames and SHA-256 values; immutable container tags must resolve to the diff --git a/src/vidxp/capabilities/contracts.py b/src/vidxp/capabilities/contracts.py index ddb9ef4..059babc 100644 --- a/src/vidxp/capabilities/contracts.py +++ b/src/vidxp/capabilities/contracts.py @@ -1,6 +1,8 @@ from __future__ import annotations -from importlib import import_module +import json +import subprocess +import sys from types import MappingProxyType from typing import Any, Callable, Mapping @@ -26,6 +28,7 @@ CAPABILITY_CONTRACT_VERSION = 1 +MODULE_IMPORT_TIMEOUT_SECONDS = 180 class _ContractModel(BaseModel): @@ -295,12 +298,27 @@ def module_import_check( *attributes: str, ) -> RuntimeCheck: def check() -> None: - module = import_module(module_name) - for attribute in attributes: - if not hasattr(module, attribute): - raise AttributeError( - f"{module_name} does not expose {attribute}." - ) + probe = subprocess.run( + [ + sys.executable, + "-c", + ( + "import importlib,json,sys;" + "name,attrs=json.loads(sys.argv[1]);" + "module=importlib.import_module(name);" + "missing=[attr for attr in attrs if not hasattr(module,attr)];" + "sys.exit(1 if missing else 0)" + ), + json.dumps((module_name, attributes)), + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=MODULE_IMPORT_TIMEOUT_SECONDS, + check=False, + ) + if probe.returncode != 0: + raise RuntimeError(f"{module_name} import probe failed.") return RuntimeCheck(label=label, check=check) diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 93575ed..ba80f0c 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -13,6 +13,7 @@ DependencyCheckCommand, DependencyKind, ErrorCategory, + Job, PrepareModelsCommand, ) from vidxp.cli_support import ( @@ -26,6 +27,7 @@ require_media_runtime, state_from_context, ) +from vidxp.core.manifest import write_json_atomic from vidxp.media_runtime import ( MediaRuntimeStatus, inspect_media_runtime, @@ -197,6 +199,13 @@ def doctor( bool, typer.Option("--json", help="Emit machine-readable JSON."), ] = False, + include_models: Annotated[ + bool, + typer.Option( + "--models/--no-models", + help="Include downloaded model artifacts in the readiness check.", + ), + ] = True, ) -> None: """Validate selected indexing dependencies without downloading models.""" @@ -247,7 +256,7 @@ def show_check_complete( result = state.service.check_dependencies( DependencyCheckCommand( modalities=selected, - include_models=True, + include_models=include_models, ), on_check_start=( show_check_start if output_format == OutputFormat.rich else None @@ -406,6 +415,10 @@ def prepare( help="Confirm the displayed model download and cache size.", ), ] = False, + progress_file: Annotated[ + Path | None, + typer.Option("--progress-file", hidden=True), + ] = None, ) -> None: """Download and cache selected runtime models before indexing.""" @@ -481,9 +494,21 @@ def prepare( ) ) if not detach: + + def report_progress(job: Job) -> None: + if show_progress: + emit_job_progress(job) + if progress_file is not None and job.progress is not None: + write_json_atomic( + progress_file, + job.progress.model_dump(mode="json"), + ) + job = state.jobs.wait( job.job_id, - progress=emit_job_progress if show_progress else None, + progress=( + report_progress if show_progress or progress_file is not None else None + ), ) if output_format == OutputFormat.json: emit_json(job.model_dump(mode="json")) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 8a9d8b5..3a3c8d8 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -16,6 +16,7 @@ CapabilityProvenance, OperationDefinition, RuntimeCheck, + module_import_check, ) from vidxp.capabilities.dialogue.config import DialogueConfig from vidxp.capabilities.registry import ( @@ -40,6 +41,24 @@ class CapabilityTests(unittest.TestCase): def setUp(self): self.registry = create_capability_registry() + def test_module_import_checks_run_in_an_isolated_process(self): + with patch( + "vidxp.capabilities.contracts.subprocess.run", + return_value=SimpleNamespace(returncode=0), + ) as run: + result = module_import_check( + "OpenCV import", + "cv2", + "VideoCapture", + ).inspect() + + self.assertTrue(result["ok"]) + command = run.call_args.args[0] + self.assertEqual(command[1], "-c") + self.assertIn('"cv2"', command[3]) + self.assertIn('"VideoCapture"', command[3]) + self.assertEqual(run.call_args.kwargs["timeout"], 180) + def test_registry_drives_capability_metadata(self): self.assertEqual( self.registry.names(), diff --git a/tests/test_cli.py b/tests/test_cli.py index c9086aa..90edd59 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -36,6 +36,7 @@ IndexStatusSummary, Job, JobKind, + JobProgress, JobQueue, JobState, MediaAsset, @@ -868,7 +869,22 @@ def test_doctor_accepts_repeated_modality_options(self): command = self.service.check_dependencies.call_args.args[0] self.assertEqual(command.modalities, ("dialogue", "scene")) - def test_prepare_announces_start_and_subscribes_to_job_progress(self): + def test_doctor_can_skip_model_readiness_for_install_validation(self): + self.service.check_dependencies.return_value = DependencyCheckResult( + ok=True, + modalities=("scene",), + checks=(), + ) + + result = self.invoke( + ["doctor", "--modalities", "scene", "--no-models", "--json"] + ) + + self.assertEqual(result.exit_code, 0, result.output) + command = self.service.check_dependencies.call_args.args[0] + self.assertFalse(command.include_models) + + def test_prepare_announces_start_and_writes_job_progress(self): self.service.model_readiness.return_value = DependencyCheckResult( ok=False, modalities=("scene",), @@ -898,18 +914,43 @@ def test_prepare_announces_start_and_subscribes_to_job_progress(self): state=JobState.queued, queue=JobQueue.cpu, ) - self.jobs.wait.return_value = Job( + completed = Job( job_id=JOB_ID, kind=JobKind.prepare_models, state=JobState.succeeded, queue=JobQueue.cpu, result=PrepareModelsJobResult(result=prepared), ) - - result = self.invoke( - ["prepare", "--modalities", "scene", "--yes"] + expected_progress = JobProgress( + stage="scene_model", + message="Preparing scene model.", + updated_at=datetime.now(timezone.utc), ) + def wait(_job_id, **kwargs): + kwargs["progress"]( + self.jobs.submit_prepare_models.return_value.model_copy( + update={"progress": expected_progress} + ) + ) + return completed + + self.jobs.wait.side_effect = wait + + with TemporaryDirectory() as temporary_directory: + progress_path = Path(temporary_directory) / "progress.json" + result = self.invoke( + [ + "prepare", + "--modalities", + "scene", + "--yes", + "--progress-file", + str(progress_path), + ] + ) + written_progress = json.loads(progress_path.read_text()) + self.assertEqual(result.exit_code, 0, result.output) self.assertIn("1.43 GiB", result.output) self.assertRegex( @@ -918,6 +959,10 @@ def test_prepare_announces_start_and_subscribes_to_job_progress(self): r"scene\.", ) self.assertTrue(callable(self.jobs.wait.call_args.kwargs["progress"])) + self.assertEqual( + written_progress, + expected_progress.model_dump(mode="json"), + ) def test_prepare_distinguishes_cached_model_verification(self): self.service.model_readiness.return_value = DependencyCheckResult(