cli: route worker lists through targets

This commit is contained in:
Keisuke Hirata 2026-07-29 15:30:31 +09:00
parent acc7281414
commit cbbc860366
No known key found for this signature in database
8 changed files with 589 additions and 308 deletions

View File

@ -21,7 +21,7 @@ pub use backend_auth::{
poll_device_login, start_device_login, wait_for_device_login,
};
pub use backend_runtime::{
BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendDiagnostic, BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,

View File

@ -54,11 +54,22 @@ impl BackendTarget {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerListRequest {
pub runtime_id: Option<String>,
pub include_stopped: bool,
}
impl WorkerListRequest {
pub fn new(runtime_id: Option<String>) -> Self {
Self { runtime_id }
Self {
runtime_id,
include_stopped: false,
}
}
pub fn with_stopped(runtime_id: Option<String>) -> Self {
Self {
runtime_id,
include_stopped: true,
}
}
}
@ -99,7 +110,9 @@ pub struct Dashboard {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList {
pub target: BackendRuntimeListTarget,
pub local_runtime_command: Option<WorkerRuntimeCommand>,
pub backend_target: Option<BackendRuntimeListTarget>,
pub include_stopped: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -182,11 +195,18 @@ impl Target for LocalTarget {
})
}
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Err(TargetError::unsupported(
"Backend runtime worker listing",
self.kind(),
))
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
if request.runtime_id.is_some() {
return Err(TargetError::unsupported(
"Explicit runtime id for local worker listing",
self.kind(),
));
}
Ok(WorkerList {
local_runtime_command: Some(self.runtime_command()?),
backend_target: None,
include_stopped: request.include_stopped,
})
}
fn connect_worker(
@ -226,11 +246,13 @@ impl Target for BackendTarget {
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList {
target: BackendRuntimeListTarget::new(
local_runtime_command: None,
backend_target: Some(BackendRuntimeListTarget::new(
self.base_url.clone(),
self.workspace_id.clone(),
request.runtime_id,
),
)),
include_stopped: request.include_stopped,
})
}
@ -259,9 +281,28 @@ mod tests {
.list_workers(WorkerListRequest::new(Some("runtime-a".to_string())))
.unwrap();
assert_eq!(workers.target.base_url, "http://127.0.0.1:8787");
assert_eq!(workers.target.workspace_id.as_deref(), Some("workspace-a"));
assert_eq!(workers.target.runtime_id.as_deref(), Some("runtime-a"));
assert_eq!(
workers.backend_target.as_ref().unwrap().base_url,
"http://127.0.0.1:8787"
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.workspace_id
.as_deref(),
Some("workspace-a")
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.runtime_id
.as_deref(),
Some("runtime-a")
);
}
#[test]
@ -288,15 +329,14 @@ mod tests {
}
#[test]
fn local_target_rejects_backend_worker_operations() {
fn local_target_builds_local_worker_list() {
let target = LocalTarget::new();
let err = target
.list_workers(WorkerListRequest::new(None))
.unwrap_err();
let workers = target
.list_workers(WorkerListRequest::with_stopped(None))
.unwrap();
assert_eq!(
err.to_string(),
"Backend runtime worker listing is not supported by local target"
);
assert!(workers.local_runtime_command.is_some());
assert!(workers.backend_target.is_none());
assert!(workers.include_stopped);
}
}

View File

@ -3,7 +3,8 @@ use std::io;
use std::time::Duration;
use client::{
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, list_backend_workers,
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary,
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::backend::CrosstermBackend;
@ -18,13 +19,30 @@ use crate::console;
const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box<dyn Error>> {
let response = list_backend_workers(&target).await.map_err(|error| {
pub(crate) async fn run(
target: BackendRuntimeListTarget,
include_stopped: bool,
) -> Result<(), Box<dyn Error>> {
let mut response = list_backend_workers(&target).await.map_err(|error| {
io::Error::other(format!(
"failed to list Backend runtime workers from {}: {error}",
target.base_url
))
})?;
if include_stopped {
match list_backend_stopped_workers(&target).await {
Ok(stopped) => {
response.items.extend(stopped.items);
response.diagnostics.extend(stopped.diagnostics);
}
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
code: "backend_stopped_workers_list_failed".to_string(),
severity: Some("error".to_string()),
message: error.to_string(),
}),
}
}
dedup_workers(&mut response.items);
if response.items.is_empty() {
let diagnostics = response
.diagnostics
@ -44,11 +62,36 @@ pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box<dyn
}
let selected = pick_worker(target.clone(), response.items)?;
let worker = if selected.state == "stopped" {
let restore_target = BackendRuntimeTarget::new(
target.base_url.clone(),
selected.runtime_id.clone(),
selected.worker_id.clone(),
);
restore_backend_worker(&restore_target)
.await
.map_err(|error| {
io::Error::other(format!(
"failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id
))
})?
.result
.worker
.unwrap_or(selected)
} else {
selected
};
let attach_target =
BackendRuntimeTarget::new(target.base_url, selected.runtime_id, selected.worker_id);
BackendRuntimeTarget::new(target.base_url, worker.runtime_id, worker.worker_id);
console::run_backend_runtime(attach_target).await
}
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
let mut seen = std::collections::HashSet::new();
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
}
fn pick_worker(
target: BackendRuntimeListTarget,
mut workers: Vec<BackendWorkerSummary>,

View File

@ -331,6 +331,15 @@ pub(crate) async fn run_resume(
runtime_command: WorkerRuntimeCommand,
workspace_root: PathBuf,
all: bool,
) -> Result<(), Box<dyn std::error::Error>> {
run_worker_picker(runtime_command, workspace_root, all, true).await
}
pub(crate) async fn run_worker_picker(
runtime_command: WorkerRuntimeCommand,
workspace_root: PathBuf,
all: bool,
include_stopped: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Pick a Worker in its own inline viewport, dropping the viewport before
// attaching/restoring so each phase gets fresh vertical room.
@ -338,7 +347,8 @@ pub(crate) async fn run_resume(
picker::PickerOptions::all()
} else {
picker::PickerOptions::workspace(workspace_root)
};
}
.with_stopped(include_stopped);
let (worker_name, socket_override) = match picker::run(picker_options).await? {
PickerOutcome::Picked {
worker_name,

View File

@ -58,7 +58,11 @@ pub enum LaunchMode {
},
/// `yoi workers` / `yoi --backend <url>`: list workers through the selected
/// connection target, then attach to the selected Worker.
Workers { runtime_id: Option<String> },
Workers {
runtime_id: Option<String>,
include_stopped: bool,
all: bool,
},
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: open one Worker
/// through the selected connection target.
OpenWorker {
@ -76,7 +80,7 @@ pub enum LaunchMode {
worker_name: Option<String>,
},
/// `yoi panel`: open the workspace Dashboard from the current workspace.
Panel,
Panel { include_stopped: bool },
}
pub async fn launch(options: LaunchOptions) -> ExitCode {
@ -128,12 +132,34 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
}
Err(e) => Err(Box::new(e) as Box<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::Workers {
runtime_id,
include_stopped,
all,
} => match target.list_workers(if include_stopped {
WorkerListRequest::with_stopped(runtime_id)
} else {
WorkerListRequest::new(runtime_id)
}) {
Ok(worker_list) => {
if let Some(target) = worker_list.backend_target {
backend_worker_picker::run(target, worker_list.include_stopped).await
} else if let Some(runtime_command) = worker_list.local_runtime_command {
console::run_worker_picker(
runtime_command,
workspace_root.clone(),
all,
worker_list.include_stopped,
)
.await
} else {
Err(Box::new(io::Error::other(
"worker list target did not include a local or backend source",
)) as Box<dyn std::error::Error>)
}
}
}
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
LaunchMode::OpenWorker {
runtime_id,
worker_id,
@ -153,7 +179,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
}
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
LaunchMode::Panel => match target.dashboard() {
LaunchMode::Panel { .. } => match target.dashboard() {
Ok(dashboard) => dashboard::launch(dashboard.runtime_command).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},

View File

@ -80,20 +80,28 @@ pub enum PickerOutcome {
#[derive(Debug, Clone)]
pub(crate) struct PickerOptions {
scope: PickerScope,
include_stopped: bool,
}
impl PickerOptions {
pub(crate) fn workspace(workspace_root: PathBuf) -> Self {
Self {
scope: PickerScope::Workspace(workspace_root),
include_stopped: true,
}
}
pub(crate) fn all() -> Self {
Self {
scope: PickerScope::All,
include_stopped: true,
}
}
pub(crate) fn with_stopped(mut self, include_stopped: bool) -> Self {
self.include_stopped = include_stopped;
self
}
}
#[derive(Debug, Clone)]
@ -134,6 +142,11 @@ fn list_for_options(
stored_workers: Vec<StoredWorkerInfo>,
live_workers: Vec<LiveWorkerInfo>,
) -> WorkerList {
let stored_workers = if options.include_stopped {
stored_workers
} else {
Vec::new()
};
match &options.scope {
PickerScope::Workspace(workspace_root) => WorkerList::from_workspace_sources(
WorkerVisibilitySource::ResumePicker,

View File

@ -54,11 +54,12 @@ impl CliCommand {
pub(crate) fn connection_requirement(self) -> CliConnectionRequirement {
match self {
CliCommand::DefaultTui => CliConnectionRequirement::ConnectionAware,
CliCommand::Workers | CliCommand::Login => CliConnectionRequirement::BackendOnly,
CliCommand::Resume
| CliCommand::Panel
| CliCommand::Keys
CliCommand::DefaultTui
| CliCommand::Workers
| CliCommand::Resume
| CliCommand::Panel => CliConnectionRequirement::ConnectionAware,
CliCommand::Login => CliConnectionRequirement::BackendOnly,
CliCommand::Keys
| CliCommand::SetupModel
| CliCommand::WorkerRuntime
| CliCommand::WorkerCleanup
@ -182,6 +183,33 @@ pub(crate) fn resolve_backend_cli_connection<R: CliConnectionResolver + ?Sized>(
}
}
pub(crate) fn resolve_connection_aware_cli_connection<R: CliConnectionResolver + ?Sized>(
resolver: &R,
command: CliCommand,
explicit_local: bool,
explicit_backend_url: Option<String>,
workspace_id: Option<&str>,
) -> Result<Box<dyn Target>, ParseError> {
if explicit_local && explicit_backend_url.is_some() {
return Err(ParseError(
"--local and --backend are mutually exclusive".to_string(),
));
}
if explicit_local {
return resolver.resolve_connection(command, CliConnectionInput::LocalDefault);
}
if explicit_backend_url.is_some() {
return resolver.resolve_connection(
command,
CliConnectionInput::BackendTarget {
explicit_backend_url,
workspace_id,
},
);
}
resolver.resolve_connection(command, CliConnectionInput::LocalDefault)
}
pub(crate) fn backend_target_option_error_for_local_command(
command: CliCommand,
option: &str,
@ -209,22 +237,25 @@ mod tests {
#[test]
fn cli_command_connection_requirements_are_explicit() {
assert_eq!(
CliCommand::DefaultTui.connection_requirement(),
CliConnectionRequirement::ConnectionAware
);
assert_eq!(
CliCommand::Workers.connection_requirement(),
CliConnectionRequirement::BackendOnly
);
for command in [
CliCommand::DefaultTui,
CliCommand::Workers,
CliCommand::Resume,
CliCommand::Panel,
] {
assert_eq!(
command.connection_requirement(),
CliConnectionRequirement::ConnectionAware,
"{} should be connection-aware",
command.display_name()
);
}
assert_eq!(
CliCommand::Login.connection_requirement(),
CliConnectionRequirement::BackendOnly
);
for command in [
CliCommand::Resume,
CliCommand::Panel,
CliCommand::Keys,
CliCommand::SetupModel,
CliCommand::WorkerRuntime,
@ -251,7 +282,7 @@ mod tests {
let resolver = ClientConfigCliConnectionResolver;
let err = resolver
.resolve_connection(
CliCommand::Resume,
CliCommand::Keys,
CliConnectionInput::BackendTarget {
explicit_backend_url: Some("http://127.0.0.1:8787".to_string()),
workspace_id: None,
@ -261,7 +292,7 @@ mod tests {
assert_eq!(
err.to_string(),
"yoi resume uses a local connection target and cannot accept Backend target options"
"yoi keys uses a local connection target and cannot accept Backend target options"
);
}
@ -269,12 +300,12 @@ mod tests {
fn cli_connection_resolver_rejects_backend_only_local_default() {
let resolver = ClientConfigCliConnectionResolver;
let err = resolver
.resolve_connection(CliCommand::Workers, CliConnectionInput::LocalDefault)
.resolve_connection(CliCommand::Login, CliConnectionInput::LocalDefault)
.unwrap_err();
assert_eq!(
err.to_string(),
"yoi workers requires a Backend connection target"
"yoi login requires a Backend connection target"
);
}
@ -293,7 +324,8 @@ mod tests {
let workers = target
.list_workers(client::WorkerListRequest::new(None))
.unwrap();
assert_eq!(workers.target.base_url, "http://127.0.0.1:8787");
assert_eq!(workers.target.workspace_id, None);
let backend_target = workers.backend_target.as_ref().unwrap();
assert_eq!(backend_target.base_url, "http://127.0.0.1:8787");
assert_eq!(backend_target.workspace_id, None);
}
}

View File

@ -18,7 +18,8 @@ use std::time::Duration;
use cli_connection::{
CliCommand, CliConnectionResolver, ClientConfigCliConnectionResolver, ClientDefaultConnection,
backend_target_option_error_for_local_command, is_backend_target_option,
resolve_backend_cli_connection, resolve_local_cli_connection,
resolve_backend_cli_connection, resolve_connection_aware_cli_connection,
resolve_local_cli_connection,
};
use client::{BackendAuthTarget, Target, start_device_login, wait_for_device_login};
use memory_lint::{LintCliOptions, LintStatus};
@ -199,6 +200,107 @@ async fn main() -> ExitCode {
}
}
#[derive(Debug, Default, Clone)]
struct TargetSelection {
explicit_local: bool,
backend_url: Option<String>,
workspace_id: Option<String>,
}
impl TargetSelection {
fn explicit_backend(&self) -> bool {
self.backend_url.is_some()
}
}
fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
connection_resolver: &R,
command: CliCommand,
selection: &TargetSelection,
workspace_root: &Path,
) -> Result<Box<dyn Target>, ParseError> {
let workspace_id = if selection.explicit_backend() && selection.workspace_id.is_none() {
resolve_workspace_id_from_root(workspace_root)?
} else {
selection.workspace_id.clone()
};
resolve_connection_aware_cli_connection(
connection_resolver,
command,
selection.explicit_local,
selection.backend_url.clone(),
workspace_id.as_deref(),
)
}
fn parse_top_level_target_selection(
args: &[String],
) -> Result<(TargetSelection, &[String]), ParseError> {
let mut selection = TargetSelection::default();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--local" => {
if selection.backend_url.is_some() {
return Err(ParseError(
"--local and --backend are mutually exclusive".to_string(),
));
}
selection.explicit_local = true;
i += 1;
}
"--backend" => {
if selection.explicit_local {
return Err(ParseError(
"--local and --backend are mutually exclusive".to_string(),
));
}
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--backend requires a URL".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--backend requires a URL".to_string()));
}
selection.backend_url = Some(value.clone());
i += 2;
}
"--workspace-id" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--workspace-id requires a value".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--workspace-id requires a value".to_string()));
}
selection.workspace_id = Some(value.clone());
i += 2;
}
arg if arg.starts_with("--backend=") => {
if selection.explicit_local {
return Err(ParseError(
"--local and --backend are mutually exclusive".to_string(),
));
}
let value = arg.trim_start_matches("--backend=");
if value.is_empty() {
return Err(ParseError("--backend requires a URL".to_string()));
}
selection.backend_url = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--workspace-id=") => {
let value = arg.trim_start_matches("--workspace-id=");
if value.is_empty() {
return Err(ParseError("--workspace-id requires a value".to_string()));
}
selection.workspace_id = Some(value.to_string());
i += 1;
}
_ => break,
}
}
Ok((selection, &args[i..]))
}
fn parse_args() -> Result<Mode, ParseError> {
parse_args_from(std::env::args().skip(1))
}
@ -221,22 +323,38 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
args: &[String],
connection_resolver: &R,
) -> Result<Mode, ParseError> {
let (target_selection, args) = parse_top_level_target_selection(args)?;
if args.is_empty() {
let target = resolve_local_cli_connection(connection_resolver, CliCommand::DefaultTui)?;
return Ok(Mode::Tui {
target,
mode: LaunchMode::Spawn {
let workspace_root = current_dir()?;
let target = resolve_tui_target(
connection_resolver,
CliCommand::DefaultTui,
&target_selection,
&workspace_root,
)?;
let mode = if target_selection.explicit_backend() {
LaunchMode::Workers {
runtime_id: None,
include_stopped: false,
all: false,
}
} else {
LaunchMode::Spawn {
worker_name: None,
profile: None,
},
workspace_root: current_dir()?,
}
};
return Ok(Mode::Tui {
target,
mode,
workspace_root,
});
}
match args[0].as_str() {
"--help" | "-h" => return Ok(Mode::Help),
"resume" => return parse_resume_args(&args[1..], connection_resolver),
"workers" => return parse_workers_args(&args[1..], connection_resolver),
"resume" => return parse_resume_args(&args[1..], &target_selection, connection_resolver),
"workers" => return parse_workers_args(&args[1..], &target_selection, connection_resolver),
"worker" => {
if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..])
.map_err(|e| ParseError(e.to_string()))?
@ -291,11 +409,19 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
return Ok(Mode::Mcp(mcp_cli));
}
"panel" => {
let target = resolve_local_cli_connection(connection_resolver, CliCommand::Panel)?;
let workspace_root = parse_panel_workspace(&args[1..])?;
let target = resolve_tui_target(
connection_resolver,
CliCommand::Panel,
&target_selection,
&workspace_root,
)?;
return Ok(Mode::Tui {
target,
mode: LaunchMode::Panel,
workspace_root: parse_panel_workspace(&args[1..])?,
mode: LaunchMode::Panel {
include_stopped: false,
},
workspace_root,
});
}
"keys" => {
@ -349,82 +475,73 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
_ => {}
}
parse_console_options(args, connection_resolver)
parse_console_options(args, &target_selection, connection_resolver)
}
fn parse_console_options<R: CliConnectionResolver + ?Sized>(
args: &[String],
target_selection: &TargetSelection,
connection_resolver: &R,
) -> Result<Mode, ParseError> {
let mut workspace_root = current_dir()?;
let mut session = None;
let mut worker_name = None;
let mut session = None;
let mut profile = None;
let mut socket_override = None;
let mut backend_url = None;
let mut workspace_id = None;
let mut runtime_id = None;
let mut worker_id = None;
let mut profile = None;
let mut i = 0;
while i < args.len() {
let arg = &args[i];
match arg.as_str() {
"--session" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--session requires a value".to_string()))?;
session = Some(parse_session_id(value)?);
i += 2;
}
"--worker" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--worker requires a value".to_string()))?;
if value.starts_with('-') {
return Err(ParseError("--worker requires a value".to_string()));
.ok_or_else(|| ParseError("--worker requires a name".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--worker requires a name".to_string()));
}
worker_name = Some(value.clone());
i += 2;
}
"--socket" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--socket requires a value".to_string()))?;
if value.starts_with('-') {
return Err(ParseError("--socket requires a value".to_string()));
}
socket_override = Some(PathBuf::from(value));
i += 2;
}
"--workspace" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--workspace requires a value".to_string()))?;
if value.starts_with('-') {
return Err(ParseError("--workspace requires a value".to_string()));
.ok_or_else(|| ParseError("--workspace requires a path".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--workspace requires a path".to_string()));
}
workspace_root = PathBuf::from(value);
i += 2;
}
"--workspace-id" => {
"--session" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--workspace-id requires a value".to_string()))?;
.ok_or_else(|| ParseError("--session requires a path".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--workspace-id requires a value".to_string()));
return Err(ParseError("--session requires a path".to_string()));
}
workspace_id = Some(value.clone());
session = Some(PathBuf::from(value));
i += 2;
}
"--backend" => {
"--socket" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--backend requires a URL".to_string()))?;
.ok_or_else(|| ParseError("--socket requires a path".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--backend requires a URL".to_string()));
return Err(ParseError("--socket requires a path".to_string()));
}
backend_url = Some(value.clone());
socket_override = Some(PathBuf::from(value));
i += 2;
}
"--profile" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--profile requires a name".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--profile requires a name".to_string()));
}
profile = Some(value.clone());
i += 2;
}
"--runtime-id" | "--runtime" => {
@ -447,62 +564,44 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
worker_id = Some(value.clone());
i += 2;
}
"--profile" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--profile requires a value".to_string()))?;
if value.starts_with('-') {
return Err(ParseError("--profile requires a value".to_string()));
}
profile = Some(value.clone());
i += 2;
}
arg if arg.starts_with("--session=") => {
let value = arg.trim_start_matches("--session=");
if value.is_empty() {
return Err(ParseError("--session requires a value".to_string()));
}
session = Some(parse_session_id(value)?);
i += 1;
}
arg if arg.starts_with("--worker=") => {
let value = arg.trim_start_matches("--worker=");
if value.is_empty() {
return Err(ParseError("--worker requires a value".to_string()));
return Err(ParseError("--worker requires a name".to_string()));
}
worker_name = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--socket=") => {
let value = arg.trim_start_matches("--socket=");
if value.is_empty() {
return Err(ParseError("--socket requires a value".to_string()));
}
socket_override = Some(PathBuf::from(value));
i += 1;
}
arg if arg.starts_with("--workspace=") => {
let value = arg.trim_start_matches("--workspace=");
if value.is_empty() {
return Err(ParseError("--workspace requires a value".to_string()));
return Err(ParseError("--workspace requires a path".to_string()));
}
workspace_root = PathBuf::from(value);
i += 1;
}
arg if arg.starts_with("--workspace-id=") => {
let value = arg.trim_start_matches("--workspace-id=");
arg if arg.starts_with("--session=") => {
let value = arg.trim_start_matches("--session=");
if value.is_empty() {
return Err(ParseError("--workspace-id requires a value".to_string()));
return Err(ParseError("--session requires a path".to_string()));
}
workspace_id = Some(value.to_string());
session = Some(PathBuf::from(value));
i += 1;
}
arg if arg.starts_with("--backend=") => {
let value = arg.trim_start_matches("--backend=");
arg if arg.starts_with("--socket=") => {
let value = arg.trim_start_matches("--socket=");
if value.is_empty() {
return Err(ParseError("--backend requires a URL".to_string()));
return Err(ParseError("--socket requires a path".to_string()));
}
backend_url = Some(value.to_string());
socket_override = Some(PathBuf::from(value));
i += 1;
}
arg if arg.starts_with("--profile=") => {
let value = arg.trim_start_matches("--profile=");
if value.is_empty() {
return Err(ParseError("--profile requires a name".to_string()));
}
profile = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--runtime-id=") => {
@ -529,144 +628,126 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
worker_id = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--profile=") => {
let value = arg.trim_start_matches("--profile=");
if value.is_empty() {
return Err(ParseError("--profile requires a value".to_string()));
}
profile = Some(value.to_string());
i += 1;
}
arg if arg.starts_with('-') => {
return Err(ParseError(format!("unknown argument: {arg}")));
}
value => {
return Err(ParseError(format!(
"unknown command `{value}`; use --worker <NAME> to open a Worker by name"
"yoi does not accept positional argument `{value}` before a subcommand"
)));
}
}
}
let backend_target_present = backend_url.is_some()
|| workspace_id.is_some()
|| runtime_id.is_some()
|| worker_id.is_some();
if backend_target_present && worker_id.is_some() && runtime_id.is_none() {
if worker_id.is_some() && runtime_id.is_none() {
return Err(ParseError(
"--worker-id requires --runtime-id for Backend Runtime API attach".to_string(),
"--worker-id requires --runtime-id for Runtime API attach".to_string(),
));
}
if backend_target_present
if (runtime_id.is_some() || worker_id.is_some())
&& (session.is_some()
|| worker_name.is_some()
|| socket_override.is_some()
|| profile.is_some())
{
return Err(ParseError(
"Backend Runtime API target cannot be combined with --worker, --socket, --session, or --profile".to_string(),
"Runtime API target cannot be combined with --worker, --socket, --session, or --profile".to_string(),
));
}
if profile.is_some() && (session.is_some() || socket_override.is_some()) {
return Err(ParseError(
"--profile can only be used for fresh spawn".to_string(),
));
}
if session.is_some() && socket_override.is_some() {
return Err(ParseError(
"--session cannot be combined with --socket".to_string(),
));
}
if socket_override.is_some() && worker_name.is_none() {
return Err(ParseError("--socket requires --worker".to_string()));
}
if socket_override.is_some() && session.is_some() {
return Err(ParseError(
"--socket can only be used with --worker attach mode".to_string(),
));
let target = resolve_tui_target(
connection_resolver,
CliCommand::DefaultTui,
target_selection,
&workspace_root,
)?;
if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) {
return Ok(Mode::Tui {
target,
mode: LaunchMode::OpenWorker {
runtime_id,
worker_id,
},
workspace_root,
});
}
if backend_target_present {
let workspace_id = match workspace_id {
Some(workspace_id) => Some(workspace_id),
None => resolve_workspace_id_from_root(&workspace_root)?,
};
let target = resolve_backend_cli_connection(
connection_resolver,
CliCommand::DefaultTui,
backend_url,
workspace_id.as_deref(),
)?;
if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) {
return Ok(Mode::Tui {
target,
mode: LaunchMode::OpenWorker {
runtime_id,
worker_id,
},
workspace_root,
});
if runtime_id.is_some() {
return Ok(Mode::Tui {
target,
mode: LaunchMode::Workers {
runtime_id,
include_stopped: false,
all: false,
},
workspace_root,
});
}
let mode = if let Some(profile) = profile {
LaunchMode::Spawn {
worker_name,
profile: Some(profile),
}
return Ok(Mode::Tui {
target,
mode: LaunchMode::Workers { runtime_id },
workspace_root,
});
}
let target = resolve_local_cli_connection(connection_resolver, CliCommand::DefaultTui)?;
if let Some(profile) = profile {
return Ok(Mode::Tui {
target,
mode: LaunchMode::Spawn {
worker_name,
profile: Some(profile),
},
workspace_root,
});
}
if let Some(id) = session {
return Ok(Mode::Tui {
target,
mode: LaunchMode::ResumeWithSession { id, worker_name },
workspace_root,
});
}
if let Some(worker_name) = worker_name {
return Ok(Mode::Tui {
target,
mode: LaunchMode::WorkerName {
worker_name,
socket_override,
},
workspace_root,
});
}
Ok(Mode::Tui {
target,
mode: LaunchMode::Spawn {
} else if let Some(session) = session {
LaunchMode::ResumeWithSession {
id: parse_session_id(&session.to_string_lossy())?,
worker_name,
}
} else if let Some(worker_name) = worker_name {
LaunchMode::WorkerName {
worker_name,
socket_override,
}
} else {
LaunchMode::Spawn {
worker_name: None,
profile: None,
},
}
};
Ok(Mode::Tui {
target,
mode,
workspace_root,
})
}
fn parse_workers_args<R: CliConnectionResolver + ?Sized>(
args: &[String],
target_selection: &TargetSelection,
connection_resolver: &R,
) -> Result<Mode, ParseError> {
let mut workspace_root = current_dir()?;
let mut workspace_id = None;
let mut backend_url = None;
let mut runtime_id = None;
let mut include_stopped = false;
let mut i = 0;
while i < args.len() {
let arg = &args[i];
match arg.as_str() {
"--help" | "-h" => {
return Err(ParseError(
"usage: yoi workers [--workspace PATH] [--workspace-id ID] [--backend URL] [--runtime-id ID]".to_string(),
"usage: yoi [--local|--backend URL] [--workspace-id ID] workers [-r|--stopped] [--workspace PATH] [--runtime-id ID]".to_string(),
));
}
"-r" | "--restoreable" | "--stopped" => {
include_stopped = true;
i += 1;
}
"--workspace" => {
let value = args
.get(i + 1)
@ -677,26 +758,6 @@ fn parse_workers_args<R: CliConnectionResolver + ?Sized>(
workspace_root = PathBuf::from(value);
i += 2;
}
"--workspace-id" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--workspace-id requires a value".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--workspace-id requires a value".to_string()));
}
workspace_id = Some(value.clone());
i += 2;
}
"--backend" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--backend requires a URL".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--backend requires a URL".to_string()));
}
backend_url = Some(value.clone());
i += 2;
}
"--runtime-id" | "--runtime" => {
let value = args
.get(i + 1)
@ -715,22 +776,6 @@ fn parse_workers_args<R: CliConnectionResolver + ?Sized>(
workspace_root = PathBuf::from(value);
i += 1;
}
arg if arg.starts_with("--workspace-id=") => {
let value = arg.trim_start_matches("--workspace-id=");
if value.is_empty() {
return Err(ParseError("--workspace-id requires a value".to_string()));
}
workspace_id = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--backend=") => {
let value = arg.trim_start_matches("--backend=");
if value.is_empty() {
return Err(ParseError("--backend requires a URL".to_string()));
}
backend_url = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--runtime-id=") => {
let value = arg.trim_start_matches("--runtime-id=");
if value.is_empty() {
@ -757,31 +802,32 @@ fn parse_workers_args<R: CliConnectionResolver + ?Sized>(
}
}
}
let workspace_id = match workspace_id {
Some(workspace_id) => Some(workspace_id),
None => resolve_workspace_id_from_root(&workspace_root)?,
};
let target = resolve_backend_cli_connection(
let target = resolve_tui_target(
connection_resolver,
CliCommand::Workers,
backend_url,
workspace_id.as_deref(),
target_selection,
&workspace_root,
)?;
Ok(Mode::Tui {
target,
mode: LaunchMode::Workers { runtime_id },
mode: LaunchMode::Workers {
runtime_id,
include_stopped,
all: false,
},
workspace_root,
})
}
fn parse_resume_args<R: CliConnectionResolver + ?Sized>(
args: &[String],
target_selection: &TargetSelection,
connection_resolver: &R,
) -> Result<Mode, ParseError> {
let mut workspace_root = current_dir()?;
let mut workspace_set = false;
let mut all = false;
let mut runtime_id = None;
let mut i = 0;
while i < args.len() {
@ -799,6 +845,16 @@ fn parse_resume_args<R: CliConnectionResolver + ?Sized>(
all = true;
i += 1;
}
"--runtime-id" | "--runtime" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--runtime-id requires a value".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--runtime-id requires a value".to_string()));
}
runtime_id = Some(value.clone());
i += 2;
}
"--workspace" => {
let value = args
.get(i + 1)
@ -819,13 +875,23 @@ fn parse_resume_args<R: CliConnectionResolver + ?Sized>(
workspace_set = true;
i += 1;
}
arg if arg.starts_with('-') => {
if is_backend_target_option(arg) {
return Err(backend_target_option_error_for_local_command(
CliCommand::Resume,
arg,
));
arg if arg.starts_with("--runtime-id=") => {
let value = arg.trim_start_matches("--runtime-id=");
if value.is_empty() {
return Err(ParseError("--runtime-id requires a value".to_string()));
}
runtime_id = Some(value.to_string());
i += 1;
}
arg if arg.starts_with("--runtime=") => {
let value = arg.trim_start_matches("--runtime=");
if value.is_empty() {
return Err(ParseError("--runtime-id requires a value".to_string()));
}
runtime_id = Some(value.to_string());
i += 1;
}
arg if arg.starts_with('-') => {
return Err(ParseError(format!("unknown yoi resume option `{arg}`")));
}
value => {
@ -842,11 +908,20 @@ fn parse_resume_args<R: CliConnectionResolver + ?Sized>(
));
}
let target = resolve_local_cli_connection(connection_resolver, CliCommand::Resume)?;
let target = resolve_tui_target(
connection_resolver,
CliCommand::Resume,
target_selection,
&workspace_root,
)?;
Ok(Mode::Tui {
target,
mode: LaunchMode::Resume { all },
mode: LaunchMode::Workers {
runtime_id,
include_stopped: true,
all,
},
workspace_root,
})
}
@ -1547,7 +1622,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
fn print_help() {
println!(
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi workers [--workspace <PATH>] [--workspace-id <ID>] [--backend <URL>] [--runtime-id <ID>]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS]
"yoi\n\nUsage:\n yoi [TARGET_OPTIONS] [OPTIONS]\n yoi [TARGET_OPTIONS] resume [--workspace <PATH>] [--all] [--runtime-id <ID>]\n yoi [TARGET_OPTIONS] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]\n yoi [TARGET_OPTIONS] panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete <NAME> [--force] [--dry-run]\n yoi worker prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace init [OPTIONS]
yoi workspace config <COMMAND> [OPTIONS]
yoi workspace identity <COMMAND> [OPTIONS]
yoi workspace trust-runtime <COMMAND> [OPTIONS]
@ -1602,7 +1677,7 @@ mod tests {
}
#[test]
fn parser_uses_injected_cli_connection_resolver_for_backend_command() {
fn parser_uses_local_target_for_workers_without_backend_option() {
let resolver = FixedCliConnectionResolver {
backend_url: "http://fake-backend.example",
};
@ -1612,13 +1687,14 @@ mod tests {
match mode {
Mode::Tui {
target,
mode: LaunchMode::Workers { runtime_id },
mode: LaunchMode::Workers { runtime_id, .. },
..
} => {
assert_eq!(runtime_id, None);
assert_eq!(target.kind(), TargetKind::Backend);
assert_eq!(target.kind(), TargetKind::Local);
let workers = target.list_workers(WorkerListRequest::new(None)).unwrap();
assert_eq!(workers.target.base_url, "http://fake-backend.example");
assert!(workers.local_runtime_command.is_some());
assert!(workers.backend_target.is_none());
}
other => panic!("expected Workers mode, got {other:?}"),
}
@ -1632,12 +1708,6 @@ mod tests {
#[test]
fn parse_local_only_commands_reject_backend_target_options() {
let err = parse_args_from(["resume", "--backend", "http://127.0.0.1:8787"]).unwrap_err();
assert_eq!(
err.to_string(),
"yoi resume uses a local connection target and cannot accept Backend target option `--backend`"
);
let err = parse_args_from(["panel", "--runtime-id", "runtime-a"]).unwrap_err();
assert_eq!(
err.to_string(),
@ -1711,7 +1781,7 @@ mod tests {
.unwrap_err();
assert_eq!(
err.to_string(),
"--worker-id requires --runtime-id for Backend Runtime API attach"
"--worker-id requires --runtime-id for Runtime API attach"
);
}
@ -1721,15 +1791,26 @@ mod tests {
{
Mode::Tui {
target,
mode: LaunchMode::Workers { runtime_id },
mode: LaunchMode::Workers { runtime_id, .. },
..
} => {
assert_eq!(target.kind(), TargetKind::Backend);
let workers = target
.list_workers(WorkerListRequest::new(runtime_id.clone()))
.unwrap();
assert_eq!(workers.target.base_url, "http://127.0.0.1:8787");
assert_eq!(workers.target.runtime_id.as_deref(), Some("r"));
assert_eq!(
workers.backend_target.as_ref().unwrap().base_url,
"http://127.0.0.1:8787"
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.runtime_id
.as_deref(),
Some("r")
);
assert_eq!(runtime_id.as_deref(), Some("r"));
}
_ => panic!("expected Workers mode"),
@ -1739,25 +1820,36 @@ mod tests {
#[test]
fn parse_workers_subcommand_uses_backend_runtime_picker() {
match parse_args_from([
"workers",
"--backend",
"http://127.0.0.1:8787",
"--workspace-id",
"workspace-a",
"workers",
])
.unwrap()
{
Mode::Tui {
target,
mode: LaunchMode::Workers { runtime_id },
mode: LaunchMode::Workers { runtime_id, .. },
..
} => {
assert_eq!(target.kind(), TargetKind::Backend);
let workers = target
.list_workers(WorkerListRequest::new(runtime_id.clone()))
.unwrap();
assert_eq!(workers.target.base_url, "http://127.0.0.1:8787");
assert_eq!(workers.target.workspace_id.as_deref(), Some("workspace-a"));
assert_eq!(
workers.backend_target.as_ref().unwrap().base_url,
"http://127.0.0.1:8787"
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.workspace_id
.as_deref(),
Some("workspace-a")
);
assert_eq!(runtime_id, None);
}
_ => panic!("expected Workers mode"),
@ -1779,7 +1871,7 @@ mod tests {
.unwrap_err();
assert_eq!(
err.to_string(),
"Backend Runtime API target cannot be combined with --worker, --socket, --session, or --profile"
"Runtime API target cannot be combined with --worker, --socket, --session, or --profile"
);
}
@ -1799,10 +1891,18 @@ mod tests {
fn parse_resume_subcommand_defaults_to_workspace_scope() {
match parse_args_from(["resume"]).unwrap() {
Mode::Tui {
mode: LaunchMode::Resume { all },
mode:
LaunchMode::Workers {
include_stopped,
all,
..
},
..
} => assert!(!all),
_ => panic!("expected Resume mode"),
} => {
assert!(include_stopped);
assert!(!all);
}
_ => panic!("expected Workers mode"),
}
}
@ -1810,14 +1910,20 @@ mod tests {
fn parse_resume_workspace_scope() {
match parse_args_from(["resume", "--workspace", "/tmp/resume-workspace"]).unwrap() {
Mode::Tui {
mode: LaunchMode::Resume { all },
mode:
LaunchMode::Workers {
include_stopped,
all,
..
},
workspace_root,
..
} => {
assert!(include_stopped);
assert!(!all);
assert_eq!(workspace_root, PathBuf::from("/tmp/resume-workspace"));
}
_ => panic!("expected Resume mode"),
_ => panic!("expected Workers mode"),
}
}
@ -1825,10 +1931,18 @@ mod tests {
fn parse_resume_all_scope() {
match parse_args_from(["resume", "--all"]).unwrap() {
Mode::Tui {
mode: LaunchMode::Resume { all },
mode:
LaunchMode::Workers {
include_stopped,
all,
..
},
..
} => assert!(all),
_ => panic!("expected Resume mode"),
} => {
assert!(include_stopped);
assert!(all);
}
_ => panic!("expected Workers mode"),
}
}
@ -2228,7 +2342,10 @@ mod tests {
fn parse_panel_mode() {
match parse_args_from(["panel", "--workspace", "/tmp/other-workspace"]).unwrap() {
Mode::Tui {
mode: LaunchMode::Panel,
mode:
LaunchMode::Panel {
include_stopped: false,
},
workspace_root,
..
} => assert_eq!(workspace_root, PathBuf::from("/tmp/other-workspace")),