diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index f25de79a133..e551073add8 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -1607,6 +1607,7 @@ pub(crate) fn convert_tool_call_result_to_input( environment_id: remote.environment_id.clone(), worker_host: remote.worker_host.clone(), computer_use_enabled: remote.computer_use_enabled, + runner_id: remote.runner_id.clone(), }, Some(api::run_agents_result::launched::ResolvedExecutionMode::Local(_)) | None => RunAgentsLaunchedExecutionMode::Local, diff --git a/app/src/ai/agent/api/convert_from.rs b/app/src/ai/agent/api/convert_from.rs index 518e49c363c..7b9c9a19ac5 100644 --- a/app/src/ai/agent/api/convert_from.rs +++ b/app/src/ai/agent/api/convert_from.rs @@ -129,6 +129,7 @@ fn convert_run_agents_execution_mode( environment_id: remote.environment_id, worker_host: remote.worker_host, computer_use_enabled: remote.computer_use_enabled, + runner_id: remote.runner_id, }, Some(api::run_agents::ExecutionMode::Local(_)) | None => RunAgentsExecutionMode::Local, } @@ -199,6 +200,9 @@ fn convert_start_agent_v2_execution_mode( // Auth secret is plumbed client-side via `RunAgentsRequest`; // StartAgentV2 from the server never carries it. auth_secret_name: None, + // StartAgentV2 (server→child) does not carry a runner + // override; runner selection flows through RunAgents. + runner_id: String::new(), // Agent identity is plumbed client-side via `RunAgentsRequest`; // StartAgentV2 from the server never carries it. agent_identity_uid: None, diff --git a/app/src/ai/agent/api/convert_from_tests.rs b/app/src/ai/agent/api/convert_from_tests.rs index 64184f72b0c..c24f5b894fa 100644 --- a/app/src/ai/agent/api/convert_from_tests.rs +++ b/app/src/ai/agent/api/convert_from_tests.rs @@ -538,6 +538,7 @@ fn converts_remote_start_agent_with_environment_id() { harness_type: String::new(), title: String::new(), auth_secret_name: None, + runner_id: String::new(), agent_identity_uid: None, } ); @@ -581,6 +582,7 @@ fn converts_remote_start_agent_v2_with_skill_references() { harness_type: "claude-code".to_string(), title: "Remote child".to_string(), auth_secret_name: None, + runner_id: String::new(), agent_identity_uid: None, } ); diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index 77c0120af8f..3d2b9f7fde1 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -216,7 +216,7 @@ fn dispatch_command( api_key::run(ctx, global_options, api_key_cmd) } CliCommand::Runner(runner_cmd) => { - if !FeatureFlag::CloudAgentRunnerCLICommands.is_enabled() { + if !FeatureFlag::CloudAgentRunners.is_enabled() { return Err(anyhow::anyhow!("invalid value 'runner'")); } runner::run(ctx, global_options, runner_cmd) diff --git a/app/src/ai/agent_sdk/telemetry.rs b/app/src/ai/agent_sdk/telemetry.rs index 300a9c66bb4..26c097a34d2 100644 --- a/app/src/ai/agent_sdk/telemetry.rs +++ b/app/src/ai/agent_sdk/telemetry.rs @@ -564,7 +564,7 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants { EnablementState::Flag(FeatureFlag::APIKeyManagement) } Self::RunnerList | Self::RunnerCreate | Self::RunnerUpdate | Self::RunnerDelete => { - EnablementState::Flag(FeatureFlag::CloudAgentRunnerCLICommands) + EnablementState::Flag(FeatureFlag::CloudAgentRunners) } Self::RunMessageWatch | Self::RunMessageSend diff --git a/app/src/ai/blocklist/action_model/execute/run_agents.rs b/app/src/ai/blocklist/action_model/execute/run_agents.rs index 6a79ad3ef1a..4afb9263b81 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents.rs @@ -344,10 +344,12 @@ impl RunAgentsExecutor { environment_id, worker_host, computer_use_enabled, + runner_id, } => RunAgentsLaunchedExecutionMode::Remote { environment_id: environment_id.clone(), worker_host: worker_host.clone(), computer_use_enabled: *computer_use_enabled, + runner_id: runner_id.clone(), }, }; let result = RunAgentsResult::Launched { @@ -714,6 +716,7 @@ pub fn run_agents_to_start_agent_mode( environment_id, worker_host, computer_use_enabled, + runner_id, } => { // OpenCode is unsupported on Remote. if run_harness_type.eq_ignore_ascii_case("opencode") { @@ -732,6 +735,7 @@ pub fn run_agents_to_start_agent_mode( auth_secret_name: run_auth_secret_name .map(str::to_string) .filter(|s| !s.trim().is_empty()), + runner_id: runner_id.clone(), agent_identity_uid: Some(cfg.agent_identity_uid.clone()) .filter(|s| !s.trim().is_empty()), }) diff --git a/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs b/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs index f70b0752d30..78e8c3a720e 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs @@ -85,6 +85,7 @@ fn persist_plan_config_with_harness( execution_mode: OrchestrationExecutionMode::Remote { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), + runner_id: String::new(), }, }, status, @@ -231,6 +232,7 @@ fn remote_run_agents_action(harness_type: &str) -> AIAgentAction { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, agent_run_configs: vec![RunAgentsAgentRunConfig { name: "child".to_string(), diff --git a/app/src/ai/blocklist/action_model/execute/start_agent.rs b/app/src/ai/blocklist/action_model/execute/start_agent.rs index 313866ae945..b98bf9d2b69 100644 --- a/app/src/ai/blocklist/action_model/execute/start_agent.rs +++ b/app/src/ai/blocklist/action_model/execute/start_agent.rs @@ -432,6 +432,7 @@ impl StartAgentExecutor { harness_type, title, auth_secret_name, + runner_id, agent_identity_uid, } => { let harness_type = Harness::parse_orchestration_harness(&harness_type) @@ -480,6 +481,7 @@ impl StartAgentExecutor { harness_type, title, auth_secret_name, + runner_id, agent_identity_uid, }, Some(parent_run_id), diff --git a/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs b/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs index cf2d93fef36..1f19ef74f30 100644 --- a/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs @@ -1039,6 +1039,7 @@ fn execute_returns_error_when_remote_opencode_harness_is_requested() { harness_type: "opencode".to_string(), title: String::new(), auth_secret_name: None, + runner_id: String::new(), agent_identity_uid: None, }, ); diff --git a/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs b/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs index 307db1cf9a1..5296d86d92a 100644 --- a/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs +++ b/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs @@ -79,6 +79,7 @@ fn start_agent_copy_uses_remote_labels_for_remote_children() { harness_type: String::new(), title: String::new(), auth_secret_name: None, + runner_id: String::new(), agent_identity_uid: None, }; diff --git a/app/src/ai/blocklist/block_tests.rs b/app/src/ai/blocklist/block_tests.rs index e39b349b23b..64c163331ad 100644 --- a/app/src/ai/blocklist/block_tests.rs +++ b/app/src/ai/blocklist/block_tests.rs @@ -397,6 +397,7 @@ fn remote_arm_propagates_skills_into_skill_references() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, "oz", "auto", @@ -414,6 +415,7 @@ fn remote_arm_propagates_skills_into_skill_references() { computer_use_enabled, title, auth_secret_name, + runner_id: _, agent_identity_uid, } = mode else { @@ -439,6 +441,7 @@ fn remote_arm_propagates_agent_identity_uid() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, "oz", "auto", @@ -473,6 +476,7 @@ fn remote_arm_with_empty_skills_propagates_empty_vec() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, "claude", "auto", @@ -497,6 +501,7 @@ fn remote_arm_rejects_opencode() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, "opencode", "auto", @@ -549,6 +554,7 @@ fn remote_arm_propagates_claude_auth_secret_into_mode() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, "claude", "auto", @@ -573,6 +579,7 @@ fn remote_arm_filters_whitespace_auth_secret_name_to_none() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, "codex", "auto", diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index c4b1a5306d6..f12ff1a2a9a 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -10,6 +10,7 @@ use ai::agent::action::RunAgentsExecutionMode; use pathfinder_color::ColorU; use pathfinder_geometry::vector::{vec2f, Vector2F}; use warp_cli::agent::Harness; +use warp_core::features::FeatureFlag; use warp_core::ui::theme::Fill; use warpui::elements::{ Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, @@ -37,9 +38,9 @@ pub use crate::ai::orchestration::{ OrchestrationConfigState, OrchestrationEditState, ORCHESTRATION_WARP_WORKER_HOST, }; use crate::ai::orchestration::{ - api_key_snapshot, environment_snapshot, harness_snapshot, host_snapshot, model_snapshot, - persist_auth_secret_selection, OptionBadge, OptionFooter, OptionRow, OptionSnapshot, - OptionSourceStatus, AUTH_SECRET_INHERIT_LABEL, + api_key_snapshot, build_runner_snapshot, environment_snapshot, harness_snapshot, host_snapshot, + model_snapshot, persist_auth_secret_selection, OptionBadge, OptionFooter, OptionRow, + OptionSnapshot, OptionSourceStatus, AUTH_SECRET_INHERIT_LABEL, }; use crate::appearance::Appearance; use crate::menu::{MenuItem, MenuItemFields}; @@ -77,6 +78,9 @@ pub trait OrchestrationControlAction: DropdownItemAction + Clone { fn harness_changed(harness_type: String) -> Self; fn environment_changed(environment_id: String) -> Self; fn create_environment_requested() -> Self; + /// Runner UID selected in the Runner dropdown; empty clears the + /// override ("Use environment default"). + fn runner_changed(runner_id: String) -> Self; /// `None` means Inherit; `Some(name)` means a named managed secret. fn auth_secret_changed(name: Option) -> Self; /// User picked the "New API key…" item; opens the workspace create modal. @@ -92,6 +96,9 @@ pub struct OrchestrationPickerHandles { pub model_picker: Option>>, pub harness_picker: Option>>, pub environment_picker: Option>>, + /// Runner picker for the Cloud variant (gated on `CloudAgentRunners`). + /// `None` until built; runners are fetched via `FactoryClient::get_runners`. + pub runner_picker: Option>>, pub host_picker: Option>, /// Picker for the managed auth secret used by non-Oz cloud children. /// `None` when the picker hasn't been built yet (e.g. harness is Oz or @@ -108,6 +115,7 @@ impl Default for OrchestrationPickerHandles { model_picker: None, harness_picker: None, environment_picker: None, + runner_picker: None, host_picker: None, auth_secret_picker: None, local_toggle: MouseStateHandle::default(), @@ -222,6 +230,7 @@ fn snapshot_execution_mode(is_local: bool) -> RunAgentsExecutionMode { environment_id: String::new(), worker_host: String::new(), computer_use_enabled: false, + runner_id: String::new(), } } } @@ -402,6 +411,7 @@ pub fn populate_environment_picker( environment_id: initial_env_id.to_string(), worker_host: String::new(), computer_use_enabled: false, + runner_id: String::new(), }, ); dropdown_handle.update(ctx, |dropdown, ctx_dropdown| { @@ -423,6 +433,62 @@ pub fn populate_environment_picker( }); } +/// Creates the Runner picker dropdown with the shared orchestration +/// chrome, then populates it from the supplied runners list. Runners are +/// not cached client-side (unlike environments), so the owning view +/// fetches them via `FactoryClient::get_runners` and passes them in; +/// `loading` renders the picker in its loading state until they arrive. +pub fn create_runner_picker( + initial_runner_id: &str, + runners: &[(String, String)], + loading: bool, + styles: &UiComponentStyles, + ctx: &mut ViewContext, +) -> ViewHandle> { + let styles = *styles; + let dropdown_handle = ctx.add_typed_action_view(move |ctx_dropdown| { + let mut dropdown = FilterableDropdown::::new(ctx_dropdown); + dropdown.set_use_overlay_layer(false, ctx_dropdown); + dropdown.set_match_menu_width_to_top_bar(true, ctx_dropdown); + dropdown.set_main_axis_size(MainAxisSize::Max, ctx_dropdown); + dropdown.set_button_variant(ButtonVariant::Secondary); + dropdown.set_style(styles); + dropdown.set_top_bar_height(ORCHESTRATION_PICKER_HEIGHT, ctx_dropdown); + dropdown.set_top_bar_max_width(f32::INFINITY); + dropdown + }); + populate_runner_picker(&dropdown_handle, runners, initial_runner_id, loading, ctx); + dropdown_handle +} + +/// Populates the runner picker from [`build_runner_snapshot`] ("Use +/// environment default" plus the supplied runners). +pub fn populate_runner_picker( + dropdown_handle: &ViewHandle>, + runners: &[(String, String)], + current_runner_id: &str, + loading: bool, + ctx: &mut ViewContext, +) { + let snapshot = build_runner_snapshot(runners.to_vec(), current_runner_id, loading); + let selected_label = selected_row_label(&snapshot); + dropdown_handle.update(ctx, |dropdown, ctx_dropdown| { + let items = snapshot + .rows + .into_iter() + .map(|row| { + MenuItem::Item(MenuItemFields::new(&row.label).with_on_select_action( + DropdownAction::select_action_and_close(A::runner_changed(row.id)), + )) + }) + .collect(); + dropdown.set_rich_items(items, ctx_dropdown); + if let Some(label) = &selected_label { + dropdown.set_selected_by_name(label, ctx_dropdown); + } + }); +} + fn render_new_environment_footer( mouse_state: MouseStateHandle, app: &AppContext, @@ -487,6 +553,7 @@ pub fn populate_host_picker( environment_id: String::new(), worker_host: initial_host.to_string(), computer_use_enabled: false, + runner_id: String::new(), }, ); let snapshot = host_snapshot(&state, ctx); @@ -1116,6 +1183,16 @@ pub fn render_picker_row_with_layout( .as_ref() .map(|p| ChildView::new(p).finish()), ); + if FeatureFlag::CloudAgentRunners.is_enabled() { + add( + &mut column, + "Runner", + handles + .runner_picker + .as_ref() + .map(|p| ChildView::new(p).finish()), + ); + } } add( &mut column, @@ -1163,6 +1240,16 @@ pub fn render_picker_row_with_layout( .as_ref() .map(|p| ChildView::new(p).finish()), ); + if FeatureFlag::CloudAgentRunners.is_enabled() { + add_picker( + &mut row, + "Runner", + handles + .runner_picker + .as_ref() + .map(|p| ChildView::new(p).finish()), + ); + } } add_picker( &mut row, diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 341222a74d9..6eef41d17ad 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -10,8 +10,10 @@ use ai::agent::action_result::{RunAgentsAgentOutcomeKind, RunAgentsResult}; use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; use ai::skills::SkillReference; use pathfinder_geometry::vector::vec2f; +use warp_core::features::FeatureFlag; use warp_core::send_telemetry_from_ctx; use warp_errors::report_error; +use warp_graphql::queries::get_runners::RunnerSortBy; use warpui::elements::{ Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack, Text, Wrap, @@ -60,6 +62,7 @@ use crate::ai::harness_availability::{ use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; use crate::appearance::Appearance; use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant}; +use crate::server::server_api::ServerApiProvider; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; use crate::view_components::action_button::{ButtonSize, KeystrokeSource, NakedTheme}; @@ -175,6 +178,9 @@ impl OrchestrationControlAction for RunAgentsCardViewAction { fn create_environment_requested() -> Self { Self::CreateEnvironmentRequested } + fn runner_changed(runner_id: String) -> Self { + Self::RunnerChanged { runner_id } + } fn auth_secret_changed(auth_secret_name: Option) -> Self { Self::AuthSecretChanged { auth_secret_name } } @@ -210,6 +216,9 @@ pub enum RunAgentsCardViewAction { environment_id: String, }, CreateEnvironmentRequested, + RunnerChanged { + runner_id: String, + }, WorkerHostChanged { worker_host: String, }, @@ -256,6 +265,11 @@ pub struct RunAgentsCardView { /// One-shot guard: cancelling the auto-popped modal must not re-pop. /// Reset on harness / execution-mode change. has_auto_opened_create_modal: bool, + /// Runners fetched via `getRunners` for the Runner picker: (uid, name). + /// Runners aren't cached client-side, so we fetch them lazily. + runners: Vec<(String, String)>, + /// True while the `getRunners` fetch is in flight. + runners_loading: bool, } /// Resolves UI-only interactive defaults on edit state that has @@ -409,6 +423,11 @@ impl RunAgentsCardView { &me.handles.pickers, ctx, ); + // The runner picker isn't part of the shared picker sync + // (its options load asynchronously and are cached on the + // view), so re-apply its selection now that the streamed + // request has finalized with the requested runner. + me.resync_runner_selection(ctx); me.refresh_accept_button_state(ctx); me.maybe_auto_open_create_modal(ctx); if let Some(conversation_id) = me.block_model.conversation_id(ctx) { @@ -537,6 +556,8 @@ impl RunAgentsCardView { entered_event_emitted: false, decision_event_emitted: false, has_auto_opened_create_modal: false, + runners: Vec::new(), + runners_loading: false, }; view.ensure_pickers(ctx); @@ -599,6 +620,32 @@ impl RunAgentsCardView { ctx, ); } + // Preserve a non-empty runner across streamed updates. `update_request` + // runs on every stream chunk, but run_agents requests usually omit a + // `runner_id`; without this, a runner the user picked in the card (or a + // runner already resolved on the call) would be clobbered back to + // "use environment default" on the next chunk. A request that *does* + // carry a runner still wins. + if let ( + RunAgentsExecutionMode::Remote { + runner_id: new_runner, + .. + }, + RunAgentsExecutionMode::Remote { + runner_id: current_runner, + .. + }, + ) = ( + &mut new_state.orchestration_config_state.execution_mode, + &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode, + ) { + if new_runner.is_empty() && !current_runner.is_empty() { + *new_runner = current_runner.clone(); + } + } if self.config_state() != new_state { let harness_or_model_changed = self .orchestration_edit_state @@ -628,6 +675,10 @@ impl RunAgentsCardView { ); self.has_auto_opened_create_modal = false; } + // Re-apply the runner selection: a streamed update can finalize + // the requested `runner_id` after the runner options have loaded, + // and the shared picker sync does not cover the runner picker. + self.resync_runner_selection(ctx); self.refresh_accept_button_state(ctx); self.maybe_auto_open_create_modal(ctx); ctx.notify(); @@ -883,6 +934,8 @@ impl RunAgentsCardView { self.handles.pickers.environment_picker = Some(handle); } + self.ensure_runner_picker(ctx); + let state = &self.orchestration_edit_state.orchestration_config_state; if self.handles.pickers.host_picker.is_none() { let initial_host = match &state.execution_mode { @@ -959,6 +1012,117 @@ impl RunAgentsCardView { self.sync_picker_selections(ctx); } + /// Builds the Runner picker and kicks off the `getRunners` fetch, but + /// only when the `CloudAgentRunners` feature is enabled and the card is + /// in remote mode — otherwise the Runner control is not rendered, so + /// there is no reason to create the picker or hit `getRunners`. + /// Idempotent, and re-invoked on the Local→Cloud toggle so the picker + /// appears (and loads) the first time the card enters remote mode. + fn ensure_runner_picker(&mut self, ctx: &mut ViewContext) { + if !FeatureFlag::CloudAgentRunners.is_enabled() { + return; + } + if !self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote() + { + return; + } + if self.handles.pickers.runner_picker.is_some() { + return; + } + let appearance = Appearance::as_ref(ctx); + let (styles, _colors) = oc::picker_styles(appearance); + let initial_runner = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + let handle = oc::create_runner_picker( + &initial_runner, + &self.runners, + self.runners_loading, + &styles, + ctx, + ); + handle.update(ctx, |d, _| { + d.set_orientation(FilterableDropdownOrientation::Up) + }); + ctx.subscribe_to_view(&handle, |me, _, event, ctx| { + if let FilterableDropdownEvent::Close = event { + me.refocus_after_picker_close(ctx); + } + }); + self.handles.pickers.runner_picker = Some(handle); + self.fetch_runners(ctx); + } + + /// Fetches available runners via `getRunners` (name-sorted server-side) + /// and repopulates the Runner picker once they resolve. Runners are not + /// cached client-side (unlike environments), so this lazy fetch backs + /// the picker. + fn fetch_runners(&mut self, ctx: &mut ViewContext) { + if self.runners_loading || !self.runners.is_empty() { + return; + } + self.runners_loading = true; + let client = ServerApiProvider::as_ref(ctx).get_factory_client(); + ctx.spawn( + async move { client.get_runners(Some(RunnerSortBy::Name)).await }, + |me, result, ctx| { + me.runners_loading = false; + match result { + Ok(runners) => { + me.runners = runners + .into_iter() + .map(|r| (r.uid.inner().to_string(), r.config.name)) + .collect(); + } + Err(err) => { + log::warn!("Failed to fetch runners for orchestration picker: {err}"); + } + } + let current = match &me + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + if let Some(handle) = me.handles.pickers.runner_picker.clone() { + oc::populate_runner_picker(&handle, &me.runners, ¤t, false, ctx); + } + ctx.notify(); + }, + ); + } + + /// Re-applies the runner picker's selection from the current + /// `runner_id`, using the view-cached runner list. The runner picker + /// is intentionally excluded from the shared picker sync (its options + /// are fetched asynchronously and cached on the view, not in a global + /// catalog), so callers must invoke this after the run-wide config's + /// `runner_id` may have changed (e.g. a streamed request finalizing). + fn resync_runner_selection(&mut self, ctx: &mut ViewContext) { + let current = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + if let Some(handle) = self.handles.pickers.runner_picker.clone() { + oc::populate_runner_picker(&handle, &self.runners, ¤t, self.runners_loading, ctx); + } + } + /// Opens the dropdown menu above the trigger to avoid overlapping /// the input box. Only used by the confirmation card — the plan /// config card renders higher up where downward menus are fine. @@ -1202,6 +1366,10 @@ impl TypedActionView for RunAgentsCardView { fallback, ctx, ); + // Switching to Cloud reveals the Runner control (when the + // flag is on); build + fetch it lazily so Local cards never + // hit `getRunners`. + self.ensure_runner_picker(ctx); // Mode change can newly reveal the auth picker (Local // → Cloud) — give the user a fresh auto-open prompt. self.has_auto_opened_create_modal = false; @@ -1243,6 +1411,22 @@ impl TypedActionView for RunAgentsCardView { RunAgentsCardViewAction::CreateEnvironmentRequested => { self.open_create_environment_modal(ctx); } + RunAgentsCardViewAction::RunnerChanged { runner_id } => { + self.orchestration_edit_state + .orchestration_config_state + .set_runner_id(runner_id.clone()); + self.refresh_accept_button_state(ctx); + // A menu click dispatches `SelectActionAndClose`, which does + // not update the dropdown's displayed selection, and we're + // mid-dispatch from the runner dropdown itself so we cannot + // repopulate it synchronously (that panics with "Circular + // view update"). Defer the re-sync so the closed dropdown + // reflects the runner the user just picked. + ctx.spawn(async {}, |me, _, ctx| { + me.resync_runner_selection(ctx); + }); + ctx.notify(); + } RunAgentsCardViewAction::WorkerHostChanged { worker_host } => { self.orchestration_edit_state .orchestration_config_state @@ -1355,6 +1539,7 @@ fn diverged_orch_fields_against_config( OrchestrationExecutionMode::Remote { environment_id: cfg_env, worker_host: cfg_host, + .. }, ) = (&state.execution_mode, &config.execution_mode) { diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs index 42827c48567..105e430de77 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs @@ -75,6 +75,7 @@ fn local_to_cloud_initializes_remote_with_empty_environment() { environment_id, worker_host, computer_use_enabled, + .. } = state.orchestration_config_state.execution_mode else { panic!("expected Remote after toggle"); @@ -92,6 +93,7 @@ fn cloud_to_local_drops_environment() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, )); state @@ -121,6 +123,7 @@ fn cloud_without_env_no_longer_disables_accept() { environment_id: String::new(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, )); assert!( @@ -141,6 +144,7 @@ fn cloud_with_opencode_disables_accept() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, )); let reason = state.orchestration_config_state.accept_disabled_reason(); @@ -194,6 +198,7 @@ fn cloud_with_env_and_non_opencode_harness_allows_accept() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, )); assert!( @@ -227,6 +232,7 @@ fn set_environment_id_updates_remote() { environment_id: "old".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, )); state @@ -240,6 +246,51 @@ fn set_environment_id_updates_remote() { assert_eq!(environment_id, "new-env"); } +#[test] +fn set_runner_id_updates_remote_and_round_trips() { + let mut state = RunAgentsEditState::from_request(&make_request( + "oz", + RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: false, + runner_id: String::new(), + }, + )); + state + .orchestration_config_state + .set_runner_id("runner-9".to_string()); + let RunAgentsExecutionMode::Remote { runner_id, .. } = + &state.orchestration_config_state.execution_mode + else { + panic!("expected Remote"); + }; + assert_eq!(runner_id, "runner-9"); + // The runner flows back out through to_request unchanged. + assert_eq!( + state.to_request().execution_mode, + RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: false, + runner_id: "runner-9".to_string(), + } + ); +} + +#[test] +fn set_runner_id_no_op_in_local_mode() { + let mut state = + RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); + state + .orchestration_config_state + .set_runner_id("runner-1".to_string()); + assert!(matches!( + state.orchestration_config_state.execution_mode, + RunAgentsExecutionMode::Local + )); +} + #[test] fn to_request_round_trips_request_fields() { let mut req = make_request_with_skills( @@ -248,6 +299,7 @@ fn to_request_round_trips_request_fields() { environment_id: "env-2".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, vec![ SkillReference::BundledSkillId("writing-pr-descriptions".to_string()), @@ -420,6 +472,7 @@ mod override_from_approved_config_tests { execution_mode: OrchestrationExecutionMode::Remote { environment_id: env.to_string(), worker_host: "warp".to_string(), + runner_id: String::new(), }, } } @@ -478,6 +531,7 @@ mod override_from_approved_config_tests { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, )); state @@ -500,6 +554,7 @@ mod override_from_approved_config_tests { environment_id: "old-env".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, )); state @@ -562,6 +617,7 @@ fn local_to_cloud_idempotent_when_already_remote() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, )); state diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index f53ba99d355..e5a2cb6f086 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -6,7 +6,9 @@ use ai::agent::action::RunAgentsExecutionMode; use ai::agent::orchestration_config::OrchestrationConfigStatus; use pathfinder_geometry::vector::vec2f; use warp_cli::agent::Harness; +use warp_core::features::FeatureFlag; use warp_core::send_telemetry_from_ctx; +use warp_graphql::queries::get_runners::RunnerSortBy; use warpui::elements::{ ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, @@ -43,6 +45,7 @@ use crate::ai::harness_availability::{ }; use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; use crate::appearance::Appearance; +use crate::server::server_api::ServerApiProvider; use crate::ui_components::blended_colors; use crate::workspace::WorkspaceAction; use crate::BlocklistAIHistoryModel; @@ -87,6 +90,9 @@ pub enum OrchestrationConfigBlockAction { environment_id: String, }, CreateEnvironmentRequested, + RunnerChanged { + runner_id: String, + }, WorkerHostChanged { worker_host: String, }, @@ -113,6 +119,9 @@ impl OrchestrationControlAction for OrchestrationConfigBlockAction { fn create_environment_requested() -> Self { Self::CreateEnvironmentRequested } + fn runner_changed(runner_id: String) -> Self { + Self::RunnerChanged { runner_id } + } fn auth_secret_changed(auth_secret_name: Option) -> Self { Self::AuthSecretChanged { auth_secret_name } } @@ -148,6 +157,10 @@ pub struct OrchestrationConfigBlockView { /// mode) suppresses the modal on restore while still firing for /// live-session enablement. user_has_interacted: bool, + /// Runners fetched via `getRunners` for the Runner picker: (uid, name). + runners: Vec<(String, String)>, + /// True while the `getRunners` fetch is in flight. + runners_loading: bool, } impl OrchestrationConfigBlockView { @@ -309,6 +322,8 @@ impl OrchestrationConfigBlockView { suppress_refresh: false, has_auto_opened_create_modal: false, user_has_interacted: false, + runners: Vec::new(), + runners_loading: false, }; if view.is_approved { view.ensure_pickers(ctx); @@ -404,6 +419,10 @@ impl OrchestrationConfigBlockView { &self.pickers, ctx, ); + // Runner picker is excluded from the shared sync (its + // options are fetched async and cached on the view), so + // re-apply its selection from the refreshed config. + self.resync_runner_selection(ctx); } ctx.notify(); } @@ -522,6 +541,8 @@ impl OrchestrationConfigBlockView { env_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); self.pickers.environment_picker = Some(env_handle); + self.ensure_runner_picker(ctx); + let initial_host = match &self .orchestration_edit_state .orchestration_config_state @@ -634,6 +655,105 @@ impl OrchestrationConfigBlockView { model.set_orchestration_config_for_plan(conversation_id, plan_id, config, status, ctx); }); } + + /// Builds the Runner picker and kicks off the `getRunners` fetch, but + /// only when the `CloudAgentRunners` feature is enabled and the config + /// is in remote mode — otherwise the Runner control is not rendered, so + /// there is no reason to create the picker or hit `getRunners`. + /// Idempotent, and re-invoked on the Local→Cloud toggle. + fn ensure_runner_picker(&mut self, ctx: &mut ViewContext) { + if !FeatureFlag::CloudAgentRunners.is_enabled() { + return; + } + if !self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote() + { + return; + } + if self.pickers.runner_picker.is_some() { + return; + } + let appearance = Appearance::as_ref(ctx); + let (styles, _colors) = oc::picker_styles(appearance); + let initial_runner = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + let runner_handle = oc::create_runner_picker( + &initial_runner, + &self.runners, + self.runners_loading, + &styles, + ctx, + ); + runner_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); + self.pickers.runner_picker = Some(runner_handle); + self.fetch_runners(ctx); + } + + /// Fetches available runners via `getRunners` (name-sorted server-side) + /// and repopulates the Runner picker once they resolve. + fn fetch_runners(&mut self, ctx: &mut ViewContext) { + if self.runners_loading || !self.runners.is_empty() { + return; + } + self.runners_loading = true; + let client = ServerApiProvider::as_ref(ctx).get_factory_client(); + ctx.spawn( + async move { client.get_runners(Some(RunnerSortBy::Name)).await }, + |me, result, ctx| { + me.runners_loading = false; + match result { + Ok(runners) => { + me.runners = runners + .into_iter() + .map(|r| (r.uid.inner().to_string(), r.config.name)) + .collect(); + } + Err(err) => { + log::warn!("Failed to fetch runners for plan-card runner picker: {err}"); + } + } + let current = match &me + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + if let Some(handle) = me.pickers.runner_picker.clone() { + oc::populate_runner_picker(&handle, &me.runners, ¤t, false, ctx); + } + ctx.notify(); + }, + ); + } + + /// Re-applies the runner picker's selection from the current + /// `runner_id` using the view-cached runner list. The runner picker + /// is excluded from the shared picker sync, so this runs after the + /// config's `runner_id` may have changed (e.g. a model refresh). + fn resync_runner_selection(&mut self, ctx: &mut ViewContext) { + let current = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + if let Some(handle) = self.pickers.runner_picker.clone() { + oc::populate_runner_picker(&handle, &self.runners, ¤t, self.runners_loading, ctx); + } + } } impl Entity for OrchestrationConfigBlockView { @@ -874,6 +994,10 @@ impl TypedActionView for OrchestrationConfigBlockView { fallback, ctx, ); + // Switching to Cloud reveals the Runner control (when the + // flag is on); build + fetch it lazily so Local configs + // never hit `getRunners`. + self.ensure_runner_picker(ctx); self.apply_field_change(ctx); // Local → Cloud can newly reveal the auth picker. self.user_has_interacted = true; @@ -918,6 +1042,21 @@ impl TypedActionView for OrchestrationConfigBlockView { OrchestrationConfigBlockAction::CreateEnvironmentRequested => { self.open_create_environment_modal(ctx); } + OrchestrationConfigBlockAction::RunnerChanged { runner_id } => { + self.orchestration_edit_state + .orchestration_config_state + .set_runner_id(runner_id.clone()); + self.apply_field_change(ctx); + // Defer re-applying the picker selection: a menu click's + // `SelectActionAndClose` doesn't update the dropdown's + // displayed value, and we're mid-dispatch from the runner + // dropdown so we can't repopulate it synchronously without a + // "Circular view update" panic. + ctx.spawn(async {}, |me, _, ctx| { + me.resync_runner_selection(ctx); + }); + ctx.notify(); + } OrchestrationConfigBlockAction::WorkerHostChanged { worker_host } => { self.orchestration_edit_state .orchestration_config_state diff --git a/app/src/ai/orchestration/config_state.rs b/app/src/ai/orchestration/config_state.rs index 7ce3f6fbc3d..352d444554a 100644 --- a/app/src/ai/orchestration/config_state.rs +++ b/app/src/ai/orchestration/config_state.rs @@ -118,10 +118,12 @@ impl OrchestrationConfigState { OrchestrationExecutionMode::Remote { environment_id, worker_host, + runner_id, } => RunAgentsExecutionMode::Remote { environment_id: environment_id.clone(), worker_host: worker_host.clone(), computer_use_enabled: false, + runner_id: runner_id.clone(), }, }; let mut state = Self { @@ -149,6 +151,7 @@ impl OrchestrationConfigState { environment_id: String::new(), worker_host: ORCHESTRATION_WARP_WORKER_HOST.to_string(), computer_use_enabled: self.remote_computer_use_enabled, + runner_id: String::new(), }; } } else { @@ -182,6 +185,12 @@ impl OrchestrationConfigState { } } + pub fn set_runner_id(&mut self, runner_id: String) { + if let RunAgentsExecutionMode::Remote { runner_id: id, .. } = &mut self.execution_mode { + *id = runner_id; + } + } + /// Returns `Some(reason)` if Accept / Apply must be disabled. /// Hard blocks: OpenCode + Cloud, and product-disabled local harnesses. pub fn accept_disabled_reason(&self) -> Option<&'static str> { @@ -256,10 +265,12 @@ impl OrchestrationConfigState { RunAgentsExecutionMode::Remote { environment_id, worker_host, + runner_id, .. } => OrchestrationExecutionMode::Remote { environment_id: environment_id.clone(), worker_host: worker_host.clone(), + runner_id: runner_id.clone(), }, }; OrchestrationConfig { diff --git a/app/src/ai/orchestration/config_state_tests.rs b/app/src/ai/orchestration/config_state_tests.rs index 5bc99114fda..6befbb6dd70 100644 --- a/app/src/ai/orchestration/config_state_tests.rs +++ b/app/src/ai/orchestration/config_state_tests.rs @@ -20,6 +20,7 @@ fn toggle_to_local_sanitizes_disabled_codex() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, ); @@ -42,6 +43,7 @@ fn local_round_trip_preserves_remote_computer_use() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, ); @@ -66,6 +68,7 @@ fn toggle_to_local_preserves_claude() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, ); @@ -96,6 +99,21 @@ fn resolve_from_config_preserves_local_claude() { )); } +#[test] +fn runner_id_round_trips_through_config() { + let config = OrchestrationConfig { + model_id: "auto".to_string(), + harness_type: "oz".to_string(), + execution_mode: OrchestrationExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + runner_id: "runner-7".to_string(), + }, + }; + let state = OrchestrationConfigState::from_orchestration_config(&config); + assert_eq!(state.to_orchestration_config(), config); +} + #[test] fn resolve_from_config_sanitizes_disabled_local_codex() { let mut state = OrchestrationConfigState::from_run_agents_fields( diff --git a/app/src/ai/orchestration/edit_state_tests.rs b/app/src/ai/orchestration/edit_state_tests.rs index 4f65dac1204..0926aa72d8b 100644 --- a/app/src/ai/orchestration/edit_state_tests.rs +++ b/app/src/ai/orchestration/edit_state_tests.rs @@ -10,6 +10,7 @@ fn remote_mode() -> RunAgentsExecutionMode { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), } } diff --git a/app/src/ai/orchestration/mod.rs b/app/src/ai/orchestration/mod.rs index d61bf5dd6f2..3a52c470e3f 100644 --- a/app/src/ai/orchestration/mod.rs +++ b/app/src/ai/orchestration/mod.rs @@ -29,8 +29,8 @@ pub use providers::{ pub use snapshots::location_snapshot; pub(crate) use snapshots::AUTH_SECRET_INHERIT_LABEL; pub use snapshots::{ - api_key_snapshot, environment_snapshot, harness_snapshot, host_snapshot, model_snapshot, - OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus, + api_key_snapshot, build_runner_snapshot, environment_snapshot, harness_snapshot, host_snapshot, + model_snapshot, OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus, }; pub use validation::{ accept_disabled_reason_with_auth, empty_env_recommendation_message, diff --git a/app/src/ai/orchestration/providers.rs b/app/src/ai/orchestration/providers.rs index 9c93c6c3e59..889543b49ca 100644 --- a/app/src/ai/orchestration/providers.rs +++ b/app/src/ai/orchestration/providers.rs @@ -24,6 +24,7 @@ const DEFAULT_HOST_ENV_VAR: &str = "WARP_CLOUD_MODE_DEFAULT_HOST"; pub const ORCHESTRATION_WARP_WORKER_HOST: &str = WARP_WORKER_HOST; pub const ORCHESTRATION_ENV_NONE_LABEL: &str = "Empty environment"; +pub const ORCHESTRATION_RUNNER_NONE_LABEL: &str = "Use default"; /// Returns Warp base-model choices for orchestration. pub(crate) fn get_base_model_choices<'a>( diff --git a/app/src/ai/orchestration/snapshots.rs b/app/src/ai/orchestration/snapshots.rs index 0afe94f49d4..1d1a500463c 100644 --- a/app/src/ai/orchestration/snapshots.rs +++ b/app/src/ai/orchestration/snapshots.rs @@ -12,7 +12,7 @@ use warpui::{AppContext, SingletonEntity}; use super::config_state::{AuthSecretSelection, OrchestrationConfigState}; use super::providers::{ get_base_model_choices, resolve_default_host_slug, resolve_recent_host_slug, - ORCHESTRATION_ENV_NONE_LABEL, ORCHESTRATION_WARP_WORKER_HOST, + ORCHESTRATION_ENV_NONE_LABEL, ORCHESTRATION_RUNNER_NONE_LABEL, ORCHESTRATION_WARP_WORKER_HOST, }; use crate::ai::auth_secret_types::auth_secret_types_for_harness; use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; @@ -572,6 +572,42 @@ fn build_environment_snapshot(envs: Vec<(String, String)>, current: &str) -> Opt OptionSnapshot::ready(rows, selected_id) } +// ── Runner ────────────────────────────────────────────────────────── + +/// Builds the runner options: a "Use environment default" clear row plus +/// the supplied runners (already sorted by name). Runners are not cached +/// client-side like environments, so the caller fetches them via +/// `FactoryClient::get_runners` and passes them in along with the current +/// load state; while `loading` is true the snapshot reports +/// [`OptionSourceStatus::Loading`] so the picker can show a spinner. +pub fn build_runner_snapshot( + runners: Vec<(String, String)>, + current: &str, + loading: bool, +) -> OptionSnapshot { + let mut rows = vec![OptionRow::new( + String::new(), + ORCHESTRATION_RUNNER_NONE_LABEL, + )]; + let mut selected_id = current.is_empty().then(String::new); + for (runner_id, runner_name) in runners { + if runner_id == current { + selected_id = Some(runner_id.clone()); + } + rows.push(OptionRow::new(runner_id, runner_name)); + } + OptionSnapshot { + rows, + selected_id, + status: if loading { + OptionSourceStatus::Loading + } else { + OptionSourceStatus::Ready + }, + footer: None, + } +} + #[cfg(test)] #[path = "snapshots_tests.rs"] mod tests; diff --git a/app/src/ai/orchestration/snapshots_tests.rs b/app/src/ai/orchestration/snapshots_tests.rs index c1ed0cf853b..574d0e0ddc8 100644 --- a/app/src/ai/orchestration/snapshots_tests.rs +++ b/app/src/ai/orchestration/snapshots_tests.rs @@ -3,8 +3,8 @@ use warp_cli::agent::Harness; use super::{ build_api_key_snapshot, build_environment_snapshot, build_harness_snapshot, build_host_snapshot, build_non_oz_model_snapshot, build_oz_model_snapshot, - AuthSecretNamesInput, HarnessEntryInput, ModelChoiceInput, OptionBadge, OptionFooter, - OptionSourceStatus, AUTH_SECRET_INHERIT_LABEL, DEFAULT_MODEL_LABEL, + build_runner_snapshot, AuthSecretNamesInput, HarnessEntryInput, ModelChoiceInput, OptionBadge, + OptionFooter, OptionSourceStatus, AUTH_SECRET_INHERIT_LABEL, DEFAULT_MODEL_LABEL, }; use crate::ai::local_harness_setup::LocalHarnessSetupState; use crate::ai::orchestration::config_state::AuthSecretSelection; @@ -277,3 +277,33 @@ fn environment_snapshot_puts_empty_option_first() { assert_eq!(snapshot.rows[0].label, super::ORCHESTRATION_ENV_NONE_LABEL); assert_eq!(snapshot.selected_id.as_deref(), Some("env-b")); } + +// ── Runner ────────────────────────────────────────────────────── + +#[test] +fn runner_snapshot_puts_use_default_first_and_selects() { + let snapshot = build_runner_snapshot( + vec![ + ("r-a".to_string(), "Alpha".to_string()), + ("r-b".to_string(), "Beta".to_string()), + ], + "r-b", + false, + ); + + assert_eq!(snapshot.rows[0].id, ""); + assert_eq!( + snapshot.rows[0].label, + super::ORCHESTRATION_RUNNER_NONE_LABEL + ); + assert_eq!(snapshot.selected_id.as_deref(), Some("r-b")); + assert_eq!(snapshot.status, OptionSourceStatus::Ready); +} + +#[test] +fn runner_snapshot_loading_reports_loading_status() { + let snapshot = build_runner_snapshot(vec![], "", true); + assert_eq!(snapshot.status, OptionSourceStatus::Loading); + // Empty selection maps to the "use environment default" row. + assert_eq!(snapshot.selected_id.as_deref(), Some("")); +} diff --git a/app/src/ai/orchestration/validation_tests.rs b/app/src/ai/orchestration/validation_tests.rs index 0f63e839d71..cd855c91f6a 100644 --- a/app/src/ai/orchestration/validation_tests.rs +++ b/app/src/ai/orchestration/validation_tests.rs @@ -35,6 +35,7 @@ fn cloud() -> RunAgentsExecutionMode { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), } } diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 963b276c050..6df7f4729e8 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -1580,6 +1580,7 @@ fn dispatch_start_agent_conversation( harness_type, title, auth_secret_name, + runner_id, agent_identity_uid, } => { launch_remote_child( @@ -1595,6 +1596,7 @@ fn dispatch_start_agent_conversation( harness_type, title, auth_secret_name, + runner_id, agent_identity_uid, }, ctx, @@ -1894,6 +1896,9 @@ struct RemoteLaunchFields { /// harness credentials. Resolved to `AgentConfigSnapshot.harness_auth_secrets` /// when applicable. auth_secret_name: Option, + /// Runner UID selecting the child's compute config. Empty means "no + /// override" — resolved at dispatch via the environment's default runner. + runner_id: String, /// UID of the named agent (service account) the remote child should /// execute as; forwarded to `SpawnAgentRequest.agent_identity_uid`. agent_identity_uid: Option, @@ -1928,6 +1933,7 @@ fn launch_remote_child( harness_type, title, auth_secret_name, + runner_id, agent_identity_uid, } = fields; @@ -2053,6 +2059,7 @@ fn launch_remote_child( config: Some(AgentConfigSnapshot { name: agent_name, environment_id, + runner_id: (!runner_id.is_empty()).then_some(runner_id), model_id: (!model_id.is_empty()).then_some(model_id), worker_host: (!worker_host.is_empty()).then_some(worker_host), computer_use_enabled, diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index 87d951709f9..7be15c9a2f0 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -1344,7 +1344,6 @@ impl ServerApiProvider { self.server_api.clone() } - #[cfg_attr(target_family = "wasm", expect(dead_code))] pub fn get_factory_client(&self) -> Arc { self.server_api.clone() } diff --git a/app/src/server/server_api/factory.rs b/app/src/server/server_api/factory.rs index d7a936feed2..a0aa4b2e8a8 100644 --- a/app/src/server/server_api/factory.rs +++ b/app/src/server/server_api/factory.rs @@ -1,6 +1,3 @@ -// We don't manage runners from agent run CLI flows on WASM, so this code is unused there. -#![cfg_attr(target_family = "wasm", expect(dead_code))] - use anyhow::{anyhow, Result}; use async_trait::async_trait; use cynic::{MutationBuilder, QueryBuilder}; @@ -21,6 +18,9 @@ use crate::server::graphql::{get_request_context, get_user_facing_error_message} /// The result of upserting a runner: the resulting [`Runner`] plus whether the /// operation updated an existing runner (vs. creating a new one). +// `upsert_runner`/`delete_runner` back CLI commands that aren't built for wasm, so +// this type is unused there while `get_runners` still powers the runner picker. +#[cfg_attr(target_family = "wasm", allow(dead_code))] pub struct UpsertedRunner { pub runner: Runner, pub is_update: bool, @@ -36,9 +36,11 @@ pub trait FactoryClient: 'static + Send + Sync { /// Create or update a runner. `input.uid` is `None` for a create and /// `Some(_)` for an update; this single method backs both CLI commands. + #[cfg_attr(target_family = "wasm", allow(dead_code))] async fn upsert_runner(&self, input: UpsertRunnerInput) -> Result; /// Delete a runner by UID, returning the deleted UID on success. + #[cfg_attr(target_family = "wasm", allow(dead_code))] async fn delete_runner(&self, uid: String) -> Result; } diff --git a/crates/ai/src/agent/action/mod.rs b/crates/ai/src/agent/action/mod.rs index f17ef090f81..01c7738d84e 100644 --- a/crates/ai/src/agent/action/mod.rs +++ b/crates/ai/src/agent/action/mod.rs @@ -240,6 +240,11 @@ pub enum RunAgentsExecutionMode { environment_id: String, worker_host: String, computer_use_enabled: bool, + /// Runner UID selecting the children's compute config (docker + /// image, instance shape, setup commands). Empty means "no + /// override" — fall back to the environment's default runner then + /// system defaults. + runner_id: String, }, } @@ -286,6 +291,10 @@ pub enum StartAgentExecutionMode { /// `None` means no client-side secret was selected — the remote /// environment falls back to its own ambient credentials. auth_secret_name: Option, + /// Runner UID selecting the child's compute config. Empty means + /// "no override" — resolved at dispatch via the environment's + /// default runner then system defaults. + runner_id: String, /// UID of the named agent (service account) the remote child run /// should execute as. `None` means the child runs as the caller. agent_identity_uid: Option, @@ -319,6 +328,7 @@ impl StartAgentExecutionMode { harness_type: String::new(), title: String::new(), auth_secret_name: None, + runner_id: String::new(), agent_identity_uid: None, } } diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index a47913bce81..c8abe9ecc79 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -1352,12 +1352,13 @@ impl From environment_id, worker_host, computer_use_enabled, + runner_id, } => api::run_agents_result::launched::ResolvedExecutionMode::Remote( api::run_agents::Remote { environment_id, worker_host, computer_use_enabled, - runner_id: Default::default(), + runner_id, }, ), } diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index 6ea878d2f5c..ab23cca2694 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -1381,6 +1381,9 @@ pub enum RunAgentsLaunchedExecutionMode { environment_id: String, worker_host: String, computer_use_enabled: bool, + /// Resolved runner UID the batch committed to; empty when none. + #[serde(default)] + runner_id: String, }, } diff --git a/crates/ai/src/agent/orchestration_config.rs b/crates/ai/src/agent/orchestration_config.rs index dd3fed299d9..c22c43780be 100644 --- a/crates/ai/src/agent/orchestration_config.rs +++ b/crates/ai/src/agent/orchestration_config.rs @@ -20,6 +20,8 @@ pub enum OrchestrationExecutionMode { Remote { environment_id: String, worker_host: String, + /// Runner UID pre-declared on the config; empty means unset. + runner_id: String, }, } @@ -79,16 +81,20 @@ pub fn matches_active_config(request: &RunAgentsRequest, config: &OrchestrationC super::action::RunAgentsExecutionMode::Remote { environment_id, worker_host, + runner_id, .. }, OrchestrationExecutionMode::Remote { environment_id: cfg_env, worker_host: cfg_host, + runner_id: cfg_runner, }, ) => { let env_matches = environment_id.is_empty() || environment_id == cfg_env; let host_matches = worker_host.is_empty() || worker_host == cfg_host; - env_matches && host_matches + // Empty runner_id on the call inherits from the config → matches. + let runner_matches = runner_id.is_empty() || runner_id == cfg_runner; + env_matches && host_matches && runner_matches } // Variant mismatch (Local vs Remote). _ => false, @@ -107,6 +113,7 @@ impl OrchestrationConfig { OrchestrationExecutionMode::Remote { environment_id: remote.environment_id.clone(), worker_host: remote.worker_host.clone(), + runner_id: remote.runner_id.clone(), } } Some(api::orchestration_config::ExecutionMode::Local(_)) | None => { @@ -134,11 +141,12 @@ impl OrchestrationConfig { OrchestrationExecutionMode::Remote { environment_id, worker_host, + runner_id, } => Some(api::orchestration_config::ExecutionMode::Remote( api::orchestration_config::Remote { environment_id: environment_id.clone(), worker_host: worker_host.clone(), - runner_id: Default::default(), + runner_id: runner_id.clone(), }, )), }; diff --git a/crates/ai/src/agent/orchestration_config_tests.rs b/crates/ai/src/agent/orchestration_config_tests.rs index ba7ff03a440..426a48c11f8 100644 --- a/crates/ai/src/agent/orchestration_config_tests.rs +++ b/crates/ai/src/agent/orchestration_config_tests.rs @@ -9,6 +9,7 @@ fn make_config(model: &str, harness: &str, remote: bool) -> OrchestrationConfig OrchestrationExecutionMode::Remote { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), + runner_id: String::new(), } } else { OrchestrationExecutionMode::Local @@ -28,6 +29,7 @@ fn make_request(model: &str, harness: &str, remote: bool) -> RunAgentsRequest { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), } } else { RunAgentsExecutionMode::Local @@ -120,6 +122,54 @@ fn remote_empty_env_inherits_and_matches() { assert!(matches_active_config(&request, &config)); } +#[test] +fn remote_matching_runner_matches() { + let mut config = make_config("auto", "oz", true); + if let OrchestrationExecutionMode::Remote { runner_id, .. } = &mut config.execution_mode { + *runner_id = "runner-1".to_string(); + } + let mut request = make_request("auto", "oz", true); + if let RunAgentsExecutionMode::Remote { runner_id, .. } = &mut request.execution_mode { + *runner_id = "runner-1".to_string(); + } + assert!(matches_active_config(&request, &config)); +} + +#[test] +fn remote_different_runner_mismatches() { + let mut config = make_config("auto", "oz", true); + if let OrchestrationExecutionMode::Remote { runner_id, .. } = &mut config.execution_mode { + *runner_id = "runner-1".to_string(); + } + let mut request = make_request("auto", "oz", true); + if let RunAgentsExecutionMode::Remote { runner_id, .. } = &mut request.execution_mode { + *runner_id = "runner-2".to_string(); + } + assert!(!matches_active_config(&request, &config)); +} + +#[test] +fn remote_empty_runner_inherits_and_matches() { + let mut config = make_config("auto", "oz", true); + if let OrchestrationExecutionMode::Remote { runner_id, .. } = &mut config.execution_mode { + *runner_id = "runner-1".to_string(); + } + // Request leaves runner_id empty → inherits from config → matches. + let request = make_request("auto", "oz", true); + assert!(matches_active_config(&request, &config)); +} + +#[test] +fn proto_round_trip_config_remote_with_runner() { + let mut config = make_config("auto", "claude", true); + if let OrchestrationExecutionMode::Remote { runner_id, .. } = &mut config.execution_mode { + *runner_id = "runner-xyz".to_string(); + } + let proto = config.to_proto(); + let round_tripped = OrchestrationConfig::from_proto(&proto); + assert_eq!(config, round_tripped); +} + #[test] fn computer_use_not_in_match_check() { let config = make_config("auto", "oz", true); diff --git a/crates/warp_cli/src/lib.rs b/crates/warp_cli/src/lib.rs index c3604a0e3ba..f1f6054cc48 100644 --- a/crates/warp_cli/src/lib.rs +++ b/crates/warp_cli/src/lib.rs @@ -274,7 +274,7 @@ impl Args { } } - if !FeatureFlag::CloudAgentRunnerCLICommands.is_enabled() { + if !FeatureFlag::CloudAgentRunners.is_enabled() { let args: Vec = env::args().collect(); if args.len() > 1 && args[1] == "runner" { eprintln!("error: unrecognized subcommand 'runner'\n"); @@ -403,7 +403,7 @@ impl Args { } // Hide the runner subcommand from help text. - if !FeatureFlag::CloudAgentRunnerCLICommands.is_enabled() { + if !FeatureFlag::CloudAgentRunners.is_enabled() { command = command.mut_subcommand("runner", |c| c.hide(true)); } diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 8f5254deb50..582bd536924 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -910,9 +910,11 @@ pub enum FeatureFlag { /// eliminating seams between adjacent box-drawing cells in the terminal. BoxDrawingGlyphs, - /// Enables the `oz runner` CRUD commands for managing cloud agent runners - /// via the CLI. - CloudAgentRunnerCLICommands, + /// Enables cloud agent runner selection: the `oz runner` CRUD commands + /// for managing runners via the CLI, and the Runner dropdown in the + /// orchestration (`run_agents`) confirmation card and plan-card config + /// block for choosing a runner when starting remote child agents. + CloudAgentRunners, } static FLAG_STATES: [AtomicBool; cardinality::()] = @@ -990,7 +992,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::McpJsonTreeView, FeatureFlag::GeminiEnterprise, FeatureFlag::BoxDrawingGlyphs, - FeatureFlag::CloudAgentRunnerCLICommands, + FeatureFlag::CloudAgentRunners, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index 4ed26bf5360..85e26055861 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -61,6 +61,7 @@ fn remote(environment_id: &str, worker_host: &str) -> RunAgentsExecutionMode { environment_id: environment_id.to_string(), worker_host: worker_host.to_string(), computer_use_enabled: true, + runner_id: String::new(), } } @@ -137,6 +138,7 @@ fn edit_state_is_overridden_by_an_approved_config() { execution_mode: OrchestrationExecutionMode::Remote { environment_id: "env-2".to_string(), worker_host: "warp".to_string(), + runner_id: String::new(), }, }; let state = TuiOrchestrationBlock::config_state_from_request( @@ -194,6 +196,7 @@ fn build_request_carries_card_fields_and_edited_run_wide_state() { environment_id: "env-9".to_string(), worker_host: "self-hosted".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, ); assert_eq!(built.harness_auth_secret_name.as_deref(), Some("codex-key")); diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 1d4b7e33ddb..6c9aa502039 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -186,6 +186,7 @@ fn remote_children_fail_cleanly() { harness_type: "oz".to_string(), title: "Researcher".to_string(), auth_secret_name: None, + runner_id: String::new(), agent_identity_uid: None, }, );