From ce7d259671f9caea4950bdae2df489b926fe0e99 Mon Sep 17 00:00:00 2001 From: Talha Amjad Date: Thu, 6 Aug 2026 18:58:25 +0500 Subject: [PATCH 01/10] fix(desktop): make guided setup reliable for fresh installations (#106) * fix(desktop): make fresh managed setup reliable * fix(desktop): preserve package index provenance * fix(desktop): confirm managed model downloads * fix(desktop): prune setup cache and disclose storage * fix(desktop): allow cold dependency validation * fix(desktop): show model download progress * fix(desktop): keep managed runtime launchers valid * fix(desktop): keep model preparation attached --- .github/workflows/ci.yml | 10 +- .github/workflows/desktop.yml | 34 ++ .github/workflows/release-candidate.yml | 5 +- README.md | 8 +- desktop/src-tauri/build.rs | 67 +++- desktop/src-tauri/src/lib.rs | 381 +++++++++++++++++++---- desktop/src-tauri/src/target_profiles.rs | 27 +- desktop/src/App.test.tsx | 47 ++- desktop/src/components/ManagedSetup.tsx | 92 +++++- desktop/src/tauri.test.ts | 23 +- desktop/src/tauri.ts | 17 + docs/CONTRIBUTING.md | 1 + docs/architecture/platform.md | 5 +- docs/desktop.md | 12 +- docs/releasing.md | 12 +- src/vidxp/capabilities/contracts.py | 32 +- src/vidxp/cli_commands/runtime.py | 29 +- tests/test_capabilities.py | 19 ++ tests/test_cli.py | 55 +++- 19 files changed, 760 insertions(+), 116 deletions(-) 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( From 322d69357d9ad860e3088de60d3afbabf5c4de68 Mon Sep 17 00:00:00 2001 From: Talha Amjad Date: Fri, 7 Aug 2026 14:32:17 +0500 Subject: [PATCH 02/10] feat(mcp): bundle VidXP plugin and install it from Desktop (#109) * feat(mcp): bundle OpenAI plugin and interactive app * feat(desktop): install bundled VidXP plugin in Codex * fix(skills): surface video evidence before analysis * fix(ci): normalize sdists without GNU tar * fix(desktop): find CLI bundled with Codex app * fix(desktop): export valid Codex marketplace layout --- MANIFEST.in | 4 + README.md | 11 +- desktop/src-tauri/src/lib.rs | 55 ++ desktop/src/App.test.tsx | 17 +- desktop/src/components/TargetSummary.tsx | 33 +- desktop/src/tauri.test.ts | 23 +- desktop/src/tauri.ts | 11 + docs/desktop.md | 14 +- docs/integrations/openai-plugin.md | 90 ++++ pyproject.toml | 6 + release-please-config.json | 5 + release-please-config.stable.json | 5 + skills/vidxp-find-video-evidence/SKILL.md | 59 +-- .../agents/openai.yaml | 4 +- src/vidxp/assets/mcp_app/index.html | 469 ++++++++++++++++++ .../vidxp/.codex-plugin/plugin.json | 28 ++ src/vidxp/bundled_plugins/vidxp/.mcp.json | 8 + .../skills/vidxp-find-video-evidence/SKILL.md | 59 +++ .../agents/openai.yaml | 13 + .../vidxp/skills/vidxp-ingest-video/SKILL.md | 35 ++ .../vidxp-ingest-video/agents/openai.yaml | 13 + src/vidxp/codex_plugin.py | 374 ++++++++++++++ src/vidxp/codex_plugin_cli.py | 47 ++ src/vidxp/mcp.py | 195 +++++++- src/vidxp/mcp_app.py | 19 + tests/test_codex_plugin.py | 218 ++++++++ tests/test_mcp.py | 71 ++- tests/test_normalize_sdist.py | 68 +++ tests/test_packaging.py | 82 +++ utils/build_package.sh | 18 +- utils/normalize_sdist.py | 80 +++ 31 files changed, 2048 insertions(+), 86 deletions(-) create mode 100644 docs/integrations/openai-plugin.md create mode 100644 src/vidxp/assets/mcp_app/index.html create mode 100644 src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json create mode 100644 src/vidxp/bundled_plugins/vidxp/.mcp.json create mode 100644 src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md create mode 100644 src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml create mode 100644 src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/SKILL.md create mode 100644 src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml create mode 100644 src/vidxp/codex_plugin.py create mode 100644 src/vidxp/codex_plugin_cli.py create mode 100644 src/vidxp/mcp_app.py create mode 100644 tests/test_codex_plugin.py create mode 100644 tests/test_normalize_sdist.py create mode 100644 utils/normalize_sdist.py diff --git a/MANIFEST.in b/MANIFEST.in index cdda21d..bdb0400 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,10 @@ include LICENSE include docs/images/logo.png recursive-include src/vidxp/assets/upload_page * recursive-include src/vidxp/assets/artifact_download * +recursive-include src/vidxp/assets/mcp_app * +include src/vidxp/bundled_plugins/vidxp/.mcp.json +include src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json +recursive-include src/vidxp/bundled_plugins/vidxp/skills * include web/upload-page/package.json include web/upload-page/package-lock.json include web/upload-page/scripts/build.mjs diff --git a/README.md b/README.md index 7dcc62f..2926077 100644 --- a/README.md +++ b/README.md @@ -166,11 +166,16 @@ VidXP includes reusable skill source folders for the two common agent workflows: - [Ingest and index videos](skills/vidxp-ingest-video/SKILL.md) - [Find moments and return inspectable evidence](skills/vidxp-find-video-evidence/SKILL.md) -Download a skill folder and add it through a supported ChatGPT desktop or Codex -Skills surface. The skills require a connected VidXP MCP server; installable -plugin packaging for additional ChatGPT surfaces will follow separately. +Download a skill folder directly, or install the versioned VidXP plugin bundle +shipped inside the Python package. The plugin keeps both skills and the local +`vidxp-mcp` server definition together. Its MCP App resource also gives +compatible hosts an interactive upload and evidence-review view; every workflow +continues to work through ordinary MCP tool results when a host has no UI. +When the MCP feature is installed, VidXP Desktop can configure another MCP +client or install the complete local plugin directly into Codex. - [Python, HTTP, and MCP installation](INSTALLATION_GUIDE.md) +- [ChatGPT and Codex plugin integration](docs/integrations/openai-plugin.md) - [Optional capability packages](INSTALLATION_GUIDE.md#optional-dependency-extras) - [Coolify server setup](docs/deployment/coolify.md) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1ab1967..1dcc5c8 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -486,6 +486,17 @@ struct LocalWorkerStatus { detail: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CodexPluginInstallResult { + plugin_name: String, + plugin_id: Option, + plugin_version: String, + marketplace_name: String, + marketplace_path: String, + installed_path: Option, + detail: String, +} + #[derive(Clone)] struct TrayMenuItems { installation: MenuItem, @@ -3641,6 +3652,49 @@ async fn mcp_client_config( .map_err(|error| format!("MCP configuration stopped unexpectedly: {error}"))? } +#[tauri::command] +async fn install_codex_plugin( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + let _active = state.active_operations.register()?; + tauri::async_runtime::spawn_blocking(move || { + let (profile, paths) = selected_target_context(&app)?; + if !profile + .surfaces + .iter() + .any(|surface| surface == "mcp" || surface == "server") + { + return Err( + "The selected VidXP installation does not expose an installed MCP surface.".into(), + ); + } + let installer_path = target_companion_executable(&profile, "vidxp-codex-plugin"); + if !installer_path.is_file() { + return Err(format!( + "The selected installation did not provide {}. Update VidXP and try again.", + installer_path.display() + )); + } + let marketplace_root = paths.private_data.join("codex-marketplace"); + let mut command = target_command(&profile, &paths, &installer_path); + command + .arg("--marketplace-root") + .arg(&marketplace_root) + .arg("--repository") + .arg("default") + .arg("--index-directory") + .arg(&profile.repository_root) + .arg("--data-dir") + .arg(&profile.data_root); + let output = checked_output(command, "VidXP Codex plugin setup")?; + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("VidXP returned invalid Codex setup details: {error}")) + }) + .await + .map_err(|error| format!("Codex plugin setup stopped unexpectedly: {error}"))? +} + fn execute_worker_action(app: &AppHandle, action: &str) -> Result { let (profile, paths) = selected_target_context(app)?; if !profile.surfaces.iter().any(|surface| surface == "worker") { @@ -4601,6 +4655,7 @@ pub fn run() { target_doctor, configure_external_installation, mcp_client_config, + install_codex_plugin, local_worker_status, start_local_worker, stop_local_worker, diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 80d7276..7020171 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({ 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(), + targetDoctor: vi.fn(), mcpClientConfig: vi.fn(), installCodexPlugin: vi.fn(), localServerStatus: vi.fn(), localWorkerStatus: vi.fn(), browserServiceStatus: vi.fn(), startLocalServer: vi.fn(), startSharedServer: vi.fn(), stopLocalServer: vi.fn(), startSharedBrowser: vi.fn(), stopBrowserService: vi.fn(), startLocalWorker: vi.fn(), stopLocalWorker: vi.fn(), configureExternalInstallation: vi.fn(), })); @@ -107,6 +107,12 @@ describe('desktop target lifecycle', () => { mocks.launchUi.mockResolvedValue(undefined); mocks.targetDoctor.mockResolvedValue({ ok: true, modalities: ['scene'], checks: [{ capability: 'media', kind: 'distribution', name: 'ffmpeg', ok: true }] }); mocks.mcpClientConfig.mockResolvedValue('{"mcpServers":{"vidxp":{"command":"vidxp-mcp"}}}'); + mocks.installCodexPlugin.mockResolvedValue({ + plugin_name: 'vidxp', plugin_id: 'vidxp@vidxp-local', plugin_version: '0.4.0+codex.1234', + marketplace_name: 'vidxp-local', marketplace_path: 'C:\\Data\\codex-marketplace\\.agents\\plugins\\marketplace.json', + installed_path: 'C:\\Users\\test\\.codex\\plugins\\vidxp', + detail: 'VidXP is installed in Codex with its MCP server and skills. Start a new Codex chat to use the updated plugin.', + }); mocks.browserServiceStatus.mockResolvedValue({ state: 'stopped', running: false, shared: false, port: null, local_url: null, network_url: null, detail: 'Stopped.' }); mocks.startSharedBrowser.mockResolvedValue({ state: 'ready', running: true, shared: true, port: 8501, local_url: 'http://127.0.0.1:8501', network_url: 'http://192.168.1.20:8501', detail: 'Shared.' }); mocks.stopBrowserService.mockResolvedValue({ state: 'stopped', running: false, shared: false, port: null, local_url: null, network_url: null, detail: 'Stopped.' }); @@ -176,7 +182,11 @@ describe('desktop target lifecycle', () => { expect(await screen.findByText('VidXP is ready')).toBeVisible(); expect(mocks.targetDoctor).toHaveBeenCalledTimes(1); - await user.click(screen.getByRole('button', { name: 'Set up connection' })); + await user.click(screen.getByRole('button', { name: 'Set up in Codex' })); + expect(await screen.findByText('VidXP is installed in Codex with its MCP server and skills. Start a new Codex chat to use the updated plugin.')).toBeVisible(); + expect(mocks.installCodexPlugin).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: 'Copy MCP setup' })); expect(await screen.findByRole('heading', { name: 'Connect an AI assistant' })).toBeVisible(); expect(mocks.mcpClientConfig).toHaveBeenCalledTimes(1); @@ -211,7 +221,8 @@ describe('desktop target lifecycle', () => { await user.click(screen.getByRole('button', { name: 'Apply changes' })); await waitFor(() => expect(mocks.configureExternalInstallation).toHaveBeenCalledWith([], ['worker', 'browser', 'mcp'])); - expect(await screen.findByRole('button', { name: 'Set up connection' })).toBeVisible(); + expect(await screen.findByRole('button', { name: 'Set up in Codex' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Copy MCP setup' })).toBeVisible(); }); it('offers an in-place manifest update when the selected runtime contract is too old', async () => { diff --git a/desktop/src/components/TargetSummary.tsx b/desktop/src/components/TargetSummary.tsx index cd5d9bd..cc0381c 100644 --- a/desktop/src/components/TargetSummary.tsx +++ b/desktop/src/components/TargetSummary.tsx @@ -1,11 +1,12 @@ import { Alert, Badge, Button, Checkbox, Code, Group, Loader, Modal, Stack, Text, Title } from '@mantine/core'; -import { IconActivityHeartbeat, IconCopy, IconExternalLink, IconPlayerPlay, IconPlayerStop, IconRefresh, IconSettings, IconShare, IconTerminal2 } from '@tabler/icons-react'; +import { IconActivityHeartbeat, IconCopy, IconExternalLink, IconPlugConnected, IconPlayerPlay, IconPlayerStop, IconRefresh, IconSettings, IconShare, IconTerminal2 } from '@tabler/icons-react'; import { useEffect, useState } from 'react'; import { errorMessage, browserServiceStatus, configureExternalInstallation, + installCodexPlugin, localServerStatus, localWorkerStatus, mcpClientConfig, @@ -20,6 +21,7 @@ import { targetDoctor, type DoctorReport, type BrowserServiceStatus, + type CodexPluginInstallResult, type LocalServerStatus, type LocalWorkerStatus, type RuntimeManifest, @@ -55,7 +57,8 @@ export function TargetSummary({ profile, validationError, checking, operationPen const [browser, setBrowser] = useState(null); const [worker, setWorker] = useState(null); const [mcpConfig, setMcpConfig] = useState(null); - const [busy, setBusy] = useState<'doctor' | 'config' | 'features' | 'worker-start' | 'worker-stop' | 'browser-share' | 'browser-stop' | 'server-start' | 'server-share' | 'server-stop' | null>(null); + const [codexSetup, setCodexSetup] = useState(null); + const [busy, setBusy] = useState<'doctor' | 'config' | 'codex' | 'features' | 'worker-start' | 'worker-stop' | 'browser-share' | 'browser-stop' | 'server-start' | 'server-share' | 'server-stop' | null>(null); const [runtimeFailure, setRuntimeFailure] = useState(null); const [copied, setCopied] = useState(false); const [shareCopied, setShareCopied] = useState(false); @@ -81,6 +84,7 @@ export function TargetSummary({ profile, validationError, checking, operationPen useEffect(() => { setDoctor(null); setMcpConfig(null); + setCodexSetup(null); setRuntimeFailure(null); if (!serverAvailable) { setServer(null); @@ -178,6 +182,19 @@ export function TargetSummary({ profile, validationError, checking, operationPen } } + async function setupCodex() { + setBusy('codex'); + setRuntimeFailure(null); + setCodexSetup(null); + try { + setCodexSetup(await installCodexPlugin()); + } catch (error) { + setRuntimeFailure(errorMessage(error, 'VidXP could not install the Codex plugin.')); + } finally { + setBusy(null); + } + } + async function openExternalSetup() { setBusy('features'); setRuntimeFailure(null); @@ -379,10 +396,18 @@ export function TargetSummary({ profile, validationError, checking, operationPen } {mcpAvailable && -
AI assistant integrationCreate the MCP setup needed to use VidXP from a compatible AI assistant.
- +
AI assistant integrationInstall VidXP's MCP server and skills in Codex, or copy the MCP setup for another compatible assistant.
+ + + +
} + {codexSetup && + {codexSetup.detail} +
Installation detailsPlugin {codexSetup.plugin_version} from {codexSetup.marketplace_name}{codexSetup.installed_path && {codexSetup.installed_path}}
+
} + {serverAvailable &&
App integration service diff --git a/desktop/src/tauri.test.ts b/desktop/src/tauri.test.ts index 8ce6466..658dbd6 100644 --- a/desktop/src/tauri.test.ts +++ b/desktop/src/tauri.test.ts @@ -9,6 +9,7 @@ import { beginManagedSetup, displayPath, installRuntime, + installCodexPlugin, configureExternalInstallation, browserServiceStatus, localServerStatus, @@ -108,6 +109,7 @@ describe('desktop IPC adapter', () => { await targetDoctor(); await mcpClientConfig(); + await installCodexPlugin(); await localWorkerStatus(); await startLocalWorker(); await stopLocalWorker(); @@ -121,16 +123,17 @@ describe('desktop IPC adapter', () => { expect(invoke).toHaveBeenNthCalledWith(1, 'target_doctor'); expect(invoke).toHaveBeenNthCalledWith(2, 'mcp_client_config'); - expect(invoke).toHaveBeenNthCalledWith(3, 'local_worker_status'); - expect(invoke).toHaveBeenNthCalledWith(4, 'start_local_worker'); - expect(invoke).toHaveBeenNthCalledWith(5, 'stop_local_worker'); - expect(invoke).toHaveBeenNthCalledWith(6, 'browser_service_status'); - expect(invoke).toHaveBeenNthCalledWith(7, 'start_shared_browser'); - expect(invoke).toHaveBeenNthCalledWith(8, 'stop_browser_service'); - expect(invoke).toHaveBeenNthCalledWith(9, 'local_server_status'); - expect(invoke).toHaveBeenNthCalledWith(10, 'start_local_server'); - expect(invoke).toHaveBeenNthCalledWith(11, 'start_shared_server'); - expect(invoke).toHaveBeenNthCalledWith(12, 'stop_local_server'); + expect(invoke).toHaveBeenNthCalledWith(3, 'install_codex_plugin'); + expect(invoke).toHaveBeenNthCalledWith(4, 'local_worker_status'); + expect(invoke).toHaveBeenNthCalledWith(5, 'start_local_worker'); + expect(invoke).toHaveBeenNthCalledWith(6, 'stop_local_worker'); + expect(invoke).toHaveBeenNthCalledWith(7, 'browser_service_status'); + expect(invoke).toHaveBeenNthCalledWith(8, 'start_shared_browser'); + expect(invoke).toHaveBeenNthCalledWith(9, 'stop_browser_service'); + expect(invoke).toHaveBeenNthCalledWith(10, 'local_server_status'); + expect(invoke).toHaveBeenNthCalledWith(11, 'start_local_server'); + expect(invoke).toHaveBeenNthCalledWith(12, 'start_shared_server'); + expect(invoke).toHaveBeenNthCalledWith(13, 'stop_local_server'); }); it('adds optional surfaces to the selected existing installation', async () => { diff --git a/desktop/src/tauri.ts b/desktop/src/tauri.ts index 123ac90..78df41e 100644 --- a/desktop/src/tauri.ts +++ b/desktop/src/tauri.ts @@ -254,6 +254,16 @@ export interface LocalWorkerStatus { detail: string; } +export interface CodexPluginInstallResult { + plugin_name: string; + plugin_id: string | null; + plugin_version: string; + marketplace_name: string; + marketplace_path: string; + installed_path: string | null; + detail: string; +} + interface WireInstallTransitionResult { install: InstallRuntimeResult; setup: WireTargetState; @@ -374,6 +384,7 @@ export function configureExternalInstallation(capabilities: string[], surfaces: return invoke('configure_external_installation', { capabilities, surfaces }).then(normalizeState); } export function mcpClientConfig(): Promise { return invoke('mcp_client_config'); } +export function installCodexPlugin(): Promise { return invoke('install_codex_plugin'); } export function localWorkerStatus(): Promise { return invoke('local_worker_status'); } export function startLocalWorker(): Promise { return invoke('start_local_worker'); } export function stopLocalWorker(): Promise { return invoke('stop_local_worker'); } diff --git a/docs/desktop.md b/docs/desktop.md index a755a3a..a899996 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -187,8 +187,18 @@ one browser tab. Closing a configured control panel hides it to the tray. action, and **Quit VidXP** runs supervised shutdown for the interface and any Desktop-owned repository worker. The active-target panel also reports and controls local video processing through the existing `JobService` worker -lifecycle, reports the app integration service, creates AI-assistant MCP config, -and presents the existing doctor result as product health rather than raw output. +lifecycle, reports the app integration service, creates copyable AI-assistant +MCP config, installs the bundled MCP-and-skills plugin directly into Codex, and +presents the existing doctor result as product health rather than raw output. +Codex setup exports a target-specific copy of the plugin from the selected +VidXP runtime into a dedicated Desktop-private `vidxp-local` marketplace. The +copy keeps the versioned skills but replaces its generic MCP command with the +selected runtime's absolute executable, repository, and data paths. Desktop +then uses `codex plugin marketplace add --json` and +`codex plugin add vidxp@vidxp-local --json`; it never edits Codex configuration +or the user's personal marketplace by hand. Re-running setup refreshes the +managed bundle using a deterministic plugin-version cache key. A new Codex chat +is required before the refreshed skills and tools are available. The browser and app integration service remain loopback-only by default. Explicit sharing controls compose their existing `--share` modes: browser sharing reports its LAN URL and warns that it is unauthenticated, while API/MCP diff --git a/docs/integrations/openai-plugin.md b/docs/integrations/openai-plugin.md new file mode 100644 index 0000000..df86622 --- /dev/null +++ b/docs/integrations/openai-plugin.md @@ -0,0 +1,90 @@ +# ChatGPT and Codex plugin integration + +VidXP ships one versioned plugin bundle with its Python distribution. The +bundle combines the local MCP server definition with VidXP's canonical ingest +and evidence-search skills, while the MCP server exposes an optional interactive +view for hosts that implement MCP Apps. + +The packaged bundle lives at +`src/vidxp/bundled_plugins/vidxp/` and contains: + +- `.codex-plugin/plugin.json`, the plugin manifest; +- `.mcp.json`, which starts the installed `vidxp-mcp` command; and +- `skills/`, a release snapshot of the canonical root `skills/` folders. + +The root skill folders remain the authoring source. Packaging tests require the +bundled snapshot to match their file inventory and text exactly, and Release +Please keeps the plugin manifest version aligned with the Python package. + +## Install in Codex + +With **AI assistant integration** enabled, VidXP Desktop shows two distinct +actions: + +- **Set up in Codex** exports a target-specific copy of this bundle to a + Desktop-managed `vidxp-local` marketplace, registers that marketplace through + the Codex CLI, and installs the plugin. The generated `.mcp.json` pins the + selected installation's absolute `vidxp-mcp` command, repository, and data + paths, so Codex does not depend on its process `PATH`. +- **Copy MCP setup** retains the transport-only JSON flow for other compatible + local MCP clients. + +The Codex action installs the MCP server and both skills as one unit. It uses +the documented JSON forms of `codex plugin marketplace add` and +`codex plugin add`, leaves personal marketplace files and `config.toml` +untouched, and asks the user to start a new Codex chat after installation. The +exported marketplace lives in VidXP Desktop's private application-data +directory. Its catalog is written to the Codex marketplace contract at +`.agents/plugins/marketplace.json`, with the plugin at `plugins/vidxp/`. The +exporter also removes the obsolete root-level `marketplace.json` from earlier +VidXP-managed exports. The canonical distributable bundle remains checked into +this repository and packaged in every VidXP wheel. + +## Interactive MCP App + +`create_media_upload` and `get_job_evidence` advertise the same +`ui://vidxp/evidence-review-v1.html` resource. A compatible host can render it +inline to: + +- open VidXP's short-lived HTTPS upload page and refresh session progress; +- review answer claims and annotated evidence-board pages; +- select up to ten ranked evidence IDs; and +- request exact keyframes or clips with `materialize_job_evidence` without + rerunning retrieval. + +The component completes the MCP Apps `ui/initialize` handshake, then uses the +standard `tools/call`, `ui/open-link`, `ui/request-display-mode`, +`ui/update-model-context`, and resize messages. ChatGPT-specific `window.openai` +helpers are feature-detected only as compatibility fallbacks and for ephemeral +widget state. VidXP remains authoritative for upload, job, search, and artifact +state, and non-UI clients receive the existing text, image, and resource-link +content. + +The resource is self-contained, uses system fonts, has no remote script or +style dependencies, and publishes an explicit empty resource/connect CSP. Add +only exact HTTPS origins if future component assets or requests require them. + +## Connect ChatGPT + +The bundled `.mcp.json` is for local plugin hosts. A ChatGPT connection still +requires a publicly reachable Streamable HTTP endpoint (VidXP serves `/mcp`), +an HTTPS deployment or secure development tunnel, and registration in ChatGPT +Developer Mode. + +Do not add a placeholder `.app.json`. That file can reference only the real app +identifier issued after the remote MCP connection is registered. Once that ID +exists, add the descriptor to the plugin bundle and validate the deployed app +through ChatGPT Developer Mode. + +Before public submission, also complete the current OpenAI review requirements, +including organization verification, public endpoint availability, privacy and +support URLs, accurate tool metadata, and CSP validation. + +## Official references + +- [Plugin architecture](https://developers.openai.com/plugins/concepts/plugins) +- [Package a plugin](https://developers.openai.com/plugins/build/plugins) +- [Add a ChatGPT UI](https://developers.openai.com/plugins/build/chatgpt-ui) +- [UI guidelines](https://developers.openai.com/plugins/concepts/ui-guidelines) +- [Connect from ChatGPT](https://developers.openai.com/plugins/deploy/connect-chatgpt) +- [App review](https://developers.openai.com/plugins/deploy/app-review) diff --git a/pyproject.toml b/pyproject.toml index e042bca..0fa6398 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ vidxp-worker = "vidxp.workflow_worker:main" vidxp-database = "vidxp.database_cli:main" vidxp-hooks = "vidxp.hook_cli:main" vidxp-mcp = "vidxp.mcp_cli:main" +vidxp-codex-plugin = "vidxp.codex_plugin_cli:main" [project.urls] Homepage = "https://github.com/grayhatdevelopers/vidxp" @@ -71,12 +72,17 @@ include = ["vidxp*"] [tool.setuptools.package-data] vidxp = [ "assets/artifact_download/*", + "assets/mcp_app/*", "assets/upload_page/*", "benchmarks/requirements.txt", "capabilities/*/requirements.txt", "requirements/*.txt", "migrations/*.py", "migrations/versions/*.py", + "bundled_plugins/vidxp/.mcp.json", + "bundled_plugins/vidxp/.codex-plugin/plugin.json", + "bundled_plugins/vidxp/skills/*/SKILL.md", + "bundled_plugins/vidxp/skills/*/agents/*.yaml", ] [tool.setuptools.dynamic.optional-dependencies] diff --git a/release-please-config.json b/release-please-config.json index 0eba41b..5ecd2f6 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,6 +16,11 @@ "path": "uv.lock", "type": "generic" }, + { + "jsonpath": "$.version", + "path": "src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json", + "type": "json" + }, { "path": "desktop/src-tauri/Cargo.toml", "type": "generic" diff --git a/release-please-config.stable.json b/release-please-config.stable.json index 3c95045..45d5255 100644 --- a/release-please-config.stable.json +++ b/release-please-config.stable.json @@ -19,6 +19,11 @@ "path": "uv.lock", "type": "generic" }, + { + "jsonpath": "$.version", + "path": "src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json", + "type": "json" + }, { "path": "desktop/src-tauri/Cargo.toml", "type": "generic" diff --git a/skills/vidxp-find-video-evidence/SKILL.md b/skills/vidxp-find-video-evidence/SKILL.md index 0f8027b..d78fe75 100644 --- a/skills/vidxp-find-video-evidence/SKILL.md +++ b/skills/vidxp-find-video-evidence/SKILL.md @@ -1,6 +1,6 @@ --- name: vidxp-find-video-evidence -description: Use VidXP to search indexed videos, answer grounded questions about video content, locate when people, actions, dialogue, or scenes occur, and return inspectable evidence boards, keyframes, or clips. Trigger for requests such as "find where X appears," "when does Y happen," "what is said," "what happens," or "show me the matching clip," even when the user does not name VidXP. Do not trigger for ingesting new media or ordinary video editing. +description: Use VidXP to search indexed videos and surface inspectable evidence boards, keyframes, and clips before analysis. Trigger for requests such as "find where X appears," "when does Y happen," "what is said," "what happens," or "show me the matching clip," even when the user does not name VidXP. Favor one-pass evidence delivery and only add brief accuracy feedback; do not trigger for ingesting new media or ordinary video editing. --- # Find video evidence with VidXP @@ -9,30 +9,28 @@ description: Use VidXP to search indexed videos, answer grounded questions about 1. Resolve the `vidxp` MCP tools, then call `get_workspace`. If the requested video is not indexed, explain that it must be indexed first. -2. Use `search_moments` to locate moments or `query_video` for a synthesized, - grounded answer. Use `command.query` with `search_moments` and - `command.question` with `query_video`. Set `command.media_id` when the user - means one video. -3. Omit `command.evidence_delivery` for the normal path. The completed job - includes an annotated board covering the ranked results. +2. Submit one retrieval job. Use `search_moments` to locate moments; use + `query_video` only when the user asks for a synthesized answer. Use + `command.query` with `search_moments` and `command.question` with + `query_video`. Set `command.media_id` when the user means one video. +3. In that initial job, put exactly this inside `command`: + `"evidence_delivery": {"mode": "keyframes_and_clips", "max_items": 3}`. + This prepares the ranked board, standalone keyframes, and clips without a + second retrieval pass. Never send `command.materialize`. 4. Call `wait_job` for bounded waits. Pass its `observation_token` as `after_observation_token` on the next wait. When terminal, call `get_job_evidence` once. It returns the concise evidence index and visual - content without the full structured job dump. Use `get_job` only when exact - machine fields not present in that index are actually needed. Search and - query may take time; update the user - when the stage changes or about once per minute, never after every wait and - never with an invented ETA. -5. Inspect and show the returned board before making visual claims. Use its tile - evidence IDs for follow-up: - - `materialize_job_evidence` accepts up to ten selected IDs and returns - model-visible standalone keyframes or clip links without rerunning retrieval. - - `create_evidence_board` is only for a custom selection or the - `next_start_rank` continuation; wait on its returned job ID the same way. -6. When standalone artifacts are required in the initial job, put exactly this - inside `command`: `"evidence_delivery": {"mode": - "keyframes_and_clips", "max_items": 3}`. Never send - `command.materialize`. + content without the full structured job dump. Search and query may take + time; update the user when the stage changes or about once per minute, never + after every wait and never with an invented ETA. +5. Surface the returned board, keyframes, and clips immediately. Do not call + `get_job`, repeat the search, materialize more evidence, create another + board, or perform a self-directed verification loop before showing the + initial evidence. +6. Stop after the first evidence delivery. Only when the user explicitly asks + for another selection or format, use tile evidence IDs with + `materialize_job_evidence`, or use `create_evidence_board` for a custom + selection or `next_start_rank` continuation. ## Actor scope @@ -40,15 +38,20 @@ description: Use VidXP to search indexed videos, answer grounded questions about anonymous, video-scoped face clusters—not a named or cross-video identity. - Treat its image as a representative full frame, not an exact face crop or proof of continuous presence. Do not claim exhaustive named appearances. -- Use scene evidence plus board inspection for named-person requests, and label - uncertain matches as candidates. +- For named-person requests, surface the best scene candidates immediately and + label uncertain matches as candidates. Do not delay delivery while trying to + prove identity through additional searches. ## Output -- The final response must visibly embed a returned board or frame, or include a - working downloadable resource link—not timestamps alone. Use the returned - `local_path` or `download_url`; never write an unlinked label such as “View - evidence board.” Use `get_artifact_download` only if neither is returned. +- Lead with evidence, not a search narrative: first embed the returned board or + frame or provide its working resource link, then list the ready clips and + keyframes. Use the returned `local_path` or `download_url`; never write an + unlinked label such as “View evidence board.” Use `get_artifact_download` + only if neither is returned. +- After the evidence, add at most a brief accuracy note. State uncertainty or + visible mismatches without launching another search. Accuracy feedback must + not replace or precede the evidence. - Preserve the source job and evidence IDs. Describe scores as retrieval scores, and distinguish a visible appearance from a dialogue or caption mention. - Stop waiting on success, failure, or cancellation. An empty result means no diff --git a/skills/vidxp-find-video-evidence/agents/openai.yaml b/skills/vidxp-find-video-evidence/agents/openai.yaml index 8324467..4d2a39f 100644 --- a/skills/vidxp-find-video-evidence/agents/openai.yaml +++ b/skills/vidxp-find-video-evidence/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "Find Video Evidence with VidXP" - short_description: "Search indexed videos and return verifiable evidence" - default_prompt: "Use $vidxp-find-video-evidence to find and verify moments in my indexed videos." + short_description: "Surface video boards, frames, and clips first" + default_prompt: "Use $vidxp-find-video-evidence to surface the best board, keyframes, and clips before brief accuracy feedback." policy: allow_implicit_invocation: true diff --git a/src/vidxp/assets/mcp_app/index.html b/src/vidxp/assets/mcp_app/index.html new file mode 100644 index 0000000..3a46add --- /dev/null +++ b/src/vidxp/assets/mcp_app/index.html @@ -0,0 +1,469 @@ + + + + + + VidXP evidence review + + + +
+
+
+

VidXP

+

Preparing video workspace…

+

Waiting for the tool result.

+
+ +
+
+

+
+ + + diff --git a/src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json b/src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json new file mode 100644 index 0000000..4232648 --- /dev/null +++ b/src/vidxp/bundled_plugins/vidxp/.codex-plugin/plugin.json @@ -0,0 +1,28 @@ +{ + "name": "vidxp", + "version": "0.4.0-b.3", + "description": "Ingest, index, search, and inspect video evidence with VidXP.", + "author": { + "name": "Grayhat" + }, + "homepage": "https://github.com/grayhatdevelopers/vidxp", + "repository": "https://github.com/grayhatdevelopers/vidxp", + "license": "MIT", + "keywords": ["video", "search", "evidence", "mcp"], + "skills": "./skills/", + "interface": { + "displayName": "VidXP", + "shortDescription": "Search video and inspect grounded evidence.", + "longDescription": "Use VidXP to ingest and index videos, search dialogue and scenes, answer grounded questions, and review evidence boards, frames, and clips.", + "developerName": "Grayhat", + "category": "Productivity", + "capabilities": ["Read", "Write", "Interactive"], + "websiteURL": "https://github.com/grayhatdevelopers/vidxp", + "defaultPrompt": [ + "Find and verify moments in my indexed videos.", + "Ingest and index a video with VidXP." + ], + "brandColor": "#6D5EF7" + }, + "mcpServers": "./.mcp.json" +} diff --git a/src/vidxp/bundled_plugins/vidxp/.mcp.json b/src/vidxp/bundled_plugins/vidxp/.mcp.json new file mode 100644 index 0000000..bed572e --- /dev/null +++ b/src/vidxp/bundled_plugins/vidxp/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "vidxp": { + "command": "vidxp-mcp", + "args": ["--repository", "default"] + } + } +} diff --git a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md new file mode 100644 index 0000000..d78fe75 --- /dev/null +++ b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/SKILL.md @@ -0,0 +1,59 @@ +--- +name: vidxp-find-video-evidence +description: Use VidXP to search indexed videos and surface inspectable evidence boards, keyframes, and clips before analysis. Trigger for requests such as "find where X appears," "when does Y happen," "what is said," "what happens," or "show me the matching clip," even when the user does not name VidXP. Favor one-pass evidence delivery and only add brief accuracy feedback; do not trigger for ingesting new media or ordinary video editing. +--- + +# Find video evidence with VidXP + +## Workflow + +1. Resolve the `vidxp` MCP tools, then call `get_workspace`. If the requested + video is not indexed, explain that it must be indexed first. +2. Submit one retrieval job. Use `search_moments` to locate moments; use + `query_video` only when the user asks for a synthesized answer. Use + `command.query` with `search_moments` and `command.question` with + `query_video`. Set `command.media_id` when the user means one video. +3. In that initial job, put exactly this inside `command`: + `"evidence_delivery": {"mode": "keyframes_and_clips", "max_items": 3}`. + This prepares the ranked board, standalone keyframes, and clips without a + second retrieval pass. Never send `command.materialize`. +4. Call `wait_job` for bounded waits. Pass its `observation_token` as + `after_observation_token` on the next wait. When terminal, call + `get_job_evidence` once. It returns the concise evidence index and visual + content without the full structured job dump. Search and query may take + time; update the user when the stage changes or about once per minute, never + after every wait and never with an invented ETA. +5. Surface the returned board, keyframes, and clips immediately. Do not call + `get_job`, repeat the search, materialize more evidence, create another + board, or perform a self-directed verification loop before showing the + initial evidence. +6. Stop after the first evidence delivery. Only when the user explicitly asks + for another selection or format, use tile evidence IDs with + `materialize_job_evidence`, or use `create_evidence_board` for a custom + selection or `next_start_rank` continuation. + +## Actor scope + +- Actor data is available through `query_video`, not name search. It represents + anonymous, video-scoped face clusters—not a named or cross-video identity. +- Treat its image as a representative full frame, not an exact face crop or + proof of continuous presence. Do not claim exhaustive named appearances. +- For named-person requests, surface the best scene candidates immediately and + label uncertain matches as candidates. Do not delay delivery while trying to + prove identity through additional searches. + +## Output + +- Lead with evidence, not a search narrative: first embed the returned board or + frame or provide its working resource link, then list the ready clips and + keyframes. Use the returned `local_path` or `download_url`; never write an + unlinked label such as “View evidence board.” Use `get_artifact_download` + only if neither is returned. +- After the evidence, add at most a brief accuracy note. State uncertainty or + visible mismatches without launching another search. Accuracy feedback must + not replace or precede the evidence. +- Preserve the source job and evidence IDs. Describe scores as retrieval scores, + and distinguish a visible appearance from a dialogue or caption mention. +- Stop waiting on success, failure, or cancellation. An empty result means no + matching indexed evidence was found, not that the event is absent from the + original video. diff --git a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml new file mode 100644 index 0000000..4d2a39f --- /dev/null +++ b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-find-video-evidence/agents/openai.yaml @@ -0,0 +1,13 @@ +interface: + display_name: "Find Video Evidence with VidXP" + short_description: "Surface video boards, frames, and clips first" + default_prompt: "Use $vidxp-find-video-evidence to surface the best board, keyframes, and clips before brief accuracy feedback." + +policy: + allow_implicit_invocation: true + +dependencies: + tools: + - type: "mcp" + value: "vidxp" + description: "VidXP video search and evidence tools" diff --git a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/SKILL.md b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/SKILL.md new file mode 100644 index 0000000..198023f --- /dev/null +++ b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/SKILL.md @@ -0,0 +1,35 @@ +--- +name: vidxp-ingest-video +description: Use VidXP to upload, import, register, and automatically index video files through its MCP tools. Trigger for requests to add, upload, ingest, import, register, or index one or more videos, including attached videos and accessible local paths, even when the user does not name VidXP. Do not trigger for editing, transcoding, or searching a video that is already indexed. +--- + +# Ingest video with VidXP + +## Workflow + +1. Resolve the `vidxp` MCP tools and call `get_workspace`. Do not import a video + that is already registered or indexed. +2. Choose indexable modalities from the workspace. Use `dialogue` and `scene` + for ordinary content retrieval. Add `actor` only when anonymous recurring-face + clusters are wanted; it does not identify people by name. +3. Call `get_runtime_readiness`. If selected models are missing, submit + `prepare_models`, use `wait_job` with its observation token for subsequent + bounded waits, then fetch `get_job` once when terminal. +4. Use `ingest_local_media` for one to ten paths accessible to VidXP; otherwise + use `create_media_upload` and give the returned link to the user. Keep + `index_after_import` enabled unless registration-only behavior was requested. +5. Poll the returned ingestion or upload ID with `get_media_ingestion` or + `get_media_upload`. Honor its poll interval, reuse the same identifiers, and + do not resubmit unchanged work. +6. Stop at a terminal state and report each file's state, media ID, index job, + and searchable snapshot or generation. If indexing fails after registration, + retry with `start_indexing`; do not upload the file again. +7. If the request also asks about the video, continue directly into the VidXP + evidence workflow once it is searchable. + +## Long operations + +- Tell the user that model preparation and indexing can take several minutes. +- Update when the stage changes or about once per minute; do not narrate every + status check or invent an ETA. +- Treat files independently so one failure does not hide successful siblings. diff --git a/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml new file mode 100644 index 0000000..f502ebe --- /dev/null +++ b/src/vidxp/bundled_plugins/vidxp/skills/vidxp-ingest-video/agents/openai.yaml @@ -0,0 +1,13 @@ +interface: + display_name: "Ingest Video with VidXP" + short_description: "Upload or ingest videos and index them with VidXP" + default_prompt: "Use $vidxp-ingest-video to ingest and index my video with VidXP." + +policy: + allow_implicit_invocation: true + +dependencies: + tools: + - type: "mcp" + value: "vidxp" + description: "VidXP video ingestion and indexing tools" diff --git a/src/vidxp/codex_plugin.py b/src/vidxp/codex_plugin.py new file mode 100644 index 0000000..5ad3410 --- /dev/null +++ b/src/vidxp/codex_plugin.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +import tempfile +import tomllib +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +from vidxp import __version__ +from vidxp.mcp_cli import stdio_client_config + + +PLUGIN_NAME = "vidxp" +MARKETPLACE_NAME = "vidxp-local" +MANAGED_MARKER = ".vidxp-managed-marketplace" +MARKETPLACE_MANIFEST = Path(".agents") / "plugins" / "marketplace.json" + + +class CodexPluginInstallError(RuntimeError): + """Raised when the bundled Codex plugin cannot be installed safely.""" + + +@dataclass(frozen=True) +class CodexPluginExport: + marketplace_root: str + marketplace_path: str + marketplace_name: str + plugin_name: str + plugin_version: str + + def to_dict(self) -> dict[str, str]: + return asdict(self) + + +@dataclass(frozen=True) +class CodexPluginInstall: + plugin_name: str + plugin_id: str | None + plugin_version: str + marketplace_name: str + marketplace_path: str + installed_path: str | None + detail: str + + def to_dict(self) -> dict[str, str | None]: + return asdict(self) + + +def bundled_codex_plugin() -> Path: + return Path(__file__).resolve().parent / "bundled_plugins" / PLUGIN_NAME + + +def _json_bytes(payload: Any) -> bytes: + return ( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + + +def _write_json(path: Path, payload: Any) -> None: + path.write_bytes(_json_bytes(payload)) + + +def _bundle_digest(source: Path, mcp_config: dict[str, Any]) -> str: + digest = hashlib.sha256(_json_bytes(mcp_config)) + for path in sorted(item for item in source.rglob("*") if item.is_file()): + digest.update(path.relative_to(source).as_posix().encode("utf-8")) + digest.update(path.read_bytes()) + return digest.hexdigest()[:12] + + +def _validated_marketplace_root(marketplace_root: Path) -> Path: + root = marketplace_root.expanduser().resolve() + if root == Path(root.anchor): + raise CodexPluginInstallError( + "The Codex marketplace cannot be written at a filesystem root." + ) + marker = root / MANAGED_MARKER + if root.exists() and any(root.iterdir()) and not marker.is_file(): + raise CodexPluginInstallError( + f"Refusing to replace the unmanaged marketplace directory at {root}." + ) + return root + + +def export_codex_plugin( + marketplace_root: Path, + *, + registry: str | None = None, + repository: str = "default", + index_directory: str | None = None, + data_directory: Path | None = None, + device: str | None = None, +) -> CodexPluginExport: + """Export a target-specific copy of VidXP's bundled Codex plugin.""" + + source = bundled_codex_plugin() + if not (source / ".codex-plugin" / "plugin.json").is_file(): + raise CodexPluginInstallError( + "This VidXP installation does not contain the bundled Codex plugin." + ) + root = _validated_marketplace_root(marketplace_root) + plugins_root = root / "plugins" + plugins_root.mkdir(parents=True, exist_ok=True) + plugin_root = plugins_root / PLUGIN_NAME + marker = root / MANAGED_MARKER + + mcp_config = stdio_client_config( + registry=registry, + repository=repository, + index_directory=index_directory, + data_directory=data_directory, + device=device, + ) + plugin_version = ( + f"{__version__.split('+', 1)[0]}+codex." + f"{_bundle_digest(source, mcp_config)}" + ) + + staging_parent = Path(tempfile.mkdtemp(prefix=".vidxp-plugin-", dir=plugins_root)) + staging_plugin = staging_parent / PLUGIN_NAME + backup = plugins_root / ".vidxp-plugin-backup" + try: + shutil.copytree(source, staging_plugin) + manifest_path = staging_plugin / ".codex-plugin" / "plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = plugin_version + _write_json(manifest_path, manifest) + _write_json(staging_plugin / ".mcp.json", mcp_config) + + if backup.exists(): + shutil.rmtree(backup) + if plugin_root.exists(): + if not marker.is_file(): + raise CodexPluginInstallError( + f"Refusing to replace the unmanaged plugin at {plugin_root}." + ) + os.replace(plugin_root, backup) + try: + os.replace(staging_plugin, plugin_root) + except Exception: + if backup.exists() and not plugin_root.exists(): + os.replace(backup, plugin_root) + raise + if backup.exists(): + shutil.rmtree(backup) + finally: + if staging_parent.exists(): + shutil.rmtree(staging_parent) + + marketplace = { + "name": MARKETPLACE_NAME, + "interface": {"displayName": "VidXP Local"}, + "plugins": [ + { + "name": PLUGIN_NAME, + "source": { + "source": "local", + "path": f"./plugins/{PLUGIN_NAME}", + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + }, + "category": "Productivity", + } + ], + } + marketplace_path = root / MARKETPLACE_MANIFEST + marketplace_path.parent.mkdir(parents=True, exist_ok=True) + _write_json(marketplace_path, marketplace) + legacy_marketplace_path = root / "marketplace.json" + if marker.is_file() and legacy_marketplace_path.is_file(): + legacy_marketplace_path.unlink() + marker.write_text("Managed by VidXP Desktop.\n", encoding="utf-8") + return CodexPluginExport( + marketplace_root=str(root), + marketplace_path=str(marketplace_path), + marketplace_name=MARKETPLACE_NAME, + plugin_name=PLUGIN_NAME, + plugin_version=plugin_version, + ) + + +CommandRunner = Callable[..., subprocess.CompletedProcess[str]] + + +def _run_codex_json( + command: str, + arguments: Sequence[str], + *, + runner: CommandRunner, +) -> dict[str, Any]: + try: + completed = runner( + [command, *arguments], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise CodexPluginInstallError(f"Codex could not be started: {exc}") from exc + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).strip() + raise CodexPluginInstallError( + detail or f"Codex exited with status {completed.returncode}." + ) + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise CodexPluginInstallError( + "Codex did not return valid plugin installation details." + ) from exc + if not isinstance(payload, dict): + raise CodexPluginInstallError( + "Codex returned an unexpected plugin installation response." + ) + return payload + + +def _configured_codex_command( + environment: Mapping[str, str], +) -> str | None: + codex_home = Path( + environment.get("CODEX_HOME", str(Path.home() / ".codex")) + ).expanduser() + try: + config = tomllib.loads( + (codex_home / "config.toml").read_text(encoding="utf-8") + ) + except (OSError, tomllib.TOMLDecodeError): + return None + servers = config.get("mcp_servers") + node_repl = servers.get("node_repl") if isinstance(servers, dict) else None + node_repl_environment = ( + node_repl.get("env") if isinstance(node_repl, dict) else None + ) + policy = config.get("shell_environment_policy") + policy_environment = policy.get("set") if isinstance(policy, dict) else None + for configured in (node_repl_environment, policy_environment): + command = ( + configured.get("CODEX_CLI_PATH") + if isinstance(configured, dict) + else None + ) + if isinstance(command, str) and command.strip(): + return command + return None + + +def resolve_codex_command( + *, + environment: Mapping[str, str] | None = None, + which: Callable[[str], str | None] = shutil.which, +) -> str | None: + """Locate the current CLI bundled with Codex or available on PATH.""" + current_environment = os.environ if environment is None else environment + candidates: list[str] = [] + environment_command = current_environment.get("CODEX_CLI_PATH") + if environment_command: + candidates.append(environment_command) + configured_command = _configured_codex_command(current_environment) + if configured_command: + candidates.append(configured_command) + + local_app_data = current_environment.get("LOCALAPPDATA") + if local_app_data: + bin_directory = Path(local_app_data) / "OpenAI" / "Codex" / "bin" + if bin_directory.is_dir(): + versioned = sorted( + bin_directory.glob("*/codex.exe"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + candidates.extend(str(path) for path in versioned) + candidates.append(str(bin_directory / "codex.exe")) + + path_command = which("codex") + if path_command: + candidates.append(path_command) + + seen: set[str] = set() + for candidate in candidates: + normalized = os.path.normcase(os.path.abspath(os.path.expanduser(candidate))) + if normalized in seen: + continue + seen.add(normalized) + if Path(candidate).expanduser().is_file(): + return str(Path(candidate).expanduser()) + return None + + +def install_codex_plugin( + marketplace_root: Path, + *, + registry: str | None = None, + repository: str = "default", + index_directory: str | None = None, + data_directory: Path | None = None, + device: str | None = None, + codex_command: str | None = None, + runner: CommandRunner = subprocess.run, +) -> CodexPluginInstall: + """Export, register, and install VidXP's local Codex plugin.""" + + exported = export_codex_plugin( + marketplace_root, + registry=registry, + repository=repository, + index_directory=index_directory, + data_directory=data_directory, + device=device, + ) + command = codex_command or resolve_codex_command() + if command is None: + raise CodexPluginInstallError( + "The Codex CLI was not found. Install or update the ChatGPT desktop " + "app, make the codex command available, and try again." + ) + + marketplace_result = _run_codex_json( + command, + [ + "plugin", + "marketplace", + "add", + exported.marketplace_root, + "--json", + ], + runner=runner, + ) + marketplace_name = str( + marketplace_result.get("marketplaceName") or exported.marketplace_name + ) + plugin_result = _run_codex_json( + command, + [ + "plugin", + "add", + f"{exported.plugin_name}@{marketplace_name}", + "--json", + ], + runner=runner, + ) + return CodexPluginInstall( + plugin_name=str(plugin_result.get("name") or exported.plugin_name), + plugin_id=( + None + if plugin_result.get("pluginId") is None + else str(plugin_result["pluginId"]) + ), + plugin_version=str( + plugin_result.get("version") or exported.plugin_version + ), + marketplace_name=str( + plugin_result.get("marketplaceName") or marketplace_name + ), + marketplace_path=exported.marketplace_path, + installed_path=( + None + if plugin_result.get("installedPath") is None + else str(plugin_result["installedPath"]) + ), + detail=( + "VidXP is installed in Codex with its MCP server and skills. " + "Start a new Codex chat to use the updated plugin." + ), + ) diff --git a/src/vidxp/codex_plugin_cli.py b/src/vidxp/codex_plugin_cli.py new file mode 100644 index 0000000..c93ebf0 --- /dev/null +++ b/src/vidxp/codex_plugin_cli.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Sequence + +from vidxp.codex_plugin import CodexPluginInstallError, install_codex_plugin + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Install VidXP's bundled MCP server and skills in Codex." + ) + parser.add_argument( + "--marketplace-root", + type=Path, + required=True, + help="Dedicated local marketplace directory managed by VidXP Desktop.", + ) + parser.add_argument("--registry") + parser.add_argument("--repository", default="default") + parser.add_argument("--index-directory") + parser.add_argument("--data-dir", type=Path) + parser.add_argument("--device") + return parser + + +def main(arguments: Sequence[str] | None = None) -> None: + parser = _parser() + options = parser.parse_args(arguments) + try: + result = install_codex_plugin( + options.marketplace_root, + registry=options.registry, + repository=options.repository, + index_directory=options.index_directory, + data_directory=options.data_dir, + device=options.device, + ) + except (CodexPluginInstallError, OSError, ValueError) as exc: + parser.exit(1, f"VidXP could not set up Codex: {exc}\n") + print(json.dumps(result.to_dict(), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/vidxp/mcp.py b/src/vidxp/mcp.py index 991afb7..c2f25ed 100644 --- a/src/vidxp/mcp.py +++ b/src/vidxp/mcp.py @@ -103,6 +103,11 @@ scoped_job_id, scoped_request_key, ) +from vidxp.mcp_app import ( + MCP_APP_MIME_TYPE, + MCP_APP_RESOURCE_URI, + load_mcp_app_html, +) from vidxp.core.identifiers import ArtifactId from vidxp.evidence_delivery import ( EvidenceDeliveryService, @@ -149,6 +154,15 @@ ) +def _mcp_app_tool_meta(invoking: str, invoked: str) -> dict[str, object]: + return { + "ui": {"resourceUri": MCP_APP_RESOURCE_URI}, + "openai/outputTemplate": MCP_APP_RESOURCE_URI, + "openai/toolInvocation/invoking": invoking, + "openai/toolInvocation/invoked": invoked, + } + + class PrincipalBridge: """Carry the principal validated by the outer ASGI boundary into tools.""" @@ -547,6 +561,27 @@ async def lifecycle(_server): lifespan=lifecycle, ) + @server.resource( + MCP_APP_RESOURCE_URI, + name="vidxp_mcp_app", + title="VidXP video workspace", + description=( + "Interactive upload progress and evidence review for MCP Apps hosts." + ), + mime_type=MCP_APP_MIME_TYPE, + meta={ + "ui": { + "prefersBorder": True, + "csp": { + "connectDomains": [], + "resourceDomains": [], + }, + } + }, + ) + async def read_mcp_app() -> str: + return load_mcp_app_html() + async def artifact_bytes( artifact_id: ArtifactId, *, @@ -1061,9 +1096,125 @@ def evidence_index( ) return "\n".join(lines) - async def evidence_content( + def evidence_app_payload( + *, job: Job, - ) -> list[ImageContent | ResourceLink | TextContent]: + source_job_id: JobId, + delivery: EvidenceDeliveryResult, + query_result: QueryAnswer | None, + ) -> dict[str, object]: + board = delivery.board + pages: list[dict[str, object]] = [] + tiles: list[dict[str, object]] = [] + if board is not None: + for page in board.pages: + artifact = page.artifact + delivery_info = artifact.delivery + pages.append( + { + "page_number": page.page_number, + "media_id": page.media_id, + "width": page.width, + "height": page.height, + "tile_ids": list(page.tile_ids), + "resource_uri": artifact.resource_uri, + "download_url": ( + delivery_info.download_url + if delivery_info is not None + else None + ), + } + ) + for tile in board.tiles: + tiles.append( + { + "evidence_id": tile.evidence_id, + "rank": tile.rank, + "page_number": tile.page_number, + "position": tile.position, + "media_id": tile.media_id, + "modalities": list(tile.modalities), + "start": tile.start, + "end": tile.end, + "display_text": concise_text(tile.display_text), + "state": tile.state.value, + } + ) + requested_count = board.requested_count + rendered_count = board.rendered_count + failed_count = board.failed_count + next_start_rank = board.next_start_rank + else: + for item in delivery.items: + resolved = item.range + tiles.append( + { + "evidence_id": item.evidence_id, + "rank": item.rank, + "page_number": None, + "position": item.rank, + "media_id": item.media_id, + "modalities": list(item.modalities), + "start": ( + resolved.source_start_seconds + if resolved is not None + else 0.0 + ), + "end": ( + resolved.source_end_seconds + if resolved is not None + else 0.0 + ), + "display_text": None, + "state": item.state.value, + } + ) + requested_count = len(delivery.items) + rendered_count = sum( + item.state.value == "ready" for item in delivery.items + ) + failed_count = requested_count - rendered_count + next_start_rank = None + + answer: dict[str, object] | None = None + if query_result is not None: + answer = { + "mode": query_result.mode.value, + "claims": [ + { + "text": concise_text(claim.text, limit=512) or "", + "evidence_ids": list(claim.evidence_ids), + } + for claim in query_result.claims + ], + "fallback_reason": concise_text( + query_result.fallback_reason, + limit=512, + ), + } + + return { + "view": "evidence", + "job_id": job.job_id, + "source_job_id": source_job_id, + "job_kind": job.kind.value, + "answer": answer, + "board": { + "requested_count": requested_count, + "rendered_count": rendered_count, + "failed_count": failed_count, + "next_start_rank": next_start_rank, + "pages": pages, + "tiles": tiles, + }, + } + + async def evidence_presentation( + job: Job, + ) -> tuple[ + dict[str, object], + list[ImageContent | ResourceLink | TextContent], + ]: query_result = None if job.kind in {JobKind.search, JobKind.query}: result = job.result.result @@ -1082,18 +1233,27 @@ async def evidence_content( delivery=projected_delivery, query_result=query_result, ) + source_job_id = job.job_id else: board = job.result.result projected_board, blocks = await project_evidence_board(board) + projected_delivery = EvidenceDeliveryResult( + policy=EvidenceDeliveryPolicy(mode=EvidenceDeliveryMode.none), + items=(), + board=projected_board, + ) index = evidence_index( source_job_id=board.source_job_id, - delivery=EvidenceDeliveryResult( - policy=EvidenceDeliveryPolicy(mode=EvidenceDeliveryMode.none), - items=(), - board=projected_board, - ), + delivery=projected_delivery, ) - return [TextContent(type="text", text=index), *blocks] + source_job_id = board.source_job_id + payload = evidence_app_payload( + job=job, + source_job_id=source_job_id, + delivery=projected_delivery, + query_result=query_result, + ) + return payload, [TextContent(type="text", text=index), *blocks] def completed_evidence_result( source_job_id: JobId, @@ -1227,6 +1387,10 @@ async def get_media(media_id: MediaId) -> MediaAsset: "Automatic indexing defaults on. Poll only get_media_upload." ), annotations=_SUBMIT, + meta=_mcp_app_tool_meta( + "Creating a VidXP upload session…", + "VidXP upload session ready.", + ), structured_output=True, ) async def create_media_upload( @@ -1826,10 +1990,14 @@ async def get_job(job_id: JobId) -> Job: description=( "Present a completed search, query, or evidence-board job as a " "concise evidence index plus model-visible board images and resource " - "links. This intentionally omits structuredContent; use get_job only " - "when the full machine record is actually needed." + "links. The compact structured result drives the optional VidXP " + "evidence-review UI without exposing the full machine record." ), annotations=_READ_ONLY, + meta=_mcp_app_tool_meta( + "Opening VidXP evidence…", + "VidXP evidence ready.", + ), ) async def get_job_evidence(job_id: JobId) -> CallToolResult: def completed_evidence_job(_actor: Principal) -> Job: @@ -1855,10 +2023,13 @@ def completed_evidence_job(_actor: Principal) -> Job: operation=completed_evidence_job, ) try: - blocks = await evidence_content(job) + structured_content, blocks = await evidence_presentation(job) except ApplicationError as exc: raise _application_error(exc) from exc - return CallToolResult(content=blocks) + return CallToolResult( + content=blocks, + structured_content=structured_content, + ) @server.tool( title="Get compact job status", diff --git a/src/vidxp/mcp_app.py b/src/vidxp/mcp_app.py new file mode 100644 index 0000000..8794824 --- /dev/null +++ b/src/vidxp/mcp_app.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from functools import lru_cache +from importlib.resources import files + + +MCP_APP_RESOURCE_URI = "ui://vidxp/evidence-review-v1.html" +MCP_APP_MIME_TYPE = "text/html;profile=mcp-app" + + +@lru_cache(maxsize=1) +def load_mcp_app_html() -> str: + """Load the self-contained MCP App resource shipped with VidXP.""" + + return ( + files("vidxp") + .joinpath("assets", "mcp_app", "index.html") + .read_text(encoding="utf-8") + ) diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py new file mode 100644 index 0000000..9aa8a73 --- /dev/null +++ b/tests/test_codex_plugin.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from vidxp.codex_plugin import ( + CodexPluginInstallError, + export_codex_plugin, + install_codex_plugin, + resolve_codex_command, +) + + +def test_export_codex_plugin_materializes_skills_and_target_mcp_config() -> None: + with TemporaryDirectory() as directory: + root = Path(directory) / "codex-marketplace" + index_directory = Path("C:/VidXP/repositories/default") + data_directory = Path("C:/VidXP") + exported = export_codex_plugin( + root, + repository="default", + index_directory=str(index_directory), + data_directory=data_directory, + ) + + plugin_root = root / "plugins" / "vidxp" + manifest = json.loads( + (plugin_root / ".codex-plugin" / "plugin.json").read_text( + encoding="utf-8" + ) + ) + mcp = json.loads((plugin_root / ".mcp.json").read_text(encoding="utf-8")) + marketplace_path = root / ".agents" / "plugins" / "marketplace.json" + marketplace = json.loads(marketplace_path.read_text(encoding="utf-8")) + + assert manifest["name"] == "vidxp" + assert manifest["version"] == exported.plugin_version + assert "+codex." in exported.plugin_version + assert (plugin_root / "skills" / "vidxp-ingest-video" / "SKILL.md").is_file() + assert ( + plugin_root / "skills" / "vidxp-find-video-evidence" / "SKILL.md" + ).is_file() + assert mcp["mcpServers"]["vidxp"]["args"] == [ + "--repository", + "default", + "--index-directory", + str(index_directory), + "--data-dir", + str(data_directory), + ] + assert Path(mcp["mcpServers"]["vidxp"]["command"]).name.lower() in { + "vidxp-mcp", + "vidxp-mcp.exe", + } + assert marketplace["name"] == "vidxp-local" + assert marketplace["plugins"][0]["source"]["path"] == "./plugins/vidxp" + assert marketplace["plugins"][0]["policy"] == { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + } + assert exported.marketplace_path == str(marketplace_path) + assert not (root / "marketplace.json").exists() + + +def test_export_migrates_the_legacy_managed_marketplace_layout() -> None: + with TemporaryDirectory() as directory: + root = Path(directory) / "marketplace" + root.mkdir() + (root / ".vidxp-managed-marketplace").write_text( + "Managed by VidXP Desktop.\n", + encoding="utf-8", + ) + legacy_path = root / "marketplace.json" + legacy_path.write_text("{}\n", encoding="utf-8") + + exported = export_codex_plugin(root) + + assert Path(exported.marketplace_path).is_file() + assert not legacy_path.exists() + + +def test_install_codex_plugin_registers_marketplace_then_installs_bundle() -> None: + calls: list[list[str]] = [] + + def runner(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: + calls.append(command) + if command[1:4] == ["plugin", "marketplace", "add"]: + payload = {"marketplaceName": "vidxp-local", "alreadyAdded": False} + else: + payload = { + "pluginId": "vidxp@vidxp-local", + "name": "vidxp", + "marketplaceName": "vidxp-local", + "version": "0.4.0+codex.example", + "installedPath": "/codex/cache/vidxp", + } + return subprocess.CompletedProcess(command, 0, json.dumps(payload), "") + + with TemporaryDirectory() as directory: + result = install_codex_plugin( + Path(directory) / "marketplace", + codex_command="codex-test", + runner=runner, + ) + + assert calls[0][0:4] == ["codex-test", "plugin", "marketplace", "add"] + assert calls[0][-1] == "--json" + assert calls[1] == [ + "codex-test", + "plugin", + "add", + "vidxp@vidxp-local", + "--json", + ] + assert result.plugin_id == "vidxp@vidxp-local" + assert result.installed_path == "/codex/cache/vidxp" + assert "MCP server and skills" in result.detail + + +def test_export_refuses_to_replace_an_unmanaged_marketplace() -> None: + with TemporaryDirectory() as directory: + root = Path(directory) / "marketplace" + root.mkdir() + (root / "keep.txt").write_text("user data", encoding="utf-8") + + with pytest.raises(CodexPluginInstallError, match="unmanaged marketplace"): + export_codex_plugin(root) + + assert (root / "keep.txt").read_text(encoding="utf-8") == "user data" + + +def test_resolve_codex_command_prefers_desktop_configured_cli() -> None: + with TemporaryDirectory() as directory: + root = Path(directory) + codex_home = root / ".codex" + configured = root / "current" / "codex.exe" + stale = root / "OpenAI" / "Codex" / "bin" / "codex.exe" + configured.parent.mkdir(parents=True) + stale.parent.mkdir(parents=True) + configured.touch() + stale.touch() + codex_home.mkdir() + escaped_command = str(configured).replace("\\", "\\\\") + (codex_home / "config.toml").write_text( + "[mcp_servers.node_repl.env]\n" + f'CODEX_CLI_PATH = "{escaped_command}"\n', + encoding="utf-8", + ) + + resolved = resolve_codex_command( + environment={ + "CODEX_HOME": str(codex_home), + "LOCALAPPDATA": str(root), + }, + which=lambda _: None, + ) + + assert resolved == str(configured) + + +def test_resolve_codex_command_uses_desktop_environment_path() -> None: + with TemporaryDirectory() as directory: + command = Path(directory) / "codex.exe" + command.touch() + + resolved = resolve_codex_command( + environment={"CODEX_CLI_PATH": str(command)}, + which=lambda _: None, + ) + + assert resolved == str(command) + + +def test_resolve_codex_command_falls_back_to_local_app_install() -> None: + with TemporaryDirectory() as directory: + root = Path(directory) + command = root / "OpenAI" / "Codex" / "bin" / "codex.exe" + command.parent.mkdir(parents=True) + command.touch() + + resolved = resolve_codex_command( + environment={ + "CODEX_HOME": str(root / "missing-codex-home"), + "LOCALAPPDATA": str(root), + }, + which=lambda _: None, + ) + + assert resolved == str(command) + + +def test_resolve_codex_command_prefers_newest_versioned_local_cli() -> None: + with TemporaryDirectory() as directory: + root = Path(directory) + bin_directory = root / "OpenAI" / "Codex" / "bin" + stable = bin_directory / "codex.exe" + older = bin_directory / "old" / "codex.exe" + current = bin_directory / "current" / "codex.exe" + for command in (stable, older, current): + command.parent.mkdir(parents=True, exist_ok=True) + command.touch() + os.utime(older, (1, 1)) + os.utime(current, (2, 2)) + + resolved = resolve_codex_command( + environment={ + "CODEX_HOME": str(root / "missing-codex-home"), + "LOCALAPPDATA": str(root), + }, + which=lambda _: None, + ) + + assert resolved == str(current) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index c6f1834..e7f1782 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -72,6 +72,7 @@ SearchMomentsPlanStep, WorkspaceOverview, ) +from vidxp.mcp_app import MCP_APP_MIME_TYPE, MCP_APP_RESOURCE_URI from vidxp.authentication import ( AuthenticatedBearer, OIDCBearerAuthenticator, @@ -439,12 +440,38 @@ async def test_curated_tools_publish_their_intended_output_contracts(self): async with Client(server) as client: discovered = await client.list_tools() result = await client.call_tool("list_capabilities", {}) + app_resource = await client.read_resource(MCP_APP_RESOURCE_URI) self.assertEqual( [tool.name for tool in discovered.tools], MCP_TOOL_NAMES, ) tools = {tool.name: tool for tool in discovered.tools} + for name in ("create_media_upload", "get_job_evidence"): + self.assertEqual( + tools[name].meta["ui"]["resourceUri"], + MCP_APP_RESOURCE_URI, + ) + self.assertEqual( + tools[name].meta["openai/outputTemplate"], + MCP_APP_RESOURCE_URI, + ) + app_contents = app_resource.contents[0] + self.assertEqual(app_contents.mime_type, MCP_APP_MIME_TYPE) + self.assertEqual( + app_contents.meta["ui"]["csp"], + {"connectDomains": [], "resourceDomains": []}, + ) + self.assertIn("ui/notifications/tool-result", app_contents.text) + self.assertIn('request("ui/initialize"', app_contents.text) + self.assertIn('notify("ui/notifications/initialized"', app_contents.text) + self.assertIn('request("tools/call"', app_contents.text) + self.assertIn('request("ui/open-link"', app_contents.text) + self.assertIn('request("ui/request-display-mode"', app_contents.text) + self.assertIn('request("ui/update-model-context"', app_contents.text) + self.assertIn("window.openai?.requestDisplayMode", app_contents.text) + self.assertIn("materialize_job_evidence", app_contents.text) + self.assertNotIn("