feat: connect standalone host to local TUI
This commit is contained in:
Generated
+1
@@ -5331,6 +5331,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"session-store",
|
"session-store",
|
||||||
|
"standalone",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"ticket",
|
"ticket",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ pub use backend_workspace::{
|
|||||||
};
|
};
|
||||||
pub use runtime_command::WorkerRuntimeCommand;
|
pub use runtime_command::WorkerRuntimeCommand;
|
||||||
pub use target::{
|
pub use target::{
|
||||||
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind,
|
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneTarget, Target, TargetError,
|
||||||
WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest,
|
TargetKind, WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList,
|
||||||
WorkerResume, WorkerSpawn,
|
WorkerListRequest, WorkerResume, WorkerSpawn,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use spawn::{
|
pub use spawn::{
|
||||||
|
|||||||
+108
-5
@@ -1,16 +1,20 @@
|
|||||||
use std::fmt;
|
use std::{fmt, path::PathBuf};
|
||||||
|
|
||||||
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TargetKind {
|
pub enum TargetKind {
|
||||||
|
/// Legacy local Runtime process/socket authority.
|
||||||
Local,
|
Local,
|
||||||
|
/// One-process Standalone authority with no Runtime or Workspace backend.
|
||||||
|
Standalone,
|
||||||
Backend,
|
Backend,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ResolvedTarget {
|
pub enum ResolvedTarget {
|
||||||
Local,
|
Local,
|
||||||
|
Standalone,
|
||||||
Backend {
|
Backend {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -21,6 +25,7 @@ impl ResolvedTarget {
|
|||||||
pub fn kind(&self) -> TargetKind {
|
pub fn kind(&self) -> TargetKind {
|
||||||
match self {
|
match self {
|
||||||
Self::Local => TargetKind::Local,
|
Self::Local => TargetKind::Local,
|
||||||
|
Self::Standalone => TargetKind::Standalone,
|
||||||
Self::Backend { .. } => TargetKind::Backend,
|
Self::Backend { .. } => TargetKind::Backend,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +35,7 @@ impl fmt::Display for TargetKind {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Local => f.write_str("local"),
|
Self::Local => f.write_str("local"),
|
||||||
|
Self::Standalone => f.write_str("Standalone"),
|
||||||
Self::Backend => f.write_str("Backend"),
|
Self::Backend => f.write_str("Backend"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,8 +113,13 @@ impl WorkerConnectionSelector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct WorkerSpawn {
|
pub enum WorkerSpawn {
|
||||||
pub runtime_command: WorkerRuntimeCommand,
|
LegacyLocal {
|
||||||
|
runtime_command: WorkerRuntimeCommand,
|
||||||
|
},
|
||||||
|
Standalone {
|
||||||
|
state_dir: PathBuf,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -213,7 +224,7 @@ impl Target for LocalTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||||
Ok(WorkerSpawn {
|
Ok(WorkerSpawn::LegacyLocal {
|
||||||
runtime_command: self.runtime_command()?,
|
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 {
|
impl Target for BackendTarget {
|
||||||
fn kind(&self) -> TargetKind {
|
fn kind(&self) -> TargetKind {
|
||||||
TargetKind::Backend
|
TargetKind::Backend
|
||||||
@@ -303,7 +373,9 @@ impl Target for BackendTarget {
|
|||||||
base_url,
|
base_url,
|
||||||
workspace_id,
|
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);
|
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]
|
#[test]
|
||||||
fn backend_target_builds_workspace_scoped_dashboard() {
|
fn backend_target_builds_workspace_scoped_dashboard() {
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ e2e-test = []
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
client = { workspace = true }
|
client = { workspace = true }
|
||||||
|
standalone = { workspace = true }
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
|
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
|||||||
+114
-27
@@ -22,7 +22,8 @@ use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
|||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use session_store::SegmentId;
|
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 base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand};
|
use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand};
|
||||||
@@ -174,13 +175,33 @@ pub(crate) async fn run_worker_name(
|
|||||||
enum ConsoleConnection {
|
enum ConsoleConnection {
|
||||||
LegacySocket(WorkerClient),
|
LegacySocket(WorkerClient),
|
||||||
BackendRuntime(BackendRuntimeClient),
|
BackendRuntime(BackendRuntimeClient),
|
||||||
|
Standalone {
|
||||||
|
host: Option<StandaloneHost>,
|
||||||
|
events: broadcast::Receiver<Event>,
|
||||||
|
initial_snapshot: Option<Event>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConsoleConnection {
|
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> {
|
fn try_next_event(&mut self) -> Option<Event> {
|
||||||
match self {
|
match self {
|
||||||
Self::LegacySocket(client) => client.try_next_event(),
|
Self::LegacySocket(client) => client.try_next_event(),
|
||||||
Self::BackendRuntime(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 {
|
match self {
|
||||||
Self::LegacySocket(client) => client.next_event().await,
|
Self::LegacySocket(client) => client.next_event().await,
|
||||||
Self::BackendRuntime(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 {
|
match self {
|
||||||
Self::LegacySocket(client) => Ok(client.send(method).await?),
|
Self::LegacySocket(client) => Ok(client.send(method).await?),
|
||||||
Self::BackendRuntime(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(
|
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 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);
|
let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root);
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
let result = run_loop(
|
let mut connection = ConsoleConnection::BackendRuntime(client);
|
||||||
&mut terminal,
|
let result = run_loop(&mut terminal, &mut app, &mut connection, None).await;
|
||||||
&mut app,
|
|
||||||
ConsoleConnection::BackendRuntime(client),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let _ = leave_fullscreen(&mut terminal);
|
let _ = leave_fullscreen(&mut terminal);
|
||||||
result
|
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 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);
|
let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
run_loop(
|
let mut connection = ConsoleConnection::LegacySocket(client);
|
||||||
terminal,
|
run_loop(terminal, &mut app, &mut connection, Some(runtime_command)).await
|
||||||
&mut app,
|
|
||||||
ConsoleConnection::LegacySocket(client),
|
|
||||||
Some(runtime_command),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn open_from_dashboard(
|
pub(crate) async fn open_from_dashboard(
|
||||||
@@ -460,13 +553,8 @@ async fn run(
|
|||||||
app.connected = true;
|
app.connected = true;
|
||||||
// The Worker sends `Event::Snapshot` automatically on connect;
|
// The Worker sends `Event::Snapshot` automatically on connect;
|
||||||
// no explicit method call is required to fetch history.
|
// no explicit method call is required to fetch history.
|
||||||
run_loop(
|
let mut connection = ConsoleConnection::LegacySocket(client);
|
||||||
terminal,
|
run_loop(terminal, &mut app, &mut connection, Some(runtime_command)).await?;
|
||||||
&mut app,
|
|
||||||
ConsoleConnection::LegacySocket(client),
|
|
||||||
Some(runtime_command),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.push_error(format!(
|
app.push_error(format!(
|
||||||
@@ -791,7 +879,7 @@ async fn drain_worker_events(
|
|||||||
async fn run_loop(
|
async fn run_loop(
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
mut client: ConsoleConnection,
|
client: &mut ConsoleConnection,
|
||||||
runtime_command: Option<WorkerRuntimeCommand>,
|
runtime_command: Option<WorkerRuntimeCommand>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||||
@@ -804,11 +892,11 @@ async fn run_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let handled_term_event =
|
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 {
|
if app.quit {
|
||||||
break;
|
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 {
|
if handled_term_event || handled_worker_event {
|
||||||
terminal.draw(|f| ui::draw(f, app))?;
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
continue;
|
continue;
|
||||||
@@ -816,8 +904,7 @@ async fn run_loop(
|
|||||||
|
|
||||||
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
|
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
|
||||||
LoopInput::Terminal(term_event) => {
|
LoopInput::Terminal(term_event) => {
|
||||||
handle_terminal_event(app, &mut client, term_event?, runtime_command.as_ref())
|
handle_terminal_event(app, client, term_event?, runtime_command.as_ref()).await?;
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
LoopInput::Worker(event) => match event {
|
LoopInput::Worker(event) => match event {
|
||||||
Some(ev) => {
|
Some(ev) => {
|
||||||
|
|||||||
+58
-14
@@ -36,7 +36,7 @@ use crossterm::execute;
|
|||||||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
|
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
|
|
||||||
use client::{Target, WorkerConnectionSelector, WorkerListRequest};
|
use client::{Target, WorkerConnectionSelector, WorkerListRequest, WorkerSpawn};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct LaunchOptions {
|
pub struct LaunchOptions {
|
||||||
@@ -85,6 +85,49 @@ pub enum LaunchMode {
|
|||||||
Panel { include_stopped: bool },
|
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 {
|
pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||||
let LaunchOptions {
|
let LaunchOptions {
|
||||||
target,
|
target,
|
||||||
@@ -109,14 +152,19 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
|||||||
eprintln!("yoi: {e}");
|
eprintln!("yoi: {e}");
|
||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
|
let mut terminal_mode = TerminalModeGuard::new();
|
||||||
|
|
||||||
let result = match mode {
|
let result = match mode {
|
||||||
LaunchMode::Spawn {
|
LaunchMode::Spawn {
|
||||||
worker_name,
|
worker_name,
|
||||||
profile,
|
profile,
|
||||||
} => match target.spawn_worker() {
|
} => match target.spawn_worker() {
|
||||||
Ok(spawn) => {
|
Ok(WorkerSpawn::LegacyLocal { runtime_command }) => {
|
||||||
console::run_spawn(None, worker_name, profile, spawn.runtime_command).await
|
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>),
|
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>),
|
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||||
},
|
},
|
||||||
LaunchMode::ResumeWithSession { id, worker_name } => match target.spawn_worker() {
|
LaunchMode::ResumeWithSession { id, worker_name } => match target.spawn_worker() {
|
||||||
Ok(spawn) => {
|
Ok(WorkerSpawn::LegacyLocal { runtime_command }) => {
|
||||||
console::run_spawn(Some(id), worker_name, None, spawn.runtime_command).await
|
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>),
|
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||||
},
|
},
|
||||||
LaunchMode::Panel { include_stopped } => match target.dashboard() {
|
LaunchMode::Panel { include_stopped } => match target.dashboard() {
|
||||||
@@ -198,15 +250,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
|||||||
// alternate-screen buffer.
|
// alternate-screen buffer.
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({}));
|
e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({}));
|
||||||
let mut stdout = io::stdout();
|
let _ = terminal_mode.restore();
|
||||||
let _ = execute!(
|
|
||||||
stdout,
|
|
||||||
DisableMouseCapture,
|
|
||||||
LeaveAlternateScreen,
|
|
||||||
DisableBracketedPaste
|
|
||||||
);
|
|
||||||
let _ = disable_raw_mode();
|
|
||||||
let _ = execute!(stdout, crossterm::cursor::Show);
|
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({}));
|
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 serde::Deserialize;
|
||||||
|
|
||||||
use super::{ParseError, read_client_default_connection, resolve_backend_url};
|
use super::{ParseError, read_client_default_connection, resolve_backend_url};
|
||||||
@@ -107,6 +107,18 @@ pub(crate) trait CliConnectionResolver {
|
|||||||
#[derive(Debug, Default, Clone, Copy)]
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
pub(crate) struct ClientConfigCliConnectionResolver;
|
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 {
|
impl CliConnectionResolver for ClientConfigCliConnectionResolver {
|
||||||
fn resolve_connection(
|
fn resolve_connection(
|
||||||
&self,
|
&self,
|
||||||
@@ -147,6 +159,11 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
|
|||||||
resolve_backend_url(explicit_backend_url, workspace_id)?,
|
resolve_backend_url(explicit_backend_url, workspace_id)?,
|
||||||
workspace_id.map(str::to_string),
|
workspace_id.map(str::to_string),
|
||||||
))),
|
))),
|
||||||
|
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget)
|
||||||
|
if command == CliCommand::DefaultTui =>
|
||||||
|
{
|
||||||
|
standalone_target()
|
||||||
|
}
|
||||||
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) => {
|
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) => {
|
||||||
Ok(Box::new(LocalTarget::new()))
|
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)?;
|
let target = resolver.resolve_connection(command, CliConnectionInput::LocalTarget)?;
|
||||||
match target.kind() {
|
match target.kind() {
|
||||||
TargetKind::Local => Ok(target),
|
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!(
|
TargetKind::Backend => Err(ParseError(format!(
|
||||||
"{} resolved a Backend target where a local target was required",
|
"{} resolved a Backend target where a local target was required",
|
||||||
command.display_name()
|
command.display_name()
|
||||||
@@ -193,8 +214,8 @@ pub(crate) fn resolve_backend_cli_connection<R: CliConnectionResolver + ?Sized>(
|
|||||||
)?;
|
)?;
|
||||||
match target.kind() {
|
match target.kind() {
|
||||||
TargetKind::Backend => Ok(target),
|
TargetKind::Backend => Ok(target),
|
||||||
TargetKind::Local => Err(ParseError(format!(
|
TargetKind::Local | TargetKind::Standalone => Err(ParseError(format!(
|
||||||
"{} resolved a local target where a Backend target was required",
|
"{} resolved a non-Backend target where a Backend target was required",
|
||||||
command.display_name()
|
command.display_name()
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-3
@@ -687,6 +687,21 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
|||||||
&workspace_root,
|
&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) {
|
if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) {
|
||||||
return Ok(Mode::Tui {
|
return Ok(Mode::Tui {
|
||||||
target,
|
target,
|
||||||
@@ -715,6 +730,11 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
|||||||
worker_name,
|
worker_name,
|
||||||
profile: Some(profile),
|
profile: Some(profile),
|
||||||
}
|
}
|
||||||
|
} else if target.kind() == TargetKind::Standalone {
|
||||||
|
LaunchMode::Spawn {
|
||||||
|
worker_name,
|
||||||
|
profile: None,
|
||||||
|
}
|
||||||
} else if let Some(session) = session {
|
} else if let Some(session) = session {
|
||||||
LaunchMode::ResumeWithSession {
|
LaunchMode::ResumeWithSession {
|
||||||
id: parse_session_id(&session.to_string_lossy())?,
|
id: parse_session_id(&session.to_string_lossy())?,
|
||||||
@@ -1650,7 +1670,7 @@ Usage:
|
|||||||
Target selection:
|
Target selection:
|
||||||
Target options are top-level options and must appear before the command.
|
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
|
--backend <URL> Use a Workspace Backend explicitly
|
||||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
--workspace-id <ID> Scope Backend routes to a Workspace id
|
||||||
|
|
||||||
@@ -1725,7 +1745,9 @@ fn print_memory_lint_help() {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::cli_connection::CliConnectionInput;
|
use crate::cli_connection::CliConnectionInput;
|
||||||
use client::{BackendTarget, LocalTarget, TargetKind, WorkerListRequest};
|
use client::{
|
||||||
|
BackendTarget, LocalTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest,
|
||||||
|
};
|
||||||
|
|
||||||
struct FixedCliConnectionResolver {
|
struct FixedCliConnectionResolver {
|
||||||
backend_url: &'static str,
|
backend_url: &'static str,
|
||||||
@@ -1761,7 +1783,11 @@ mod tests {
|
|||||||
let workspace_id = match input {
|
let workspace_id = match input {
|
||||||
CliConnectionInput::DefaultTarget { workspace_id }
|
CliConnectionInput::DefaultTarget { workspace_id }
|
||||||
| CliConnectionInput::BackendTarget { workspace_id, .. } => 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(
|
Ok(Box::new(BackendTarget::new(
|
||||||
self.backend_url,
|
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]
|
#[test]
|
||||||
fn parse_backend_runtime_target_rejects_legacy_socket_mix() {
|
fn parse_backend_runtime_target_rejects_legacy_socket_mix() {
|
||||||
let err = parse_args_from([
|
let err = parse_args_from([
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ pub fn run(
|
|||||||
})?;
|
})?;
|
||||||
run_in_workspace(cli, &workspace)
|
run_in_workspace(cli, &workspace)
|
||||||
}
|
}
|
||||||
|
ResolvedTarget::Standalone => Err(ObjectiveCliError::new(
|
||||||
|
"Standalone is a one-shot Worker host, not Objective storage authority",
|
||||||
|
)),
|
||||||
ResolvedTarget::Backend {
|
ResolvedTarget::Backend {
|
||||||
base_url,
|
base_url,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
|||||||
@@ -214,6 +214,9 @@ pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, Ti
|
|||||||
})?;
|
})?;
|
||||||
run_in_workspace(cli, &workspace)
|
run_in_workspace(cli, &workspace)
|
||||||
}
|
}
|
||||||
|
ResolvedTarget::Standalone => Err(TicketCliError::new(
|
||||||
|
"Standalone is a one-shot Worker host, not Ticket storage authority",
|
||||||
|
)),
|
||||||
ResolvedTarget::Backend {
|
ResolvedTarget::Backend {
|
||||||
base_url,
|
base_url,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user