From 724205b1dff558e02c3d2c13cac5cee18152fd0b Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 5 Sep 2026 21:19:12 +0900 Subject: [PATCH] feat: create Backend workers from bare TUI launch --- crates/client/src/backend_runtime.rs | 286 +++++++++++++++- crates/client/src/lib.rs | 19 +- crates/client/src/target.rs | 23 +- crates/tui/src/backend_spawn.rs | 483 +++++++++++++++++++++++++++ crates/tui/src/lib.rs | 7 + crates/yoi/src/main.rs | 52 ++- 6 files changed, 850 insertions(+), 20 deletions(-) create mode 100644 crates/tui/src/backend_spawn.rs diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index a3cf0609..9837a474 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -7,10 +7,15 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; pub use workspace_api::{ - Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, - ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary, + BrowserCreateWorkerResponse as BackendCreateWorkerResponse, + CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic, + DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse, + RuntimeSummary as BackendRuntimeSummary, WorkerCapabilitySummary as BackendWorkerCapabilitySummary, WorkerImplementationSummary as BackendWorkerImplementationSummary, + WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions, + WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate, + WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption, WorkerRestoreResponse as BackendWorkerRestoreResponse, WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, @@ -171,6 +176,47 @@ struct UploadedFileResponse { file: protocol::UploadedFileRef, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendWorkerLaunchTarget { + pub base_url: String, + pub workspace_id: Option, +} + +impl BackendWorkerLaunchTarget { + pub fn new(base_url: impl Into, workspace_id: Option) -> Self { + Self { + base_url: base_url.into(), + workspace_id, + } + } + + pub fn select_workspace(&mut self, workspace_id: impl Into) { + self.workspace_id = Some(workspace_id.into()); + } + + pub fn workspace_id(&self) -> Option<&str> { + self.workspace_id.as_deref() + } + + pub fn runtime_target( + &self, + runtime_id: impl Into, + worker_id: impl Into, + ) -> Result { + let workspace_id = self.workspace_id.clone().ok_or_else(|| { + BackendRuntimeClientError::InvalidTarget( + "workspace_id is required before creating a Backend worker".to_string(), + ) + })?; + Ok(BackendRuntimeTarget::new( + self.base_url.clone(), + workspace_id, + runtime_id, + worker_id, + )) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendRuntimeListTarget { pub base_url: String, @@ -255,6 +301,58 @@ impl From for BackendRuntimeClientError { } } +pub async fn get_backend_worker_launch_options( + target: &BackendWorkerLaunchTarget, +) -> Result { + validate_launch_target(target)?; + let api = BackendApiClient::from_stored_token(&target.base_url)?; + get_backend_worker_launch_options_with_client(target, &api).await +} + +async fn get_backend_worker_launch_options_with_client( + target: &BackendWorkerLaunchTarget, + api: &BackendApiClient, +) -> Result { + let path = backend_workspace_workers_launch_options_path( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"), + ); + let response = api.request(HttpMethod::GET, &path)?.send().await?; + let response = api.require_success(response).await?; + Ok(response.json::().await?) +} + +pub async fn create_backend_worker( + target: &BackendWorkerLaunchTarget, + request: &BackendCreateWorkerRequest, +) -> Result { + validate_launch_target(target)?; + let api = BackendApiClient::from_stored_token(&target.base_url)?; + create_backend_worker_with_client(target, request, &api).await +} + +async fn create_backend_worker_with_client( + target: &BackendWorkerLaunchTarget, + request: &BackendCreateWorkerRequest, + api: &BackendApiClient, +) -> Result { + let path = backend_workspace_workers_path( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"), + ); + let response = api + .request(HttpMethod::POST, &path)? + .json(request) + .send() + .await?; + let response = api.require_success(response).await?; + Ok(response.json::().await?) +} + pub async fn list_backend_workers( target: &BackendRuntimeListTarget, ) -> Result, BackendRuntimeClientError> { @@ -462,6 +560,30 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl Ok(()) } +fn validate_launch_target( + target: &BackendWorkerLaunchTarget, +) -> Result<(), BackendRuntimeClientError> { + if target.base_url.trim().is_empty() { + return Err(BackendRuntimeClientError::InvalidTarget( + "Backend API base URL is required".to_string(), + )); + } + if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) { + return Err(BackendRuntimeClientError::InvalidTarget( + "Backend API base URL must start with http:// or https://".to_string(), + )); + } + match target.workspace_id.as_deref() { + Some("") => Err(BackendRuntimeClientError::InvalidTarget( + "workspace_id must not be empty".to_string(), + )), + None => Err(BackendRuntimeClientError::InvalidTarget( + "workspace selection is required before creating a Backend worker".to_string(), + )), + Some(_) => Ok(()), + } +} + fn validate_list_target( target: &BackendRuntimeListTarget, ) -> Result<(), BackendRuntimeClientError> { @@ -496,6 +618,17 @@ fn validate_list_target( Ok(()) } +fn backend_workspace_workers_path(workspace_id: &str) -> String { + format!("/api/w/{}/workers", path_segment_encode(workspace_id)) +} + +fn backend_workspace_workers_launch_options_path(workspace_id: &str) -> String { + format!( + "{}/launch-options", + backend_workspace_workers_path(workspace_id) + ) +} + fn backend_runtimes_path(workspace_id: &str) -> String { format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)) } @@ -580,6 +713,155 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String { #[cfg(test)] mod tests { use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + async fn serve_json_once(body: serde_json::Value) -> (String, tokio::task::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let header_end = loop { + let mut buffer = [0_u8; 4096]; + let read = socket.read(&mut buffer).await.unwrap(); + assert!(read > 0, "client closed before sending HTTP headers"); + request.extend_from_slice(&buffer[..read]); + if let Some(position) = request.windows(4).position(|part| part == b"\r\n\r\n") { + break position + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + while request.len() < header_end + content_length { + let mut buffer = [0_u8; 4096]; + let read = socket.read(&mut buffer).await.unwrap(); + assert!(read > 0, "client closed before sending HTTP body"); + request.extend_from_slice(&buffer[..read]); + } + + let body = serde_json::to_vec(&body).unwrap(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + socket.write_all(&body).await.unwrap(); + String::from_utf8(request).unwrap() + }); + (base_url, task) + } + + #[tokio::test] + async fn launch_options_request_uses_workspace_path_and_bearer_auth() { + let (base_url, server) = serve_json_once(serde_json::json!({ + "workspace_id": "team main", + "runtimes": [{ + "runtime_id": "embedded", + "display_name": "Embedded", + "built_in": true, + "worker_creation_available": true, + "working_directory_required": false, + "status": "online", + "diagnostics": [] + }], + "default_profile": "builtin:default", + "profiles": [{ + "id": "builtin:default", + "label": "Default", + "description": "" + }], + "repositories": [], + "working_directories": [], + "diagnostics": [] + })) + .await; + let target = BackendWorkerLaunchTarget::new(&base_url, Some("team main".to_string())); + let api = BackendApiClient::from_access_token_for_test(&base_url, "launch-secret").unwrap(); + + let response = get_backend_worker_launch_options_with_client(&target, &api) + .await + .unwrap(); + assert_eq!(response.runtimes[0].runtime_id, "embedded"); + let request = server.await.unwrap(); + assert!(request.starts_with("GET /api/w/team%20main/workers/launch-options HTTP/1.1\r\n")); + assert!( + request + .to_ascii_lowercase() + .contains("authorization: bearer launch-secret\r\n") + ); + } + + #[tokio::test] + async fn create_worker_posts_frontend_contract_to_workspace_path() { + let (base_url, server) = serve_json_once(serde_json::json!({ + "workspace_id": "workspace-1", + "runtime_id": "embedded", + "worker_id": "worker-1", + "console_href": "/w/workspace-1/workers/embedded/worker-1", + "worker": { + "runtime_id": "embedded", + "worker_id": "worker-1", + "host_id": "host-1", + "display_name": "Coder one", + "label": "Coder one", + "profile": "builtin:coder", + "singleton_key": null, + "tags": [], + "workspace": { + "visibility": "workspace", + "identity": "workspace", + "workspace_id": "workspace-1" + }, + "state": "idle", + "last_seen_at": null, + "pinned": false, + "retention_state": "resident", + "implementation": {"kind": "embedded", "display_hint": "Embedded"}, + "capabilities": {"can_stop": true, "can_spawn_followup": false}, + "diagnostics": [] + }, + "diagnostics": [] + })) + .await; + let target = BackendWorkerLaunchTarget::new(&base_url, Some("workspace-1".to_string())); + let api = BackendApiClient::from_access_token_for_test(&base_url, "create-secret").unwrap(); + let create = BackendCreateWorkerRequest { + runtime_id: "embedded".to_string(), + display_name: "Coder one".to_string(), + profile: Some("builtin:coder".to_string()), + ticket_assignment: None, + initial_submit: Vec::new(), + working_directory: None, + control_operation_id: None, + }; + + let response = create_backend_worker_with_client(&target, &create, &api) + .await + .unwrap(); + assert_eq!(response.worker_id, "worker-1"); + let request = server.await.unwrap(); + assert!(request.starts_with("POST /api/w/workspace-1/workers HTTP/1.1\r\n")); + assert!( + request + .to_ascii_lowercase() + .contains("authorization: bearer create-secret\r\n") + ); + let body = request.split_once("\r\n\r\n").unwrap().1; + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(body["runtime_id"], "embedded"); + assert_eq!(body["display_name"], "Coder one"); + assert_eq!(body["profile"], "builtin:coder"); + assert_eq!(body["initial_submit"], serde_json::json!([])); + assert_eq!(body["working_directory"], serde_json::Value::Null); + } #[test] fn protocol_url_uses_backend_runtime_worker_identity() { diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 3c2e8a51..a1051bbe 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -21,11 +21,14 @@ pub use backend_auth::{ poll_device_login, start_device_login, wait_for_device_login, }; pub use backend_runtime::{ - BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClientError, - BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary, - BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, - BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary, - BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime, + BackendCreateWorkerRequest, BackendCreateWorkerResponse, BackendDiagnostic, + BackendDiagnosticSeverity, BackendRuntimeClientError, BackendRuntimeListResponse, + BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget, + BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions, + BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption, + BackendWorkerLaunchTarget, BackendWorkerRestoreResponse, BackendWorkerRestoreResult, + BackendWorkerSummary, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, + connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers, restore_backend_worker, }; pub use backend_workspace::{ @@ -35,9 +38,9 @@ pub use backend_workspace::{ }; pub use client::{Client, ClientError}; pub use target::{ - BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent, - StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection, - WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, + BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget, + StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, + WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, }; pub use workspace_api::{ CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition, diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index 6b5fc783..1d2aea9b 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -2,7 +2,7 @@ use std::{fmt, path::PathBuf}; use crate::{ BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget, - BackendRuntimeTarget, + BackendRuntimeTarget, BackendWorkerLaunchTarget, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -123,6 +123,11 @@ pub struct Dashboard { pub workspace_id: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendWorkerLaunch { + pub target: BackendWorkerLaunchTarget, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkerList { pub backend_target: BackendRuntimeListTarget, @@ -199,6 +204,13 @@ pub trait Target: fmt::Debug + Send + Sync { Err(TargetError::unsupported("Worker dashboard", self.kind())) } + fn launch_backend_worker(&self) -> Result { + Err(TargetError::unsupported( + "Backend Worker launch", + self.kind(), + )) + } + fn list_workers(&self, _request: WorkerListRequest) -> Result { Err(TargetError::unsupported("Worker listing", self.kind())) } @@ -299,6 +311,15 @@ impl Target for BackendTarget { }) } + fn launch_backend_worker(&self) -> Result { + Ok(BackendWorkerLaunch { + target: BackendWorkerLaunchTarget::new( + self.base_url.clone(), + self.workspace_id.clone(), + ), + }) + } + fn list_workers(&self, request: WorkerListRequest) -> Result { Ok(WorkerList { backend_target: BackendRuntimeListTarget::new( diff --git a/crates/tui/src/backend_spawn.rs b/crates/tui/src/backend_spawn.rs new file mode 100644 index 00000000..4c4203d0 --- /dev/null +++ b/crates/tui/src/backend_spawn.rs @@ -0,0 +1,483 @@ +use client::{ + BackendCreateWorkerRequest, BackendWorkerLaunchOptions, BackendWorkerLaunchProfileCandidate, + BackendWorkerLaunchRuntimeOption, BackendWorkerLaunchTarget, create_backend_worker, + get_backend_worker_launch_options, +}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; + +use crate::backend_workspace_picker::select_backend_workspace; +use crate::console; +use crate::inline_terminal::{InlineTerminal, with_inline_terminal}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Field { + Name, + Runtime, + Profile, +} + +impl Field { + fn next(self) -> Self { + match self { + Self::Name => Self::Runtime, + Self::Runtime => Self::Profile, + Self::Profile => Self::Name, + } + } + + fn previous(self) -> Self { + match self { + Self::Name => Self::Profile, + Self::Runtime => Self::Name, + Self::Profile => Self::Runtime, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Selection { + runtime_id: String, + display_name: String, + profile: String, +} + +struct FormState { + field: Field, + display_name: String, + runtime_index: usize, + profile_index: usize, + status: String, +} + +impl FormState { + fn new(options: &BackendWorkerLaunchOptions) -> Self { + let runtime_index = options + .runtimes + .iter() + .position(runtime_supports_workdirless_creation) + .unwrap_or(0); + let profile_index = options + .default_profile + .as_deref() + .and_then(|default| { + options + .profiles + .iter() + .position(|candidate| candidate.id == default) + }) + .unwrap_or(0); + Self { + field: Field::Name, + display_name: "Worker".to_string(), + runtime_index, + profile_index, + status: String::new(), + } + } + + fn current_runtime<'a>( + &self, + options: &'a BackendWorkerLaunchOptions, + ) -> Option<&'a BackendWorkerLaunchRuntimeOption> { + options.runtimes.get(self.runtime_index) + } + + fn current_profile<'a>( + &self, + options: &'a BackendWorkerLaunchOptions, + ) -> Option<&'a BackendWorkerLaunchProfileCandidate> { + options.profiles.get(self.profile_index) + } + + fn cycle_runtime(&mut self, options: &BackendWorkerLaunchOptions, delta: isize) { + self.runtime_index = cycle_index(self.runtime_index, options.runtimes.len(), delta); + self.status.clear(); + } + + fn cycle_profile(&mut self, options: &BackendWorkerLaunchOptions, delta: isize) { + self.profile_index = cycle_index(self.profile_index, options.profiles.len(), delta); + self.status.clear(); + } + + fn submit(&mut self, options: &BackendWorkerLaunchOptions) -> Option { + let display_name = self.display_name.trim(); + if display_name.is_empty() { + self.status = "Worker name is required.".to_string(); + self.field = Field::Name; + return None; + } + let Some(runtime) = self.current_runtime(options) else { + self.status = "No Runtime is available in this Workspace.".to_string(); + self.field = Field::Runtime; + return None; + }; + if !runtime.worker_creation_available { + self.status = "The selected Runtime cannot create Workers right now.".to_string(); + self.field = Field::Runtime; + return None; + } + if runtime.working_directory_required { + self.status = + "The selected Runtime requires a workdir; this launch flow does not select one yet." + .to_string(); + self.field = Field::Runtime; + return None; + } + let Some(profile) = self.current_profile(options) else { + self.status = "No Worker profile is available.".to_string(); + self.field = Field::Profile; + return None; + }; + Some(Selection { + runtime_id: runtime.runtime_id.clone(), + display_name: display_name.to_string(), + profile: profile.id.clone(), + }) + } +} + +pub async fn run(mut target: BackendWorkerLaunchTarget) -> Result<(), Box> { + if target.workspace_id().is_none() { + let Some(workspace) = select_backend_workspace(&target.base_url).await? else { + return Ok(()); + }; + target.select_workspace(workspace); + } + + let options = get_backend_worker_launch_options(&target).await?; + let Some(selection) = select_worker(&options)? else { + return Ok(()); + }; + let request = request_from_selection(selection); + let created = create_backend_worker(&target, &request).await?; + let runtime_target = target.runtime_target(created.runtime_id, created.worker_id)?; + console::run_backend_runtime(runtime_target).await +} + +fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest { + BackendCreateWorkerRequest { + runtime_id: selection.runtime_id, + display_name: selection.display_name, + profile: Some(selection.profile), + initial_submit: Vec::new(), + working_directory: None, + ticket_assignment: None, + control_operation_id: None, + } +} + +const VIEWPORT_LINES: u16 = 14; + +fn select_worker( + options: &BackendWorkerLaunchOptions, +) -> Result, Box> { + with_inline_terminal(VIEWPORT_LINES, |terminal| run_form(terminal, options)) +} + +fn run_form( + terminal: &mut InlineTerminal, + options: &BackendWorkerLaunchOptions, +) -> Result, Box> { + let mut state = FormState::new(options); + + loop { + terminal.draw(|frame| render(frame, &state, options))?; + let event = event::read()?; + let Event::Key(key) = event else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(None); + } + + match key.code { + KeyCode::Esc => { + return Ok(None); + } + KeyCode::Tab | KeyCode::Down => { + state.field = state.field.next(); + state.status.clear(); + } + KeyCode::BackTab | KeyCode::Up => { + state.field = state.field.previous(); + state.status.clear(); + } + KeyCode::Left => match state.field { + Field::Runtime => state.cycle_runtime(options, -1), + Field::Profile => state.cycle_profile(options, -1), + Field::Name => {} + }, + KeyCode::Right => match state.field { + Field::Runtime => state.cycle_runtime(options, 1), + Field::Profile => state.cycle_profile(options, 1), + Field::Name => {} + }, + KeyCode::Enter => { + if let Some(selection) = state.submit(options) { + return Ok(Some(selection)); + } + } + KeyCode::Backspace if state.field == Field::Name => { + state.display_name.pop(); + state.status.clear(); + } + KeyCode::Char(character) + if state.field == Field::Name + && !key.modifiers.contains(KeyModifiers::CONTROL) + && !character.is_control() => + { + state.display_name.push(character); + state.status.clear(); + } + _ => {} + } + } +} + +fn render(frame: &mut ratatui::Frame<'_>, state: &FormState, options: &BackendWorkerLaunchOptions) { + let area = frame.area(); + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(1), + ]) + .split(area); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + "New Backend Worker", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" Workspace: {}", options.workspace_id)), + ])), + vertical[0], + ); + + let focused = Style::default().fg(Color::Cyan); + frame.render_widget( + Paragraph::new(state.display_name.as_str()).block( + Block::default() + .borders(Borders::ALL) + .title(" Name ") + .border_style(if state.field == Field::Name { + focused + } else { + Style::default() + }), + ), + vertical[1], + ); + + let runtime_text = state + .current_runtime(options) + .map(runtime_label) + .unwrap_or_else(|| "No Runtime available".to_string()); + frame.render_widget( + Paragraph::new(runtime_text).block( + Block::default() + .borders(Borders::ALL) + .title(runtime_title(state, options)) + .border_style(if state.field == Field::Runtime { + focused + } else { + Style::default() + }), + ), + vertical[2], + ); + + let profile_text = state + .current_profile(options) + .map(|profile| { + if profile.description.is_empty() { + profile.label.clone() + } else { + format!("{} — {}", profile.label, profile.description) + } + }) + .unwrap_or_else(|| "No profile available".to_string()); + frame.render_widget( + Paragraph::new(profile_text).block( + Block::default() + .borders(Borders::ALL) + .title(profile_title(state, options)) + .border_style(if state.field == Field::Profile { + focused + } else { + Style::default() + }), + ), + vertical[3], + ); + + let status = if state.status.is_empty() { + "Tab/↑/↓: field ←/→: choice Enter: create Esc/Ctrl-C: cancel" + } else { + state.status.as_str() + }; + frame.render_widget( + Paragraph::new(status) + .style(if state.status.is_empty() { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::Yellow) + }) + .wrap(Wrap { trim: true }), + vertical[4], + ); + + if state.field == Field::Name { + let max_cursor = vertical[1].width.saturating_sub(2) as usize; + frame.set_cursor_position(( + vertical[1].x + 1 + state.display_name.chars().count().min(max_cursor) as u16, + vertical[1].y + 1, + )); + } +} + +fn runtime_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> String { + if options.runtimes.is_empty() { + " Runtime ".to_string() + } else { + format!( + " Runtime ({}/{}) ", + state.runtime_index + 1, + options.runtimes.len() + ) + } +} + +fn profile_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> String { + if options.profiles.is_empty() { + " Profile ".to_string() + } else { + format!( + " Profile ({}/{}) ", + state.profile_index + 1, + options.profiles.len() + ) + } +} + +fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String { + let availability = if !runtime.worker_creation_available { + "unavailable" + } else if runtime.working_directory_required { + "workdir required" + } else { + "no workdir" + }; + format!( + "{} [{}] — {availability}", + runtime.display_name, runtime.runtime_id + ) +} + +fn runtime_supports_workdirless_creation(runtime: &BackendWorkerLaunchRuntimeOption) -> bool { + runtime.worker_creation_available && !runtime.working_directory_required +} + +fn cycle_index(current: usize, len: usize, delta: isize) -> usize { + if len == 0 { + return 0; + } + (current as isize + delta).rem_euclid(len as isize) as usize +} + +#[cfg(test)] +mod tests { + use super::*; + use client::{BackendDiagnostic, BackendWorkerLaunchOptions}; + + fn options() -> BackendWorkerLaunchOptions { + BackendWorkerLaunchOptions { + workspace_id: "workspace-1".to_string(), + runtimes: vec![ + BackendWorkerLaunchRuntimeOption { + runtime_id: "external".to_string(), + display_name: "External".to_string(), + built_in: false, + worker_creation_available: true, + working_directory_required: true, + status: "online".to_string(), + diagnostics: Vec::new(), + }, + BackendWorkerLaunchRuntimeOption { + runtime_id: "embedded".to_string(), + display_name: "Embedded".to_string(), + built_in: true, + worker_creation_available: true, + working_directory_required: false, + status: "online".to_string(), + diagnostics: Vec::new(), + }, + ], + profiles: vec![ + BackendWorkerLaunchProfileCandidate { + id: "builtin:default".to_string(), + label: "Default".to_string(), + description: String::new(), + }, + BackendWorkerLaunchProfileCandidate { + id: "builtin:coder".to_string(), + label: "Coder".to_string(), + description: "Ticket implementation".to_string(), + }, + ], + default_profile: Some("builtin:coder".to_string()), + repositories: Vec::new(), + working_directories: Vec::new(), + diagnostics: Vec::::new(), + } + } + + #[test] + fn defaults_to_workdirless_runtime_and_backend_default_profile() { + let options = options(); + let state = FormState::new(&options); + assert_eq!( + state.current_runtime(&options).unwrap().runtime_id, + "embedded" + ); + assert_eq!(state.current_profile(&options).unwrap().id, "builtin:coder"); + assert_eq!(state.display_name, "Worker"); + } + + #[test] + fn workdir_required_runtime_cannot_be_submitted() { + let options = options(); + let mut state = FormState::new(&options); + state.runtime_index = 0; + assert_eq!(state.submit(&options), None); + assert!(state.status.contains("requires a workdir")); + assert_eq!(state.field, Field::Runtime); + } + + #[test] + fn selection_builds_workdirless_create_request() { + let request = request_from_selection(Selection { + runtime_id: "embedded".to_string(), + display_name: "Coder one".to_string(), + profile: "builtin:coder".to_string(), + }); + assert_eq!(request.runtime_id, "embedded"); + assert_eq!(request.display_name, "Coder one"); + assert_eq!(request.profile.as_deref(), Some("builtin:coder")); + assert!(request.initial_submit.is_empty()); + assert!(request.working_directory.is_none()); + assert!(request.ticket_assignment.is_none()); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 2cd9f031..437e554b 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1,5 +1,6 @@ mod app; mod backend_dashboard; +mod backend_spawn; mod backend_worker_picker; mod backend_workspace_picker; mod block; @@ -51,6 +52,8 @@ pub enum LaunchMode { /// Restore one client-owned standalone Worker. The current cwd is the default scope; /// `include_all` opts into all standalone Workers under the same client data root. StandaloneResume { include_all: bool }, + /// Create one Backend Worker and attach to it. + BackendSpawn, /// List Backend Workers and attach to the selected Worker. Workers { runtime_id: Option, @@ -161,6 +164,10 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Err(error) => Err(Box::new(error) as Box), } } + LaunchMode::BackendSpawn => match target.launch_backend_worker() { + Ok(launch) => backend_spawn::run(launch.target).await, + Err(e) => Err(Box::new(e) as Box), + }, LaunchMode::Workers { runtime_id, include_stopped, diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 6ae7d313..f30737f3 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -363,10 +363,7 @@ fn parse_args_slice_with_connection_resolver( &workspace_root, )?; let mode = if target.kind() == client::TargetKind::Backend { - LaunchMode::Workers { - runtime_id: None, - include_stopped: false, - } + LaunchMode::BackendSpawn } else { LaunchMode::Spawn { worker_name: None, @@ -822,10 +819,7 @@ fn parse_console_options( .to_string(), )); } - LaunchMode::Workers { - runtime_id: None, - include_stopped: false, - } + LaunchMode::BackendSpawn }; Ok(Mode::Tui { @@ -1720,7 +1714,7 @@ Target selection: Ticket, Objective, Worker catalog, PID, socket, or subprocess authority. Connection-aware commands: - yoi Standalone: new Console. Backend: Worker picker. + yoi Standalone: new Console. Backend: create and attach to a new Worker. yoi resume Standalone Worker picker or stopped Backend Worker picker. yoi workers Backend Workspace Worker picker. yoi panel Backend Workspace dashboard. @@ -2115,6 +2109,46 @@ backend = "shared" } } + #[test] + fn parse_default_backend_creates_a_worker_before_attach() { + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://default-backend.example", + }; + + match parse_args_slice_with_connection_resolver(&[], &resolver).unwrap() { + Mode::Tui { + target, + mode: LaunchMode::BackendSpawn, + .. + } => assert_eq!(target.kind(), TargetKind::Backend), + other => panic!("expected BackendSpawn mode, got {other:?}"), + } + } + + #[test] + fn parse_bare_backend_creates_a_worker_before_attach() { + match parse_args_from([ + "--backend", + "http://127.0.0.1:8787", + "--workspace-id", + "workspace-a", + ]) + .unwrap() + { + Mode::Tui { + target, + mode: LaunchMode::BackendSpawn, + .. + } => { + assert_eq!(target.kind(), TargetKind::Backend); + let launch = target.launch_backend_worker().unwrap(); + assert_eq!(launch.target.base_url, "http://127.0.0.1:8787"); + assert_eq!(launch.target.workspace_id.as_deref(), Some("workspace-a")); + } + other => panic!("expected BackendSpawn mode, got {other:?}"), + } + } + #[test] fn parse_workers_subcommand_uses_backend_runtime_picker() { match parse_args_from([