cli: introduce connection target abstraction

This commit is contained in:
Keisuke Hirata 2026-07-29 02:18:02 +09:00
parent 450e0cddbd
commit 98c1599d1a
No known key found for this signature in database
6 changed files with 903 additions and 63 deletions

View File

@ -12,6 +12,7 @@ pub mod backend_auth;
pub mod backend_runtime; pub mod backend_runtime;
pub mod runtime_command; pub mod runtime_command;
pub mod spawn; pub mod spawn;
pub mod target;
pub mod ticket_role; pub mod ticket_role;
mod worker_client; mod worker_client;
@ -26,6 +27,11 @@ pub use backend_runtime::{
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_workers, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_workers,
}; };
pub use runtime_command::WorkerRuntimeCommand; pub use runtime_command::WorkerRuntimeCommand;
pub use target::{
BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName,
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume,
WorkerSpawn,
};
pub use spawn::{ pub use spawn::{
SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions, SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,

302
crates/client/src/target.rs Normal file
View File

@ -0,0 +1,302 @@
use std::fmt;
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind {
Local,
Backend,
}
impl fmt::Display for TargetKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Local => f.write_str("local"),
Self::Backend => f.write_str("Backend"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalTarget;
impl LocalTarget {
pub fn new() -> Self {
Self
}
fn runtime_command(&self) -> Result<WorkerRuntimeCommand, TargetError> {
WorkerRuntimeCommand::resolve().map_err(TargetError::local_runtime_command)
}
}
impl Default for LocalTarget {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendTarget {
pub base_url: String,
pub workspace_id: Option<String>,
}
impl BackendTarget {
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
Self {
base_url: base_url.into(),
workspace_id: workspace_id.map(Into::into),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerListRequest {
pub runtime_id: Option<String>,
}
impl WorkerListRequest {
pub fn new(runtime_id: Option<String>) -> Self {
Self { runtime_id }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerConnectionSelector {
pub runtime_id: String,
pub worker_id: String,
}
impl WorkerConnectionSelector {
pub fn new(runtime_id: impl Into<String>, worker_id: impl Into<String>) -> Self {
Self {
runtime_id: runtime_id.into(),
worker_id: worker_id.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerSpawn {
pub runtime_command: WorkerRuntimeCommand,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerByName {
pub runtime_command: WorkerRuntimeCommand,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerResume {
pub runtime_command: WorkerRuntimeCommand,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dashboard {
pub runtime_command: WorkerRuntimeCommand,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList {
pub target: BackendRuntimeListTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerConnection {
pub target: BackendRuntimeTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetError {
message: String,
}
impl TargetError {
fn unsupported(operation: &'static str, target: TargetKind) -> Self {
Self {
message: format!("{operation} is not supported by {target} target"),
}
}
fn local_runtime_command(error: std::io::Error) -> Self {
Self {
message: format!("failed to resolve local Worker runtime command: {error}"),
}
}
}
impl fmt::Display for TargetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for TargetError {}
pub trait Target: fmt::Debug + Send + Sync {
fn kind(&self) -> TargetKind;
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
fn resume_worker(&self) -> Result<WorkerResume, TargetError>;
fn dashboard(&self) -> Result<Dashboard, TargetError>;
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError>;
fn connect_worker(
&self,
selector: WorkerConnectionSelector,
) -> Result<WorkerConnection, TargetError>;
}
impl Target for LocalTarget {
fn kind(&self) -> TargetKind {
TargetKind::Local
}
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Ok(WorkerSpawn {
runtime_command: self.runtime_command()?,
})
}
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
Ok(WorkerByName {
runtime_command: self.runtime_command()?,
})
}
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
Ok(WorkerResume {
runtime_command: self.runtime_command()?,
})
}
fn dashboard(&self) -> Result<Dashboard, TargetError> {
Ok(Dashboard {
runtime_command: self.runtime_command()?,
})
}
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Err(TargetError::unsupported(
"Backend runtime 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
}
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Err(TargetError::unsupported("Worker spawn", self.kind()))
}
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 resume", self.kind()))
}
fn dashboard(&self) -> Result<Dashboard, TargetError> {
Err(TargetError::unsupported("Dashboard", self.kind()))
}
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList {
target: BackendRuntimeListTarget::new(
self.base_url.clone(),
self.workspace_id.clone(),
request.runtime_id,
),
})
}
fn connect_worker(
&self,
selector: WorkerConnectionSelector,
) -> Result<WorkerConnection, TargetError> {
Ok(WorkerConnection {
target: BackendRuntimeTarget::new(
self.base_url.clone(),
selector.runtime_id,
selector.worker_id,
),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_target_builds_worker_list() {
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
let workers = target
.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"));
}
#[test]
fn backend_target_builds_worker_connection() {
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
let connection = target
.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b"))
.unwrap();
assert_eq!(connection.target.base_url, "http://127.0.0.1:8787");
assert_eq!(connection.target.runtime_id, "runtime-a");
assert_eq!(connection.target.worker_id, "worker-b");
}
#[test]
fn backend_target_rejects_local_worker_operations() {
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
let err = target.spawn_worker().unwrap_err();
assert_eq!(
err.to_string(),
"Worker spawn is not supported by Backend target"
);
}
#[test]
fn local_target_rejects_backend_worker_operations() {
let target = LocalTarget::new();
let err = target
.list_workers(WorkerListRequest::new(None))
.unwrap_err();
assert_eq!(
err.to_string(),
"Backend runtime worker listing is not supported by local target"
);
}
}

View File

@ -317,6 +317,9 @@ mod tests {
worker_id: worker_id.to_string(), worker_id: worker_id.to_string(),
host_id: "host".to_string(), host_id: "host".to_string(),
label: "label".to_string(), label: "label".to_string(),
display_name: "label".to_string(),
singleton_key: None,
tags: Vec::new(),
role: None, role: None,
profile: profile.map(str::to_string), profile: profile.map(str::to_string),
workspace: BackendWorkerWorkspaceSummary { workspace: BackendWorkerWorkspaceSummary {

View File

@ -34,12 +34,12 @@ 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::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; use client::{Target, WorkerConnectionSelector, WorkerListRequest};
#[derive(Debug, Clone)] #[derive(Debug)]
pub struct LaunchOptions { pub struct LaunchOptions {
pub target: Box<dyn Target>,
pub mode: LaunchMode, pub mode: LaunchMode,
pub runtime_command: WorkerRuntimeCommand,
pub workspace_root: PathBuf, pub workspace_root: PathBuf,
} }
@ -56,12 +56,15 @@ pub enum LaunchMode {
worker_name: String, worker_name: String,
socket_override: Option<PathBuf>, socket_override: Option<PathBuf>,
}, },
/// `yoi workers` / `yoi --backend <url>`: list Backend-authoritative runtime workers, /// `yoi workers` / `yoi --backend <url>`: list workers through the selected
/// then attach to the selected Worker through the Backend Runtime API. /// connection target, then attach to the selected Worker.
BackendRuntimePicker { target: BackendRuntimeListTarget }, Workers { runtime_id: Option<String> },
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: connect through the /// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: open one Worker
/// Workspace Backend Runtime API and observe the Backend-proxied event stream. /// through the selected connection target.
BackendRuntime { target: BackendRuntimeTarget }, OpenWorker {
runtime_id: String,
worker_id: String,
},
/// `yoi resume`: open the Worker picker, then attach to the selected live Worker /// `yoi resume`: open the Worker picker, then attach to the selected live Worker
/// or restore the selected stopped Worker by name. Without `--all`, the picker /// or restore the selected stopped Worker by name. Without `--all`, the picker
/// is scoped to the current runtime workspace. /// is scoped to the current runtime workspace.
@ -78,8 +81,8 @@ pub enum LaunchMode {
pub async fn launch(options: LaunchOptions) -> ExitCode { pub async fn launch(options: LaunchOptions) -> ExitCode {
let LaunchOptions { let LaunchOptions {
target,
mode, mode,
runtime_command,
workspace_root, workspace_root,
} = options; } = options;
@ -105,20 +108,55 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
LaunchMode::Spawn { LaunchMode::Spawn {
worker_name, worker_name,
profile, profile,
} => console::run_spawn(None, worker_name, profile, runtime_command).await, } => match target.spawn_worker() {
Ok(spawn) => {
console::run_spawn(None, worker_name, profile, spawn.runtime_command).await
}
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
LaunchMode::WorkerName { LaunchMode::WorkerName {
worker_name, worker_name,
socket_override, socket_override,
} => console::run_worker_name(worker_name, socket_override, runtime_command).await, } => match target.worker_by_name() {
LaunchMode::BackendRuntimePicker { target } => backend_worker_picker::run(target).await, Ok(worker_by_name) => {
LaunchMode::BackendRuntime { target } => console::run_backend_runtime(target).await, console::run_worker_name(
LaunchMode::Resume { all } => { worker_name,
console::run_resume(runtime_command, workspace_root.clone(), all).await socket_override,
worker_by_name.runtime_command,
)
.await
}
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
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<dyn std::error::Error>),
}
} }
LaunchMode::ResumeWithSession { id, worker_name } => { LaunchMode::OpenWorker {
console::run_spawn(Some(id), worker_name, None, runtime_command).await runtime_id,
} worker_id,
LaunchMode::Panel => dashboard::launch(runtime_command).await, } => match target.connect_worker(WorkerConnectionSelector::new(runtime_id, worker_id)) {
Ok(connection) => console::run_backend_runtime(connection.target).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
LaunchMode::Resume { all } => match target.resume_worker() {
Ok(resume) => {
console::run_resume(resume.runtime_command, workspace_root.clone(), all).await
}
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
}
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
LaunchMode::Panel => match target.dashboard() {
Ok(dashboard) => dashboard::launch(dashboard.runtime_command).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
}; };
// Always restore the terminal first so any pending eprintln below // Always restore the terminal first so any pending eprintln below

View File

@ -0,0 +1,299 @@
use client::{BackendTarget, LocalTarget, Target, TargetKind};
use serde::Deserialize;
use super::{ParseError, resolve_backend_url};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CliConnectionRequirement {
LocalOnly,
BackendOnly,
ConnectionAware,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CliCommand {
DefaultTui,
Workers,
Resume,
Panel,
Keys,
SetupModel,
WorkerRuntime,
WorkerCleanup,
Ticket,
Objective,
Plugin,
Mcp,
MemoryLint,
Session,
WorkspaceServer,
Login,
}
impl CliCommand {
pub(crate) fn display_name(self) -> &'static str {
match self {
CliCommand::DefaultTui => "yoi",
CliCommand::Workers => "yoi workers",
CliCommand::Resume => "yoi resume",
CliCommand::Panel => "yoi panel",
CliCommand::Keys => "yoi keys",
CliCommand::SetupModel => "yoi setup-model",
CliCommand::WorkerRuntime => "yoi worker",
CliCommand::WorkerCleanup => "yoi worker management",
CliCommand::Ticket => "yoi ticket",
CliCommand::Objective => "yoi objective",
CliCommand::Plugin => "yoi plugin",
CliCommand::Mcp => "yoi mcp",
CliCommand::MemoryLint => "yoi memory lint",
CliCommand::Session => "yoi session",
CliCommand::WorkspaceServer => "yoi workspace/server",
CliCommand::Login => "yoi login",
}
}
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::SetupModel
| CliCommand::WorkerRuntime
| CliCommand::WorkerCleanup
| CliCommand::Ticket
| CliCommand::Objective
| CliCommand::Plugin
| CliCommand::Mcp
| CliCommand::MemoryLint
| CliCommand::Session
| CliCommand::WorkspaceServer => CliConnectionRequirement::LocalOnly,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[allow(dead_code)]
pub(crate) enum ClientDefaultConnection {
Local,
Backend,
}
impl Default for ClientDefaultConnection {
fn default() -> Self {
Self::Local
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CliConnectionInput<'a> {
LocalDefault,
BackendTarget {
explicit_backend_url: Option<String>,
workspace_id: Option<&'a str>,
},
}
pub(crate) trait CliConnectionResolver {
fn resolve_connection(
&self,
command: CliCommand,
input: CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ClientConfigCliConnectionResolver;
impl CliConnectionResolver for ClientConfigCliConnectionResolver {
fn resolve_connection(
&self,
command: CliCommand,
input: CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError> {
match (command.connection_requirement(), input) {
(CliConnectionRequirement::LocalOnly, CliConnectionInput::LocalDefault) => {
Ok(Box::new(LocalTarget::new()))
}
(CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => {
Err(ParseError(format!(
"{} uses a local connection target and cannot accept Backend target options",
command.display_name()
)))
}
(CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalDefault) => {
Err(ParseError(format!(
"{} requires a Backend connection target",
command.display_name()
)))
}
(
CliConnectionRequirement::BackendOnly | CliConnectionRequirement::ConnectionAware,
CliConnectionInput::BackendTarget {
explicit_backend_url,
workspace_id,
},
) => Ok(Box::new(BackendTarget::new(
resolve_backend_url(explicit_backend_url, workspace_id)?,
workspace_id.map(str::to_string),
))),
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalDefault) => {
Ok(Box::new(LocalTarget::new()))
}
}
}
}
pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
resolver: &R,
command: CliCommand,
) -> Result<Box<dyn Target>, ParseError> {
let target = resolver.resolve_connection(command, CliConnectionInput::LocalDefault)?;
match target.kind() {
TargetKind::Local => Ok(target),
TargetKind::Backend => Err(ParseError(format!(
"{} resolved a Backend target where a local target was required",
command.display_name()
))),
}
}
pub(crate) fn resolve_backend_cli_connection<R: CliConnectionResolver + ?Sized>(
resolver: &R,
command: CliCommand,
explicit_backend_url: Option<String>,
workspace_id: Option<&str>,
) -> Result<Box<dyn Target>, ParseError> {
let target = resolver.resolve_connection(
command,
CliConnectionInput::BackendTarget {
explicit_backend_url,
workspace_id,
},
)?;
match target.kind() {
TargetKind::Backend => Ok(target),
TargetKind::Local => Err(ParseError(format!(
"{} resolved a local target where a Backend target was required",
command.display_name()
))),
}
}
pub(crate) fn backend_target_option_error_for_local_command(
command: CliCommand,
option: &str,
) -> ParseError {
ParseError(format!(
"{} uses a local connection target and cannot accept Backend target option `{option}`",
command.display_name()
))
}
pub(crate) fn is_backend_target_option(arg: &str) -> bool {
matches!(
arg,
"--backend" | "--workspace-id" | "--runtime-id" | "--runtime" | "--worker-id"
) || arg.starts_with("--backend=")
|| arg.starts_with("--workspace-id=")
|| arg.starts_with("--runtime-id=")
|| arg.starts_with("--runtime=")
|| arg.starts_with("--worker-id=")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_command_connection_requirements_are_explicit() {
assert_eq!(
CliCommand::DefaultTui.connection_requirement(),
CliConnectionRequirement::ConnectionAware
);
assert_eq!(
CliCommand::Workers.connection_requirement(),
CliConnectionRequirement::BackendOnly
);
assert_eq!(
CliCommand::Login.connection_requirement(),
CliConnectionRequirement::BackendOnly
);
for command in [
CliCommand::Resume,
CliCommand::Panel,
CliCommand::Keys,
CliCommand::SetupModel,
CliCommand::WorkerRuntime,
CliCommand::WorkerCleanup,
CliCommand::Ticket,
CliCommand::Objective,
CliCommand::Plugin,
CliCommand::Mcp,
CliCommand::MemoryLint,
CliCommand::Session,
CliCommand::WorkspaceServer,
] {
assert_eq!(
command.connection_requirement(),
CliConnectionRequirement::LocalOnly,
"{} should be local-only",
command.display_name()
);
}
}
#[test]
fn cli_connection_resolver_rejects_local_only_backend_target() {
let resolver = ClientConfigCliConnectionResolver;
let err = resolver
.resolve_connection(
CliCommand::Resume,
CliConnectionInput::BackendTarget {
explicit_backend_url: Some("http://127.0.0.1:8787".to_string()),
workspace_id: None,
},
)
.unwrap_err();
assert_eq!(
err.to_string(),
"yoi resume uses a local connection target and cannot accept Backend target options"
);
}
#[test]
fn cli_connection_resolver_rejects_backend_only_local_default() {
let resolver = ClientConfigCliConnectionResolver;
let err = resolver
.resolve_connection(CliCommand::Workers, CliConnectionInput::LocalDefault)
.unwrap_err();
assert_eq!(
err.to_string(),
"yoi workers requires a Backend connection target"
);
}
#[test]
fn cli_connection_resolver_selects_backend_for_connection_aware_target() {
let resolver = ClientConfigCliConnectionResolver;
let target = resolve_backend_cli_connection(
&resolver,
CliCommand::DefaultTui,
Some("http://127.0.0.1:8787".to_string()),
None,
)
.unwrap();
assert_eq!(target.kind(), TargetKind::Backend);
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);
}
}

View File

@ -1,3 +1,4 @@
mod cli_connection;
mod mcp_cli; mod mcp_cli;
mod memory_lint; mod memory_lint;
mod objective_cli; mod objective_cli;
@ -14,10 +15,12 @@ use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode}; use std::process::{Command, ExitCode};
use std::time::Duration; use std::time::Duration;
use client::{ use cli_connection::{
BackendAuthTarget, BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand, CliCommand, CliConnectionResolver, ClientConfigCliConnectionResolver, ClientDefaultConnection,
start_device_login, wait_for_device_login, backend_target_option_error_for_local_command, is_backend_target_option,
resolve_backend_cli_connection, resolve_local_cli_connection,
}; };
use client::{BackendAuthTarget, Target, start_device_login, wait_for_device_login};
use memory_lint::{LintCliOptions, LintStatus}; use memory_lint::{LintCliOptions, LintStatus};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::SegmentId; use session_store::SegmentId;
@ -48,6 +51,7 @@ enum Mode {
Keys, Keys,
SetupModel, SetupModel,
Tui { Tui {
target: Box<dyn Target>,
mode: LaunchMode, mode: LaunchMode,
workspace_root: PathBuf, workspace_root: PathBuf,
}, },
@ -181,19 +185,13 @@ async fn main() -> ExitCode {
Mode::Keys => tui::keys::launch().await, Mode::Keys => tui::keys::launch().await,
Mode::SetupModel => tui::setup_model::launch().await, Mode::SetupModel => tui::setup_model::launch().await,
Mode::Tui { Mode::Tui {
target,
mode, mode,
workspace_root, workspace_root,
} => { } => {
let runtime_command = match WorkerRuntimeCommand::resolve() {
Ok(command) => command,
Err(e) => {
eprintln!("yoi: failed to resolve Worker runtime command: {e}");
return ExitCode::FAILURE;
}
};
tui::launch(LaunchOptions { tui::launch(LaunchOptions {
target,
mode, mode,
runtime_command,
workspace_root, workspace_root,
}) })
.await .await
@ -215,8 +213,18 @@ where
} }
fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> { fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
let resolver = ClientConfigCliConnectionResolver;
parse_args_slice_with_connection_resolver(args, &resolver)
}
fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
args: &[String],
connection_resolver: &R,
) -> Result<Mode, ParseError> {
if args.is_empty() { if args.is_empty() {
let target = resolve_local_cli_connection(connection_resolver, CliCommand::DefaultTui)?;
return Ok(Mode::Tui { return Ok(Mode::Tui {
target,
mode: LaunchMode::Spawn { mode: LaunchMode::Spawn {
worker_name: None, worker_name: None,
profile: None, profile: None,
@ -227,69 +235,101 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
match args[0].as_str() { match args[0].as_str() {
"--help" | "-h" => return Ok(Mode::Help), "--help" | "-h" => return Ok(Mode::Help),
"resume" => return parse_resume_args(&args[1..]), "resume" => return parse_resume_args(&args[1..], connection_resolver),
"workers" => return parse_workers_args(&args[1..]), "workers" => return parse_workers_args(&args[1..], connection_resolver),
"worker" => { "worker" => {
if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..]) if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..])
.map_err(|e| ParseError(e.to_string()))? .map_err(|e| ParseError(e.to_string()))?
{ {
let _target =
resolve_local_cli_connection(connection_resolver, CliCommand::WorkerCleanup)?;
return Ok(Mode::WorkerCleanup(cli)); return Ok(Mode::WorkerCleanup(cli));
} }
let _target =
resolve_local_cli_connection(connection_resolver, CliCommand::WorkerRuntime)?;
return Ok(Mode::WorkerRuntime(args[1..].to_vec())); return Ok(Mode::WorkerRuntime(args[1..].to_vec()));
} }
"objective" => { "objective" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Objective)?;
let objective_cli = objective_cli::parse_objective_args(&args[1..]) let objective_cli = objective_cli::parse_objective_args(&args[1..])
.map_err(|e| ParseError(e.to_string()))?; .map_err(|e| ParseError(e.to_string()))?;
return Ok(Mode::Objective(objective_cli)); return Ok(Mode::Objective(objective_cli));
} }
"session" => { "session" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?;
let session_cli = session_cli::parse_session_args(&args[1..]) let session_cli = session_cli::parse_session_args(&args[1..])
.map_err(|e| ParseError(e.to_string()))?; .map_err(|e| ParseError(e.to_string()))?;
return Ok(Mode::Session(session_cli)); return Ok(Mode::Session(session_cli));
} }
"ticket" => { "ticket" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Ticket)?;
let ticket_cli = let ticket_cli =
ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?; ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?;
return Ok(Mode::Ticket(ticket_cli)); return Ok(Mode::Ticket(ticket_cli));
} }
"plugin" => { "plugin" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?;
let plugin_cli = parse_plugin_args(&args[1..])?; let plugin_cli = parse_plugin_args(&args[1..])?;
return Ok(Mode::Plugin(plugin_cli)); return Ok(Mode::Plugin(plugin_cli));
} }
"workspace" => { "workspace" => {
let _target =
resolve_local_cli_connection(connection_resolver, CliCommand::WorkspaceServer)?;
return parse_workspace_args(&args[1..]); return parse_workspace_args(&args[1..]);
} }
"server" => { "server" => {
let _target =
resolve_local_cli_connection(connection_resolver, CliCommand::WorkspaceServer)?;
return parse_workspace_args(&args[1..]); return parse_workspace_args(&args[1..]);
} }
"login" => { "login" => {
return parse_login_args(&args[1..]); return parse_login_args(&args[1..], connection_resolver);
} }
"mcp" => { "mcp" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Mcp)?;
let mcp_cli = parse_mcp_args(&args[1..])?; let mcp_cli = parse_mcp_args(&args[1..])?;
return Ok(Mode::Mcp(mcp_cli)); return Ok(Mode::Mcp(mcp_cli));
} }
"panel" => { "panel" => {
let target = resolve_local_cli_connection(connection_resolver, CliCommand::Panel)?;
return Ok(Mode::Tui { return Ok(Mode::Tui {
target,
mode: LaunchMode::Panel, mode: LaunchMode::Panel,
workspace_root: parse_panel_workspace(&args[1..])?, workspace_root: parse_panel_workspace(&args[1..])?,
}); });
} }
"keys" => { "keys" => {
if args.len() != 1 { if args.len() != 1 {
if let Some(arg) = args[1..].iter().find(|arg| is_backend_target_option(arg)) {
return Err(backend_target_option_error_for_local_command(
CliCommand::Keys,
arg,
));
}
return Err(ParseError("yoi keys does not accept arguments".into())); return Err(ParseError("yoi keys does not accept arguments".into()));
} }
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Keys)?;
return Ok(Mode::Keys); return Ok(Mode::Keys);
} }
"setup-model" => { "setup-model" => {
if args.len() != 1 { if args.len() != 1 {
if let Some(arg) = args[1..].iter().find(|arg| is_backend_target_option(arg)) {
return Err(backend_target_option_error_for_local_command(
CliCommand::SetupModel,
arg,
));
}
return Err(ParseError( return Err(ParseError(
"yoi setup-model does not accept arguments".into(), "yoi setup-model does not accept arguments".into(),
)); ));
} }
let _target =
resolve_local_cli_connection(connection_resolver, CliCommand::SetupModel)?;
return Ok(Mode::SetupModel); return Ok(Mode::SetupModel);
} }
"memory" if args.get(1).map(String::as_str) == Some("lint") => { "memory" if args.get(1).map(String::as_str) == Some("lint") => {
let _target =
resolve_local_cli_connection(connection_resolver, CliCommand::MemoryLint)?;
let lint_args = &args[2..]; let lint_args = &args[2..];
if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") { if lint_args.iter().any(|arg| arg == "--help" || arg == "-h") {
return Ok(Mode::MemoryLintHelp); return Ok(Mode::MemoryLintHelp);
@ -309,10 +349,13 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
_ => {} _ => {}
} }
parse_console_options(args) parse_console_options(args, connection_resolver)
} }
fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> { fn parse_console_options<R: CliConnectionResolver + ?Sized>(
args: &[String],
connection_resolver: &R,
) -> Result<Mode, ParseError> {
let mut workspace_root = current_dir()?; let mut workspace_root = current_dir()?;
let mut session = None; let mut session = None;
let mut worker_name = None; let mut worker_name = None;
@ -544,25 +587,34 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
Some(workspace_id) => Some(workspace_id), Some(workspace_id) => Some(workspace_id),
None => resolve_workspace_id_from_root(&workspace_root)?, None => resolve_workspace_id_from_root(&workspace_root)?,
}; };
let backend_url = resolve_backend_url(backend_url, workspace_id.as_deref())?; 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) { if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) {
return Ok(Mode::Tui { return Ok(Mode::Tui {
mode: LaunchMode::BackendRuntime { target,
target: BackendRuntimeTarget::new(backend_url, runtime_id, worker_id), mode: LaunchMode::OpenWorker {
runtime_id,
worker_id,
}, },
workspace_root, workspace_root,
}); });
} }
return Ok(Mode::Tui { return Ok(Mode::Tui {
mode: LaunchMode::BackendRuntimePicker { target,
target: BackendRuntimeListTarget::new(backend_url, workspace_id, runtime_id), mode: LaunchMode::Workers { runtime_id },
},
workspace_root, workspace_root,
}); });
} }
let target = resolve_local_cli_connection(connection_resolver, CliCommand::DefaultTui)?;
if let Some(profile) = profile { if let Some(profile) = profile {
return Ok(Mode::Tui { return Ok(Mode::Tui {
target,
mode: LaunchMode::Spawn { mode: LaunchMode::Spawn {
worker_name, worker_name,
profile: Some(profile), profile: Some(profile),
@ -572,12 +624,14 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
} }
if let Some(id) = session { if let Some(id) = session {
return Ok(Mode::Tui { return Ok(Mode::Tui {
target,
mode: LaunchMode::ResumeWithSession { id, worker_name }, mode: LaunchMode::ResumeWithSession { id, worker_name },
workspace_root, workspace_root,
}); });
} }
if let Some(worker_name) = worker_name { if let Some(worker_name) = worker_name {
return Ok(Mode::Tui { return Ok(Mode::Tui {
target,
mode: LaunchMode::WorkerName { mode: LaunchMode::WorkerName {
worker_name, worker_name,
socket_override, socket_override,
@ -586,6 +640,7 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
}); });
} }
Ok(Mode::Tui { Ok(Mode::Tui {
target,
mode: LaunchMode::Spawn { mode: LaunchMode::Spawn {
worker_name: None, worker_name: None,
profile: None, profile: None,
@ -594,7 +649,10 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
}) })
} }
fn parse_workers_args(args: &[String]) -> Result<Mode, ParseError> { fn parse_workers_args<R: CliConnectionResolver + ?Sized>(
args: &[String],
connection_resolver: &R,
) -> Result<Mode, ParseError> {
let mut workspace_root = current_dir()?; let mut workspace_root = current_dir()?;
let mut workspace_id = None; let mut workspace_id = None;
let mut backend_url = None; let mut backend_url = None;
@ -704,16 +762,23 @@ fn parse_workers_args(args: &[String]) -> Result<Mode, ParseError> {
Some(workspace_id) => Some(workspace_id), Some(workspace_id) => Some(workspace_id),
None => resolve_workspace_id_from_root(&workspace_root)?, None => resolve_workspace_id_from_root(&workspace_root)?,
}; };
let backend_url = resolve_backend_url(backend_url, workspace_id.as_deref())?; let target = resolve_backend_cli_connection(
connection_resolver,
CliCommand::Workers,
backend_url,
workspace_id.as_deref(),
)?;
Ok(Mode::Tui { Ok(Mode::Tui {
mode: LaunchMode::BackendRuntimePicker { target,
target: BackendRuntimeListTarget::new(backend_url, workspace_id, runtime_id), mode: LaunchMode::Workers { runtime_id },
},
workspace_root, workspace_root,
}) })
} }
fn parse_resume_args(args: &[String]) -> Result<Mode, ParseError> { fn parse_resume_args<R: CliConnectionResolver + ?Sized>(
args: &[String],
connection_resolver: &R,
) -> Result<Mode, ParseError> {
let mut workspace_root = current_dir()?; let mut workspace_root = current_dir()?;
let mut workspace_set = false; let mut workspace_set = false;
let mut all = false; let mut all = false;
@ -755,6 +820,12 @@ fn parse_resume_args(args: &[String]) -> Result<Mode, ParseError> {
i += 1; i += 1;
} }
arg if arg.starts_with('-') => { arg if arg.starts_with('-') => {
if is_backend_target_option(arg) {
return Err(backend_target_option_error_for_local_command(
CliCommand::Resume,
arg,
));
}
return Err(ParseError(format!("unknown yoi resume option `{arg}`"))); return Err(ParseError(format!("unknown yoi resume option `{arg}`")));
} }
value => { value => {
@ -771,7 +842,10 @@ fn parse_resume_args(args: &[String]) -> Result<Mode, ParseError> {
)); ));
} }
let target = resolve_local_cli_connection(connection_resolver, CliCommand::Resume)?;
Ok(Mode::Tui { Ok(Mode::Tui {
target,
mode: LaunchMode::Resume { all }, mode: LaunchMode::Resume { all },
workspace_root, workspace_root,
}) })
@ -791,6 +865,11 @@ struct WorkspaceIdentityFile {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ClientConfigFile { struct ClientConfigFile {
default_backend: Option<String>, default_backend: Option<String>,
// Parsed as part of the target model; applying configured connection defaults is left to
// the later user-facing connection-mode work so current `yoi` default launch stays local.
#[allow(dead_code)]
#[serde(default)]
default_connection: ClientDefaultConnection,
#[serde(default)] #[serde(default)]
backends: BTreeMap<String, ClientBackendConfig>, backends: BTreeMap<String, ClientBackendConfig>,
#[serde(default)] #[serde(default)]
@ -895,7 +974,10 @@ fn client_config_missing_message(workspace_id: Option<&str>) -> String {
} }
} }
fn parse_login_args(args: &[String]) -> Result<Mode, ParseError> { fn parse_login_args<R: CliConnectionResolver + ?Sized>(
args: &[String],
connection_resolver: &R,
) -> Result<Mode, ParseError> {
if args.iter().any(|arg| arg == "--help" || arg == "-h") { if args.iter().any(|arg| arg == "--help" || arg == "-h") {
return Err(ParseError( return Err(ParseError(
"yoi login usage: yoi login [--backend <URL>] [--no-wait]".to_string(), "yoi login usage: yoi login [--backend <URL>] [--no-wait]".to_string(),
@ -935,6 +1017,12 @@ fn parse_login_args(args: &[String]) -> Result<Mode, ParseError> {
} }
} }
} }
let _target = resolve_backend_cli_connection(
connection_resolver,
CliCommand::Login,
backend_url.clone(),
None,
)?;
let backend_url = resolve_backend_url(backend_url, None)?; let backend_url = resolve_backend_url(backend_url, None)?;
Ok(Mode::Login { Ok(Mode::Login {
backend_url, backend_url,
@ -1423,6 +1511,13 @@ fn mcp_usage() -> &'static str {
} }
fn parse_panel_workspace(args: &[String]) -> Result<PathBuf, ParseError> { fn parse_panel_workspace(args: &[String]) -> Result<PathBuf, ParseError> {
if let Some(arg) = args.iter().find(|arg| is_backend_target_option(arg)) {
return Err(backend_target_option_error_for_local_command(
CliCommand::Panel,
arg,
));
}
match args { match args {
[] => std::env::current_dir() [] => std::env::current_dir()
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))), .map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))),
@ -1484,6 +1579,77 @@ fn print_memory_lint_help() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::cli_connection::CliConnectionInput;
use client::{BackendTarget, LocalTarget, TargetKind, WorkerListRequest};
struct FixedCliConnectionResolver {
backend_url: &'static str,
}
impl CliConnectionResolver for FixedCliConnectionResolver {
fn resolve_connection(
&self,
_command: CliCommand,
input: CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError> {
match input {
CliConnectionInput::LocalDefault => Ok(Box::new(LocalTarget::new())),
CliConnectionInput::BackendTarget { workspace_id, .. } => Ok(Box::new(
BackendTarget::new(self.backend_url, workspace_id.map(str::to_string)),
)),
}
}
}
#[test]
fn parser_uses_injected_cli_connection_resolver_for_backend_command() {
let resolver = FixedCliConnectionResolver {
backend_url: "http://fake-backend.example",
};
let args = vec!["workers".to_string()];
let mode = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap();
match mode {
Mode::Tui {
target,
mode: LaunchMode::Workers { runtime_id },
..
} => {
assert_eq!(runtime_id, None);
assert_eq!(target.kind(), TargetKind::Backend);
let workers = target.list_workers(WorkerListRequest::new(None)).unwrap();
assert_eq!(workers.target.base_url, "http://fake-backend.example");
}
other => panic!("expected Workers mode, got {other:?}"),
}
}
#[test]
fn client_config_default_connection_defaults_to_local() {
let config = ClientConfigFile::default();
assert_eq!(config.default_connection, ClientDefaultConnection::Local);
}
#[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(),
"yoi panel uses a local connection target and cannot accept Backend target option `--runtime-id`"
);
let err = parse_args_from(["keys", "--workspace-id=workspace-a"]).unwrap_err();
assert_eq!(
err.to_string(),
"yoi keys uses a local connection target and cannot accept Backend target option `--workspace-id=workspace-a`"
);
}
#[test] #[test]
fn parse_worker_name_mode() { fn parse_worker_name_mode() {
@ -1516,14 +1682,26 @@ mod tests {
.unwrap() .unwrap()
{ {
Mode::Tui { Mode::Tui {
mode: LaunchMode::BackendRuntime { target }, target,
mode:
LaunchMode::OpenWorker {
runtime_id,
worker_id,
},
.. ..
} => { } => {
assert_eq!(target.base_url, "http://127.0.0.1:8787"); assert_eq!(target.kind(), TargetKind::Backend);
assert_eq!(target.runtime_id, "runtime-a"); let connection = target
assert_eq!(target.worker_id, "worker-b"); .connect_worker(client::WorkerConnectionSelector::new(
runtime_id.clone(),
worker_id.clone(),
))
.unwrap();
assert_eq!(connection.target.base_url, "http://127.0.0.1:8787");
assert_eq!(runtime_id, "runtime-a");
assert_eq!(worker_id, "worker-b");
} }
_ => panic!("expected BackendRuntime mode"), _ => panic!("expected OpenWorker mode"),
} }
} }
@ -1542,13 +1720,19 @@ mod tests {
match parse_args_from(["--backend", "http://127.0.0.1:8787", "--runtime-id", "r"]).unwrap() match parse_args_from(["--backend", "http://127.0.0.1:8787", "--runtime-id", "r"]).unwrap()
{ {
Mode::Tui { Mode::Tui {
mode: LaunchMode::BackendRuntimePicker { target }, target,
mode: LaunchMode::Workers { runtime_id },
.. ..
} => { } => {
assert_eq!(target.base_url, "http://127.0.0.1:8787"); assert_eq!(target.kind(), TargetKind::Backend);
assert_eq!(target.runtime_id.as_deref(), Some("r")); 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!(runtime_id.as_deref(), Some("r"));
} }
_ => panic!("expected BackendRuntimePicker mode"), _ => panic!("expected Workers mode"),
} }
} }
@ -1564,14 +1748,19 @@ mod tests {
.unwrap() .unwrap()
{ {
Mode::Tui { Mode::Tui {
mode: LaunchMode::BackendRuntimePicker { target }, target,
mode: LaunchMode::Workers { runtime_id },
.. ..
} => { } => {
assert_eq!(target.base_url, "http://127.0.0.1:8787"); assert_eq!(target.kind(), TargetKind::Backend);
assert_eq!(target.workspace_id.as_deref(), Some("workspace-a")); let workers = target
assert_eq!(target.runtime_id, None); .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!(runtime_id, None);
} }
_ => panic!("expected BackendRuntimePicker mode"), _ => panic!("expected Workers mode"),
} }
} }
@ -1623,6 +1812,7 @@ mod tests {
Mode::Tui { Mode::Tui {
mode: LaunchMode::Resume { all }, mode: LaunchMode::Resume { all },
workspace_root, workspace_root,
..
} => { } => {
assert!(!all); assert!(!all);
assert_eq!(workspace_root, PathBuf::from("/tmp/resume-workspace")); assert_eq!(workspace_root, PathBuf::from("/tmp/resume-workspace"));
@ -1994,6 +2184,7 @@ mod tests {
profile, profile,
}, },
workspace_root, workspace_root,
..
} => { } => {
assert_eq!(worker_name, Some("agent".to_string())); assert_eq!(worker_name, Some("agent".to_string()));
assert_eq!(profile, Some("project:companion".to_string())); assert_eq!(profile, Some("project:companion".to_string()));
@ -2039,6 +2230,7 @@ mod tests {
Mode::Tui { Mode::Tui {
mode: LaunchMode::Panel, mode: LaunchMode::Panel,
workspace_root, workspace_root,
..
} => assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")), } => assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")),
_ => panic!("expected Panel mode"), _ => panic!("expected Panel mode"),
} }