feat: create Backend workers from bare TUI launch

This commit is contained in:
2026-09-05 21:19:12 +09:00
parent 69824ea45d
commit 724205b1df
6 changed files with 850 additions and 20 deletions
+284 -2
View File
@@ -7,10 +7,15 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workspace_api::{ pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, BrowserCreateWorkerResponse as BackendCreateWorkerResponse,
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary, CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic,
DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse,
RuntimeSummary as BackendRuntimeSummary,
WorkerCapabilitySummary as BackendWorkerCapabilitySummary, WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
WorkerImplementationSummary as BackendWorkerImplementationSummary, WorkerImplementationSummary as BackendWorkerImplementationSummary,
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
WorkerRestoreResponse as BackendWorkerRestoreResponse, WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
@@ -171,6 +176,47 @@ struct UploadedFileResponse {
file: protocol::UploadedFileRef, file: protocol::UploadedFileRef,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkerLaunchTarget {
pub base_url: String,
pub workspace_id: Option<String>,
}
impl BackendWorkerLaunchTarget {
pub fn new(base_url: impl Into<String>, workspace_id: Option<String>) -> Self {
Self {
base_url: base_url.into(),
workspace_id,
}
}
pub fn select_workspace(&mut self, workspace_id: impl Into<String>) {
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<String>,
worker_id: impl Into<String>,
) -> Result<BackendRuntimeTarget, BackendRuntimeClientError> {
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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendRuntimeListTarget { pub struct BackendRuntimeListTarget {
pub base_url: String, pub base_url: String,
@@ -255,6 +301,58 @@ impl From<reqwest::Error> for BackendRuntimeClientError {
} }
} }
pub async fn get_backend_worker_launch_options(
target: &BackendWorkerLaunchTarget,
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
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<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
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::<BackendWorkerLaunchOptions>().await?)
}
pub async fn create_backend_worker(
target: &BackendWorkerLaunchTarget,
request: &BackendCreateWorkerRequest,
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
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<BackendCreateWorkerResponse, BackendRuntimeClientError> {
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::<BackendCreateWorkerResponse>().await?)
}
pub async fn list_backend_workers( pub async fn list_backend_workers(
target: &BackendRuntimeListTarget, target: &BackendRuntimeListTarget,
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> { ) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
@@ -462,6 +560,30 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
Ok(()) 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( fn validate_list_target(
target: &BackendRuntimeListTarget, target: &BackendRuntimeListTarget,
) -> Result<(), BackendRuntimeClientError> { ) -> Result<(), BackendRuntimeClientError> {
@@ -496,6 +618,17 @@ fn validate_list_target(
Ok(()) 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 { fn backend_runtimes_path(workspace_id: &str) -> String {
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn serve_json_once(body: serde_json::Value) -> (String, tokio::task::JoinHandle<String>) {
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::<usize>().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] #[test]
fn protocol_url_uses_backend_runtime_worker_identity() { fn protocol_url_uses_backend_runtime_worker_identity() {
+11 -8
View File
@@ -21,11 +21,14 @@ pub use backend_auth::{
poll_device_login, start_device_login, wait_for_device_login, poll_device_login, start_device_login, wait_for_device_login,
}; };
pub use backend_runtime::{ pub use backend_runtime::{
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClientError, BackendCreateWorkerRequest, BackendCreateWorkerResponse, BackendDiagnostic,
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary, BackendDiagnosticSeverity, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime, 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, list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
}; };
pub use backend_workspace::{ pub use backend_workspace::{
@@ -35,9 +38,9 @@ pub use backend_workspace::{
}; };
pub use client::{Client, ClientError}; pub use client::{Client, ClientError};
pub use target::{ pub use target::{
BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent, BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget,
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection, StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind,
WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
}; };
pub use workspace_api::{ pub use workspace_api::{
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition, CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
+22 -1
View File
@@ -2,7 +2,7 @@ use std::{fmt, path::PathBuf};
use crate::{ use crate::{
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget, BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
BackendRuntimeTarget, BackendRuntimeTarget, BackendWorkerLaunchTarget,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -123,6 +123,11 @@ pub struct Dashboard {
pub workspace_id: String, pub workspace_id: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkerLaunch {
pub target: BackendWorkerLaunchTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList { pub struct WorkerList {
pub backend_target: BackendRuntimeListTarget, pub backend_target: BackendRuntimeListTarget,
@@ -199,6 +204,13 @@ pub trait Target: fmt::Debug + Send + Sync {
Err(TargetError::unsupported("Worker dashboard", self.kind())) Err(TargetError::unsupported("Worker dashboard", self.kind()))
} }
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
Err(TargetError::unsupported(
"Backend Worker launch",
self.kind(),
))
}
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> { fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Err(TargetError::unsupported("Worker listing", self.kind())) Err(TargetError::unsupported("Worker listing", self.kind()))
} }
@@ -299,6 +311,15 @@ impl Target for BackendTarget {
}) })
} }
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
Ok(BackendWorkerLaunch {
target: BackendWorkerLaunchTarget::new(
self.base_url.clone(),
self.workspace_id.clone(),
),
})
}
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> { fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList { Ok(WorkerList {
backend_target: BackendRuntimeListTarget::new( backend_target: BackendRuntimeListTarget::new(
+483
View File
@@ -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<Selection> {
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<dyn std::error::Error>> {
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<Option<Selection>, Box<dyn std::error::Error>> {
with_inline_terminal(VIEWPORT_LINES, |terminal| run_form(terminal, options))
}
fn run_form(
terminal: &mut InlineTerminal,
options: &BackendWorkerLaunchOptions,
) -> Result<Option<Selection>, Box<dyn std::error::Error>> {
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::<BackendDiagnostic>::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());
}
}
+7
View File
@@ -1,5 +1,6 @@
mod app; mod app;
mod backend_dashboard; mod backend_dashboard;
mod backend_spawn;
mod backend_worker_picker; mod backend_worker_picker;
mod backend_workspace_picker; mod backend_workspace_picker;
mod block; mod block;
@@ -51,6 +52,8 @@ pub enum LaunchMode {
/// Restore one client-owned standalone Worker. The current cwd is the default scope; /// 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. /// `include_all` opts into all standalone Workers under the same client data root.
StandaloneResume { include_all: bool }, StandaloneResume { include_all: bool },
/// Create one Backend Worker and attach to it.
BackendSpawn,
/// List Backend Workers and attach to the selected Worker. /// List Backend Workers and attach to the selected Worker.
Workers { Workers {
runtime_id: Option<String>, runtime_id: Option<String>,
@@ -161,6 +164,10 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>), Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
} }
} }
LaunchMode::BackendSpawn => match target.launch_backend_worker() {
Ok(launch) => backend_spawn::run(launch.target).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
LaunchMode::Workers { LaunchMode::Workers {
runtime_id, runtime_id,
include_stopped, include_stopped,
+43 -9
View File
@@ -363,10 +363,7 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
&workspace_root, &workspace_root,
)?; )?;
let mode = if target.kind() == client::TargetKind::Backend { let mode = if target.kind() == client::TargetKind::Backend {
LaunchMode::Workers { LaunchMode::BackendSpawn
runtime_id: None,
include_stopped: false,
}
} else { } else {
LaunchMode::Spawn { LaunchMode::Spawn {
worker_name: None, worker_name: None,
@@ -822,10 +819,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
.to_string(), .to_string(),
)); ));
} }
LaunchMode::Workers { LaunchMode::BackendSpawn
runtime_id: None,
include_stopped: false,
}
}; };
Ok(Mode::Tui { Ok(Mode::Tui {
@@ -1720,7 +1714,7 @@ Target selection:
Ticket, Objective, Worker catalog, PID, socket, or subprocess authority. Ticket, Objective, Worker catalog, PID, socket, or subprocess authority.
Connection-aware commands: 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 resume Standalone Worker picker or stopped Backend Worker picker.
yoi workers Backend Workspace Worker picker. yoi workers Backend Workspace Worker picker.
yoi panel Backend Workspace dashboard. 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] #[test]
fn parse_workers_subcommand_uses_backend_runtime_picker() { fn parse_workers_subcommand_uses_backend_runtime_picker() {
match parse_args_from([ match parse_args_from([