From af2047a77430a452192d2ac38f4fb05ac9026453 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 2 Aug 2026 02:47:28 -0700 Subject: [PATCH] refactor(dispatch): install only published releases; flatten target setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compiling BitFun on someone else's machine is not something a dispatch controller should be able to do. It needed a Rust toolchain, a C compiler, git, ~6 GB of scratch space, and tens of minutes on a host the user only meant to run one task on — and it existed solely to paper over targets no published binary fits. Those targets are now reported as unusable instead. The removal is end to end, so the capability cannot come back through an unused door: the `dispatch_install_cli_source_start` command, its Tauri adapter, the Server Host route, the Desktop controller-source archiver, both remote build script generators, `source_build_availability`, and the probe's cargo/git/cc/free-space detection are all gone. The probe now fills `install_error` in the two branches that previously returned nothing and leaned on the source-build card, so a target with no compatible release explains itself rather than failing later at submit. A contract test pins that no routing table or client API mentions the command. The dialog loses the source build and, with it, the install poll/cancel and console machinery — nothing in this dialog starts an install anymore; submit does. What remains is a form that was four box levels deep for one path: a modal, a section card with a filled header bar, an inner card, and a `code` pill. It is now one flat stack separated by hairlines, with a border reserved for the two things that are actually interactive (the revision input and the approval options). The automatic-setup and model-sync panels fold into the target check they belong to, so five sections become three and the whole form fits without scrolling. --- docs/architecture/detached-task-dispatch.md | 9 +- src/apps/cli/src/peer_host/deny.rs | 2 - src/apps/desktop/src/api/dispatch_api.rs | 118 +--- src/apps/desktop/src/api/peer_host_invoke.rs | 1 - .../src/api/remote_workspace_policy.rs | 4 - src/apps/desktop/src/lib.rs | 1 - src/apps/server/src/routes/dispatch.rs | 16 +- .../core/src/service/dispatch/controller.rs | 9 - .../src/service/dispatch/device_controller.rs | 5 +- .../assembly/core/src/service/dispatch/mod.rs | 1 - .../src/remote_ssh/dispatch_ssh.rs | 490 ++------------- .../dispatch/DispatchInstallDialog.scss | 233 ++++--- .../dispatch/DispatchInstallDialog.test.tsx | 168 +---- .../dispatch/DispatchInstallDialog.tsx | 582 +++++++----------- src/web-ui/src/features/dispatch/README.md | 6 +- .../dispatch/dispatch.contract.test.ts | 17 +- .../src/features/dispatch/dispatchApi.ts | 7 - src/web-ui/src/features/dispatch/types.ts | 10 - .../api/adapters/peer-device-adapter.ts | 1 - src/web-ui/src/locales/en-US/common.json | 15 +- src/web-ui/src/locales/zh-CN/common.json | 15 +- src/web-ui/src/locales/zh-TW/common.json | 15 +- 22 files changed, 406 insertions(+), 1319 deletions(-) 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')}