diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 67fdeb92..cab1b245 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -21,7 +21,7 @@ pub use backend_auth::{ poll_device_login, start_device_login, wait_for_device_login, }; pub use backend_runtime::{ - BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse, + BackendDiagnostic, BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary, diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index e8e97025..595aeff3 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -54,11 +54,22 @@ impl BackendTarget { #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkerListRequest { pub runtime_id: Option, + pub include_stopped: bool, } impl WorkerListRequest { pub fn new(runtime_id: Option) -> Self { - Self { runtime_id } + Self { + runtime_id, + include_stopped: false, + } + } + + pub fn with_stopped(runtime_id: Option) -> Self { + Self { + runtime_id, + include_stopped: true, + } } } @@ -99,7 +110,9 @@ pub struct Dashboard { #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkerList { - pub target: BackendRuntimeListTarget, + pub local_runtime_command: Option, + pub backend_target: Option, + pub include_stopped: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -182,11 +195,18 @@ impl Target for LocalTarget { }) } - fn list_workers(&self, _request: WorkerListRequest) -> Result { - Err(TargetError::unsupported( - "Backend runtime worker listing", - self.kind(), - )) + fn list_workers(&self, request: WorkerListRequest) -> Result { + if request.runtime_id.is_some() { + return Err(TargetError::unsupported( + "Explicit runtime id for local worker listing", + self.kind(), + )); + } + Ok(WorkerList { + local_runtime_command: Some(self.runtime_command()?), + backend_target: None, + include_stopped: request.include_stopped, + }) } fn connect_worker( @@ -226,11 +246,13 @@ impl Target for BackendTarget { fn list_workers(&self, request: WorkerListRequest) -> Result { Ok(WorkerList { - target: BackendRuntimeListTarget::new( + local_runtime_command: None, + backend_target: Some(BackendRuntimeListTarget::new( self.base_url.clone(), self.workspace_id.clone(), request.runtime_id, - ), + )), + include_stopped: request.include_stopped, }) } @@ -259,9 +281,28 @@ mod tests { .list_workers(WorkerListRequest::new(Some("runtime-a".to_string()))) .unwrap(); - assert_eq!(workers.target.base_url, "http://127.0.0.1:8787"); - assert_eq!(workers.target.workspace_id.as_deref(), Some("workspace-a")); - assert_eq!(workers.target.runtime_id.as_deref(), Some("runtime-a")); + assert_eq!( + workers.backend_target.as_ref().unwrap().base_url, + "http://127.0.0.1:8787" + ); + assert_eq!( + workers + .backend_target + .as_ref() + .unwrap() + .workspace_id + .as_deref(), + Some("workspace-a") + ); + assert_eq!( + workers + .backend_target + .as_ref() + .unwrap() + .runtime_id + .as_deref(), + Some("runtime-a") + ); } #[test] @@ -288,15 +329,14 @@ mod tests { } #[test] - fn local_target_rejects_backend_worker_operations() { + fn local_target_builds_local_worker_list() { let target = LocalTarget::new(); - let err = target - .list_workers(WorkerListRequest::new(None)) - .unwrap_err(); + let workers = target + .list_workers(WorkerListRequest::with_stopped(None)) + .unwrap(); - assert_eq!( - err.to_string(), - "Backend runtime worker listing is not supported by local target" - ); + assert!(workers.local_runtime_command.is_some()); + assert!(workers.backend_target.is_none()); + assert!(workers.include_stopped); } } diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 17fa1499..e8588958 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -3,7 +3,8 @@ use std::io; use std::time::Duration; use client::{ - BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, list_backend_workers, + BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, + list_backend_stopped_workers, list_backend_workers, restore_backend_worker, }; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::backend::CrosstermBackend; @@ -18,13 +19,30 @@ use crate::console; const MAX_ROWS: usize = 10; const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4; -pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box> { - let response = list_backend_workers(&target).await.map_err(|error| { +pub(crate) async fn run( + target: BackendRuntimeListTarget, + include_stopped: bool, +) -> Result<(), Box> { + let mut 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 include_stopped { + match list_backend_stopped_workers(&target).await { + Ok(stopped) => { + response.items.extend(stopped.items); + response.diagnostics.extend(stopped.diagnostics); + } + Err(error) => response.diagnostics.push(client::BackendDiagnostic { + code: "backend_stopped_workers_list_failed".to_string(), + severity: Some("error".to_string()), + message: error.to_string(), + }), + } + } + dedup_workers(&mut response.items); if response.items.is_empty() { let diagnostics = response .diagnostics @@ -44,11 +62,36 @@ pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box) { + let mut seen = std::collections::HashSet::new(); + workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone()))); +} + fn pick_worker( target: BackendRuntimeListTarget, mut workers: Vec, diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index e447a80f..78dfb339 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -331,6 +331,15 @@ pub(crate) async fn run_resume( runtime_command: WorkerRuntimeCommand, workspace_root: PathBuf, all: bool, +) -> Result<(), Box> { + run_worker_picker(runtime_command, workspace_root, all, true).await +} + +pub(crate) async fn run_worker_picker( + runtime_command: WorkerRuntimeCommand, + workspace_root: PathBuf, + all: bool, + include_stopped: bool, ) -> Result<(), Box> { // Pick a Worker in its own inline viewport, dropping the viewport before // attaching/restoring so each phase gets fresh vertical room. @@ -338,7 +347,8 @@ pub(crate) async fn run_resume( picker::PickerOptions::all() } else { picker::PickerOptions::workspace(workspace_root) - }; + } + .with_stopped(include_stopped); let (worker_name, socket_override) = match picker::run(picker_options).await? { PickerOutcome::Picked { worker_name, diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 0b49a300..0236fcfb 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -58,7 +58,11 @@ pub enum LaunchMode { }, /// `yoi workers` / `yoi --backend `: list workers through the selected /// connection target, then attach to the selected Worker. - Workers { runtime_id: Option }, + Workers { + runtime_id: Option, + include_stopped: bool, + all: bool, + }, /// `yoi --backend --runtime-id --worker-id `: open one Worker /// through the selected connection target. OpenWorker { @@ -76,7 +80,7 @@ pub enum LaunchMode { worker_name: Option, }, /// `yoi panel`: open the workspace Dashboard from the current workspace. - Panel, + Panel { include_stopped: bool }, } pub async fn launch(options: LaunchOptions) -> ExitCode { @@ -128,12 +132,34 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } Err(e) => Err(Box::new(e) as Box), }, - LaunchMode::Workers { runtime_id } => { - match target.list_workers(WorkerListRequest::new(runtime_id)) { - Ok(worker_list) => backend_worker_picker::run(worker_list.target).await, - Err(e) => Err(Box::new(e) as Box), + LaunchMode::Workers { + runtime_id, + include_stopped, + all, + } => match target.list_workers(if include_stopped { + WorkerListRequest::with_stopped(runtime_id) + } else { + WorkerListRequest::new(runtime_id) + }) { + Ok(worker_list) => { + if let Some(target) = worker_list.backend_target { + backend_worker_picker::run(target, worker_list.include_stopped).await + } else if let Some(runtime_command) = worker_list.local_runtime_command { + console::run_worker_picker( + runtime_command, + workspace_root.clone(), + all, + worker_list.include_stopped, + ) + .await + } else { + Err(Box::new(io::Error::other( + "worker list target did not include a local or backend source", + )) as Box) + } } - } + Err(e) => Err(Box::new(e) as Box), + }, LaunchMode::OpenWorker { runtime_id, worker_id, @@ -153,7 +179,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } Err(e) => Err(Box::new(e) as Box), }, - LaunchMode::Panel => match target.dashboard() { + LaunchMode::Panel { .. } => match target.dashboard() { Ok(dashboard) => dashboard::launch(dashboard.runtime_command).await, Err(e) => Err(Box::new(e) as Box), }, diff --git a/crates/tui/src/picker.rs b/crates/tui/src/picker.rs index 3c9690e4..ac1eba95 100644 --- a/crates/tui/src/picker.rs +++ b/crates/tui/src/picker.rs @@ -80,20 +80,28 @@ pub enum PickerOutcome { #[derive(Debug, Clone)] pub(crate) struct PickerOptions { scope: PickerScope, + include_stopped: bool, } impl PickerOptions { pub(crate) fn workspace(workspace_root: PathBuf) -> Self { Self { scope: PickerScope::Workspace(workspace_root), + include_stopped: true, } } pub(crate) fn all() -> Self { Self { scope: PickerScope::All, + include_stopped: true, } } + + pub(crate) fn with_stopped(mut self, include_stopped: bool) -> Self { + self.include_stopped = include_stopped; + self + } } #[derive(Debug, Clone)] @@ -134,6 +142,11 @@ fn list_for_options( stored_workers: Vec, live_workers: Vec, ) -> WorkerList { + let stored_workers = if options.include_stopped { + stored_workers + } else { + Vec::new() + }; match &options.scope { PickerScope::Workspace(workspace_root) => WorkerList::from_workspace_sources( WorkerVisibilitySource::ResumePicker, diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index bf032745..22f07677 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -54,11 +54,12 @@ impl CliCommand { pub(crate) fn connection_requirement(self) -> CliConnectionRequirement { match self { - CliCommand::DefaultTui => CliConnectionRequirement::ConnectionAware, - CliCommand::Workers | CliCommand::Login => CliConnectionRequirement::BackendOnly, - CliCommand::Resume - | CliCommand::Panel - | CliCommand::Keys + CliCommand::DefaultTui + | CliCommand::Workers + | CliCommand::Resume + | CliCommand::Panel => CliConnectionRequirement::ConnectionAware, + CliCommand::Login => CliConnectionRequirement::BackendOnly, + CliCommand::Keys | CliCommand::SetupModel | CliCommand::WorkerRuntime | CliCommand::WorkerCleanup @@ -182,6 +183,33 @@ pub(crate) fn resolve_backend_cli_connection( } } +pub(crate) fn resolve_connection_aware_cli_connection( + resolver: &R, + command: CliCommand, + explicit_local: bool, + explicit_backend_url: Option, + workspace_id: Option<&str>, +) -> Result, ParseError> { + if explicit_local && explicit_backend_url.is_some() { + return Err(ParseError( + "--local and --backend are mutually exclusive".to_string(), + )); + } + if explicit_local { + return resolver.resolve_connection(command, CliConnectionInput::LocalDefault); + } + if explicit_backend_url.is_some() { + return resolver.resolve_connection( + command, + CliConnectionInput::BackendTarget { + explicit_backend_url, + workspace_id, + }, + ); + } + resolver.resolve_connection(command, CliConnectionInput::LocalDefault) +} + pub(crate) fn backend_target_option_error_for_local_command( command: CliCommand, option: &str, @@ -209,22 +237,25 @@ mod tests { #[test] fn cli_command_connection_requirements_are_explicit() { - assert_eq!( - CliCommand::DefaultTui.connection_requirement(), - CliConnectionRequirement::ConnectionAware - ); - assert_eq!( - CliCommand::Workers.connection_requirement(), - CliConnectionRequirement::BackendOnly - ); + for command in [ + CliCommand::DefaultTui, + CliCommand::Workers, + CliCommand::Resume, + CliCommand::Panel, + ] { + assert_eq!( + command.connection_requirement(), + CliConnectionRequirement::ConnectionAware, + "{} should be connection-aware", + command.display_name() + ); + } assert_eq!( CliCommand::Login.connection_requirement(), CliConnectionRequirement::BackendOnly ); for command in [ - CliCommand::Resume, - CliCommand::Panel, CliCommand::Keys, CliCommand::SetupModel, CliCommand::WorkerRuntime, @@ -251,7 +282,7 @@ mod tests { let resolver = ClientConfigCliConnectionResolver; let err = resolver .resolve_connection( - CliCommand::Resume, + CliCommand::Keys, CliConnectionInput::BackendTarget { explicit_backend_url: Some("http://127.0.0.1:8787".to_string()), workspace_id: None, @@ -261,7 +292,7 @@ mod tests { assert_eq!( err.to_string(), - "yoi resume uses a local connection target and cannot accept Backend target options" + "yoi keys uses a local connection target and cannot accept Backend target options" ); } @@ -269,12 +300,12 @@ mod tests { fn cli_connection_resolver_rejects_backend_only_local_default() { let resolver = ClientConfigCliConnectionResolver; let err = resolver - .resolve_connection(CliCommand::Workers, CliConnectionInput::LocalDefault) + .resolve_connection(CliCommand::Login, CliConnectionInput::LocalDefault) .unwrap_err(); assert_eq!( err.to_string(), - "yoi workers requires a Backend connection target" + "yoi login requires a Backend connection target" ); } @@ -293,7 +324,8 @@ mod tests { let workers = target .list_workers(client::WorkerListRequest::new(None)) .unwrap(); - assert_eq!(workers.target.base_url, "http://127.0.0.1:8787"); - assert_eq!(workers.target.workspace_id, None); + let backend_target = workers.backend_target.as_ref().unwrap(); + assert_eq!(backend_target.base_url, "http://127.0.0.1:8787"); + assert_eq!(backend_target.workspace_id, None); } } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 8fd7fe93..9eee1898 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -18,7 +18,8 @@ use std::time::Duration; use cli_connection::{ CliCommand, CliConnectionResolver, ClientConfigCliConnectionResolver, ClientDefaultConnection, backend_target_option_error_for_local_command, is_backend_target_option, - resolve_backend_cli_connection, resolve_local_cli_connection, + resolve_backend_cli_connection, resolve_connection_aware_cli_connection, + resolve_local_cli_connection, }; use client::{BackendAuthTarget, Target, start_device_login, wait_for_device_login}; use memory_lint::{LintCliOptions, LintStatus}; @@ -199,6 +200,107 @@ async fn main() -> ExitCode { } } +#[derive(Debug, Default, Clone)] +struct TargetSelection { + explicit_local: bool, + backend_url: Option, + workspace_id: Option, +} + +impl TargetSelection { + fn explicit_backend(&self) -> bool { + self.backend_url.is_some() + } +} + +fn resolve_tui_target( + connection_resolver: &R, + command: CliCommand, + selection: &TargetSelection, + workspace_root: &Path, +) -> Result, ParseError> { + let workspace_id = if selection.explicit_backend() && selection.workspace_id.is_none() { + resolve_workspace_id_from_root(workspace_root)? + } else { + selection.workspace_id.clone() + }; + resolve_connection_aware_cli_connection( + connection_resolver, + command, + selection.explicit_local, + selection.backend_url.clone(), + workspace_id.as_deref(), + ) +} + +fn parse_top_level_target_selection( + args: &[String], +) -> Result<(TargetSelection, &[String]), ParseError> { + let mut selection = TargetSelection::default(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--local" => { + if selection.backend_url.is_some() { + return Err(ParseError( + "--local and --backend are mutually exclusive".to_string(), + )); + } + selection.explicit_local = true; + i += 1; + } + "--backend" => { + if selection.explicit_local { + return Err(ParseError( + "--local and --backend are mutually exclusive".to_string(), + )); + } + 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())); + } + selection.backend_url = Some(value.clone()); + 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())); + } + selection.workspace_id = Some(value.clone()); + i += 2; + } + arg if arg.starts_with("--backend=") => { + if selection.explicit_local { + return Err(ParseError( + "--local and --backend are mutually exclusive".to_string(), + )); + } + let value = arg.trim_start_matches("--backend="); + if value.is_empty() { + return Err(ParseError("--backend requires a URL".to_string())); + } + selection.backend_url = Some(value.to_string()); + 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())); + } + selection.workspace_id = Some(value.to_string()); + i += 1; + } + _ => break, + } + } + Ok((selection, &args[i..])) +} + fn parse_args() -> Result { parse_args_from(std::env::args().skip(1)) } @@ -221,22 +323,38 @@ fn parse_args_slice_with_connection_resolver( args: &[String], connection_resolver: &R, ) -> Result { + let (target_selection, args) = parse_top_level_target_selection(args)?; if args.is_empty() { - let target = resolve_local_cli_connection(connection_resolver, CliCommand::DefaultTui)?; - return Ok(Mode::Tui { - target, - mode: LaunchMode::Spawn { + let workspace_root = current_dir()?; + let target = resolve_tui_target( + connection_resolver, + CliCommand::DefaultTui, + &target_selection, + &workspace_root, + )?; + let mode = if target_selection.explicit_backend() { + LaunchMode::Workers { + runtime_id: None, + include_stopped: false, + all: false, + } + } else { + LaunchMode::Spawn { worker_name: None, profile: None, - }, - workspace_root: current_dir()?, + } + }; + return Ok(Mode::Tui { + target, + mode, + workspace_root, }); } match args[0].as_str() { "--help" | "-h" => return Ok(Mode::Help), - "resume" => return parse_resume_args(&args[1..], connection_resolver), - "workers" => return parse_workers_args(&args[1..], connection_resolver), + "resume" => return parse_resume_args(&args[1..], &target_selection, connection_resolver), + "workers" => return parse_workers_args(&args[1..], &target_selection, connection_resolver), "worker" => { if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..]) .map_err(|e| ParseError(e.to_string()))? @@ -291,11 +409,19 @@ fn parse_args_slice_with_connection_resolver( return Ok(Mode::Mcp(mcp_cli)); } "panel" => { - let target = resolve_local_cli_connection(connection_resolver, CliCommand::Panel)?; + let workspace_root = parse_panel_workspace(&args[1..])?; + let target = resolve_tui_target( + connection_resolver, + CliCommand::Panel, + &target_selection, + &workspace_root, + )?; return Ok(Mode::Tui { target, - mode: LaunchMode::Panel, - workspace_root: parse_panel_workspace(&args[1..])?, + mode: LaunchMode::Panel { + include_stopped: false, + }, + workspace_root, }); } "keys" => { @@ -349,82 +475,73 @@ fn parse_args_slice_with_connection_resolver( _ => {} } - parse_console_options(args, connection_resolver) + parse_console_options(args, &target_selection, connection_resolver) } fn parse_console_options( args: &[String], + target_selection: &TargetSelection, connection_resolver: &R, ) -> Result { let mut workspace_root = current_dir()?; - let mut session = None; let mut worker_name = None; + let mut session = None; + let mut profile = 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; - let mut i = 0; while i < args.len() { let arg = &args[i]; match arg.as_str() { - "--session" => { - let value = args - .get(i + 1) - .ok_or_else(|| ParseError("--session requires a value".to_string()))?; - session = Some(parse_session_id(value)?); - i += 2; - } "--worker" => { let value = args .get(i + 1) - .ok_or_else(|| ParseError("--worker requires a value".to_string()))?; - if value.starts_with('-') { - return Err(ParseError("--worker requires a value".to_string())); + .ok_or_else(|| ParseError("--worker requires a name".to_string()))?; + if value.starts_with('-') || value.is_empty() { + return Err(ParseError("--worker requires a name".to_string())); } worker_name = Some(value.clone()); i += 2; } - "--socket" => { - let value = args - .get(i + 1) - .ok_or_else(|| ParseError("--socket requires a value".to_string()))?; - if value.starts_with('-') { - return Err(ParseError("--socket requires a value".to_string())); - } - socket_override = Some(PathBuf::from(value)); - i += 2; - } "--workspace" => { let value = args .get(i + 1) - .ok_or_else(|| ParseError("--workspace requires a value".to_string()))?; - if value.starts_with('-') { - return Err(ParseError("--workspace requires a value".to_string())); + .ok_or_else(|| ParseError("--workspace requires a path".to_string()))?; + if value.starts_with('-') || value.is_empty() { + return Err(ParseError("--workspace requires a path".to_string())); } workspace_root = PathBuf::from(value); i += 2; } - "--workspace-id" => { + "--session" => { let value = args .get(i + 1) - .ok_or_else(|| ParseError("--workspace-id requires a value".to_string()))?; + .ok_or_else(|| ParseError("--session requires a path".to_string()))?; if value.starts_with('-') || value.is_empty() { - return Err(ParseError("--workspace-id requires a value".to_string())); + return Err(ParseError("--session requires a path".to_string())); } - workspace_id = Some(value.clone()); + session = Some(PathBuf::from(value)); i += 2; } - "--backend" => { + "--socket" => { let value = args .get(i + 1) - .ok_or_else(|| ParseError("--backend requires a URL".to_string()))?; + .ok_or_else(|| ParseError("--socket requires a path".to_string()))?; if value.starts_with('-') || value.is_empty() { - return Err(ParseError("--backend requires a URL".to_string())); + return Err(ParseError("--socket requires a path".to_string())); } - backend_url = Some(value.clone()); + socket_override = Some(PathBuf::from(value)); + i += 2; + } + "--profile" => { + let value = args + .get(i + 1) + .ok_or_else(|| ParseError("--profile requires a name".to_string()))?; + if value.starts_with('-') || value.is_empty() { + return Err(ParseError("--profile requires a name".to_string())); + } + profile = Some(value.clone()); i += 2; } "--runtime-id" | "--runtime" => { @@ -447,62 +564,44 @@ fn parse_console_options( worker_id = Some(value.clone()); i += 2; } - "--profile" => { - let value = args - .get(i + 1) - .ok_or_else(|| ParseError("--profile requires a value".to_string()))?; - if value.starts_with('-') { - return Err(ParseError("--profile requires a value".to_string())); - } - profile = Some(value.clone()); - i += 2; - } - arg if arg.starts_with("--session=") => { - let value = arg.trim_start_matches("--session="); - if value.is_empty() { - return Err(ParseError("--session requires a value".to_string())); - } - session = Some(parse_session_id(value)?); - i += 1; - } arg if arg.starts_with("--worker=") => { let value = arg.trim_start_matches("--worker="); if value.is_empty() { - return Err(ParseError("--worker requires a value".to_string())); + return Err(ParseError("--worker requires a name".to_string())); } worker_name = Some(value.to_string()); i += 1; } - arg if arg.starts_with("--socket=") => { - let value = arg.trim_start_matches("--socket="); - if value.is_empty() { - return Err(ParseError("--socket requires a value".to_string())); - } - socket_override = Some(PathBuf::from(value)); - i += 1; - } 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())); + return Err(ParseError("--workspace requires a path".to_string())); } workspace_root = PathBuf::from(value); i += 1; } - arg if arg.starts_with("--workspace-id=") => { - let value = arg.trim_start_matches("--workspace-id="); + arg if arg.starts_with("--session=") => { + let value = arg.trim_start_matches("--session="); if value.is_empty() { - return Err(ParseError("--workspace-id requires a value".to_string())); + return Err(ParseError("--session requires a path".to_string())); } - workspace_id = Some(value.to_string()); + session = Some(PathBuf::from(value)); i += 1; } - arg if arg.starts_with("--backend=") => { - let value = arg.trim_start_matches("--backend="); + arg if arg.starts_with("--socket=") => { + let value = arg.trim_start_matches("--socket="); if value.is_empty() { - return Err(ParseError("--backend requires a URL".to_string())); + return Err(ParseError("--socket requires a path".to_string())); } - backend_url = Some(value.to_string()); + socket_override = Some(PathBuf::from(value)); + i += 1; + } + arg if arg.starts_with("--profile=") => { + let value = arg.trim_start_matches("--profile="); + if value.is_empty() { + return Err(ParseError("--profile requires a name".to_string())); + } + profile = Some(value.to_string()); i += 1; } arg if arg.starts_with("--runtime-id=") => { @@ -529,144 +628,126 @@ fn parse_console_options( worker_id = Some(value.to_string()); i += 1; } - arg if arg.starts_with("--profile=") => { - let value = arg.trim_start_matches("--profile="); - if value.is_empty() { - return Err(ParseError("--profile requires a value".to_string())); - } - profile = Some(value.to_string()); - i += 1; - } arg if arg.starts_with('-') => { return Err(ParseError(format!("unknown argument: {arg}"))); } value => { return Err(ParseError(format!( - "unknown command `{value}`; use --worker to open a Worker by name" + "yoi does not accept positional argument `{value}` before a subcommand" ))); } } } - 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() { + if worker_id.is_some() && runtime_id.is_none() { return Err(ParseError( - "--worker-id requires --runtime-id for Backend Runtime API attach".to_string(), + "--worker-id requires --runtime-id for Runtime API attach".to_string(), )); } - if backend_target_present + if (runtime_id.is_some() || worker_id.is_some()) && (session.is_some() || worker_name.is_some() || socket_override.is_some() || profile.is_some()) { return Err(ParseError( - "Backend Runtime API target cannot be combined with --worker, --socket, --session, or --profile".to_string(), + "Runtime API target cannot be combined with --worker, --socket, --session, or --profile".to_string(), )); } - if profile.is_some() && (session.is_some() || socket_override.is_some()) { return Err(ParseError( "--profile can only be used for fresh spawn".to_string(), )); } + if session.is_some() && socket_override.is_some() { + return Err(ParseError( + "--session cannot be combined with --socket".to_string(), + )); + } if socket_override.is_some() && worker_name.is_none() { return Err(ParseError("--socket requires --worker".to_string())); } - if socket_override.is_some() && session.is_some() { - return Err(ParseError( - "--socket can only be used with --worker attach mode".to_string(), - )); + + let target = resolve_tui_target( + connection_resolver, + CliCommand::DefaultTui, + target_selection, + &workspace_root, + )?; + + if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) { + return Ok(Mode::Tui { + target, + mode: LaunchMode::OpenWorker { + runtime_id, + worker_id, + }, + workspace_root, + }); } - 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 target = resolve_backend_cli_connection( - connection_resolver, - CliCommand::DefaultTui, - backend_url, - workspace_id.as_deref(), - )?; - if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) { - return Ok(Mode::Tui { - target, - mode: LaunchMode::OpenWorker { - runtime_id, - worker_id, - }, - workspace_root, - }); + if runtime_id.is_some() { + return Ok(Mode::Tui { + target, + mode: LaunchMode::Workers { + runtime_id, + include_stopped: false, + all: false, + }, + workspace_root, + }); + } + + let mode = if let Some(profile) = profile { + LaunchMode::Spawn { + worker_name, + profile: Some(profile), } - return Ok(Mode::Tui { - target, - mode: LaunchMode::Workers { runtime_id }, - workspace_root, - }); - } - - let target = resolve_local_cli_connection(connection_resolver, CliCommand::DefaultTui)?; - - if let Some(profile) = profile { - return Ok(Mode::Tui { - target, - mode: LaunchMode::Spawn { - worker_name, - profile: Some(profile), - }, - workspace_root, - }); - } - if let Some(id) = session { - return Ok(Mode::Tui { - target, - mode: LaunchMode::ResumeWithSession { id, worker_name }, - workspace_root, - }); - } - if let Some(worker_name) = worker_name { - return Ok(Mode::Tui { - target, - mode: LaunchMode::WorkerName { - worker_name, - socket_override, - }, - workspace_root, - }); - } - Ok(Mode::Tui { - target, - mode: LaunchMode::Spawn { + } else if let Some(session) = session { + LaunchMode::ResumeWithSession { + id: parse_session_id(&session.to_string_lossy())?, + worker_name, + } + } else if let Some(worker_name) = worker_name { + LaunchMode::WorkerName { + worker_name, + socket_override, + } + } else { + LaunchMode::Spawn { worker_name: None, profile: None, - }, + } + }; + + Ok(Mode::Tui { + target, + mode, workspace_root, }) } fn parse_workers_args( args: &[String], + target_selection: &TargetSelection, connection_resolver: &R, ) -> Result { let mut workspace_root = current_dir()?; - let mut workspace_id = None; - let mut backend_url = None; let mut runtime_id = None; - + let mut include_stopped = false; 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(), + "usage: yoi [--local|--backend URL] [--workspace-id ID] workers [-r|--stopped] [--workspace PATH] [--runtime-id ID]".to_string(), )); } + "-r" | "--restoreable" | "--stopped" => { + include_stopped = true; + i += 1; + } "--workspace" => { let value = args .get(i + 1) @@ -677,26 +758,6 @@ fn parse_workers_args( 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) @@ -715,22 +776,6 @@ fn parse_workers_args( 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() { @@ -757,31 +802,32 @@ fn parse_workers_args( } } } - - let workspace_id = match workspace_id { - Some(workspace_id) => Some(workspace_id), - None => resolve_workspace_id_from_root(&workspace_root)?, - }; - let target = resolve_backend_cli_connection( + let target = resolve_tui_target( connection_resolver, CliCommand::Workers, - backend_url, - workspace_id.as_deref(), + target_selection, + &workspace_root, )?; Ok(Mode::Tui { target, - mode: LaunchMode::Workers { runtime_id }, + mode: LaunchMode::Workers { + runtime_id, + include_stopped, + all: false, + }, workspace_root, }) } fn parse_resume_args( args: &[String], + target_selection: &TargetSelection, connection_resolver: &R, ) -> Result { let mut workspace_root = current_dir()?; let mut workspace_set = false; let mut all = false; + let mut runtime_id = None; let mut i = 0; while i < args.len() { @@ -799,6 +845,16 @@ fn parse_resume_args( all = true; i += 1; } + "--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; + } "--workspace" => { let value = args .get(i + 1) @@ -819,13 +875,23 @@ fn parse_resume_args( workspace_set = true; i += 1; } - arg if arg.starts_with('-') => { - if is_backend_target_option(arg) { - return Err(backend_target_option_error_for_local_command( - CliCommand::Resume, - arg, - )); + 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 resume option `{arg}`"))); } value => { @@ -842,11 +908,20 @@ fn parse_resume_args( )); } - let target = resolve_local_cli_connection(connection_resolver, CliCommand::Resume)?; + let target = resolve_tui_target( + connection_resolver, + CliCommand::Resume, + target_selection, + &workspace_root, + )?; Ok(Mode::Tui { target, - mode: LaunchMode::Resume { all }, + mode: LaunchMode::Workers { + runtime_id, + include_stopped: true, + all, + }, workspace_root, }) } @@ -1547,7 +1622,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "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\n\nUsage:\n yoi [TARGET_OPTIONS] [OPTIONS]\n yoi [TARGET_OPTIONS] resume [--workspace ] [--all] [--runtime-id ]\n yoi [TARGET_OPTIONS] workers [-r|--stopped] [--workspace ] [--runtime-id ]\n yoi [TARGET_OPTIONS] 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 identity [OPTIONS] yoi workspace trust-runtime [OPTIONS] @@ -1602,7 +1677,7 @@ mod tests { } #[test] - fn parser_uses_injected_cli_connection_resolver_for_backend_command() { + fn parser_uses_local_target_for_workers_without_backend_option() { let resolver = FixedCliConnectionResolver { backend_url: "http://fake-backend.example", }; @@ -1612,13 +1687,14 @@ mod tests { match mode { Mode::Tui { target, - mode: LaunchMode::Workers { runtime_id }, + mode: LaunchMode::Workers { runtime_id, .. }, .. } => { assert_eq!(runtime_id, None); - assert_eq!(target.kind(), TargetKind::Backend); + assert_eq!(target.kind(), TargetKind::Local); let workers = target.list_workers(WorkerListRequest::new(None)).unwrap(); - assert_eq!(workers.target.base_url, "http://fake-backend.example"); + assert!(workers.local_runtime_command.is_some()); + assert!(workers.backend_target.is_none()); } other => panic!("expected Workers mode, got {other:?}"), } @@ -1632,12 +1708,6 @@ mod tests { #[test] fn parse_local_only_commands_reject_backend_target_options() { - let err = parse_args_from(["resume", "--backend", "http://127.0.0.1:8787"]).unwrap_err(); - assert_eq!( - err.to_string(), - "yoi resume uses a local connection target and cannot accept Backend target option `--backend`" - ); - let err = parse_args_from(["panel", "--runtime-id", "runtime-a"]).unwrap_err(); assert_eq!( err.to_string(), @@ -1711,7 +1781,7 @@ mod tests { .unwrap_err(); assert_eq!( err.to_string(), - "--worker-id requires --runtime-id for Backend Runtime API attach" + "--worker-id requires --runtime-id for Runtime API attach" ); } @@ -1721,15 +1791,26 @@ mod tests { { Mode::Tui { target, - mode: LaunchMode::Workers { runtime_id }, + mode: LaunchMode::Workers { runtime_id, .. }, .. } => { assert_eq!(target.kind(), TargetKind::Backend); let workers = target .list_workers(WorkerListRequest::new(runtime_id.clone())) .unwrap(); - assert_eq!(workers.target.base_url, "http://127.0.0.1:8787"); - assert_eq!(workers.target.runtime_id.as_deref(), Some("r")); + assert_eq!( + workers.backend_target.as_ref().unwrap().base_url, + "http://127.0.0.1:8787" + ); + assert_eq!( + workers + .backend_target + .as_ref() + .unwrap() + .runtime_id + .as_deref(), + Some("r") + ); assert_eq!(runtime_id.as_deref(), Some("r")); } _ => panic!("expected Workers mode"), @@ -1739,25 +1820,36 @@ mod tests { #[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", + "workers", ]) .unwrap() { Mode::Tui { target, - mode: LaunchMode::Workers { runtime_id }, + mode: LaunchMode::Workers { runtime_id, .. }, .. } => { assert_eq!(target.kind(), TargetKind::Backend); let workers = target .list_workers(WorkerListRequest::new(runtime_id.clone())) .unwrap(); - assert_eq!(workers.target.base_url, "http://127.0.0.1:8787"); - assert_eq!(workers.target.workspace_id.as_deref(), Some("workspace-a")); + assert_eq!( + workers.backend_target.as_ref().unwrap().base_url, + "http://127.0.0.1:8787" + ); + assert_eq!( + workers + .backend_target + .as_ref() + .unwrap() + .workspace_id + .as_deref(), + Some("workspace-a") + ); assert_eq!(runtime_id, None); } _ => panic!("expected Workers mode"), @@ -1779,7 +1871,7 @@ mod tests { .unwrap_err(); assert_eq!( err.to_string(), - "Backend Runtime API target cannot be combined with --worker, --socket, --session, or --profile" + "Runtime API target cannot be combined with --worker, --socket, --session, or --profile" ); } @@ -1799,10 +1891,18 @@ mod tests { fn parse_resume_subcommand_defaults_to_workspace_scope() { match parse_args_from(["resume"]).unwrap() { Mode::Tui { - mode: LaunchMode::Resume { all }, + mode: + LaunchMode::Workers { + include_stopped, + all, + .. + }, .. - } => assert!(!all), - _ => panic!("expected Resume mode"), + } => { + assert!(include_stopped); + assert!(!all); + } + _ => panic!("expected Workers mode"), } } @@ -1810,14 +1910,20 @@ mod tests { fn parse_resume_workspace_scope() { match parse_args_from(["resume", "--workspace", "/tmp/resume-workspace"]).unwrap() { Mode::Tui { - mode: LaunchMode::Resume { all }, + mode: + LaunchMode::Workers { + include_stopped, + all, + .. + }, workspace_root, .. } => { + assert!(include_stopped); assert!(!all); assert_eq!(workspace_root, PathBuf::from("/tmp/resume-workspace")); } - _ => panic!("expected Resume mode"), + _ => panic!("expected Workers mode"), } } @@ -1825,10 +1931,18 @@ mod tests { fn parse_resume_all_scope() { match parse_args_from(["resume", "--all"]).unwrap() { Mode::Tui { - mode: LaunchMode::Resume { all }, + mode: + LaunchMode::Workers { + include_stopped, + all, + .. + }, .. - } => assert!(all), - _ => panic!("expected Resume mode"), + } => { + assert!(include_stopped); + assert!(all); + } + _ => panic!("expected Workers mode"), } } @@ -2228,7 +2342,10 @@ mod tests { fn parse_panel_mode() { match parse_args_from(["panel", "--workspace", "/tmp/other-workspace"]).unwrap() { Mode::Tui { - mode: LaunchMode::Panel, + mode: + LaunchMode::Panel { + include_stopped: false, + }, workspace_root, .. } => assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")),