feat: create Backend workers from bare TUI launch
This commit is contained in:
@@ -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<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)]
|
||||
pub struct BackendRuntimeListTarget {
|
||||
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(
|
||||
target: &BackendRuntimeListTarget,
|
||||
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, 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<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]
|
||||
fn protocol_url_uses_backend_runtime_worker_identity() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BackendWorkerLaunch, TargetError> {
|
||||
Err(TargetError::unsupported(
|
||||
"Backend Worker launch",
|
||||
self.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
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> {
|
||||
Ok(WorkerList {
|
||||
backend_target: BackendRuntimeListTarget::new(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
@@ -161,6 +164,10 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
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 {
|
||||
runtime_id,
|
||||
include_stopped,
|
||||
|
||||
+43
-9
@@ -363,10 +363,7 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
||||
&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<R: CliConnectionResolver + ?Sized>(
|
||||
.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([
|
||||
|
||||
Reference in New Issue
Block a user