diff --git a/docs/architecture/detached-task-dispatch.md b/docs/architecture/detached-task-dispatch.md index b54e21bfa4..e6f6ccedaf 100644 --- a/docs/architecture/detached-task-dispatch.md +++ b/docs/architecture/detached-task-dispatch.md @@ -224,10 +224,11 @@ checksum sidecar, its publisher signature when present, and the mandatory archive signature, pins the SHA-256 passed to the installer, waits with a bounded deadline, and probes the installed binary again before continuing. -A source build is different: it uploads the controller's repository and -compiles it on the target. It therefore remains a separate user-confirmed -operation and uses only the clean, confirmed controller revision. Automatic -prebuilt installation never escalates to a source build. +The signed prebuilt release is the only install path. The controller never +compiles BitFun on a target, and exposes no command to do so: when no published +binary can run there — an unsupported platform, a libc floor, a missing `tar`, +an unreachable release, or a release that predates a required capability — the +probe reports why and the target cannot be selected. Account-device transport wraps target verbs in names reserved for detached dispatch, such as `dispatch_target_submit`. They are handled before the diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index c83b4a015b..e0eb53b7e5 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -75,7 +75,6 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_targets", "dispatch_probe_target", "dispatch_install_cli_start", - "dispatch_install_cli_source_start", "dispatch_install_cli_poll", "dispatch_install_cli_cancel", "dispatch_sync_model_config", @@ -137,7 +136,6 @@ mod tests { "dispatch_list_targets", "dispatch_probe_target", "dispatch_install_cli_start", - "dispatch_install_cli_source_start", "dispatch_install_cli_poll", "dispatch_install_cli_cancel", "dispatch_sync_model_config", diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs index b34056f95c..4ae3483b3a 100644 --- a/src/apps/desktop/src/api/dispatch_api.rs +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -4,10 +4,7 @@ //! thin host adapters around the platform-neutral dispatch controller and its //! observer-only outbound index. -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; +use std::sync::Arc; use async_trait::async_trait; use bitfun_core::infrastructure::PathManager; @@ -18,7 +15,7 @@ use bitfun_core::service::dispatch::{ get_dispatch_status, list_device_dispatch_jobs, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, probe_device_dispatch_target, probe_dispatch_target, query_device_dispatch_job, query_dispatch_job, start_dispatch_cli_install, - start_dispatch_cli_source_build, submit_device_dispatch, submit_dispatch, + submit_device_dispatch, submit_dispatch, sync_device_dispatch_result, sync_dispatch_model_config, sync_dispatch_result, DeviceDispatchRpc, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchContinueRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, @@ -30,7 +27,6 @@ use bitfun_core::service::dispatch::{ use bitfun_core::service::remote_ssh::dispatch_ssh::{ DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, }; -use bitfun_services_integrations::remote_ssh::dispatch_ssh::install_cli_source_archive_start; use serde_json::Value; use tauri::State; @@ -38,77 +34,6 @@ use super::app_state::AppState; struct AccountDeviceDispatchRpc; -const MAX_CONTROLLER_SOURCE_ARCHIVE_BYTES: usize = 512 * 1024 * 1024; - -#[cfg(debug_assertions)] -fn controller_source_root() -> Option { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(3)? - .to_path_buf(); - (root.join("Cargo.toml").is_file() && root.join(".git").exists()).then_some(root) -} - -#[cfg(not(debug_assertions))] -fn controller_source_root() -> Option { - None -} - -async fn archive_controller_source(root: PathBuf) -> anyhow::Result<(Vec, String)> { - tokio::task::spawn_blocking(move || { - let status = std::process::Command::new("git") - .args(["status", "--porcelain"]) - .current_dir(&root) - .output() - .map_err(|error| anyhow::anyhow!("inspect controller source checkout: {error}"))?; - if !status.status.success() { - anyhow::bail!( - "inspect controller source checkout: {}", - String::from_utf8_lossy(&status.stderr).trim() - ); - } - if !status.stdout.is_empty() { - anyhow::bail!( - "the controller source checkout has uncommitted changes; commit them and restart Desktop before updating the target CLI" - ); - } - - let revision = std::process::Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir(&root) - .output() - .map_err(|error| anyhow::anyhow!("resolve controller source revision: {error}"))?; - if !revision.status.success() { - anyhow::bail!( - "resolve controller source revision: {}", - String::from_utf8_lossy(&revision.stderr).trim() - ); - } - let revision = String::from_utf8(revision.stdout) - .map_err(|error| anyhow::anyhow!("controller source revision is not UTF-8: {error}"))? - .trim() - .to_string(); - - let archive = std::process::Command::new("git") - .args(["archive", "--format=tar.gz", "HEAD"]) - .current_dir(&root) - .output() - .map_err(|error| anyhow::anyhow!("archive controller source: {error}"))?; - if !archive.status.success() { - anyhow::bail!( - "archive controller source: {}", - String::from_utf8_lossy(&archive.stderr).trim() - ); - } - if archive.stdout.is_empty() || archive.stdout.len() > MAX_CONTROLLER_SOURCE_ARCHIVE_BYTES { - anyhow::bail!("controller source archive is empty or exceeds the 512 MB limit"); - } - Ok((archive.stdout, revision)) - }) - .await - .map_err(|error| anyhow::anyhow!("controller source archive task failed: {error}"))? -} - #[async_trait] impl DeviceDispatchRpc for AccountDeviceDispatchRpc { async fn invoke(&self, device_id: &str, command: &str, args: Value) -> anyhow::Result { @@ -203,15 +128,9 @@ pub async fn dispatch_probe_target( .get_ssh_manager_async() .await .map_err(|error| error.to_string())?; - let mut probe = probe_dispatch_target(&manager, request) + probe_dispatch_target(&manager, request) .await - .map_err(|error| error.to_string())?; - if controller_source_root().is_some() { - if let Some(source_build) = probe.source_build.as_mut() { - source_build.git_ref = "current-controller-checkout".to_string(); - } - } - Ok(probe) + .map_err(|error| error.to_string()) } #[tauri::command] @@ -228,35 +147,6 @@ pub async fn dispatch_install_cli_start( .map_err(|error| error.to_string()) } -/// Build the CLI from source on the target. Offered when no published binary -/// can run there. -#[tauri::command] -pub async fn dispatch_install_cli_source_start( - state: State<'_, AppState>, - request: DispatchConnectionRequest, -) -> Result { - let manager = state - .get_ssh_manager_async() - .await - .map_err(|error| error.to_string())?; - if let Some(root) = controller_source_root() { - let (archive, revision) = archive_controller_source(root) - .await - .map_err(|error| error.to_string())?; - return install_cli_source_archive_start( - &manager, - request.connection_id.trim(), - &archive, - &revision, - ) - .await - .map_err(|error| error.to_string()); - } - start_dispatch_cli_source_build(&manager, request) - .await - .map_err(|error| error.to_string()) -} - #[tauri::command] pub async fn dispatch_install_cli_poll( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index c00cde3205..3ee3077b59 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -98,7 +98,6 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_targets", "dispatch_probe_target", "dispatch_install_cli_start", - "dispatch_install_cli_source_start", "dispatch_install_cli_poll", "dispatch_install_cli_cancel", "dispatch_sync_model_config", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index c2284bfca5..150ac4b9fa 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -349,10 +349,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "dispatch_install_cli_poll", RemoteWorkspacePolicy::WorkspaceAgnostic, ), - ( - "dispatch_install_cli_source_start", - RemoteWorkspacePolicy::WorkspaceAgnostic, - ), ( "dispatch_install_cli_start", RemoteWorkspacePolicy::WorkspaceAgnostic, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 1be84e089c..f8ad8d1063 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1749,7 +1749,6 @@ pub async fn run() { api::dispatch_api::dispatch_list_targets, api::dispatch_api::dispatch_probe_target, api::dispatch_api::dispatch_install_cli_start, - api::dispatch_api::dispatch_install_cli_source_start, api::dispatch_api::dispatch_install_cli_poll, api::dispatch_api::dispatch_install_cli_cancel, api::dispatch_api::dispatch_sync_model_config, diff --git a/src/apps/server/src/routes/dispatch.rs b/src/apps/server/src/routes/dispatch.rs index f17e273290..0885447772 100644 --- a/src/apps/server/src/routes/dispatch.rs +++ b/src/apps/server/src/routes/dispatch.rs @@ -10,8 +10,8 @@ use bitfun_core::external_sources::{ use bitfun_core::service::dispatch::{ answer_dispatch, append_dispatch, cancel_dispatch, cancel_dispatch_cli_install, get_dispatch_status, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, - probe_dispatch_target, start_dispatch_cli_install, start_dispatch_cli_source_build, - submit_dispatch, sync_dispatch_model_config, sync_dispatch_result, DispatchAnswerRequest, + probe_dispatch_target, start_dispatch_cli_install, submit_dispatch, + sync_dispatch_model_config, sync_dispatch_result, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, @@ -27,7 +27,6 @@ pub(crate) fn supports(method: &str) -> bool { "dispatch_list_targets" | "dispatch_probe_target" | "dispatch_install_cli_start" - | "dispatch_install_cli_source_start" | "dispatch_install_cli_poll" | "dispatch_install_cli_cancel" | "dispatch_sync_model_config" @@ -79,14 +78,6 @@ pub(crate) async fn dispatch( .map_err(operation_error)?, ) } - "dispatch_install_cli_source_start" => { - let request = parse_request::(¶ms)?; - encode( - start_dispatch_cli_source_build(&host.ssh_manager, request) - .await - .map_err(operation_error)?, - ) - } "dispatch_install_cli_poll" => { let request = parse_request::(¶ms)?; encode( @@ -202,7 +193,6 @@ mod tests { "dispatch_list_targets", "dispatch_probe_target", "dispatch_install_cli_start", - "dispatch_install_cli_source_start", "dispatch_install_cli_poll", "dispatch_install_cli_cancel", "dispatch_sync_model_config", @@ -227,7 +217,7 @@ mod tests { } #[test] - fn source_build_route_accepts_a_structured_connection_request() { + fn connection_scoped_routes_accept_a_structured_connection_request() { let request = parse_request::(&serde_json::json!({ "request": { "connectionId": "ssh-target" } })) diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index 27db58d920..caed475394 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -274,14 +274,6 @@ pub async fn install_cli_start( dispatch_ssh::install_cli_start(manager, request.connection_id.trim(), &request.release).await } -/// Build and install the CLI from source, for targets no published binary fits. -pub async fn install_cli_source_start( - manager: &SSHConnectionManager, - request: DispatchConnectionRequest, -) -> anyhow::Result { - dispatch_ssh::install_cli_source_start(manager, request.connection_id.trim()).await -} - pub async fn install_cli_poll( manager: &SSHConnectionManager, request: DispatchInstallPollRequest, @@ -1500,7 +1492,6 @@ mod tests { release: None, protocol: Some(json!({ "cliVersion": "1.2.3" })), prebuilt_incompatible: None, - source_build: None, }; recover_interrupted_cli_install_audit(&store, "job-install-recovery", &probe) diff --git a/src/crates/assembly/core/src/service/dispatch/device_controller.rs b/src/crates/assembly/core/src/service/dispatch/device_controller.rs index 0399af5eb4..f81121ed0b 100644 --- a/src/crates/assembly/core/src/service/dispatch/device_controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/device_controller.rs @@ -114,10 +114,9 @@ pub async fn probe_device( protocol_error, release: None, protocol: Some(protocol), - // An account device runs its own already-installed CLI; this controller - // neither installs nor builds anything for it. + // An account device runs its own already-installed CLI; this + // controller installs nothing for it. prebuilt_incompatible: None, - source_build: None, }) } diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index e8044a088d..fe8be98d2e 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -25,7 +25,6 @@ pub use controller::{ answer as answer_dispatch, append as append_dispatch, cancel as cancel_dispatch, continue_job as continue_dispatch_job, install_cli_cancel as cancel_dispatch_cli_install, install_cli_poll as poll_dispatch_cli_install, - install_cli_source_start as start_dispatch_cli_source_build, install_cli_start as start_dispatch_cli_install, list_jobs as list_dispatch_jobs, list_targets as list_dispatch_targets, probe_target as probe_dispatch_target, query_job as query_dispatch_job, status as get_dispatch_status, submit as submit_dispatch, diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index 84094b03d0..fe498d4c60 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -80,10 +80,6 @@ impl Drop for UnverifiedResultBundle { /// Oldest glibc the published Linux binaries run against. Kept in step with /// `scripts/ci/check-glibc-floor.sh`, which enforces it at release time. const GLIBC_FLOOR: &str = "2.35"; -/// A release build of the workspace needs roughly this much scratch space. -/// Same figure the relay source build uses. -const SOURCE_BUILD_FREE_KB: u64 = 6 * 1024 * 1024; -const REPO_GIT_URL: &str = "https://github.com/GCWing/BitFun.git"; const DISPATCH_PROTOCOL_VERSION: u64 = bitfun_services_core::dispatch_contract::DISPATCH_PROTOCOL_VERSION as u64; /// First stable release whose CLI is known to contain every capability below. @@ -124,8 +120,6 @@ pub struct DispatchSshProbe { /// Present only when the published binaries cannot run here, so the UI can /// explain why instead of offering an install that would fail the same way. pub prebuilt_incompatible: Option, - /// Offered as the way forward when a prebuilt install cannot work. - pub source_build: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -171,10 +165,6 @@ struct RemoteTarget { libc: Option, /// glibc version, when the target reported one. libc_version: Option, - cargo_version: Option, - git_available: bool, - cc_available: bool, - free_kb: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -272,9 +262,15 @@ pub async fn probe( Some("remote target has no tar executable; install tar and retry".to_string()), ) } else if !published_release_supports_required_dispatch_protocol(RELEASE_VERSION) { - // The controller is ahead of the latest stable artifact. Avoid an - // unnecessary release request and offer its exact source instead. - (None, None) + // The controller is ahead of the latest stable artifact, so no + // published binary carries the capabilities it needs. Skip the + // release request and say so. + ( + None, + Some(format!( + "no published BitFun CLI yet carries the dispatch capabilities this controller requires (controller {RELEASE_VERSION})" + )), + ) } else { match resolve_release(&target.os, &target.arch).await { // Capability support is a fact about the published artifact, @@ -288,12 +284,13 @@ pub async fn probe( { (Some(release.public), None) } - // Before the first compatible stable release exists, the exact - // controller source is the only deterministic repair path. Do - // not show a speculative "same version means same binary" - // warning; the readiness row already names the missing - // capability and the source-build action explains the remedy. - Ok(_) => (None, None), + Ok(release) => ( + None, + Some(format!( + "published BitFun CLI {} does not carry the dispatch capabilities this controller requires", + release.public.version + )), + ), Err(error) => (None, Some(error.to_string())), } } @@ -301,13 +298,6 @@ pub async fn probe( (None, None) }; let install_supported = release.is_some(); - // Offer the source build whenever the target needs a CLI but no prebuilt - // install can deliver one — an unsupported platform, a libc floor, a - // missing tar, an unreachable release, or a release that does not carry - // dispatch. Gating this on platform incompatibility alone left the last - // case with a warning and no way forward. - let source_build = - (needs_install && release.is_none()).then(|| source_build_availability(&target)); Ok(DispatchSshProbe { cli_installed: target.cli_path.is_some(), @@ -322,15 +312,13 @@ pub async fn probe( prebuilt_incompatible: incompatibility .as_ref() .map(PrebuiltIncompatibility::describe), - source_build, }) } /// Why the published binaries cannot run on this target. /// /// Kept structured rather than a flat string so the UI can say what is actually -/// wrong — and, when a source build could fix it, offer that instead of leaving -/// the user with an unexplained failure. +/// wrong instead of leaving the user with an unexplained failure. #[derive(Debug, Clone, PartialEq, Eq)] enum PrebuiltIncompatibility { UnsupportedPlatform { os: String, arch: String }, @@ -354,19 +342,6 @@ impl PrebuiltIncompatibility { } } -/// Whether a source build could produce a working CLI on this target. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DispatchSourceBuild { - /// Whether a build could start right now. - pub supported: bool, - /// What the user must install or free up first, when it cannot. - pub blockers: Vec, - pub cargo_version: Option, - /// The git ref that would be built. - pub git_ref: String, -} - /// Detect an incompatibility that no amount of reinstalling can fix. fn prebuilt_incompatibility(target: &RemoteTarget) -> Option { if release_target(&target.os, &target.arch).is_err() { @@ -416,39 +391,6 @@ fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering { std::cmp::Ordering::Equal } -fn source_build_availability(target: &RemoteTarget) -> DispatchSourceBuild { - let mut blockers = Vec::new(); - if target.cargo_version.is_none() { - blockers.push( - "no cargo on the target; install a Rust toolchain (https://rustup.rs) and retry" - .to_string(), - ); - } - if !target.git_available { - blockers.push("no git on the target".to_string()); - } - if !target.cc_available { - blockers.push( - "no C compiler on the target (install build-essential or equivalent)".to_string(), - ); - } - if let Some(free_kb) = target.free_kb { - if free_kb < SOURCE_BUILD_FREE_KB { - blockers.push(format!( - "needs about {} GB free under $HOME, found {} GB", - SOURCE_BUILD_FREE_KB / 1024 / 1024, - free_kb / 1024 / 1024 - )); - } - } - DispatchSourceBuild { - supported: blockers.is_empty(), - blockers, - cargo_version: target.cargo_version.clone(), - git_ref: release_tag_for_version(RELEASE_VERSION), - } -} - /// Whether a published artifact is expected to implement the controller's /// required protocol. /// @@ -555,7 +497,7 @@ pub async fn install_cli_start( let release = resolve_release(&target.os, &target.arch).await?; if !published_release_supports_required_dispatch_protocol(&release.public.version) { return Err(anyhow!( - "published BitFun CLI {} does not contain the dispatch capabilities required by this controller; build from the controller source instead", + "published BitFun CLI {} does not contain the dispatch capabilities required by this controller", release.public.version )); } @@ -627,7 +569,7 @@ pub async fn install_cli_start( manager, connection_id, &dir, - Some(&archive_path), + &archive_path, &body_path, &script_path, &body, @@ -780,14 +722,14 @@ trap - EXIT /// Stage the installer scripts and launch the detached body. /// -/// Shared by the release and source-build paths so both get the same token -/// handshake, log truncation, and channel-leak-free launch. +/// Kept separate from `install_cli_start` so the token handshake, log +/// truncation, and channel-leak-free launch stay in one place. #[allow(clippy::too_many_arguments)] async fn stage_and_launch_installer( manager: &SSHConnectionManager, connection_id: &str, dir: &str, - archive_path: Option<&str>, + archive_path: &str, body_path: &str, script_path: &str, body: &str, @@ -849,190 +791,6 @@ async fn stage_and_launch_installer( Ok(()) } -/// Build and install the CLI from source on the target. -/// -/// The way forward when no published binary can run there. Shares the install -/// driver, log, and poll/cancel machinery with the release path, so progress -/// reporting and cancellation behave identically. -pub async fn install_cli_source_start( - manager: &SSHConnectionManager, - connection_id: &str, -) -> Result { - ensure_plain_ssh_target(manager, connection_id).await?; - let target = probe_remote_target(manager, connection_id).await?; - let availability = source_build_availability(&target); - if !availability.supported { - return Err(anyhow!( - "target cannot build BitFun from source: {}", - availability.blockers.join("; ") - )); - } - - install_cli_cancel(manager, connection_id) - .await - .context("stop an earlier BitFun CLI installation")?; - - let dir = format!("{}/{}", target.home, INSTALL_STATE_DIR); - let body_path = format!("{dir}/{INSTALL_STEM}-body.sh"); - let script_path = format!("{dir}/{INSTALL_STEM}.sh"); - let install_token = format!("bitfun-install-{}", uuid::Uuid::new_v4().as_simple()); - let version = RELEASE_VERSION - .split('+') - .next() - .unwrap_or(RELEASE_VERSION) - .to_string(); - - exec_ok( - manager, - connection_id, - &format!( - "mkdir -p {dir} && chmod 700 {root} {dispatch} {dir}", - root = shell_quote_posix(&format!("{}/.bitfun", target.home)), - dispatch = shell_quote_posix(&format!("{}/.bitfun/dispatch", target.home)), - dir = shell_quote_posix(&dir), - ), - ) - .await?; - - let body = to_unix_script(&source_build_body_script( - &dir, - &version, - &availability.git_ref, - )); - let driver = to_unix_script(&install_driver_script(&dir, &body_path, &install_token)); - stage_and_launch_installer( - manager, - connection_id, - &dir, - None, - &body_path, - &script_path, - &body, - &driver, - &install_token, - ) - .await?; - - Ok(DispatchInstallStart { - script_path, - version, - target: format!("{} {}", target.os, target.arch), - url: REPO_GIT_URL.to_string(), - sha256: String::new(), - }) -} - -/// Build and install the CLI from an exact controller-provided source archive. -/// -/// Development Desktop builds use this after the same explicit source-build -/// confirmation as the repository-clone path. It prevents an untagged -/// controller from "updating" a same-semver target back to an older release -/// whose dispatch protocol is missing required behavioral capabilities. -pub async fn install_cli_source_archive_start( - manager: &SSHConnectionManager, - connection_id: &str, - source_archive: &[u8], - revision: &str, -) -> Result { - ensure_plain_ssh_target(manager, connection_id).await?; - if source_archive.is_empty() { - return Err(anyhow!("controller source archive is empty")); - } - if source_archive.len() > MAX_ARCHIVE_BYTES { - return Err(anyhow!( - "controller source archive exceeds the {} MB safety limit", - MAX_ARCHIVE_BYTES / (1024 * 1024) - )); - } - let revision = revision.trim(); - if revision.is_empty() - || revision.len() > 80 - || !revision.chars().all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') - }) - { - return Err(anyhow!("controller source revision is invalid")); - } - - let target = probe_remote_target(manager, connection_id).await?; - let mut availability = source_build_availability(&target); - // The controller supplied a complete archive, so the target does not need - // git. It still needs tar to unpack that archive. - availability - .blockers - .retain(|blocker| blocker != "no git on the target"); - if !target.tar_available { - availability - .blockers - .push("no tar executable on the target".to_string()); - } - if !availability.blockers.is_empty() { - return Err(anyhow!( - "target cannot build BitFun from controller source: {}", - availability.blockers.join("; ") - )); - } - - install_cli_cancel(manager, connection_id) - .await - .context("stop an earlier BitFun CLI installation")?; - - let dir = format!("{}/{}", target.home, INSTALL_STATE_DIR); - let archive_path = format!("{dir}/controller-source.tar.gz"); - let body_path = format!("{dir}/{INSTALL_STEM}-body.sh"); - let script_path = format!("{dir}/{INSTALL_STEM}.sh"); - let install_token = format!("bitfun-install-{}", uuid::Uuid::new_v4().as_simple()); - let version = RELEASE_VERSION - .split('+') - .next() - .unwrap_or(RELEASE_VERSION) - .to_string(); - - exec_ok( - manager, - connection_id, - &format!( - "mkdir -p {dir} && chmod 700 {root} {dispatch} {dir}", - root = shell_quote_posix(&format!("{}/.bitfun", target.home)), - dispatch = shell_quote_posix(&format!("{}/.bitfun/dispatch", target.home)), - dir = shell_quote_posix(&dir), - ), - ) - .await?; - manager - .sftp_write(connection_id, &archive_path, source_archive) - .await - .context("stage controller BitFun source archive")?; - - let body = to_unix_script(&source_archive_build_body_script( - &dir, - &archive_path, - &version, - revision, - )); - let driver = to_unix_script(&install_driver_script(&dir, &body_path, &install_token)); - stage_and_launch_installer( - manager, - connection_id, - &dir, - Some(&archive_path), - &body_path, - &script_path, - &body, - &driver, - &install_token, - ) - .await?; - - Ok(DispatchInstallStart { - script_path, - version, - target: format!("{} {}", target.os, target.arch), - url: format!("controller-source:{revision}"), - sha256: String::new(), - }) -} - fn ensure_confirmed_release( resolved: &DispatchCliRelease, expected: &DispatchCliRelease, @@ -1818,10 +1576,8 @@ where )); } if let Some(reason) = probed.prebuilt_incompatible.as_deref() { - // A source build needs its own confirmation, so stop here with the - // reason rather than silently escalating to compiling on the target. return Err(anyhow!( - "no published BitFun CLI can run on this target ({reason}); build it from source explicitly" + "no published BitFun CLI can run on this target ({reason}); install BitFun there manually and retry" )); } let release = probed.release.clone().ok_or_else(|| { @@ -2155,10 +1911,6 @@ async fn probe_remote_target( _ => None, }, libc_version: (!get("libcversion").is_empty()).then(|| get("libcversion")), - cargo_version: (!get("cargo").is_empty()).then(|| get("cargo")), - git_available: get("git") == "1", - cc_available: get("cc") == "1", - free_kb: get("freekb").parse().ok(), }) } @@ -2189,16 +1941,6 @@ if [ "$(uname -s 2>/dev/null || true)" = "Linux" ]; then printf 'libcversion=%s\n' "$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $NF}' || true)" fi fi -if command -v cargo >/dev/null 2>&1; then - printf 'cargo=%s\n' "$(cargo --version 2>/dev/null | awk '{print $2}' || true)" -fi -if command -v git >/dev/null 2>&1; then printf 'git=1\n'; else printf 'git=0\n'; fi -if command -v cc >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1; then - printf 'cc=1\n' -else - printf 'cc=0\n' -fi -printf 'freekb=%s\n' "$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {print $4}' || true)" "# } @@ -2540,82 +2282,6 @@ done ) } -/// Build the CLI from source on the target, for hosts no published binary fits. -/// -/// Deliberately does not install a Rust toolchain: fetching and running an -/// installer script on someone's server is a bigger decision than this flow -/// should make silently. A missing toolchain is reported as a blocker instead. -fn source_build_body_script(dir: &str, expected_version: &str, git_ref: &str) -> String { - let build = format!( - r#"SRC="$D/source" -GIT_REF={git_ref} -echo "Building BitFun CLI {git_ref_plain} from source on the target. This can take a while." -FREE_KB="$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {{print $4}}' || echo 0)" -if [ "${{FREE_KB:-0}}" -lt {free_kb} ]; then - echo "ERROR: source build needs about {free_gb} GB free under $HOME, found $((FREE_KB / 1024 / 1024)) GB" >&2 - exit 1 -fi -rm -rf "$SRC" -git clone --depth 1 --branch "$GIT_REF" {repo} "$SRC" -echo ">>> cargo build --release (bitfun, bitfun-cli)" -( cd "$SRC" && cargo build --release --locked -p bitfun-cli --bin bitfun --bin bitfun-cli ) -PRIMARY="$SRC/target/release/bitfun" -LEGACY="$SRC/target/release/bitfun-cli" -[ -f "$PRIMARY" ] || {{ echo "ERROR: source build produced no bitfun binary" >&2; exit 1; }} -[ -f "$LEGACY" ] || {{ echo "ERROR: source build produced no bitfun-cli binary" >&2; exit 1; }} -"#, - git_ref = shell_quote_posix(git_ref), - git_ref_plain = git_ref, - repo = shell_quote_posix(REPO_GIT_URL), - free_kb = SOURCE_BUILD_FREE_KB, - free_gb = SOURCE_BUILD_FREE_KB / 1024 / 1024, - ); - format!( - "{preamble}{build}{commit}", - preamble = install_preamble_fragment(dir, expected_version), - // The checkout is many gigabytes; leaving it behind would silently fill - // the target's home directory after a few installs. - commit = install_commit_fragment(r#"rm -rf "$SRC""#), - ) -} - -fn source_archive_build_body_script( - dir: &str, - archive_path: &str, - expected_version: &str, - revision: &str, -) -> String { - let build = format!( - r#"SRC="$D/source" -SOURCE_ARCHIVE={archive} -echo "Building BitFun CLI controller source {revision_plain} on the target. This can take a while." -FREE_KB="$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {{print $4}}' || echo 0)" -if [ "${{FREE_KB:-0}}" -lt {free_kb} ]; then - echo "ERROR: source build needs about {free_gb} GB free under $HOME, found $((FREE_KB / 1024 / 1024)) GB" >&2 - exit 1 -fi -rm -rf "$SRC" -mkdir -p "$SRC" -tar -xzf "$SOURCE_ARCHIVE" -C "$SRC" -echo ">>> cargo build --release (bitfun, bitfun-cli)" -( cd "$SRC" && cargo build --release --locked -p bitfun-cli --bin bitfun --bin bitfun-cli ) -PRIMARY="$SRC/target/release/bitfun" -LEGACY="$SRC/target/release/bitfun-cli" -[ -f "$PRIMARY" ] || {{ echo "ERROR: source build produced no bitfun binary" >&2; exit 1; }} -[ -f "$LEGACY" ] || {{ echo "ERROR: source build produced no bitfun-cli binary" >&2; exit 1; }} -"#, - archive = shell_quote_posix(archive_path), - revision_plain = revision, - free_kb = SOURCE_BUILD_FREE_KB, - free_gb = SOURCE_BUILD_FREE_KB / 1024 / 1024, - ); - format!( - "{preamble}{build}{commit}", - preamble = install_preamble_fragment(dir, expected_version), - commit = install_commit_fragment(r#"rm -rf "$SRC"; rm -f "$SOURCE_ARCHIVE""#), - ) -} - fn install_driver_script(dir: &str, body_path: &str, install_token: &str) -> String { format!( r#"#!/bin/bash @@ -2672,8 +2338,7 @@ exit 0 #[allow(clippy::too_many_arguments)] fn stage_install_command( - // Absent for a source build, which has no archive to protect. - archive_path: Option<&str>, + archive_path: &str, body_path: &str, script_path: &str, log_path: &str, @@ -2683,14 +2348,12 @@ fn stage_install_command( exit_path: &str, install_token: &str, ) -> String { - let archive = archive_path - .map(|path| format!("chmod 600 {} && ", shell_quote_posix(path))) - .unwrap_or_default(); format!( - "{archive}chmod 700 {body} {script} \ + "chmod 600 {archive} && chmod 700 {body} {script} \ && rm -f {pid} {driver_pid} {exit} \ && : > {log} && chmod 600 {log} \ && printf '%s\\n' {token} > {prepare} && chmod 600 {prepare}", + archive = shell_quote_posix(archive_path), body = shell_quote_posix(body_path), script = shell_quote_posix(script_path), pid = shell_quote_posix(pid_path), @@ -2970,9 +2633,7 @@ mod tests { "/home/user/.bitfun/dispatch/install/install-cli-body.sh", "bitfun-install-test-token", ); - let source = - source_build_body_script("/home/user/.bitfun/dispatch/install", "1.2.3", "v1.2.3"); - for (name, script) in [("body", body), ("driver", driver), ("source", source)] { + for (name, script) in [("body", body), ("driver", driver)] { let script = to_unix_script(&script); assert!(!script.contains('\r'), "{name} must be LF-only"); assert!( @@ -3014,10 +2675,6 @@ mod tests { digest_tool, libc: Some(RemoteLibc::Glibc), libc_version: Some("2.39".to_string()), - cargo_version: None, - git_available: true, - cc_available: true, - free_kb: Some(SOURCE_BUILD_FREE_KB * 2), } } @@ -3179,97 +2836,37 @@ mod tests { } #[test] - fn source_build_reports_every_missing_prerequisite() { - let mut target = test_target(None, None); - target.cargo_version = None; - target.git_available = false; - target.cc_available = false; - target.free_kb = Some(1024); - - let availability = source_build_availability(&target); - assert!(!availability.supported); - assert_eq!( - availability.blockers.len(), - 4, - "every prerequisite must be listed at once, not one per retry: {:?}", - availability.blockers - ); - assert!( - availability - .blockers - .iter() - .any(|b| b.contains("rustup.rs")), - "a missing toolchain must say where to get one" - ); - - target.cargo_version = Some("1.90.0".to_string()); - target.git_available = true; - target.cc_available = true; - target.free_kb = Some(SOURCE_BUILD_FREE_KB * 2); - let availability = source_build_availability(&target); - assert!(availability.supported, "{:?}", availability.blockers); - assert!(availability.git_ref.starts_with('v') || availability.git_ref == "nightly"); - } - - #[test] - fn both_install_paths_share_one_staging_and_commit_implementation() { + fn the_install_path_uses_the_shared_staging_and_commit_implementation() { let release = install_body_script( "/home/user/.bitfun/dispatch/install", "/home/user/.bitfun/dispatch/install/archive.tar.gz", "1.2.3", &ArchiveSource::TargetDownload, ); - let source = - source_build_body_script("/home/user/.bitfun/dispatch/install", "1.2.3", "v1.2.3"); - let controller_source = source_archive_build_body_script( - "/home/user/.bitfun/dispatch/install", - "/home/user/.bitfun/dispatch/install/controller-source.tar.gz", - "1.2.3", - "abc123", - ); - // The atomic-replace and rollback semantics must not be able to drift - // between the two paths. + // Installing a signed release is the only way a target gets a CLI, so + // its atomic-replace and rollback semantics must come from the shared + // fragment rather than being restated inline. let commit = install_commit_fragment(r#"rm -f "$ARCHIVE""#); let shared = commit .lines() .find(|line| line.contains("mv -f \"$PRIMARY_NEW\"")) .expect("commit fragment swaps the primary"); - for (name, script) in [ - ("release", &release), - ("source", &source), - ("controller source", &controller_source), - ] { - assert!(script.contains(shared), "{name} must use the shared commit"); - assert!( - script.contains(r#"PRIMARY_NEW="$STAGE/bitfun""#), - "{name} must stage under real filenames" - ); - assert!( - script.contains("rollback_install"), - "{name} must keep rollback" - ); - assert!( - script.contains("dispatch_worker_cli_profile"), - "{name} must reject a CLI whose detached worker can select the wrong profile" - ); - } + assert!(release.contains(shared), "release must use the shared commit"); assert!( - source.contains("cargo build --release --locked"), - "source build must be reproducible" + release.contains(r#"PRIMARY_NEW="$STAGE/bitfun""#), + "release must stage under real filenames" ); assert!( - !source.contains("rustup") && !source.contains("sudo"), - "source build must not install a toolchain or escalate" + release.contains("rollback_install"), + "release must keep rollback" ); assert!( - source.contains(r#"rm -rf "$SRC""#), - "the checkout must be cleaned up after a successful build" + release.contains("dispatch_worker_cli_profile"), + "release must reject a CLI whose detached worker can select the wrong profile" ); assert!( - controller_source.contains("tar -xzf \"$SOURCE_ARCHIVE\"") - && controller_source.contains("controller source abc123") - && !controller_source.contains("git clone"), - "a controller archive must build exactly the confirmed local revision" + !release.contains("cargo build"), + "no install path may compile BitFun on the target" ); } @@ -3687,13 +3284,6 @@ mod tests { "/home/user/.bitfun/dispatch/install/install-cli-body.sh", "bitfun-install-test-token", ), - source_build_body_script("/home/user/.bitfun/dispatch/install", "1.2.3", "v1.2.3"), - source_archive_build_body_script( - "/home/user/.bitfun/dispatch/install", - "/home/user/.bitfun/dispatch/install/controller-source.tar.gz", - "1.2.3", - "abc123", - ), target_download_script( RemoteDownloader::Curl, RemoteDigestTool::Sha256Sum, diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss index 4aa0aa60a3..eb660d3532 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss @@ -8,6 +8,12 @@ // support 12px — descriptions under a title // Nothing uses 10px; at that size the descriptions were unreadable and every // level looked the same. +// +// One box level only. Sections are separated by a hairline rule instead of +// being drawn as cards: the modal is already a surface, and nesting a card +// inside it (and a second card inside that) made a three-field form read as a +// stack of unrelated panels. The approval options stay bordered because they +// are the one thing here that is clickable and selectable. .dispatch-install-dialog { display: flex; min-height: 0; @@ -36,14 +42,13 @@ font-size: var(--font-size-xs); } - // The one scrolling region. Keeps the footer reachable once the install card - // and the output console are both open, which used to push it off-screen. + // The one scrolling region. Keeps the footer reachable on short viewports, + // which the section stack used to push off-screen. &__body { display: flex; min-height: 0; flex: 1; flex-direction: column; - gap: $size-gap-3; padding: 0 $size-gap-4 $size-gap-4; overflow-y: auto; @@ -52,26 +57,29 @@ > * { flex-shrink: 0; } + + > [role='alert'] { + margin-bottom: $size-gap-3; + } } &__section { display: flex; flex-direction: column; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; - // Lets the header fill bleed to the card edge. - overflow: hidden; - } - - &__section-header { - display: flex; - min-height: 38px; - align-items: center; - justify-content: space-between; gap: $size-gap-2; - padding: $size-gap-2 $size-gap-3; - border-bottom: 1px solid var(--border-subtle); - background: var(--element-bg-subtle); + padding: $size-gap-3 0; + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } + + + .dispatch-install-dialog__section { + border-top: 1px solid var(--border-subtle); + } } &__section-title { @@ -80,13 +88,6 @@ font-weight: 600; } - &__section-body { - display: flex; - flex-direction: column; - gap: $size-gap-2; - padding: $size-gap-3; - } - &__hint { color: var(--color-text-muted); font-size: var(--font-size-xs); @@ -105,11 +106,67 @@ font-weight: 600; } - &__field-row { + // A read-only path, not a code sample: drop the global `code` pill so the + // section keeps exactly one box level (the revision input). + &__path { + padding: 0; + border: 0; + background: none; + overflow-wrap: anywhere; + color: var(--color-text-secondary); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + } + + &__base-ref { + cursor: text; + + input { + box-sizing: border-box; + width: 100%; + min-height: 32px; + padding: 0 $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + + &:focus-visible { + border-color: var(--color-accent-500); + outline: 2px solid color-mix(in srgb, var(--color-accent-500) 24%, transparent); + outline-offset: 1px; + } + } + } + + // Checkbox with its explanation attached, rather than a label and a loose + // hint that could belong to anything below it. + &__toggle { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; gap: $size-gap-2; - align-items: center; + font-size: var(--font-size-xs); + line-height: 1.4; + cursor: pointer; + + input { + margin: 1px 0 0; + } + + > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + font-weight: 500; + } + + small { + font-weight: 400; + } } // Selectable approval-policy cards. @@ -190,64 +247,6 @@ } } - // Project summary and the opt-in for carrying local Git-visible changes. - // This is a normal setup choice, so it uses a neutral surface rather than a - // warning treatment. - &__consent { - display: flex; - flex-direction: column; - gap: $size-gap-2; - padding: $size-gap-3; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; - background: var(--element-bg-subtle); - font-size: var(--font-size-xs); - - code { - overflow-wrap: anywhere; - color: var(--color-text-secondary); - font-family: var(--font-family-mono); - } - - > span { - color: var(--color-text-secondary); - line-height: 1.45; - } - - label { - display: flex; - align-items: flex-start; - gap: $size-gap-2; - font-weight: 500; - line-height: 1.4; - cursor: pointer; - } - - .dispatch-install-dialog__base-ref { - flex-direction: column; - gap: $size-gap-1; - cursor: text; - - input { - box-sizing: border-box; - width: 100%; - min-height: 32px; - padding: 0 $size-gap-2; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; - background: var(--color-bg-primary); - color: var(--color-text-primary); - font-family: var(--font-family-mono); - - &:focus-visible { - border-color: var(--color-accent-500); - outline: 2px solid color-mix(in srgb, var(--color-accent-500) 24%, transparent); - outline-offset: 1px; - } - } - } - } - &__checks { display: grid; gap: 0; @@ -304,20 +303,32 @@ } } - // Neutral action panel. Deliberately not warning-coloured: this is something - // to do, not something to accept. - &__action-panel { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: $size-gap-2; + &__details { + width: 100%; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + + summary { + width: fit-content; + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; + + &:hover, + &:focus-visible { + color: var(--color-text-primary); + } + } + + &[open] summary { + margin-bottom: $size-gap-2; + } dl { display: grid; gap: $size-gap-1; width: 100%; margin: 0; - font-size: var(--font-size-xs); } dl > div { @@ -338,42 +349,6 @@ } } - &__details { - width: 100%; - color: var(--color-text-secondary); - font-size: var(--font-size-xs); - - summary { - width: fit-content; - color: var(--color-text-secondary); - cursor: pointer; - user-select: none; - - &:hover, - &:focus-visible { - color: var(--color-text-primary); - } - } - - &[open] summary { - margin-bottom: $size-gap-2; - } - } - - &__output { - box-sizing: border-box; - max-height: 180px; - margin: 0; - padding: $size-gap-2; - overflow: auto; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-sm; - background: var(--color-bg-secondary); - color: var(--color-text-secondary); - font: var(--font-size-xs)/1.5 var(--font-family-mono); - white-space: pre-wrap; - } - // Pinned so the primary action never scrolls away. &__actions { display: flex; diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index aa19912e37..95720db4c4 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -10,9 +10,6 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const mocks = vi.hoisted(() => ({ probeTarget: vi.fn(), - installCliSourceStart: vi.fn(), - installCliPoll: vi.fn(), - installCliCancel: vi.fn(), syncModelConfig: vi.fn(), confirmWarning: vi.fn(), getConfig: vi.fn(), @@ -28,9 +25,6 @@ const mocks = vi.hoisted(() => ({ vi.mock('./dispatchApi', () => ({ dispatchApi: { probeTarget: mocks.probeTarget, - installCliSourceStart: mocks.installCliSourceStart, - installCliPoll: mocks.installCliPoll, - installCliCancel: mocks.installCliCancel, syncModelConfig: mocks.syncModelConfig, }, })); @@ -125,7 +119,7 @@ function createDeferred() { return { promise, reject, resolve }; } -describe('DispatchInstallDialog installation lifecycle', () => { +describe('DispatchInstallDialog target preparation', () => { let container: HTMLDivElement; let root: Root; @@ -146,7 +140,6 @@ describe('DispatchInstallDialog installation lifecycle', () => { }, }); mocks.confirmWarning.mockResolvedValue(true); - mocks.installCliCancel.mockResolvedValue(undefined); mocks.getConfig.mockResolvedValue([]); mocks.getFreshConfig.mockResolvedValue(undefined); mocks.resolveRevision.mockResolvedValue('a'.repeat(40)); @@ -182,7 +175,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { await Promise.resolve(); }); - expect(container.textContent).toContain('dispatch.installAutomaticTitle'); + expect(container.textContent).toContain('dispatch.installAutomaticDescription'); expect(container.textContent).toContain('1.2.3'); expect(container.textContent).toContain('abc123'); expect(container.querySelector('details')?.open).toBe(false); @@ -307,64 +300,17 @@ describe('DispatchInstallDialog installation lifecycle', () => { expect(container.querySelector('.dispatch-install-dialog')).not.toBeNull(); }); - it('offers a source build only when the target can actually run one', async () => { - // A target no published binary fits: the release install is not offered, - // and the source build is gated on its prerequisites rather than failing - // partway through. + it('never offers to compile on the target and explains why it cannot be prepared', async () => { + // A target no published binary fits. Preparing it is not something this + // controller can do, so the dialog says so instead of offering to build + // BitFun on someone else's machine. mocks.probeTarget.mockResolvedValue({ cliInstalled: false, os: 'linux', arch: 'x86_64', installSupported: false, prebuiltIncompatible: 'target uses musl libc', - sourceBuild: { - supported: false, - blockers: ['no cargo on the target'], - gitRef: 'v1.2.3', - }, - }); - - await act(async () => { - root.render( - , - ); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('dispatch.sourceBuildUnavailable'); - expect(container.textContent).not.toContain('target uses musl libc'); - expect(container.textContent).not.toContain('no cargo on the target'); - const buttons = () => Array.from(container.querySelectorAll('button')); - expect( - buttons().find(button => button.textContent?.includes('dispatch.installConfirm')), - 'a prebuilt install that cannot work must not be offered', - ).toBeUndefined(); - const blocked = buttons() - .find(button => button.textContent?.includes('dispatch.sourceBuildConfirm')); - expect(blocked?.disabled).toBe(true); - - // Same target once a toolchain is present. - mocks.probeTarget.mockResolvedValue({ - cliInstalled: false, - os: 'linux', - arch: 'x86_64', - installSupported: false, - prebuiltIncompatible: 'target uses musl libc', - sourceBuild: { supported: true, blockers: [], gitRef: 'v1.2.3', cargoVersion: '1.90.0' }, - }); - mocks.installCliSourceStart.mockResolvedValue({ - scriptPath: '/tmp/install-bitfun.sh', - version: '1.2.3', - target: 'linux x86_64', - url: 'https://github.com/GCWing/BitFun.git', - sha256: '', }); - mocks.installCliPoll.mockResolvedValue({ cursor: 1, output: '', status: 'failed' }); await act(async () => { root.render( @@ -380,16 +326,22 @@ describe('DispatchInstallDialog installation lifecycle', () => { await Promise.resolve(); }); - const ready = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.sourceBuildConfirm')); - expect(ready?.disabled).toBe(false); + expect(container.textContent).toContain('dispatch.installUnavailable'); + expect(container.textContent).not.toContain('target uses musl libc'); + expect(container.textContent).not.toContain('sourceBuild'); + expect(container.textContent).not.toContain('dispatch.installAutomaticDescription'); + await act(async () => { - ready?.click(); - await Promise.resolve(); - await Promise.resolve(); + Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.approvalReject')) + ?.click(); }); - expect(mocks.confirmWarning).toHaveBeenCalled(); - expect(mocks.installCliSourceStart).toHaveBeenCalledWith('ssh-1'); + const useTarget = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.useTarget')); + expect( + useTarget?.disabled, + 'a target that cannot be prepared must not be selectable', + ).toBe(true); }); it('keeps protocol capability names and probe failures out of the user interface', async () => { @@ -526,86 +478,6 @@ describe('DispatchInstallDialog installation lifecycle', () => { })); }); - it('cancels an acknowledged installer when the parent closes the dialog during polling', async () => { - const poll = createDeferred<{ - cursor: number; - output: string; - status: 'running'; - }>(); - mocks.probeTarget.mockResolvedValue({ - cliInstalled: false, - os: 'linux', - arch: 'x86_64', - installSupported: false, - prebuiltIncompatible: 'target uses musl libc', - sourceBuild: { - supported: true, - blockers: [], - gitRef: 'v1.2.3', - cargoVersion: '1.90.0', - }, - }); - mocks.installCliSourceStart.mockResolvedValue({ - scriptPath: '/tmp/install-bitfun.sh', - version: '1.2.3', - target: 'linux x86_64', - url: 'https://github.com/GCWing/BitFun.git', - sha256: '', - }); - mocks.installCliPoll.mockReturnValue(poll.promise); - const target = { - kind: 'ssh' as const, - connectionId: 'ssh-1', - displayName: 'build-host', - }; - - await act(async () => { - root.render( - , - ); - await Promise.resolve(); - }); - - const installButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('dispatch.sourceBuildConfirm')); - await act(async () => { - installButton?.click(); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(mocks.installCliSourceStart).toHaveBeenCalledTimes(1); - expect(mocks.installCliPoll).toHaveBeenCalledTimes(1); - - await act(async () => { - root.render( - , - ); - await Promise.resolve(); - }); - expect(mocks.installCliCancel).toHaveBeenCalledTimes(1); - expect(mocks.installCliCancel).toHaveBeenCalledWith('ssh-1'); - - await act(async () => { - poll.resolve({ - cursor: 1, - output: 'still running', - status: 'running', - }); - await Promise.resolve(); - }); - expect(mocks.installCliPoll).toHaveBeenCalledTimes(1); - expect(mocks.installCliCancel).toHaveBeenCalledTimes(1); - }); }); describe('DispatchInstallDialog model configuration sync', () => { diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index d9d6b14c55..3c980a93ed 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -18,7 +18,6 @@ import { import { dispatchApi } from './dispatchApi'; import type { DispatchApprovalPolicy, - DispatchInstallStart, DispatchSelection, DispatchSshProbe, DispatchTargetOption, @@ -40,15 +39,8 @@ import type { WorktreeSettings } from '@/infrastructure/api/service-api/Worktree import './DispatchInstallDialog.scss'; const log = createLogger('DispatchInstallDialog'); -const INSTALL_POLL_INTERVAL_MS = 1200; const DIALOG_TITLE_ID = 'dispatch-install-dialog-title'; -interface ActiveInstall { - connectionId: string; - generation: number; - phase: 'starting' | 'polling'; -} - function approvalCapability(policy: DispatchApprovalPolicy | null): string | null { if (policy === 'auto') return 'approval_auto'; if (policy === 'reject-and-report') return 'approval_reject_and_report'; @@ -81,14 +73,10 @@ export const DispatchInstallDialog: React.FC = ({ const [probe, setProbe] = useState(null); const [probing, setProbing] = useState(false); const [probeError, setProbeError] = useState(false); - const [installing, setInstalling] = useState(false); const [syncingModel, setSyncingModel] = useState(false); - const [installStart, setInstallStart] = useState(null); - const [installOutput, setInstallOutput] = useState(''); const [error, setError] = useState(null); const [localModels, setLocalModels] = useState(null); const generationRef = useRef(0); - const activeInstallRef = useRef(null); const includeUncommittedTouchedRef = useRef(false); const connectionId = target?.connectionId?.trim() ?? ''; @@ -140,9 +128,6 @@ export const DispatchInstallDialog: React.FC = ({ setWorktreeSettingsLoading(true); setProbe(null); setProbeError(false); - setInstallStart(null); - setInstallOutput(''); - setInstalling(false); setSyncingModel(false); setError(null); void runProbe(); @@ -199,124 +184,16 @@ export const DispatchInstallDialog: React.FC = ({ }; }, [open, targetId]); - const clearActiveInstall = useCallback((generation: number) => { - if (activeInstallRef.current?.generation === generation) { - activeInstallRef.current = null; - } - }, []); - - const cancelActiveInstall = useCallback(() => { - const activeInstall = activeInstallRef.current; - if (!activeInstall) return; - activeInstallRef.current = null; - void dispatchApi.installCliCancel(activeInstall.connectionId).catch(nextError => { - log.warn('Failed to cancel SSH CLI installation', { error: nextError }); - }); - }, []); - - const invalidateInstallLifecycle = useCallback(() => { + // Retires every in-flight probe, model sync, and revision check, so a result + // that lands after the dialog moved on cannot write to a closed dialog. + const invalidatePendingWork = useCallback(() => { generationRef.current += 1; - cancelActiveInstall(); - }, [cancelActiveInstall]); + }, []); useEffect(() => { if (!open || !targetId) return; - return invalidateInstallLifecycle; - }, [invalidateInstallLifecycle, open, targetId]); - - const pollInstallation = useCallback(async (generation: number) => { - if (!connectionId) return; - let cursor = 0; - if ( - generation !== generationRef.current || - activeInstallRef.current?.generation !== generation - ) { - return; - } - activeInstallRef.current = { - connectionId, - generation, - phase: 'polling', - }; - setInstalling(true); - try { - while (generation === generationRef.current) { - const result = await dispatchApi.installCliPoll(connectionId, cursor); - if (generation !== generationRef.current) return; - cursor = result.cursor; - if (result.output) { - setInstallOutput(previous => previous + result.output); - } - if (result.status === 'succeeded') { - clearActiveInstall(generation); - setInstalling(false); - await runProbe(); - return; - } - if (result.status === 'failed') { - clearActiveInstall(generation); - setInstalling(false); - setError(t('dispatch.installFailed')); - return; - } - await new Promise(resolve => window.setTimeout(resolve, INSTALL_POLL_INTERVAL_MS)); - } - clearActiveInstall(generation); - } catch (nextError) { - if (generation === generationRef.current) { - clearActiveInstall(generation); - setInstalling(false); - setError(t('dispatch.installFailed')); - log.warn('Failed while polling SSH CLI installation', { - connectionId, - error: nextError, - }); - } - } - }, [clearActiveInstall, connectionId, runProbe, t]); - - // Same lifecycle as a release install — it shares the target-side driver, - // log, and poll/cancel machinery, so only the start call differs. - const startSourceBuild = useCallback(async () => { - if (!connectionId) return; - const generation = ++generationRef.current; - const confirmed = await confirmWarning( - t('dispatch.sourceBuildConfirmTitle'), - t('dispatch.sourceBuildConfirmMessage'), - { - confirmText: t('dispatch.sourceBuildConfirm'), - cancelText: t('dispatch.cancel'), - }, - ); - if (!confirmed || generation !== generationRef.current) return; - - setError(null); - setInstallOutput(''); - setInstalling(true); - activeInstallRef.current = { connectionId, generation, phase: 'starting' }; - try { - const started = await dispatchApi.installCliSourceStart(connectionId); - if (generation !== generationRef.current) { - clearActiveInstall(generation); - await dispatchApi.installCliCancel(connectionId).catch(nextError => { - log.warn('Failed to cancel stale SSH CLI source build', { error: nextError }); - }); - return; - } - setInstallStart(started); - void pollInstallation(generation); - } catch (nextError) { - clearActiveInstall(generation); - if (generation === generationRef.current) { - setInstalling(false); - setError(t('dispatch.sourceBuildFailed')); - log.warn('Failed to start SSH CLI source build', { - connectionId, - error: nextError, - }); - } - } - }, [clearActiveInstall, connectionId, pollInstallation, t]); + return invalidatePendingWork; + }, [invalidatePendingWork, open, targetId]); const syncModelConfiguration = useCallback(async () => { if (!connectionId) return; @@ -352,19 +229,17 @@ export const DispatchInstallDialog: React.FC = ({ }, [connectionId, runProbe, t]); const closeDialog = useCallback(() => { - invalidateInstallLifecycle(); - setInstalling(false); + invalidatePendingWork(); setSyncingModel(false); onClose(); - }, [invalidateInstallLifecycle, onClose]); + }, [invalidatePendingWork, onClose]); const handleModalClose = useCallback(() => { // Keep Escape, the close button, and backdrop clicks from silently - // abandoning a target mutation. A source build can still be stopped with - // the explicit footer action. - if (installing || syncingModel) return; + // abandoning a target mutation that is already under way. + if (syncingModel) return; closeDialog(); - }, [closeDialog, installing, syncingModel]); + }, [closeDialog, syncingModel]); const protocol = probe?.protocol; const selectedApprovalCapability = approvalCapability(approvalPolicy); @@ -395,6 +270,11 @@ export const DispatchInstallDialog: React.FC = ({ && target?.kind === 'ssh' && !!probe?.installSupported && !probe?.prebuiltIncompatible; + /** + * The published binary is the only way a target gets a CLI. When none fits, + * say so here rather than letting submit fail on an unusable target. + */ + const installUnavailable = !cliReady && target?.kind === 'ssh' && !!probe && !installPending; const ready = approvalPolicy !== null && workspaceReady @@ -482,15 +362,13 @@ export const DispatchInstallDialog: React.FC = ({ }); }; - const sourceBuild = probe?.sourceBuild; - return ( = ({ ) : null}
-
-

- {t('dispatch.readinessTitle')} -

-
-
- {probing || (!probe && !probeError) ? ( -
- - {t('dispatch.checkingTarget')} -
- ) : null} - {probeError ? ( -
- - {t('dispatch.probeFailed')} - - -
- ) : null} - {probe ? ( -
-
- {t('dispatch.cliStatus')} - - {cliReady - ? t('dispatch.cliReady', { version: protocol?.cliVersion }) - : installPending - ? t('dispatch.cliWillInstall') - : probe.cliInstalled - ? t('dispatch.cliUpdateRequired') - : t('dispatch.cliUnavailable')} - -
-
- {t('dispatch.modelStatus')} - - {!protocol - ? t('dispatch.modelCheckPending') - : !modelReady - ? localModelIds?.length === 0 - ? t('dispatch.modelMissingOnBoth') - : t('dispatch.modelMissing') - : modelParity === 'match' - ? t('dispatch.modelMatchesLocal', { model: targetDefaultModelLabel }) - : modelParity === 'diverged' - ? t('dispatch.modelDiffersFromLocal', { count: targetModelCount }) - : t('dispatch.modelReadyCount', { count: targetModelCount })} - -
-
- ) : null} - {target?.kind === 'device' && probe && !cliReady ? ( -
- - {t('dispatch.deviceUpdateRequired')} - - -
- ) : null} -
-
- -
-
-

- {t('dispatch.deliveryTitle')} -

-
-
-
- {t('dispatch.baselineSource')} - {sourceWorkspacePath} - {t('dispatch.baselineDescription')} - - - {t('dispatch.baseRefHint')} - - +

+ {t('dispatch.readinessTitle')} +

+ {probing || (!probe && !probeError) ? ( +
+ + {t('dispatch.checkingTarget')} +
+ ) : null} + {probeError ? ( +
- {t('dispatch.includeUncommittedHint')} + {t('dispatch.probeFailed')} +
-
-
- - {target?.kind === 'ssh' && !cliReady && probe?.release ? ( -
-
-

- {t('dispatch.installAutomaticTitle')} -

+ ) : null} + {probe ? ( +
+
+ {t('dispatch.cliStatus')} + + {cliReady + ? t('dispatch.cliReady', { version: protocol?.cliVersion }) + : installPending + ? t('dispatch.cliWillInstall') + : probe.cliInstalled + ? t('dispatch.cliUpdateRequired') + : t('dispatch.cliUnavailable')} + +
+
+ {t('dispatch.modelStatus')} + + {!protocol + ? t('dispatch.modelCheckPending') + : !modelReady + ? localModelIds?.length === 0 + ? t('dispatch.modelMissingOnBoth') + : t('dispatch.modelMissing') + : modelParity === 'match' + ? t('dispatch.modelMatchesLocal', { model: targetDefaultModelLabel }) + : modelParity === 'diverged' + ? t('dispatch.modelDiffersFromLocal', { count: targetModelCount }) + : t('dispatch.modelReadyCount', { count: targetModelCount })} + +
-
+ ) : null} + {installPending && probe?.release ? ( + <> {t('dispatch.installAutomaticDescription')} @@ -665,133 +468,160 @@ export const DispatchInstallDialog: React.FC = ({
{t('dispatch.integrity')}
{probe.release.sha256}
-
-
- ) : null} - - {target?.kind === 'ssh' && !cliReady && sourceBuild ? ( -
-
-

- {t('dispatch.sourceBuildTitle')} -

-
-
+ + ) : null} + {installUnavailable ? ( + + ) : null} + {target?.kind === 'device' && probe && !cliReady ? ( +
- {t('dispatch.sourceBuildDescription')} + {t('dispatch.deviceUpdateRequired')} - {!sourceBuild.supported ? ( - - ) : null}
-
- ) : null} - - {offerModelSync ? ( -
-
-

- {t('dispatch.syncModelTitle')} -

-
-
+ ) : null} + {offerModelSync ? ( +
{t('dispatch.syncModelDescription')}
-
- ) : null} - - {installStart || installOutput ? ( -
-              {installOutput || t('dispatch.installWaiting')}
-            
- ) : null} + ) : null} +
-
-

- {t('dispatch.approvalTitle')} -

+

+ {t('dispatch.deliveryTitle')} +

+
+ + {t('dispatch.baselineSource')} + + {sourceWorkspacePath}
-
+ + {t('dispatch.baselineDescription')} + +
+ +
+

+ {t('dispatch.approvalTitle')} +

+ + {t('dispatch.approvalHint')} + +
+ - - -
- + + + {t('dispatch.approvalReject')} + {t('dispatch.approvalRejectDescription')} + + {approvalPolicy === 'reject-and-report' ? : null} + + + +
@@ -802,14 +632,14 @@ export const DispatchInstallDialog: React.FC = ({ disabled={syncingModel} onClick={closeDialog} > - {installing ? t('dispatch.stopSourceBuild') : t('dispatch.cancel')} + {t('dispatch.cancel')}