feat: surface backend runtime workers in tui
This commit is contained in:
parent
2b307b8040
commit
f79892baef
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -5815,6 +5815,7 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"ticket",
|
"ticket",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml",
|
||||||
"tui",
|
"tui",
|
||||||
"worker",
|
"worker",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
#[derive(Debug)]
|
||||||
pub struct BackendRuntimeClient {
|
pub struct BackendRuntimeClient {
|
||||||
target: BackendRuntimeTarget,
|
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 {
|
impl BackendRuntimeClient {
|
||||||
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
||||||
validate_target(&target)?;
|
validate_target(&target)?;
|
||||||
|
|
@ -375,6 +557,50 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
|
||||||
Ok(())
|
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 {
|
fn observation_ws_url(target: &BackendRuntimeTarget) -> String {
|
||||||
let path = format!(
|
let path = format!(
|
||||||
"/api/runtimes/{}/workers/{}/events/ws",
|
"/api/runtimes/{}/workers/{}/events/ws",
|
||||||
|
|
@ -449,10 +675,12 @@ struct WorkerLifecycleResult {
|
||||||
diagnostics: Vec<BackendDiagnostic>,
|
diagnostics: Vec<BackendDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
struct BackendDiagnostic {
|
pub struct BackendDiagnostic {
|
||||||
code: String,
|
pub code: String,
|
||||||
message: String,
|
#[serde(default)]
|
||||||
|
pub severity: Option<String>,
|
||||||
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[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]
|
#[test]
|
||||||
fn observation_url_uses_backend_runtime_worker_identity() {
|
fn observation_url_uses_backend_runtime_worker_identity() {
|
||||||
let target =
|
let target =
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ pub mod spawn;
|
||||||
pub mod ticket_role;
|
pub mod ticket_role;
|
||||||
mod worker_client;
|
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 runtime_command::WorkerRuntimeCommand;
|
||||||
|
|
||||||
pub use spawn::{
|
pub use spawn::{
|
||||||
|
|
|
||||||
271
crates/tui/src/backend_worker_picker.rs
Normal file
271
crates/tui/src/backend_worker_picker.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod app;
|
mod app;
|
||||||
|
mod backend_worker_picker;
|
||||||
mod block;
|
mod block;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod command;
|
mod command;
|
||||||
|
|
@ -33,7 +34,7 @@ use crossterm::execute;
|
||||||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
|
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
|
|
||||||
use client::{BackendRuntimeTarget, WorkerRuntimeCommand};
|
use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LaunchOptions {
|
pub struct LaunchOptions {
|
||||||
|
|
@ -55,6 +56,9 @@ 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,
|
||||||
|
/// 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
|
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: connect through the
|
||||||
/// Workspace Backend Runtime API and observe the Backend-proxied event stream.
|
/// Workspace Backend Runtime API and observe the Backend-proxied event stream.
|
||||||
BackendRuntime { target: BackendRuntimeTarget },
|
BackendRuntime { target: BackendRuntimeTarget },
|
||||||
|
|
@ -106,6 +110,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||||
worker_name,
|
worker_name,
|
||||||
socket_override,
|
socket_override,
|
||||||
} => console::run_worker_name(worker_name, socket_override, runtime_command).await,
|
} => 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::BackendRuntime { target } => console::run_backend_runtime(target).await,
|
||||||
LaunchMode::Resume { all } => {
|
LaunchMode::Resume { all } => {
|
||||||
console::run_resume(runtime_command, workspace_root.clone(), all).await
|
console::run_resume(runtime_command, workspace_root.clone(), all).await
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
|
toml = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,16 @@ mod session_cli;
|
||||||
mod ticket_cli;
|
mod ticket_cli;
|
||||||
mod worker_cleanup_cli;
|
mod worker_cleanup_cli;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::path::PathBuf;
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, ExitCode};
|
use std::process::{Command, ExitCode};
|
||||||
|
|
||||||
use client::{BackendRuntimeTarget, WorkerRuntimeCommand};
|
use client::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||||
use memory_lint::{LintCliOptions, LintStatus};
|
use memory_lint::{LintCliOptions, LintStatus};
|
||||||
|
use serde::Deserialize;
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
use tui::{LaunchMode, LaunchOptions};
|
use tui::{LaunchMode, LaunchOptions};
|
||||||
|
|
||||||
|
|
@ -207,6 +210,7 @@ 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..]),
|
||||||
|
"workers" => return parse_workers_args(&args[1..]),
|
||||||
"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()))?
|
||||||
|
|
@ -290,6 +294,7 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
let mut worker_name = None;
|
let mut worker_name = None;
|
||||||
let mut socket_override = None;
|
let mut socket_override = None;
|
||||||
let mut backend_url = None;
|
let mut backend_url = None;
|
||||||
|
let mut workspace_id = None;
|
||||||
let mut runtime_id = None;
|
let mut runtime_id = None;
|
||||||
let mut worker_id = None;
|
let mut worker_id = None;
|
||||||
let mut profile = None;
|
let mut profile = None;
|
||||||
|
|
@ -335,6 +340,16 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
workspace_root = PathBuf::from(value);
|
workspace_root = PathBuf::from(value);
|
||||||
i += 2;
|
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" => {
|
"--backend" => {
|
||||||
let value = args
|
let value = args
|
||||||
.get(i + 1)
|
.get(i + 1)
|
||||||
|
|
@ -407,6 +422,14 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
workspace_root = PathBuf::from(value);
|
workspace_root = PathBuf::from(value);
|
||||||
i += 1;
|
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=") => {
|
arg if arg.starts_with("--backend=") => {
|
||||||
let value = arg.trim_start_matches("--backend=");
|
let value = arg.trim_start_matches("--backend=");
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
|
|
@ -458,13 +481,13 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let backend_target_present =
|
let backend_target_present = backend_url.is_some()
|
||||||
backend_url.is_some() || runtime_id.is_some() || worker_id.is_some();
|
|| workspace_id.is_some()
|
||||||
if backend_target_present
|
|| runtime_id.is_some()
|
||||||
&& (backend_url.is_none() || runtime_id.is_none() || worker_id.is_none())
|
|| worker_id.is_some();
|
||||||
{
|
if backend_target_present && worker_id.is_some() && runtime_id.is_none() {
|
||||||
return Err(ParseError(
|
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
|
if backend_target_present
|
||||||
|
|
@ -493,13 +516,22 @@ fn parse_console_options(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if backend_target_present {
|
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 {
|
return Ok(Mode::Tui {
|
||||||
mode: LaunchMode::BackendRuntime {
|
mode: LaunchMode::BackendRuntime {
|
||||||
target: BackendRuntimeTarget::new(
|
target: BackendRuntimeTarget::new(backend_url, runtime_id, worker_id),
|
||||||
backend_url.expect("checked by backend_target_present"),
|
},
|
||||||
runtime_id.expect("checked by backend_target_present"),
|
workspace_root,
|
||||||
worker_id.expect("checked by backend_target_present"),
|
});
|
||||||
),
|
}
|
||||||
|
return Ok(Mode::Tui {
|
||||||
|
mode: LaunchMode::BackendRuntimePicker {
|
||||||
|
target: BackendRuntimeListTarget::new(backend_url, workspace_id, runtime_id),
|
||||||
},
|
},
|
||||||
workspace_root,
|
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> {
|
fn parse_resume_args(args: &[String]) -> 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;
|
||||||
|
|
@ -607,6 +758,127 @@ fn current_dir() -> Result<PathBuf, ParseError> {
|
||||||
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))
|
.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> {
|
fn parse_workspace_args(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
|
|
@ -1024,9 +1296,9 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
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 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]
|
#[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"])
|
let err = parse_args_from(["--backend", "http://127.0.0.1:8787", "--worker-id", "w"])
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
err.to_string(),
|
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]
|
#[test]
|
||||||
fn parse_backend_runtime_target_rejects_legacy_socket_mix() {
|
fn parse_backend_runtime_target_rejects_legacy_socket_mix() {
|
||||||
let err = parse_args_from([
|
let err = parse_args_from([
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ pkgs.mkShell {
|
||||||
];
|
];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
if repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
|
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
|
else
|
||||||
# export YOI_POD_RUNTIME_COMMAND="$PWD/target/debug/yoi"
|
: # export YOI_POD_RUNTIME_COMMAND="$PWD/target/debug/yoi"
|
||||||
fi
|
fi
|
||||||
echo "dev-shell-loaded"
|
echo "dev-shell-loaded"
|
||||||
echo "YOI_POD_RUNTIME_COMMAND=$YOI_POD_RUNTIME_COMMAND"
|
echo "YOI_POD_RUNTIME_COMMAND=$YOI_POD_RUNTIME_COMMAND"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user