diff --git a/Cargo.lock b/Cargo.lock index 6dffc64e..e30142e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5815,6 +5815,7 @@ dependencies = [ "tempfile", "ticket", "tokio", + "toml", "tui", "worker", ] diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 4e85e433..7fda54cf 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -41,6 +41,122 @@ impl BackendRuntimeTarget { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendRuntimeListTarget { + pub base_url: String, + pub workspace_id: Option, + pub runtime_id: Option, +} + +impl BackendRuntimeListTarget { + pub fn new( + base_url: impl Into, + workspace_id: Option, + runtime_id: Option, + ) -> Self { + Self { + base_url: base_url.into(), + workspace_id, + runtime_id, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BackendRuntimeListResponse { + pub workspace_id: String, + pub limit: usize, + pub items: Vec, + pub source: String, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendRuntimeSummary { + pub runtime_id: String, + pub label: String, + pub kind: String, + pub status: String, + #[serde(default)] + pub host_ids: Vec, + #[serde(default)] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendWorkerWorkspaceSummary { + pub visibility: String, + pub identity: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendWorkerImplementationSummary { + pub kind: String, + pub display_hint: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendWorkerCapabilitySummary { + pub can_stop: bool, + pub can_spawn_followup: bool, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendWorkingDirectoryCleanupTarget { + pub kind: String, + pub working_directory_id: String, + pub repository_id: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendWorkingDirectorySummary { + pub working_directory_id: String, + pub repository_id: String, + #[serde(default)] + pub requested_selector: Option, + pub materializer_kind: String, + #[serde(default)] + pub resolved_commit: Option, + #[serde(default)] + pub resolved_tree: Option, + #[serde(default)] + pub cleanup_target: Option, + pub status: String, + #[serde(default)] + pub cleanliness: Option, + #[serde(default)] + pub primary_worker_id: Option, + #[serde(default)] + pub management_kind: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendWorkerSummary { + pub runtime_id: String, + pub worker_id: String, + pub host_id: String, + pub label: String, + #[serde(default)] + pub role: Option, + #[serde(default)] + pub profile: Option, + pub workspace: BackendWorkerWorkspaceSummary, + pub state: String, + #[serde(default)] + pub last_seen_at: Option, + #[serde(default)] + pub pinned: bool, + #[serde(default)] + pub retention_state: String, + pub implementation: BackendWorkerImplementationSummary, + pub capabilities: BackendWorkerCapabilitySummary, + #[serde(default)] + pub working_directory: Option, + #[serde(default)] + pub diagnostics: Vec, +} + #[derive(Debug)] pub struct BackendRuntimeClient { target: BackendRuntimeTarget, @@ -73,6 +189,72 @@ impl From for BackendRuntimeClientError { } } +pub async fn list_backend_workers( + target: &BackendRuntimeListTarget, +) -> Result, BackendRuntimeClientError> { + validate_list_target(target)?; + let http = reqwest::Client::new(); + if let Some(runtime_id) = target.runtime_id.as_deref() { + let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id); + let url = join_base_and_path(&target.base_url, &path); + return Ok(http + .get(url) + .send() + .await? + .error_for_status()? + .json::>() + .await?); + } + + let runtime_path = backend_runtimes_path(target.workspace_id.as_deref()); + let runtime_url = join_base_and_path(&target.base_url, &runtime_path); + let runtimes = http + .get(runtime_url) + .send() + .await? + .error_for_status()? + .json::>() + .await?; + + let mut items = Vec::new(); + let mut diagnostics = runtimes.diagnostics; + for runtime in runtimes.items { + let path = + backend_runtime_workers_path(target.workspace_id.as_deref(), &runtime.runtime_id); + let url = join_base_and_path(&target.base_url, &path); + match http + .get(url) + .send() + .await + .and_then(|response| response.error_for_status()) + { + Ok(response) => { + let response = response + .json::>() + .await?; + diagnostics.extend(response.diagnostics); + items.extend(response.items); + } + Err(error) => diagnostics.push(BackendDiagnostic { + code: "runtime_worker_list_failed".to_string(), + severity: Some("error".to_string()), + message: format!( + "failed to list workers for runtime {}: {error}", + runtime.runtime_id + ), + }), + } + } + + Ok(BackendRuntimeListResponse { + workspace_id: runtimes.workspace_id, + limit: runtimes.limit, + items, + source: "backend_runtime_worker_summary".to_string(), + diagnostics, + }) +} + impl BackendRuntimeClient { pub async fn connect(target: BackendRuntimeTarget) -> Result { validate_target(&target)?; @@ -375,6 +557,50 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl Ok(()) } +fn validate_list_target( + target: &BackendRuntimeListTarget, +) -> 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(), + )); + } + if target.workspace_id.as_deref().is_some_and(str::is_empty) { + return Err(BackendRuntimeClientError::InvalidTarget( + "workspace_id must not be empty when provided".to_string(), + )); + } + if target.runtime_id.as_deref().is_some_and(str::is_empty) { + return Err(BackendRuntimeClientError::InvalidTarget( + "runtime_id must not be empty when provided".to_string(), + )); + } + Ok(()) +} + +fn backend_runtimes_path(workspace_id: Option<&str>) -> String { + match workspace_id { + Some(workspace_id) => format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)), + None => "/api/runtimes".to_string(), + } +} + +fn backend_runtime_workers_path(workspace_id: Option<&str>, runtime_id: &str) -> String { + match workspace_id { + Some(workspace_id) => format!( + "/api/w/{}/runtimes/{}/workers", + path_segment_encode(workspace_id), + path_segment_encode(runtime_id) + ), + None => format!("/api/runtimes/{}/workers", path_segment_encode(runtime_id)), + } +} + fn observation_ws_url(target: &BackendRuntimeTarget) -> String { let path = format!( "/api/runtimes/{}/workers/{}/events/ws", @@ -449,10 +675,12 @@ struct WorkerLifecycleResult { diagnostics: Vec, } -#[derive(Debug, Deserialize)] -struct BackendDiagnostic { - code: String, - message: String, +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendDiagnostic { + pub code: String, + #[serde(default)] + pub severity: Option, + pub message: String, } #[derive(Debug, Deserialize)] @@ -502,6 +730,22 @@ mod tests { ); } + #[test] + fn backend_worker_list_paths_use_scoped_workspace_when_available() { + assert_eq!( + backend_runtimes_path(Some("workspace/one")), + "/api/w/workspace%2Fone/runtimes" + ); + assert_eq!( + backend_runtime_workers_path(Some("workspace/one"), "runtime one"), + "/api/w/workspace%2Fone/runtimes/runtime%20one/workers" + ); + assert_eq!( + backend_runtime_workers_path(None, "runtime one"), + "/api/runtimes/runtime%20one/workers" + ); + } + #[test] fn observation_url_uses_backend_runtime_worker_identity() { let target = diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index cd918676..5d58181a 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -14,7 +14,12 @@ pub mod spawn; pub mod ticket_role; mod worker_client; -pub use backend_runtime::{BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeTarget}; +pub use backend_runtime::{ + BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse, + BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget, + BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerSummary, + BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_workers, +}; pub use runtime_command::WorkerRuntimeCommand; pub use spawn::{ diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs new file mode 100644 index 00000000..394825df --- /dev/null +++ b/crates/tui/src/backend_worker_picker.rs @@ -0,0 +1,271 @@ +use std::error::Error; +use std::io; + +use client::{ + BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, list_backend_workers, +}; +use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind}; +use ratatui::Frame; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; + +use crate::console; + +pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box> { + let response = list_backend_workers(&target).await.map_err(|error| { + io::Error::other(format!( + "failed to list Backend runtime workers from {}: {error}", + target.base_url + )) + })?; + if response.items.is_empty() { + return Err(Box::new(io::Error::other(format!( + "Backend returned no runtime workers for workspace {}", + response.workspace_id + )))); + } + + let selected = pick_worker(target.clone(), response.items)?; + let attach_target = + BackendRuntimeTarget::new(target.base_url, selected.runtime_id, selected.worker_id); + console::run_backend_runtime(attach_target).await +} + +fn pick_worker( + target: BackendRuntimeListTarget, + mut workers: Vec, +) -> Result> { + workers.sort_by(|a, b| { + a.runtime_id + .cmp(&b.runtime_id) + .then_with(|| a.label.cmp(&b.label)) + .then_with(|| a.worker_id.cmp(&b.worker_id)) + }); + let mut state = BackendWorkerPickerState::new(target, workers); + let mut terminal = ratatui::Terminal::new(CrosstermBackend::new(io::stdout()))?; + terminal.clear()?; + + loop { + terminal.draw(|frame| draw(frame, &mut state))?; + match event::read()? { + CrosstermEvent::Key(KeyEvent { + code, + kind: KeyEventKind::Press, + .. + }) => match code { + KeyCode::Up | KeyCode::Char('k') => state.previous(), + KeyCode::Down | KeyCode::Char('j') => state.next(), + KeyCode::Enter => return Ok(state.selected_worker().clone()), + KeyCode::Esc | KeyCode::Char('q') => { + return Err(Box::new(io::Error::other( + "Backend worker picker cancelled", + ))); + } + _ => {} + }, + _ => {} + } + } +} + +struct BackendWorkerPickerState { + target: BackendRuntimeListTarget, + workers: Vec, + selected: usize, +} + +impl BackendWorkerPickerState { + fn new(target: BackendRuntimeListTarget, workers: Vec) -> Self { + Self { + target, + workers, + selected: 0, + } + } + + fn next(&mut self) { + if self.workers.is_empty() { + return; + } + self.selected = (self.selected + 1).min(self.workers.len() - 1); + } + + fn previous(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + fn selected_worker(&self) -> &BackendWorkerSummary { + &self.workers[self.selected] + } +} + +fn draw(frame: &mut Frame<'_>, state: &mut BackendWorkerPickerState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Min(8), + Constraint::Length(5), + ]) + .split(frame.area()); + + draw_header(frame, chunks[0], state); + draw_list(frame, chunks[1], state); + draw_details(frame, chunks[2], state.selected_worker()); +} + +fn draw_header(frame: &mut Frame<'_>, area: Rect, state: &BackendWorkerPickerState) { + let workspace = state + .target + .workspace_id + .as_deref() + .unwrap_or("unscoped backend"); + let runtime = state.target.runtime_id.as_deref().unwrap_or("all runtimes"); + let text = vec![ + Line::from(vec![ + Span::styled( + "Backend runtime workers", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" {}", state.target.base_url)), + ]), + Line::from(format!("workspace: {workspace} runtime: {runtime}")), + Line::from("↑/↓ or k/j select Enter attach q/Esc cancel"), + ]; + frame.render_widget( + Paragraph::new(text).block(Block::default().borders(Borders::ALL)), + area, + ); +} + +fn draw_list(frame: &mut Frame<'_>, area: Rect, state: &mut BackendWorkerPickerState) { + let items: Vec<_> = state + .workers + .iter() + .map(|worker| ListItem::new(worker_row(worker))) + .collect(); + let mut list_state = ListState::default().with_selected(Some(state.selected)); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Workers")) + .highlight_symbol("▶ ") + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + frame.render_stateful_widget(list, area, &mut list_state); +} + +fn worker_row(worker: &BackendWorkerSummary) -> Line<'static> { + let label = if worker.label.is_empty() { + worker.worker_id.as_str() + } else { + worker.label.as_str() + }; + let profile = worker.profile.as_deref().unwrap_or("-"); + let wd = working_directory_text(worker); + Line::from(vec![ + Span::styled( + format!("{}:{}", worker.runtime_id, worker.worker_id), + Style::default().fg(Color::Cyan), + ), + Span::raw(format!(" {label}")), + Span::raw(format!(" profile:{profile}")), + Span::raw(format!(" state:{}", worker.state)), + Span::raw(format!(" wd:{wd}")), + ]) +} + +fn draw_details(frame: &mut Frame<'_>, area: Rect, worker: &BackendWorkerSummary) { + let profile = worker.profile.as_deref().unwrap_or("-"); + let role = worker.role.as_deref().unwrap_or("-"); + let text = vec![ + Line::from(format!( + "runtime={} worker={} host={}", + worker.runtime_id, worker.worker_id, worker.host_id + )), + Line::from(format!( + "label={} role={} profile={} state={}", + worker.label, role, profile, worker.state + )), + Line::from(format!( + "working_directory={}", + working_directory_text(worker) + )), + ]; + frame.render_widget( + Paragraph::new(text) + .block( + Block::default() + .borders(Borders::ALL) + .title("Selected worker"), + ) + .wrap(Wrap { trim: true }), + area, + ); +} + +fn working_directory_text(worker: &BackendWorkerSummary) -> String { + let Some(wd) = worker.working_directory.as_ref() else { + return "-".to_string(); + }; + let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown"); + format!( + "{}:{} {} {}", + wd.repository_id, wd.working_directory_id, wd.status, cleanliness + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use client::{ + BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, + BackendWorkerWorkspaceSummary, + }; + + fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary { + BackendWorkerSummary { + runtime_id: runtime_id.to_string(), + worker_id: worker_id.to_string(), + host_id: "host".to_string(), + label: "label".to_string(), + role: None, + profile: profile.map(str::to_string), + workspace: BackendWorkerWorkspaceSummary { + visibility: "workspace".to_string(), + identity: "ws".to_string(), + }, + state: "running".to_string(), + last_seen_at: None, + pinned: false, + retention_state: String::new(), + implementation: BackendWorkerImplementationSummary { + kind: "embedded".to_string(), + display_hint: "embedded".to_string(), + }, + capabilities: BackendWorkerCapabilitySummary { + can_stop: true, + can_spawn_followup: false, + }, + working_directory: None, + diagnostics: Vec::new(), + } + } + + #[test] + fn worker_row_contains_backend_authority_fields() { + let row = worker_row(&worker("runtime-a", "worker-b", Some("default"))); + let text = row + .spans + .into_iter() + .map(|span| span.content) + .collect::(); + assert!(text.contains("runtime-a:worker-b")); + assert!(text.contains("profile:default")); + assert!(text.contains("state:running")); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 902732df..beb4f552 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1,4 +1,5 @@ mod app; +mod backend_worker_picker; mod block; mod cache; mod command; @@ -33,7 +34,7 @@ use crossterm::execute; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; use session_store::SegmentId; -use client::{BackendRuntimeTarget, WorkerRuntimeCommand}; +use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; #[derive(Debug, Clone)] pub struct LaunchOptions { @@ -55,6 +56,9 @@ pub enum LaunchMode { worker_name: String, socket_override: Option, }, + /// `yoi workers` / `yoi --backend `: list Backend-authoritative runtime workers, + /// then attach to the selected Worker through the Backend Runtime API. + BackendRuntimePicker { target: BackendRuntimeListTarget }, /// `yoi --backend --runtime-id --worker-id `: connect through the /// Workspace Backend Runtime API and observe the Backend-proxied event stream. BackendRuntime { target: BackendRuntimeTarget }, @@ -106,6 +110,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { worker_name, socket_override, } => console::run_worker_name(worker_name, socket_override, runtime_command).await, + LaunchMode::BackendRuntimePicker { target } => backend_worker_picker::run(target).await, LaunchMode::BackendRuntime { target } => console::run_backend_runtime(target).await, LaunchMode::Resume { all } => { console::run_resume(runtime_command, workspace_root.clone(), all).await diff --git a/crates/yoi/Cargo.toml b/crates/yoi/Cargo.toml index 075ac71a..95b37c56 100644 --- a/crates/yoi/Cargo.toml +++ b/crates/yoi/Cargo.toml @@ -23,6 +23,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_yaml = "0.9.34" tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +toml = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 94bbd721..91263bc3 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -6,13 +6,16 @@ mod session_cli; mod ticket_cli; mod worker_cleanup_cli; +use std::collections::BTreeMap; use std::ffi::OsString; use std::fmt; -use std::path::PathBuf; +use std::fs; +use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; -use client::{BackendRuntimeTarget, WorkerRuntimeCommand}; +use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; use memory_lint::{LintCliOptions, LintStatus}; +use serde::Deserialize; use session_store::SegmentId; use tui::{LaunchMode, LaunchOptions}; @@ -207,6 +210,7 @@ fn parse_args_slice(args: &[String]) -> Result { match args[0].as_str() { "--help" | "-h" => return Ok(Mode::Help), "resume" => return parse_resume_args(&args[1..]), + "workers" => return parse_workers_args(&args[1..]), "worker" => { if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..]) .map_err(|e| ParseError(e.to_string()))? @@ -290,6 +294,7 @@ fn parse_console_options(args: &[String]) -> Result { let mut worker_name = None; let mut socket_override = None; let mut backend_url = None; + let mut workspace_id = None; let mut runtime_id = None; let mut worker_id = None; let mut profile = None; @@ -335,6 +340,16 @@ fn parse_console_options(args: &[String]) -> Result { workspace_root = PathBuf::from(value); i += 2; } + "--workspace-id" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--workspace-id requires a value".to_string()))?; + if value.starts_with('-') || value.is_empty() { + return Err(ParseError("--workspace-id requires a value".to_string())); + } + workspace_id = Some(value.clone()); + i += 2; + } "--backend" => { let value = args .get(i + 1) @@ -407,6 +422,14 @@ fn parse_console_options(args: &[String]) -> Result { workspace_root = PathBuf::from(value); i += 1; } + arg if arg.starts_with("--workspace-id=") => { + let value = arg.trim_start_matches("--workspace-id="); + if value.is_empty() { + return Err(ParseError("--workspace-id requires a value".to_string())); + } + workspace_id = Some(value.to_string()); + i += 1; + } arg if arg.starts_with("--backend=") => { let value = arg.trim_start_matches("--backend="); if value.is_empty() { @@ -458,13 +481,13 @@ fn parse_console_options(args: &[String]) -> Result { } } - let backend_target_present = - backend_url.is_some() || runtime_id.is_some() || worker_id.is_some(); - if backend_target_present - && (backend_url.is_none() || runtime_id.is_none() || worker_id.is_none()) - { + let backend_target_present = backend_url.is_some() + || workspace_id.is_some() + || runtime_id.is_some() + || worker_id.is_some(); + if backend_target_present && worker_id.is_some() && runtime_id.is_none() { return Err(ParseError( - "--backend, --runtime-id, and --worker-id are required together".to_string(), + "--worker-id requires --runtime-id for Backend Runtime API attach".to_string(), )); } if backend_target_present @@ -493,13 +516,22 @@ fn parse_console_options(args: &[String]) -> Result { } if backend_target_present { + let workspace_id = match workspace_id { + Some(workspace_id) => Some(workspace_id), + None => resolve_workspace_id_from_root(&workspace_root)?, + }; + let backend_url = resolve_backend_url(backend_url, workspace_id.as_deref())?; + if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) { + return Ok(Mode::Tui { + mode: LaunchMode::BackendRuntime { + target: BackendRuntimeTarget::new(backend_url, runtime_id, worker_id), + }, + workspace_root, + }); + } return Ok(Mode::Tui { - mode: LaunchMode::BackendRuntime { - target: BackendRuntimeTarget::new( - backend_url.expect("checked by backend_target_present"), - runtime_id.expect("checked by backend_target_present"), - worker_id.expect("checked by backend_target_present"), - ), + mode: LaunchMode::BackendRuntimePicker { + target: BackendRuntimeListTarget::new(backend_url, workspace_id, runtime_id), }, workspace_root, }); @@ -538,6 +570,125 @@ fn parse_console_options(args: &[String]) -> Result { }) } +fn parse_workers_args(args: &[String]) -> Result { + let mut workspace_root = current_dir()?; + let mut workspace_id = None; + let mut backend_url = None; + let mut runtime_id = None; + + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + match arg.as_str() { + "--help" | "-h" => { + return Err(ParseError( + "usage: yoi workers [--workspace PATH] [--workspace-id ID] [--backend URL] [--runtime-id ID]".to_string(), + )); + } + "--workspace" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--workspace requires a value".to_string()))?; + if value.starts_with('-') || value.is_empty() { + return Err(ParseError("--workspace requires a value".to_string())); + } + workspace_root = PathBuf::from(value); + i += 2; + } + "--workspace-id" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--workspace-id requires a value".to_string()))?; + if value.starts_with('-') || value.is_empty() { + return Err(ParseError("--workspace-id requires a value".to_string())); + } + workspace_id = Some(value.clone()); + i += 2; + } + "--backend" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--backend requires a URL".to_string()))?; + if value.starts_with('-') || value.is_empty() { + return Err(ParseError("--backend requires a URL".to_string())); + } + backend_url = Some(value.clone()); + i += 2; + } + "--runtime-id" | "--runtime" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--runtime-id requires a value".to_string()))?; + if value.starts_with('-') || value.is_empty() { + return Err(ParseError("--runtime-id requires a value".to_string())); + } + runtime_id = Some(value.clone()); + i += 2; + } + arg if arg.starts_with("--workspace=") => { + let value = arg.trim_start_matches("--workspace="); + if value.is_empty() { + return Err(ParseError("--workspace requires a value".to_string())); + } + workspace_root = PathBuf::from(value); + i += 1; + } + arg if arg.starts_with("--workspace-id=") => { + let value = arg.trim_start_matches("--workspace-id="); + if value.is_empty() { + return Err(ParseError("--workspace-id requires a value".to_string())); + } + workspace_id = Some(value.to_string()); + i += 1; + } + arg if arg.starts_with("--backend=") => { + let value = arg.trim_start_matches("--backend="); + if value.is_empty() { + return Err(ParseError("--backend requires a URL".to_string())); + } + backend_url = Some(value.to_string()); + i += 1; + } + arg if arg.starts_with("--runtime-id=") => { + let value = arg.trim_start_matches("--runtime-id="); + if value.is_empty() { + return Err(ParseError("--runtime-id requires a value".to_string())); + } + runtime_id = Some(value.to_string()); + i += 1; + } + arg if arg.starts_with("--runtime=") => { + let value = arg.trim_start_matches("--runtime="); + if value.is_empty() { + return Err(ParseError("--runtime-id requires a value".to_string())); + } + runtime_id = Some(value.to_string()); + i += 1; + } + arg if arg.starts_with('-') => { + return Err(ParseError(format!("unknown yoi workers option `{arg}`"))); + } + value => { + return Err(ParseError(format!( + "yoi workers does not accept positional argument `{value}`" + ))); + } + } + } + + let workspace_id = match workspace_id { + Some(workspace_id) => Some(workspace_id), + None => resolve_workspace_id_from_root(&workspace_root)?, + }; + let backend_url = resolve_backend_url(backend_url, workspace_id.as_deref())?; + Ok(Mode::Tui { + mode: LaunchMode::BackendRuntimePicker { + target: BackendRuntimeListTarget::new(backend_url, workspace_id, runtime_id), + }, + workspace_root, + }) +} + fn parse_resume_args(args: &[String]) -> Result { let mut workspace_root = current_dir()?; let mut workspace_set = false; @@ -607,6 +758,127 @@ fn current_dir() -> Result { .map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))) } +#[derive(Debug, Deserialize)] +struct WorkspaceIdentityFile { + #[serde(alias = "workspace_id")] + id: String, +} + +#[derive(Debug, Default, Deserialize)] +struct ClientConfigFile { + default_backend: Option, + #[serde(default)] + backends: BTreeMap, + #[serde(default)] + workspaces: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct ClientBackendConfig { + url: String, +} + +#[derive(Debug, Deserialize)] +struct ClientWorkspaceConfig { + backend: String, +} + +fn resolve_workspace_id_from_root(workspace_root: &Path) -> Result, ParseError> { + let mut current = if workspace_root.is_absolute() { + workspace_root.to_path_buf() + } else { + current_dir()?.join(workspace_root) + }; + loop { + let path = current.join(".yoi").join("workspace.toml"); + if path.is_file() { + let contents = fs::read_to_string(&path) + .map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?; + let identity: WorkspaceIdentityFile = toml::from_str(&contents) + .map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display())))?; + let id = identity.id.trim(); + if id.is_empty() { + return Err(ParseError(format!( + "{} must contain a non-empty workspace id", + path.display() + ))); + } + return Ok(Some(id.to_string())); + } + if !current.pop() { + return Ok(None); + } + } +} + +fn resolve_backend_url( + explicit_backend_url: Option, + workspace_id: Option<&str>, +) -> Result { + if let Some(url) = explicit_backend_url { + return Ok(url); + } + let Some(config) = read_client_config()? else { + return Err(ParseError(client_config_missing_message(workspace_id))); + }; + let backend_name = workspace_id + .and_then(|id| { + config + .workspaces + .get(id) + .map(|workspace| workspace.backend.as_str()) + }) + .or(config.default_backend.as_deref()) + .ok_or_else(|| ParseError(client_config_missing_message(workspace_id)))?; + let backend = config.backends.get(backend_name).ok_or_else(|| { + ParseError(format!( + "client config references backend `{backend_name}`, but [backends.{backend_name}] is not defined" + )) + })?; + let url = backend.url.trim(); + if url.is_empty() { + return Err(ParseError(format!( + "client config backend `{backend_name}` must contain a non-empty url" + ))); + } + Ok(url.to_string()) +} + +fn read_client_config() -> Result, ParseError> { + let Some(path) = client_config_path() else { + return Ok(None); + }; + if !path.is_file() { + return Ok(None); + } + let contents = fs::read_to_string(&path) + .map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?; + toml::from_str::(&contents) + .map(Some) + .map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display()))) +} + +fn client_config_path() -> Option { + if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") { + return Some(PathBuf::from(home).join("yoi").join("client.toml")); + } + std::env::var_os("HOME").map(|home| { + PathBuf::from(home) + .join(".config") + .join("yoi") + .join("client.toml") + }) +} + +fn client_config_missing_message(workspace_id: Option<&str>) -> String { + match workspace_id { + Some(workspace_id) => format!( + "Backend URL is required. Pass --backend or configure $XDG_CONFIG_HOME/yoi/client.toml with [workspaces.{workspace_id}] backend = and [backends.].url" + ), + None => "Backend URL is required. Pass --backend or configure default_backend in $XDG_CONFIG_HOME/yoi/client.toml".to_string(), + } +} + fn parse_workspace_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError( @@ -1024,9 +1296,9 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete [--force] [--dry-run]\n yoi worker prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace init [OPTIONS] + "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi workers [--workspace ] [--workspace-id ] [--backend ] [--runtime-id ]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete [--force] [--dry-run]\n yoi worker prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace init [OPTIONS] yoi workspace config [OPTIONS] - yoi workspace serve [OPTIONS]\n yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--worker (defaults to cwd)\n --worker Open the Worker Console by name (attach/restore/create)\n --socket Attach a Worker Console to a specific socket with --worker\n --session Resume a specific session segment in the Worker Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + yoi workspace serve [OPTIONS]\n yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id Workspace identity for Backend scoped routes\n --backend Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id Backend Runtime identity for attach/list\n --worker Open the Worker Console by name (attach/restore/create)\n --socket Attach a Worker Console to a specific socket with --worker\n --session Resume a specific session segment in the Worker Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } @@ -1095,15 +1367,53 @@ mod tests { } #[test] - fn parse_backend_runtime_target_requires_complete_identity() { + fn parse_backend_runtime_target_requires_runtime_for_worker_identity() { let err = parse_args_from(["--backend", "http://127.0.0.1:8787", "--worker-id", "w"]) .unwrap_err(); assert_eq!( err.to_string(), - "--backend, --runtime-id, and --worker-id are required together" + "--worker-id requires --runtime-id for Backend Runtime API attach" ); } + #[test] + fn parse_backend_runtime_picker_target_mode() { + match parse_args_from(["--backend", "http://127.0.0.1:8787", "--runtime-id", "r"]).unwrap() + { + Mode::Tui { + mode: LaunchMode::BackendRuntimePicker { target }, + .. + } => { + assert_eq!(target.base_url, "http://127.0.0.1:8787"); + assert_eq!(target.runtime_id.as_deref(), Some("r")); + } + _ => panic!("expected BackendRuntimePicker mode"), + } + } + + #[test] + fn parse_workers_subcommand_uses_backend_runtime_picker() { + match parse_args_from([ + "workers", + "--backend", + "http://127.0.0.1:8787", + "--workspace-id", + "workspace-a", + ]) + .unwrap() + { + Mode::Tui { + mode: LaunchMode::BackendRuntimePicker { target }, + .. + } => { + assert_eq!(target.base_url, "http://127.0.0.1:8787"); + assert_eq!(target.workspace_id.as_deref(), Some("workspace-a")); + assert_eq!(target.runtime_id, None); + } + _ => panic!("expected BackendRuntimePicker mode"), + } + } + #[test] fn parse_backend_runtime_target_rejects_legacy_socket_mix() { let err = parse_args_from([ diff --git a/devshell.nix b/devshell.nix index 2fb51c8d..690d1acd 100644 --- a/devshell.nix +++ b/devshell.nix @@ -13,9 +13,9 @@ pkgs.mkShell { ]; shellHook = '' if repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then - # export YOI_POD_RUNTIME_COMMAND="$repo_root/target/debug/yoi" + : # export YOI_POD_RUNTIME_COMMAND="$repo_root/target/debug/yoi" else - # export YOI_POD_RUNTIME_COMMAND="$PWD/target/debug/yoi" + : # export YOI_POD_RUNTIME_COMMAND="$PWD/target/debug/yoi" fi echo "dev-shell-loaded" echo "YOI_POD_RUNTIME_COMMAND=$YOI_POD_RUNTIME_COMMAND"