feat: surface backend runtime workers in tui

This commit is contained in:
Keisuke Hirata 2026-07-21 17:41:50 +09:00
parent 2b307b8040
commit f79892baef
No known key found for this signature in database
8 changed files with 863 additions and 26 deletions

1
Cargo.lock generated
View File

@ -5815,6 +5815,7 @@ dependencies = [
"tempfile",
"ticket",
"tokio",
"toml",
"tui",
"worker",
]

View File

@ -41,6 +41,122 @@ impl BackendRuntimeTarget {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendRuntimeListTarget {
pub base_url: String,
pub workspace_id: Option<String>,
pub runtime_id: Option<String>,
}
impl BackendRuntimeListTarget {
pub fn new(
base_url: impl Into<String>,
workspace_id: Option<String>,
runtime_id: Option<String>,
) -> Self {
Self {
base_url: base_url.into(),
workspace_id,
runtime_id,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct BackendRuntimeListResponse<T> {
pub workspace_id: String,
pub limit: usize,
pub items: Vec<T>,
pub source: String,
#[serde(default)]
pub diagnostics: Vec<BackendDiagnostic>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendRuntimeSummary {
pub runtime_id: String,
pub label: String,
pub kind: String,
pub status: String,
#[serde(default)]
pub host_ids: Vec<String>,
#[serde(default)]
pub diagnostics: Vec<BackendDiagnostic>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkerWorkspaceSummary {
pub visibility: String,
pub identity: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkerImplementationSummary {
pub kind: String,
pub display_hint: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkerCapabilitySummary {
pub can_stop: bool,
pub can_spawn_followup: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkingDirectoryCleanupTarget {
pub kind: String,
pub working_directory_id: String,
pub repository_id: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkingDirectorySummary {
pub working_directory_id: String,
pub repository_id: String,
#[serde(default)]
pub requested_selector: Option<String>,
pub materializer_kind: String,
#[serde(default)]
pub resolved_commit: Option<String>,
#[serde(default)]
pub resolved_tree: Option<String>,
#[serde(default)]
pub cleanup_target: Option<BackendWorkingDirectoryCleanupTarget>,
pub status: String,
#[serde(default)]
pub cleanliness: Option<String>,
#[serde(default)]
pub primary_worker_id: Option<String>,
#[serde(default)]
pub management_kind: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendWorkerSummary {
pub runtime_id: String,
pub worker_id: String,
pub host_id: String,
pub label: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub profile: Option<String>,
pub workspace: BackendWorkerWorkspaceSummary,
pub state: String,
#[serde(default)]
pub last_seen_at: Option<String>,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub retention_state: String,
pub implementation: BackendWorkerImplementationSummary,
pub capabilities: BackendWorkerCapabilitySummary,
#[serde(default)]
pub working_directory: Option<BackendWorkingDirectorySummary>,
#[serde(default)]
pub diagnostics: Vec<BackendDiagnostic>,
}
#[derive(Debug)]
pub struct BackendRuntimeClient {
target: BackendRuntimeTarget,
@ -73,6 +189,72 @@ impl From<reqwest::Error> for BackendRuntimeClientError {
}
}
pub async fn list_backend_workers(
target: &BackendRuntimeListTarget,
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
validate_list_target(target)?;
let http = reqwest::Client::new();
if let Some(runtime_id) = target.runtime_id.as_deref() {
let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id);
let url = join_base_and_path(&target.base_url, &path);
return Ok(http
.get(url)
.send()
.await?
.error_for_status()?
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?);
}
let runtime_path = backend_runtimes_path(target.workspace_id.as_deref());
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
let runtimes = http
.get(runtime_url)
.send()
.await?
.error_for_status()?
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
.await?;
let mut items = Vec::new();
let mut diagnostics = runtimes.diagnostics;
for runtime in runtimes.items {
let path =
backend_runtime_workers_path(target.workspace_id.as_deref(), &runtime.runtime_id);
let url = join_base_and_path(&target.base_url, &path);
match http
.get(url)
.send()
.await
.and_then(|response| response.error_for_status())
{
Ok(response) => {
let response = response
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?;
diagnostics.extend(response.diagnostics);
items.extend(response.items);
}
Err(error) => diagnostics.push(BackendDiagnostic {
code: "runtime_worker_list_failed".to_string(),
severity: Some("error".to_string()),
message: format!(
"failed to list workers for runtime {}: {error}",
runtime.runtime_id
),
}),
}
}
Ok(BackendRuntimeListResponse {
workspace_id: runtimes.workspace_id,
limit: runtimes.limit,
items,
source: "backend_runtime_worker_summary".to_string(),
diagnostics,
})
}
impl BackendRuntimeClient {
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
validate_target(&target)?;
@ -375,6 +557,50 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
Ok(())
}
fn validate_list_target(
target: &BackendRuntimeListTarget,
) -> Result<(), BackendRuntimeClientError> {
if target.base_url.trim().is_empty() {
return Err(BackendRuntimeClientError::InvalidTarget(
"Backend API base URL is required".to_string(),
));
}
if !(target.base_url.starts_with("http://")) && !(target.base_url.starts_with("https://")) {
return Err(BackendRuntimeClientError::InvalidTarget(
"Backend API base URL must start with http:// or https://".to_string(),
));
}
if target.workspace_id.as_deref().is_some_and(str::is_empty) {
return Err(BackendRuntimeClientError::InvalidTarget(
"workspace_id must not be empty when provided".to_string(),
));
}
if target.runtime_id.as_deref().is_some_and(str::is_empty) {
return Err(BackendRuntimeClientError::InvalidTarget(
"runtime_id must not be empty when provided".to_string(),
));
}
Ok(())
}
fn backend_runtimes_path(workspace_id: Option<&str>) -> String {
match workspace_id {
Some(workspace_id) => format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)),
None => "/api/runtimes".to_string(),
}
}
fn backend_runtime_workers_path(workspace_id: Option<&str>, runtime_id: &str) -> String {
match workspace_id {
Some(workspace_id) => format!(
"/api/w/{}/runtimes/{}/workers",
path_segment_encode(workspace_id),
path_segment_encode(runtime_id)
),
None => format!("/api/runtimes/{}/workers", path_segment_encode(runtime_id)),
}
}
fn observation_ws_url(target: &BackendRuntimeTarget) -> String {
let path = format!(
"/api/runtimes/{}/workers/{}/events/ws",
@ -449,10 +675,12 @@ struct WorkerLifecycleResult {
diagnostics: Vec<BackendDiagnostic>,
}
#[derive(Debug, Deserialize)]
struct BackendDiagnostic {
code: String,
message: String,
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct BackendDiagnostic {
pub code: String,
#[serde(default)]
pub severity: Option<String>,
pub message: String,
}
#[derive(Debug, Deserialize)]
@ -502,6 +730,22 @@ mod tests {
);
}
#[test]
fn backend_worker_list_paths_use_scoped_workspace_when_available() {
assert_eq!(
backend_runtimes_path(Some("workspace/one")),
"/api/w/workspace%2Fone/runtimes"
);
assert_eq!(
backend_runtime_workers_path(Some("workspace/one"), "runtime one"),
"/api/w/workspace%2Fone/runtimes/runtime%20one/workers"
);
assert_eq!(
backend_runtime_workers_path(None, "runtime one"),
"/api/runtimes/runtime%20one/workers"
);
}
#[test]
fn observation_url_uses_backend_runtime_worker_identity() {
let target =

View File

@ -14,7 +14,12 @@ pub mod spawn;
pub mod ticket_role;
mod worker_client;
pub use backend_runtime::{BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeTarget};
pub use backend_runtime::{
BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerSummary,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_workers,
};
pub use runtime_command::WorkerRuntimeCommand;
pub use spawn::{

View File

@ -0,0 +1,271 @@
use std::error::Error;
use std::io;
use client::{
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, list_backend_workers,
};
use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind};
use ratatui::Frame;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use crate::console;
pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box<dyn Error>> {
let 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 response.items.is_empty() {
return Err(Box::new(io::Error::other(format!(
"Backend returned no runtime workers for workspace {}",
response.workspace_id
))));
}
let selected = pick_worker(target.clone(), response.items)?;
let attach_target =
BackendRuntimeTarget::new(target.base_url, selected.runtime_id, selected.worker_id);
console::run_backend_runtime(attach_target).await
}
fn pick_worker(
target: BackendRuntimeListTarget,
mut workers: Vec<BackendWorkerSummary>,
) -> Result<BackendWorkerSummary, Box<dyn Error>> {
workers.sort_by(|a, b| {
a.runtime_id
.cmp(&b.runtime_id)
.then_with(|| a.label.cmp(&b.label))
.then_with(|| a.worker_id.cmp(&b.worker_id))
});
let mut state = BackendWorkerPickerState::new(target, workers);
let mut terminal = ratatui::Terminal::new(CrosstermBackend::new(io::stdout()))?;
terminal.clear()?;
loop {
terminal.draw(|frame| draw(frame, &mut state))?;
match event::read()? {
CrosstermEvent::Key(KeyEvent {
code,
kind: KeyEventKind::Press,
..
}) => match code {
KeyCode::Up | KeyCode::Char('k') => state.previous(),
KeyCode::Down | KeyCode::Char('j') => state.next(),
KeyCode::Enter => return Ok(state.selected_worker().clone()),
KeyCode::Esc | KeyCode::Char('q') => {
return Err(Box::new(io::Error::other(
"Backend worker picker cancelled",
)));
}
_ => {}
},
_ => {}
}
}
}
struct BackendWorkerPickerState {
target: BackendRuntimeListTarget,
workers: Vec<BackendWorkerSummary>,
selected: usize,
}
impl BackendWorkerPickerState {
fn new(target: BackendRuntimeListTarget, workers: Vec<BackendWorkerSummary>) -> Self {
Self {
target,
workers,
selected: 0,
}
}
fn next(&mut self) {
if self.workers.is_empty() {
return;
}
self.selected = (self.selected + 1).min(self.workers.len() - 1);
}
fn previous(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
fn selected_worker(&self) -> &BackendWorkerSummary {
&self.workers[self.selected]
}
}
fn draw(frame: &mut Frame<'_>, state: &mut BackendWorkerPickerState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Min(8),
Constraint::Length(5),
])
.split(frame.area());
draw_header(frame, chunks[0], state);
draw_list(frame, chunks[1], state);
draw_details(frame, chunks[2], state.selected_worker());
}
fn draw_header(frame: &mut Frame<'_>, area: Rect, state: &BackendWorkerPickerState) {
let workspace = state
.target
.workspace_id
.as_deref()
.unwrap_or("unscoped backend");
let runtime = state.target.runtime_id.as_deref().unwrap_or("all runtimes");
let text = vec![
Line::from(vec![
Span::styled(
"Backend runtime workers",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {}", state.target.base_url)),
]),
Line::from(format!("workspace: {workspace} runtime: {runtime}")),
Line::from("↑/↓ or k/j select Enter attach q/Esc cancel"),
];
frame.render_widget(
Paragraph::new(text).block(Block::default().borders(Borders::ALL)),
area,
);
}
fn draw_list(frame: &mut Frame<'_>, area: Rect, state: &mut BackendWorkerPickerState) {
let items: Vec<_> = state
.workers
.iter()
.map(|worker| ListItem::new(worker_row(worker)))
.collect();
let mut list_state = ListState::default().with_selected(Some(state.selected));
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Workers"))
.highlight_symbol("")
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, area, &mut list_state);
}
fn worker_row(worker: &BackendWorkerSummary) -> Line<'static> {
let label = if worker.label.is_empty() {
worker.worker_id.as_str()
} else {
worker.label.as_str()
};
let profile = worker.profile.as_deref().unwrap_or("-");
let wd = working_directory_text(worker);
Line::from(vec![
Span::styled(
format!("{}:{}", worker.runtime_id, worker.worker_id),
Style::default().fg(Color::Cyan),
),
Span::raw(format!(" {label}")),
Span::raw(format!(" profile:{profile}")),
Span::raw(format!(" state:{}", worker.state)),
Span::raw(format!(" wd:{wd}")),
])
}
fn draw_details(frame: &mut Frame<'_>, area: Rect, worker: &BackendWorkerSummary) {
let profile = worker.profile.as_deref().unwrap_or("-");
let role = worker.role.as_deref().unwrap_or("-");
let text = vec![
Line::from(format!(
"runtime={} worker={} host={}",
worker.runtime_id, worker.worker_id, worker.host_id
)),
Line::from(format!(
"label={} role={} profile={} state={}",
worker.label, role, profile, worker.state
)),
Line::from(format!(
"working_directory={}",
working_directory_text(worker)
)),
];
frame.render_widget(
Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title("Selected worker"),
)
.wrap(Wrap { trim: true }),
area,
);
}
fn working_directory_text(worker: &BackendWorkerSummary) -> String {
let Some(wd) = worker.working_directory.as_ref() else {
return "-".to_string();
};
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown");
format!(
"{}:{} {} {}",
wd.repository_id, wd.working_directory_id, wd.status, cleanliness
)
}
#[cfg(test)]
mod tests {
use super::*;
use client::{
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
BackendWorkerWorkspaceSummary,
};
fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary {
BackendWorkerSummary {
runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(),
host_id: "host".to_string(),
label: "label".to_string(),
role: None,
profile: profile.map(str::to_string),
workspace: BackendWorkerWorkspaceSummary {
visibility: "workspace".to_string(),
identity: "ws".to_string(),
},
state: "running".to_string(),
last_seen_at: None,
pinned: false,
retention_state: String::new(),
implementation: BackendWorkerImplementationSummary {
kind: "embedded".to_string(),
display_hint: "embedded".to_string(),
},
capabilities: BackendWorkerCapabilitySummary {
can_stop: true,
can_spawn_followup: false,
},
working_directory: None,
diagnostics: Vec::new(),
}
}
#[test]
fn worker_row_contains_backend_authority_fields() {
let row = worker_row(&worker("runtime-a", "worker-b", Some("default")));
let text = row
.spans
.into_iter()
.map(|span| span.content)
.collect::<String>();
assert!(text.contains("runtime-a:worker-b"));
assert!(text.contains("profile:default"));
assert!(text.contains("state:running"));
}
}

View File

@ -1,4 +1,5 @@
mod app;
mod backend_worker_picker;
mod block;
mod cache;
mod command;
@ -33,7 +34,7 @@ use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
use session_store::SegmentId;
use client::{BackendRuntimeTarget, WorkerRuntimeCommand};
use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
#[derive(Debug, Clone)]
pub struct LaunchOptions {
@ -55,6 +56,9 @@ pub enum LaunchMode {
worker_name: String,
socket_override: Option<PathBuf>,
},
/// `yoi workers` / `yoi --backend <url>`: list Backend-authoritative runtime workers,
/// then attach to the selected Worker through the Backend Runtime API.
BackendRuntimePicker { target: BackendRuntimeListTarget },
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: connect through the
/// Workspace Backend Runtime API and observe the Backend-proxied event stream.
BackendRuntime { target: BackendRuntimeTarget },
@ -106,6 +110,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
worker_name,
socket_override,
} => console::run_worker_name(worker_name, socket_override, runtime_command).await,
LaunchMode::BackendRuntimePicker { target } => backend_worker_picker::run(target).await,
LaunchMode::BackendRuntime { target } => console::run_backend_runtime(target).await,
LaunchMode::Resume { all } => {
console::run_resume(runtime_command, workspace_root.clone(), all).await

View File

@ -23,6 +23,7 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = "0.9.34"
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
toml = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@ -6,13 +6,16 @@ mod session_cli;
mod ticket_cli;
mod worker_cleanup_cli;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt;
use std::path::PathBuf;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use client::{BackendRuntimeTarget, WorkerRuntimeCommand};
use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
use memory_lint::{LintCliOptions, LintStatus};
use serde::Deserialize;
use session_store::SegmentId;
use tui::{LaunchMode, LaunchOptions};
@ -207,6 +210,7 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
match args[0].as_str() {
"--help" | "-h" => return Ok(Mode::Help),
"resume" => return parse_resume_args(&args[1..]),
"workers" => return parse_workers_args(&args[1..]),
"worker" => {
if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..])
.map_err(|e| ParseError(e.to_string()))?
@ -290,6 +294,7 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
let mut worker_name = 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;
@ -335,6 +340,16 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
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)
@ -407,6 +422,14 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
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() {
@ -458,13 +481,13 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
}
}
let backend_target_present =
backend_url.is_some() || runtime_id.is_some() || worker_id.is_some();
if backend_target_present
&& (backend_url.is_none() || runtime_id.is_none() || worker_id.is_none())
{
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() {
return Err(ParseError(
"--backend, --runtime-id, and --worker-id are required together".to_string(),
"--worker-id requires --runtime-id for Backend Runtime API attach".to_string(),
));
}
if backend_target_present
@ -493,13 +516,22 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
}
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 backend_url = resolve_backend_url(backend_url, workspace_id.as_deref())?;
if let (Some(runtime_id), Some(worker_id)) = (runtime_id.clone(), worker_id) {
return Ok(Mode::Tui {
mode: LaunchMode::BackendRuntime {
target: BackendRuntimeTarget::new(backend_url, runtime_id, worker_id),
},
workspace_root,
});
}
return Ok(Mode::Tui {
mode: LaunchMode::BackendRuntime {
target: BackendRuntimeTarget::new(
backend_url.expect("checked by backend_target_present"),
runtime_id.expect("checked by backend_target_present"),
worker_id.expect("checked by backend_target_present"),
),
mode: LaunchMode::BackendRuntimePicker {
target: BackendRuntimeListTarget::new(backend_url, workspace_id, runtime_id),
},
workspace_root,
});
@ -538,6 +570,125 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
})
}
fn parse_workers_args(args: &[String]) -> 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 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(),
));
}
"--workspace" => {
let value = args
.get(i + 1)
.ok_or_else(|| ParseError("--workspace requires a value".to_string()))?;
if value.starts_with('-') || value.is_empty() {
return Err(ParseError("--workspace requires a value".to_string()));
}
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)
.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;
}
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()));
}
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() {
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 workers option `{arg}`")));
}
value => {
return Err(ParseError(format!(
"yoi workers does not accept positional argument `{value}`"
)));
}
}
}
let workspace_id = match workspace_id {
Some(workspace_id) => Some(workspace_id),
None => resolve_workspace_id_from_root(&workspace_root)?,
};
let backend_url = resolve_backend_url(backend_url, workspace_id.as_deref())?;
Ok(Mode::Tui {
mode: LaunchMode::BackendRuntimePicker {
target: BackendRuntimeListTarget::new(backend_url, workspace_id, runtime_id),
},
workspace_root,
})
}
fn parse_resume_args(args: &[String]) -> Result<Mode, ParseError> {
let mut workspace_root = current_dir()?;
let mut workspace_set = false;
@ -607,6 +758,127 @@ fn current_dir() -> Result<PathBuf, ParseError> {
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))
}
#[derive(Debug, Deserialize)]
struct WorkspaceIdentityFile {
#[serde(alias = "workspace_id")]
id: String,
}
#[derive(Debug, Default, Deserialize)]
struct ClientConfigFile {
default_backend: Option<String>,
#[serde(default)]
backends: BTreeMap<String, ClientBackendConfig>,
#[serde(default)]
workspaces: BTreeMap<String, ClientWorkspaceConfig>,
}
#[derive(Debug, Deserialize)]
struct ClientBackendConfig {
url: String,
}
#[derive(Debug, Deserialize)]
struct ClientWorkspaceConfig {
backend: String,
}
fn resolve_workspace_id_from_root(workspace_root: &Path) -> Result<Option<String>, ParseError> {
let mut current = if workspace_root.is_absolute() {
workspace_root.to_path_buf()
} else {
current_dir()?.join(workspace_root)
};
loop {
let path = current.join(".yoi").join("workspace.toml");
if path.is_file() {
let contents = fs::read_to_string(&path)
.map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?;
let identity: WorkspaceIdentityFile = toml::from_str(&contents)
.map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display())))?;
let id = identity.id.trim();
if id.is_empty() {
return Err(ParseError(format!(
"{} must contain a non-empty workspace id",
path.display()
)));
}
return Ok(Some(id.to_string()));
}
if !current.pop() {
return Ok(None);
}
}
}
fn resolve_backend_url(
explicit_backend_url: Option<String>,
workspace_id: Option<&str>,
) -> Result<String, ParseError> {
if let Some(url) = explicit_backend_url {
return Ok(url);
}
let Some(config) = read_client_config()? else {
return Err(ParseError(client_config_missing_message(workspace_id)));
};
let backend_name = workspace_id
.and_then(|id| {
config
.workspaces
.get(id)
.map(|workspace| workspace.backend.as_str())
})
.or(config.default_backend.as_deref())
.ok_or_else(|| ParseError(client_config_missing_message(workspace_id)))?;
let backend = config.backends.get(backend_name).ok_or_else(|| {
ParseError(format!(
"client config references backend `{backend_name}`, but [backends.{backend_name}] is not defined"
))
})?;
let url = backend.url.trim();
if url.is_empty() {
return Err(ParseError(format!(
"client config backend `{backend_name}` must contain a non-empty url"
)));
}
Ok(url.to_string())
}
fn read_client_config() -> Result<Option<ClientConfigFile>, ParseError> {
let Some(path) = client_config_path() else {
return Ok(None);
};
if !path.is_file() {
return Ok(None);
}
let contents = fs::read_to_string(&path)
.map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?;
toml::from_str::<ClientConfigFile>(&contents)
.map(Some)
.map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display())))
}
fn client_config_path() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(home).join("yoi").join("client.toml"));
}
std::env::var_os("HOME").map(|home| {
PathBuf::from(home)
.join(".config")
.join("yoi")
.join("client.toml")
})
}
fn client_config_missing_message(workspace_id: Option<&str>) -> String {
match workspace_id {
Some(workspace_id) => format!(
"Backend URL is required. Pass --backend <URL> or configure $XDG_CONFIG_HOME/yoi/client.toml with [workspaces.{workspace_id}] backend = <name> and [backends.<name>].url"
),
None => "Backend URL is required. Pass --backend <URL> or configure default_backend in $XDG_CONFIG_HOME/yoi/client.toml".to_string(),
}
}
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError(
@ -1024,9 +1296,9 @@ 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 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 [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 workspace config <COMMAND> [OPTIONS]
yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker (defaults to cwd)\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id <ID> Workspace identity for Backend scoped routes\n --backend <URL> Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id <ID> Backend Runtime identity for attach/list\n --worker <NAME> Open the Worker Console by name (attach/restore/create)\n --socket <PATH> Attach a Worker Console to a specific socket with --worker\n --session <UUID> Resume a specific session segment in the Worker Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
);
}
@ -1095,15 +1367,53 @@ mod tests {
}
#[test]
fn parse_backend_runtime_target_requires_complete_identity() {
fn parse_backend_runtime_target_requires_runtime_for_worker_identity() {
let err = parse_args_from(["--backend", "http://127.0.0.1:8787", "--worker-id", "w"])
.unwrap_err();
assert_eq!(
err.to_string(),
"--backend, --runtime-id, and --worker-id are required together"
"--worker-id requires --runtime-id for Backend Runtime API attach"
);
}
#[test]
fn parse_backend_runtime_picker_target_mode() {
match parse_args_from(["--backend", "http://127.0.0.1:8787", "--runtime-id", "r"]).unwrap()
{
Mode::Tui {
mode: LaunchMode::BackendRuntimePicker { target },
..
} => {
assert_eq!(target.base_url, "http://127.0.0.1:8787");
assert_eq!(target.runtime_id.as_deref(), Some("r"));
}
_ => panic!("expected BackendRuntimePicker mode"),
}
}
#[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",
])
.unwrap()
{
Mode::Tui {
mode: LaunchMode::BackendRuntimePicker { target },
..
} => {
assert_eq!(target.base_url, "http://127.0.0.1:8787");
assert_eq!(target.workspace_id.as_deref(), Some("workspace-a"));
assert_eq!(target.runtime_id, None);
}
_ => panic!("expected BackendRuntimePicker mode"),
}
}
#[test]
fn parse_backend_runtime_target_rejects_legacy_socket_mix() {
let err = parse_args_from([

View File

@ -13,9 +13,9 @@ pkgs.mkShell {
];
shellHook = ''
if repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
# export YOI_POD_RUNTIME_COMMAND="$repo_root/target/debug/yoi"
: # export YOI_POD_RUNTIME_COMMAND="$repo_root/target/debug/yoi"
else
# export YOI_POD_RUNTIME_COMMAND="$PWD/target/debug/yoi"
: # export YOI_POD_RUNTIME_COMMAND="$PWD/target/debug/yoi"
fi
echo "dev-shell-loaded"
echo "YOI_POD_RUNTIME_COMMAND=$YOI_POD_RUNTIME_COMMAND"