feat: connect standalone host to local TUI
This commit is contained in:
@@ -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::{
|
||||
|
||||
+108
-5
@@ -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<WorkerSpawn, TargetError> {
|
||||
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<PathBuf>) -> Self {
|
||||
Self {
|
||||
state_dir: state_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Target for StandaloneTarget {
|
||||
fn kind(&self) -> TargetKind {
|
||||
TargetKind::Standalone
|
||||
}
|
||||
|
||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
||||
Ok(ResolvedTarget::Standalone)
|
||||
}
|
||||
|
||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||
Ok(WorkerSpawn::Standalone {
|
||||
state_dir: self.state_dir.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
|
||||
Err(TargetError::unsupported(
|
||||
"Worker name attachment",
|
||||
self.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
|
||||
Err(TargetError::unsupported("Worker restore", self.kind()))
|
||||
}
|
||||
|
||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||
Err(TargetError::unsupported("Worker dashboard", self.kind()))
|
||||
}
|
||||
|
||||
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
Err(TargetError::unsupported("Worker listing", self.kind()))
|
||||
}
|
||||
|
||||
fn connect_worker(
|
||||
&self,
|
||||
_selector: WorkerConnectionSelector,
|
||||
) -> Result<WorkerConnection, TargetError> {
|
||||
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"));
|
||||
|
||||
@@ -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"
|
||||
|
||||
+114
-27
@@ -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<StandaloneHost>,
|
||||
events: broadcast::Receiver<Event>,
|
||||
initial_snapshot: Option<Event>,
|
||||
},
|
||||
}
|
||||
|
||||
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<Event> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<String>,
|
||||
profile: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
mut client: ConsoleConnection,
|
||||
client: &mut ConsoleConnection,
|
||||
runtime_command: Option<WorkerRuntimeCommand>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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) => {
|
||||
|
||||
+58
-14
@@ -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<dyn std::error::Error>),
|
||||
},
|
||||
@@ -176,9 +224,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
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<dyn std::error::Error>),
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
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!({}));
|
||||
|
||||
|
||||
@@ -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<Box<dyn Target>, 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<R: CliConnectionResolver + ?Sized>(
|
||||
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<R: CliConnectionResolver + ?Sized>(
|
||||
)?;
|
||||
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()
|
||||
))),
|
||||
}
|
||||
|
||||
+98
-3
@@ -687,6 +687,21 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
||||
&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<R: CliConnectionResolver + ?Sized>(
|
||||
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 <URL> Use a Workspace Backend explicitly
|
||||
--workspace-id <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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -214,6 +214,9 @@ pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, Ti
|
||||
})?;
|
||||
run_in_workspace(cli, &workspace)
|
||||
}
|
||||
ResolvedTarget::Standalone => Err(TicketCliError::new(
|
||||
"Standalone is a one-shot Worker host, not Ticket storage authority",
|
||||
)),
|
||||
ResolvedTarget::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
|
||||
Reference in New Issue
Block a user