From 4b3b4fda61ed1c5d1eca7c0227ef002c59db9594 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 12:40:22 +0900 Subject: [PATCH] feat: connect standalone host to local TUI --- Cargo.lock | 1 + crates/client/src/lib.rs | 6 +- crates/client/src/target.rs | 113 +++++++++++++++++++++++-- crates/tui/Cargo.toml | 1 + crates/tui/src/console/mod.rs | 141 +++++++++++++++++++++++++------ crates/tui/src/lib.rs | 72 +++++++++++++--- crates/yoi/src/cli_connection.rs | 27 +++++- crates/yoi/src/main.rs | 101 +++++++++++++++++++++- crates/yoi/src/objective_cli.rs | 3 + crates/yoi/src/ticket_cli.rs | 3 + 10 files changed, 413 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b6397bf..e4ceb03e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5331,6 +5331,7 @@ dependencies = [ "serde", "serde_json", "session-store", + "standalone", "tempfile", "ticket", "tokio", diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 4ea7a11a..9969238f 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -37,9 +37,9 @@ pub use backend_workspace::{ }; pub use runtime_command::WorkerRuntimeCommand; pub use target::{ - BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind, - WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, - WorkerResume, WorkerSpawn, + BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneTarget, Target, TargetError, + TargetKind, WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, + WorkerListRequest, WorkerResume, WorkerSpawn, }; pub use spawn::{ diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index 1b98819f..73eed1b3 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -1,16 +1,20 @@ -use std::fmt; +use std::{fmt, path::PathBuf}; use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TargetKind { + /// Legacy local Runtime process/socket authority. Local, + /// One-process Standalone authority with no Runtime or Workspace backend. + Standalone, Backend, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResolvedTarget { Local, + Standalone, Backend { base_url: String, workspace_id: String, @@ -21,6 +25,7 @@ impl ResolvedTarget { pub fn kind(&self) -> TargetKind { match self { Self::Local => TargetKind::Local, + Self::Standalone => TargetKind::Standalone, Self::Backend { .. } => TargetKind::Backend, } } @@ -30,6 +35,7 @@ impl fmt::Display for TargetKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Local => f.write_str("local"), + Self::Standalone => f.write_str("Standalone"), Self::Backend => f.write_str("Backend"), } } @@ -107,8 +113,13 @@ impl WorkerConnectionSelector { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkerSpawn { - pub runtime_command: WorkerRuntimeCommand, +pub enum WorkerSpawn { + LegacyLocal { + runtime_command: WorkerRuntimeCommand, + }, + Standalone { + state_dir: PathBuf, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -213,7 +224,7 @@ impl Target for LocalTarget { } fn spawn_worker(&self) -> Result { - Ok(WorkerSpawn { + Ok(WorkerSpawn::LegacyLocal { runtime_command: self.runtime_command()?, }) } @@ -261,6 +272,65 @@ impl Target for LocalTarget { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StandaloneTarget { + state_dir: PathBuf, +} + +impl StandaloneTarget { + #[must_use] + pub fn new(state_dir: impl Into) -> Self { + Self { + state_dir: state_dir.into(), + } + } +} + +impl Target for StandaloneTarget { + fn kind(&self) -> TargetKind { + TargetKind::Standalone + } + + fn resolve(&self) -> Result { + Ok(ResolvedTarget::Standalone) + } + + fn spawn_worker(&self) -> Result { + Ok(WorkerSpawn::Standalone { + state_dir: self.state_dir.clone(), + }) + } + + fn worker_by_name(&self) -> Result { + Err(TargetError::unsupported( + "Worker name attachment", + self.kind(), + )) + } + + fn resume_worker(&self) -> Result { + Err(TargetError::unsupported("Worker restore", self.kind())) + } + + fn dashboard(&self) -> Result { + Err(TargetError::unsupported("Worker dashboard", self.kind())) + } + + fn list_workers(&self, _request: WorkerListRequest) -> Result { + Err(TargetError::unsupported("Worker listing", self.kind())) + } + + fn connect_worker( + &self, + _selector: WorkerConnectionSelector, + ) -> Result { + Err(TargetError::unsupported( + "Backend runtime worker connection", + self.kind(), + )) + } +} + impl Target for BackendTarget { fn kind(&self) -> TargetKind { TargetKind::Backend @@ -303,7 +373,9 @@ impl Target for BackendTarget { base_url, workspace_id, }), - ResolvedTarget::Local => unreachable!("BackendTarget cannot resolve as Local"), + ResolvedTarget::Local | ResolvedTarget::Standalone => { + unreachable!("BackendTarget cannot resolve as a local target") + } } } @@ -375,6 +447,37 @@ mod tests { assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local); } + #[test] + fn standalone_target_carries_in_process_state_without_resolving_runtime_command() { + let target = StandaloneTarget::new("/tmp/yoi-standalone-state"); + + assert_eq!(target.kind(), TargetKind::Standalone); + assert_eq!(target.resolve().unwrap(), ResolvedTarget::Standalone); + assert_eq!( + target.spawn_worker().unwrap(), + WorkerSpawn::Standalone { + state_dir: PathBuf::from("/tmp/yoi-standalone-state"), + } + ); + } + + #[test] + fn standalone_target_never_falls_back_to_legacy_local_operations() { + let target = StandaloneTarget::new("/tmp/yoi-standalone-state"); + + assert_eq!( + target.worker_by_name().unwrap_err().to_string(), + "Worker name attachment is not supported by Standalone target" + ); + assert_eq!( + target + .list_workers(WorkerListRequest::new(None)) + .unwrap_err() + .to_string(), + "Worker listing is not supported by Standalone target" + ); + } + #[test] fn backend_target_builds_workspace_scoped_dashboard() { let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a")); diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 0c087539..f9f2a267 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -10,6 +10,7 @@ e2e-test = [] [dependencies] client = { workspace = true } +standalone = { workspace = true } protocol = { workspace = true } ratatui = { version = "0.30.0", features = ["scrolling-regions"] } base64 = "0.22.1" diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 0a6262ee..2618fc4f 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -22,7 +22,8 @@ use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use session_store::SegmentId; -use tokio::sync::mpsc; +use standalone::{StandaloneHost, StandaloneLaunchConfig}; +use tokio::sync::{broadcast, mpsc}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand}; @@ -174,13 +175,33 @@ pub(crate) async fn run_worker_name( enum ConsoleConnection { LegacySocket(WorkerClient), BackendRuntime(BackendRuntimeClient), + Standalone { + host: Option, + events: broadcast::Receiver, + initial_snapshot: Option, + }, } impl ConsoleConnection { + fn standalone(host: StandaloneHost) -> Self { + let events = host.subscribe(); + let initial_snapshot = Some(host.snapshot()); + Self::Standalone { + host: Some(host), + events, + initial_snapshot, + } + } + fn try_next_event(&mut self) -> Option { match self { Self::LegacySocket(client) => client.try_next_event(), Self::BackendRuntime(client) => client.try_next_event(), + Self::Standalone { + events, + initial_snapshot, + .. + } => initial_snapshot.take().or_else(|| events.try_recv().ok()), } } @@ -188,6 +209,18 @@ impl ConsoleConnection { match self { Self::LegacySocket(client) => client.next_event().await, Self::BackendRuntime(client) => client.next_event().await, + Self::Standalone { host, events, .. } => loop { + match events.recv().await { + Ok(event) => break Some(event), + Err(broadcast::error::RecvError::Lagged(_)) => { + let Some(host) = host.as_ref() else { + break None; + }; + break Some(host.snapshot()); + } + Err(broadcast::error::RecvError::Closed) => break None, + } + }, } } @@ -195,8 +228,78 @@ impl ConsoleConnection { match self { Self::LegacySocket(client) => Ok(client.send(method).await?), Self::BackendRuntime(client) => Ok(client.send(method).await?), + Self::Standalone { host, .. } => { + let host = host.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "Standalone Worker has already shut down", + ) + })?; + Ok(host.send(method.clone()).await?) + } } } + + async fn shutdown(&mut self) -> Result<(), Box> { + if let Self::Standalone { host, .. } = self + && let Some(host) = host.take() + { + host.shutdown().await?; + } + Ok(()) + } +} + +pub(crate) async fn run_standalone( + workspace_root: PathBuf, + state_dir: PathBuf, + worker_name: Option, + profile: Option, +) -> Result<(), Box> { + let worker_name = worker_name.unwrap_or_else(|| "local".to_string()); + let profile = profile.map_or(manifest::ProfileSelector::Default, |profile| { + manifest::ProfileSelector::parse_cli(&profile) + }); + let history_root = workspace_root.clone(); + let launch = StandaloneLaunchConfig { + state_dir, + cwd: workspace_root, + profile, + worker_name: worker_name.clone(), + } + .resolve() + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("Standalone launch configuration failed: {error}"), + ) + })?; + let host = StandaloneHost::start(launch) + .await + .map_err(|error| io::Error::other(format!("Standalone Worker startup failed: {error}")))?; + let mut connection = ConsoleConnection::standalone(host); + + let mut terminal = match enter_fullscreen() { + Ok(terminal) => terminal, + Err(error) => { + let _ = connection.shutdown().await; + return Err(error); + } + }; + let mut app = App::new_with_persistent_input_history(worker_name, &history_root); + let run_result = run_loop(&mut terminal, &mut app, &mut connection, None).await; + let shutdown_result = connection + .shutdown() + .await + .map_err(|error| io::Error::other(format!("Standalone Worker shutdown failed: {error}"))); + let leave_result = leave_fullscreen(&mut terminal); + + if let Err(error) = run_result { + return Err(error); + } + shutdown_result?; + leave_result?; + Ok(()) } pub(crate) async fn run_backend_runtime( @@ -208,13 +311,8 @@ pub(crate) async fn run_backend_runtime( let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root); app.connected = true; - let result = run_loop( - &mut terminal, - &mut app, - ConsoleConnection::BackendRuntime(client), - None, - ) - .await; + let mut connection = ConsoleConnection::BackendRuntime(client); + let result = run_loop(&mut terminal, &mut app, &mut connection, None).await; let _ = leave_fullscreen(&mut terminal); result } @@ -228,13 +326,8 @@ async fn run_connected_pod( let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root); app.connected = true; - run_loop( - terminal, - &mut app, - ConsoleConnection::LegacySocket(client), - Some(runtime_command), - ) - .await + let mut connection = ConsoleConnection::LegacySocket(client); + run_loop(terminal, &mut app, &mut connection, Some(runtime_command)).await } pub(crate) async fn open_from_dashboard( @@ -460,13 +553,8 @@ async fn run( app.connected = true; // The Worker sends `Event::Snapshot` automatically on connect; // no explicit method call is required to fetch history. - run_loop( - terminal, - &mut app, - ConsoleConnection::LegacySocket(client), - Some(runtime_command), - ) - .await?; + let mut connection = ConsoleConnection::LegacySocket(client); + run_loop(terminal, &mut app, &mut connection, Some(runtime_command)).await?; } Err(e) => { app.push_error(format!( @@ -791,7 +879,7 @@ async fn drain_worker_events( async fn run_loop( terminal: &mut Terminal>, app: &mut App, - mut client: ConsoleConnection, + client: &mut ConsoleConnection, runtime_command: Option, ) -> Result<(), Box> { let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; @@ -804,11 +892,11 @@ async fn run_loop( } let handled_term_event = - drain_terminal_events(app, &mut client, &mut term_rx, runtime_command.as_ref()).await?; + drain_terminal_events(app, client, &mut term_rx, runtime_command.as_ref()).await?; if app.quit { break; } - let handled_worker_event = drain_worker_events(app, &mut client).await?; + let handled_worker_event = drain_worker_events(app, client).await?; if handled_term_event || handled_worker_event { terminal.draw(|f| ui::draw(f, app))?; continue; @@ -816,8 +904,7 @@ async fn run_loop( match next_loop_input(&mut term_rx, app.connected, client.next_event()).await { LoopInput::Terminal(term_event) => { - handle_terminal_event(app, &mut client, term_event?, runtime_command.as_ref()) - .await?; + handle_terminal_event(app, client, term_event?, runtime_command.as_ref()).await?; } LoopInput::Worker(event) => match event { Some(ev) => { diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index e7b7ec5b..f30da214 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -36,7 +36,7 @@ use crossterm::execute; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; use session_store::SegmentId; -use client::{Target, WorkerConnectionSelector, WorkerListRequest}; +use client::{Target, WorkerConnectionSelector, WorkerListRequest, WorkerSpawn}; #[derive(Debug)] pub struct LaunchOptions { @@ -85,6 +85,49 @@ pub enum LaunchMode { Panel { include_stopped: bool }, } +struct TerminalModeGuard { + active: bool, +} + +impl TerminalModeGuard { + fn new() -> Self { + Self { active: true } + } + + fn restore(&mut self) -> io::Result<()> { + if !self.active { + return Ok(()); + } + self.active = false; + let mut stdout = io::stdout(); + execute!( + stdout, + DisableMouseCapture, + LeaveAlternateScreen, + DisableBracketedPaste, + crossterm::cursor::Show + )?; + disable_raw_mode() + } +} + +impl Drop for TerminalModeGuard { + fn drop(&mut self) { + if self.active { + let mut stdout = io::stdout(); + let _ = execute!( + stdout, + DisableMouseCapture, + LeaveAlternateScreen, + DisableBracketedPaste, + crossterm::cursor::Show + ); + let _ = disable_raw_mode(); + self.active = false; + } + } +} + pub async fn launch(options: LaunchOptions) -> ExitCode { let LaunchOptions { target, @@ -109,14 +152,19 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { eprintln!("yoi: {e}"); return ExitCode::FAILURE; } + let mut terminal_mode = TerminalModeGuard::new(); let result = match mode { LaunchMode::Spawn { worker_name, profile, } => match target.spawn_worker() { - Ok(spawn) => { - console::run_spawn(None, worker_name, profile, spawn.runtime_command).await + Ok(WorkerSpawn::LegacyLocal { runtime_command }) => { + console::run_spawn(None, worker_name, profile, runtime_command).await + } + Ok(WorkerSpawn::Standalone { state_dir }) => { + console::run_standalone(workspace_root.clone(), state_dir, worker_name, profile) + .await } Err(e) => Err(Box::new(e) as Box), }, @@ -176,9 +224,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Err(e) => Err(Box::new(e) as Box), }, LaunchMode::ResumeWithSession { id, worker_name } => match target.spawn_worker() { - Ok(spawn) => { - console::run_spawn(Some(id), worker_name, None, spawn.runtime_command).await + Ok(WorkerSpawn::LegacyLocal { runtime_command }) => { + console::run_spawn(Some(id), worker_name, None, runtime_command).await } + Ok(WorkerSpawn::Standalone { .. }) => Err(Box::new(io::Error::new( + io::ErrorKind::Unsupported, + "Standalone session restore is not implemented", + )) as Box), Err(e) => Err(Box::new(e) as Box), }, LaunchMode::Panel { include_stopped } => match target.dashboard() { @@ -198,15 +250,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { // alternate-screen buffer. #[cfg(feature = "e2e-test")] e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); - let mut stdout = io::stdout(); - let _ = execute!( - stdout, - DisableMouseCapture, - LeaveAlternateScreen, - DisableBracketedPaste - ); - let _ = disable_raw_mode(); - let _ = execute!(stdout, crossterm::cursor::Show); + let _ = terminal_mode.restore(); #[cfg(feature = "e2e-test")] e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 4d5bd379..7d438eeb 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -1,4 +1,4 @@ -use client::{BackendTarget, LocalTarget, Target, TargetKind}; +use client::{BackendTarget, LocalTarget, StandaloneTarget, Target, TargetKind}; use serde::Deserialize; use super::{ParseError, read_client_default_connection, resolve_backend_url}; @@ -107,6 +107,18 @@ pub(crate) trait CliConnectionResolver { #[derive(Debug, Default, Clone, Copy)] pub(crate) struct ClientConfigCliConnectionResolver; +fn standalone_target() -> Result, ParseError> { + let state_dir = manifest::paths::data_dir() + .ok_or_else(|| { + ParseError( + "Standalone state directory is unavailable; set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME" + .to_string(), + ) + })? + .join("standalone"); + Ok(Box::new(StandaloneTarget::new(state_dir))) +} + impl CliConnectionResolver for ClientConfigCliConnectionResolver { fn resolve_connection( &self, @@ -147,6 +159,11 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver { resolve_backend_url(explicit_backend_url, workspace_id)?, workspace_id.map(str::to_string), ))), + (CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) + if command == CliCommand::DefaultTui => + { + standalone_target() + } (CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) => { Ok(Box::new(LocalTarget::new())) } @@ -171,6 +188,10 @@ pub(crate) fn resolve_local_cli_connection( let target = resolver.resolve_connection(command, CliConnectionInput::LocalTarget)?; match target.kind() { TargetKind::Local => Ok(target), + TargetKind::Standalone => Err(ParseError(format!( + "{} resolved Standalone where a legacy local target was required", + command.display_name() + ))), TargetKind::Backend => Err(ParseError(format!( "{} resolved a Backend target where a local target was required", command.display_name() @@ -193,8 +214,8 @@ pub(crate) fn resolve_backend_cli_connection( )?; match target.kind() { TargetKind::Backend => Ok(target), - TargetKind::Local => Err(ParseError(format!( - "{} resolved a local target where a Backend target was required", + TargetKind::Local | TargetKind::Standalone => Err(ParseError(format!( + "{} resolved a non-Backend target where a Backend target was required", command.display_name() ))), } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index c2f10655..0db52b6b 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -687,6 +687,21 @@ fn parse_console_options( &workspace_root, )?; + if target.kind() == TargetKind::Standalone { + if session.is_some() { + return Err(ParseError( + "--local starts a fresh Standalone Worker; --session restore requires the legacy local Runtime" + .to_string(), + )); + } + if socket_override.is_some() { + return Err(ParseError( + "--local uses an in-process Standalone connection and does not accept --socket" + .to_string(), + )); + } + } + if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) { return Ok(Mode::Tui { target, @@ -715,6 +730,11 @@ fn parse_console_options( worker_name, profile: Some(profile), } + } else if target.kind() == TargetKind::Standalone { + LaunchMode::Spawn { + worker_name, + profile: None, + } } else if let Some(session) = session { LaunchMode::ResumeWithSession { id: parse_session_id(&session.to_string_lossy())?, @@ -1650,7 +1670,7 @@ Usage: Target selection: Target options are top-level options and must appear before the command. - --local Use the local Worker runtime explicitly + --local Start a one-process Standalone Worker (no Server or Runtime) --backend Use a Workspace Backend explicitly --workspace-id Scope Backend routes to a Workspace id @@ -1725,7 +1745,9 @@ fn print_memory_lint_help() { mod tests { use super::*; use crate::cli_connection::CliConnectionInput; - use client::{BackendTarget, LocalTarget, TargetKind, WorkerListRequest}; + use client::{ + BackendTarget, LocalTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest, + }; struct FixedCliConnectionResolver { backend_url: &'static str, @@ -1761,7 +1783,11 @@ mod tests { let workspace_id = match input { CliConnectionInput::DefaultTarget { workspace_id } | CliConnectionInput::BackendTarget { workspace_id, .. } => workspace_id, - CliConnectionInput::LocalTarget => return Ok(Box::new(LocalTarget::new())), + CliConnectionInput::LocalTarget => { + return Ok(Box::new(StandaloneTarget::new( + "/tmp/yoi-test-standalone-state", + ))); + } }; Ok(Box::new(BackendTarget::new( self.backend_url, @@ -2026,6 +2052,75 @@ backend = "shared" } } + #[test] + fn parser_explicit_local_overrides_default_backend_with_standalone_target() { + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://default-backend.example", + }; + let args = ["--local", "--worker", "my-local-worker"] + .into_iter() + .map(String::from) + .collect::>(); + let mode = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap(); + let Mode::Tui { target, mode, .. } = mode else { + panic!("expected TUI mode") + }; + + assert_eq!(target.kind(), TargetKind::Standalone); + assert!(matches!( + mode, + LaunchMode::Spawn { + worker_name: Some(ref name), + profile: None, + } if name == "my-local-worker" + )); + assert!(matches!( + target.spawn_worker().unwrap(), + client::WorkerSpawn::Standalone { .. } + )); + } + + #[test] + fn parser_rejects_standalone_and_backend_target_together() { + let err = parse_args_from(["--local", "--backend", "http://backend.example"]).unwrap_err(); + assert_eq!( + err.to_string(), + "--local and --backend are mutually exclusive" + ); + } + + #[test] + fn parser_standalone_rejects_legacy_session_and_socket_inputs() { + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://default-backend.example", + }; + let session_args = ["--local", "--session", "session-1"] + .into_iter() + .map(String::from) + .collect::>(); + let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err(); + assert_eq!( + err.0, + "--local starts a fresh Standalone Worker; --session restore requires the legacy local Runtime" + ); + + let socket_args = [ + "--local", + "--worker", + "worker-a", + "--socket", + "/tmp/worker.sock", + ] + .into_iter() + .map(String::from) + .collect::>(); + let err = parse_args_slice_with_connection_resolver(&socket_args, &resolver).unwrap_err(); + assert_eq!( + err.0, + "--local uses an in-process Standalone connection and does not accept --socket" + ); + } + #[test] fn parse_backend_runtime_target_rejects_legacy_socket_mix() { let err = parse_args_from([ diff --git a/crates/yoi/src/objective_cli.rs b/crates/yoi/src/objective_cli.rs index 844f5305..3ce9c7eb 100644 --- a/crates/yoi/src/objective_cli.rs +++ b/crates/yoi/src/objective_cli.rs @@ -179,6 +179,9 @@ pub fn run( })?; run_in_workspace(cli, &workspace) } + ResolvedTarget::Standalone => Err(ObjectiveCliError::new( + "Standalone is a one-shot Worker host, not Objective storage authority", + )), ResolvedTarget::Backend { base_url, workspace_id, diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index b84909f1..fd965e62 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -214,6 +214,9 @@ pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result Err(TicketCliError::new( + "Standalone is a one-shot Worker host, not Ticket storage authority", + )), ResolvedTarget::Backend { base_url, workspace_id,