From 7b29cd68176079b628d901ce91ec3b4ef4410341 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 6 Jun 2026 08:55:40 +0900 Subject: [PATCH] feat: add workspace orchestrator panel lifecycle --- crates/tui/src/multi_pod.rs | 430 +++++++++++++++++++++++++++--- crates/tui/src/single_pod.rs | 2 +- crates/tui/src/workspace_panel.rs | 372 +++++++++++++++++++++++++- 3 files changed, 761 insertions(+), 43 deletions(-) diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index d735a76a..61a9ad7b 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -2,6 +2,8 @@ use std::io; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; +use client::ticket_role::{TicketRole, TicketRoleLaunchContext}; +use client::{PodRuntimeCommand, SpawnConfig, launch_ticket_role_pod, spawn_pod}; use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read}; use pod_store::FsPodStore; use protocol::stream::{JsonLineReader, JsonLineWriter}; @@ -23,8 +25,11 @@ use crate::pod_list::{ read_stored_pod_infos, }; use crate::workspace_panel::{ - ActionPriority, NextUserAction, PanelRow, PanelRowKey, WorkspacePanelViewModel, - build_workspace_panel, + ActionPriority, NextUserAction, OrchestratorLifecyclePlan, OrchestratorPanelState, + OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow, PanelRowKey, + TicketConfigAvailability, WorkspacePanelViewModel, bounded_panel_diagnostic, + build_workspace_panel, decide_orchestrator_lifecycle, orchestrator_pod_presence, + ticket_config_availability, workspace_orchestrator_pod_name, }; const MAX_ENTRIES: usize = 50; @@ -78,15 +83,17 @@ pub(crate) struct OpenPodRequest { pub(crate) socket_override: Option, } -pub(crate) async fn load_app() -> Result { - MultiPodApp::load(None).await +pub(crate) async fn load_app( + runtime_command: PodRuntimeCommand, +) -> Result { + MultiPodApp::load(None, runtime_command).await } pub(crate) async fn run( terminal: &mut Terminal>, app: &mut MultiPodApp, ) -> Result { - if app.panel.rows.is_empty() { + if app.panel.rows.is_empty() && app.panel.header.diagnostics.is_empty() { return Err(MultiPodError::NoPods); } @@ -151,7 +158,9 @@ impl PendingReload { if self.handle.is_some() { return false; } - self.handle = Some(tokio::spawn(async { load_multi_pod_snapshot(None).await })); + self.handle = Some(tokio::spawn(async { + load_multi_pod_snapshot(None, OrchestratorLifecycleMode::Observe).await + })); true } @@ -243,8 +252,15 @@ pub(crate) struct MultiPodApp { } impl MultiPodApp { - async fn load(selected_name: Option) -> Result { - let snapshot = load_multi_pod_snapshot(selected_name).await?; + async fn load( + selected_name: Option, + runtime_command: PodRuntimeCommand, + ) -> Result { + let snapshot = load_multi_pod_snapshot( + selected_name, + OrchestratorLifecycleMode::Ensure { runtime_command }, + ) + .await?; let mut app = Self { list: snapshot.list, panel: snapshot.panel, @@ -258,7 +274,7 @@ impl MultiPodApp { } pub(crate) async fn reload_or_notice(&mut self) { - let result = load_multi_pod_snapshot(None).await; + let result = load_multi_pod_snapshot(None, OrchestratorLifecycleMode::Observe).await; self.apply_reload_result(result); } @@ -395,14 +411,32 @@ impl MultiPodApp { .is_some_and(|key| visible.iter().any(|visible_key| visible_key == key)); if !selected_visible { let has_action_rows = self.panel.rows.iter().any(|row| row.is_ticket_action()); + let orchestrator_pod_name = self + .panel + .header + .orchestrator + .as_ref() + .map(|state| state.pod_name.as_str()); if !has_action_rows { if let Some(selected_name) = self.list.selected_name.as_ref() { - let key = PanelRowKey::Pod(selected_name.clone()); - if visible.iter().any(|visible_key| visible_key == &key) { - self.select_panel_key(key); - return; + if Some(selected_name.as_str()) != orchestrator_pod_name { + let key = PanelRowKey::Pod(selected_name.clone()); + if visible.iter().any(|visible_key| visible_key == &key) { + self.select_panel_key(key); + return; + } } } + if let Some(key) = visible.iter().find(|key| match key { + PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name, + PanelRowKey::Ticket(_) => true, + }) { + self.select_panel_key(key.clone()); + return; + } + self.selected_row = None; + self.list.selected_name = None; + return; } self.select_panel_key(visible[0].clone()); } else if let Some(PanelRowKey::Pod(name)) = self.selected_row.as_ref() { @@ -591,19 +625,231 @@ struct MultiPodSnapshot { panel: WorkspacePanelViewModel, } +#[derive(Debug, Clone)] +enum OrchestratorLifecycleMode { + Ensure { runtime_command: PodRuntimeCommand }, + Observe, +} + async fn load_multi_pod_snapshot( selected_name: Option, + lifecycle_mode: OrchestratorLifecycleMode, ) -> Result { - let list = load_pod_list(selected_name).await?; - let panel = build_workspace_panel(¤t_workspace_root(), &list); + let workspace_root = current_workspace_root(); + let mut list = load_pod_list(selected_name.clone(), MAX_ENTRIES).await?; + let config = ticket_config_availability(&workspace_root); + let orchestrator_pod_name = workspace_orchestrator_pod_name(&workspace_root); + let orchestrator_presence = match &config { + TicketConfigAvailability::Absent | TicketConfigAvailability::Unusable(_) => None, + TicketConfigAvailability::Usable => { + Some(load_exact_pod_presence(&orchestrator_pod_name).await?) + } + }; + let orchestrator = match lifecycle_mode { + OrchestratorLifecycleMode::Ensure { runtime_command } => { + ensure_workspace_orchestrator( + &workspace_root, + config, + orchestrator_pod_name, + orchestrator_presence, + runtime_command, + ) + .await + } + OrchestratorLifecycleMode::Observe => { + observe_workspace_orchestrator(config, orchestrator_pod_name, orchestrator_presence) + } + }; + if orchestrator.reload_pods { + list = load_pod_list(selected_name, MAX_ENTRIES).await?; + } + let mut panel = build_workspace_panel(&workspace_root, &list); + panel.header.orchestrator = orchestrator.state; + panel.header.diagnostics.extend(orchestrator.diagnostics); Ok(MultiPodSnapshot { list, panel }) } +#[derive(Debug, Clone)] +struct OrchestratorLifecycleReport { + state: Option, + diagnostics: Vec, + reload_pods: bool, +} + +impl OrchestratorLifecycleReport { + fn skipped() -> Self { + Self { + state: None, + diagnostics: Vec::new(), + reload_pods: false, + } + } + + fn with_state(state: OrchestratorPanelState) -> Self { + Self { + state: Some(state), + diagnostics: Vec::new(), + reload_pods: false, + } + } + + fn unavailable(pod_name: String, detail: String) -> Self { + let detail = bounded_panel_diagnostic(detail); + Self { + state: Some(OrchestratorPanelState::new( + pod_name, + OrchestratorPanelStatus::Unavailable, + Some(detail.clone()), + )), + diagnostics: vec![detail], + reload_pods: false, + } + } + + fn mark_reload(mut self) -> Self { + self.reload_pods = true; + self + } +} + +async fn ensure_workspace_orchestrator( + workspace_root: &Path, + config: TicketConfigAvailability, + pod_name: String, + presence: Option, + runtime_command: PodRuntimeCommand, +) -> OrchestratorLifecycleReport { + orchestrator_lifecycle(workspace_root, config, pod_name, presence, runtime_command).await +} + +fn observe_workspace_orchestrator( + config: TicketConfigAvailability, + pod_name: String, + presence: Option, +) -> OrchestratorLifecycleReport { + if matches!(config, TicketConfigAvailability::Absent) { + return OrchestratorLifecycleReport::skipped(); + } + if let TicketConfigAvailability::Unusable(message) = config { + return OrchestratorLifecycleReport::unavailable( + pod_name, + format!("Ticket config is unusable; workspace Orchestrator not observed: {message}"), + ); + } + match presence.unwrap_or(OrchestratorPodPresence::Missing) { + OrchestratorPodPresence::Live => OrchestratorLifecycleReport::with_state( + OrchestratorPanelState::new(pod_name, OrchestratorPanelStatus::Live, None), + ), + OrchestratorPodPresence::Restorable => OrchestratorLifecycleReport::with_state( + OrchestratorPanelState::new(pod_name, OrchestratorPanelStatus::Stopped, None), + ), + OrchestratorPodPresence::Missing => OrchestratorLifecycleReport::with_state( + OrchestratorPanelState::new(pod_name, OrchestratorPanelStatus::Missing, None), + ), + OrchestratorPodPresence::Unavailable(message) => { + OrchestratorLifecycleReport::unavailable(pod_name, message) + } + } +} + +async fn orchestrator_lifecycle( + workspace_root: &Path, + config: TicketConfigAvailability, + pod_name: String, + presence: Option, + runtime_command: PodRuntimeCommand, +) -> OrchestratorLifecycleReport { + if matches!(config, TicketConfigAvailability::Absent) { + return OrchestratorLifecycleReport::skipped(); + } + let presence = presence.unwrap_or(OrchestratorPodPresence::Missing); + match decide_orchestrator_lifecycle(&config, &presence) { + OrchestratorLifecyclePlan::SkipNoTicketConfig => OrchestratorLifecycleReport::skipped(), + OrchestratorLifecyclePlan::ReportLive => OrchestratorLifecycleReport::with_state( + OrchestratorPanelState::new(pod_name, OrchestratorPanelStatus::Live, None), + ), + OrchestratorLifecyclePlan::Restore => { + match restore_orchestrator_pod(workspace_root, &pod_name, runtime_command.clone()).await + { + Ok(()) => OrchestratorLifecycleReport::with_state(OrchestratorPanelState::new( + pod_name, + OrchestratorPanelStatus::Restored, + Some("restored existing Pod state".to_string()), + )) + .mark_reload(), + Err(error) => OrchestratorLifecycleReport::unavailable( + pod_name, + format!("could not restore workspace Orchestrator: {error}"), + ), + } + } + OrchestratorLifecyclePlan::Spawn => { + match spawn_orchestrator_pod(workspace_root, &pod_name, runtime_command).await { + Ok(profile) => { + OrchestratorLifecycleReport::with_state(OrchestratorPanelState::new( + pod_name, + OrchestratorPanelStatus::Spawned, + Some(format!("launched with profile {profile}")), + )) + .mark_reload() + } + Err(error) => OrchestratorLifecycleReport::unavailable( + pod_name, + format!("could not spawn workspace Orchestrator: {error}"), + ), + } + } + OrchestratorLifecyclePlan::Unavailable(message) => { + OrchestratorLifecycleReport::unavailable(pod_name, message) + } + } +} + +async fn restore_orchestrator_pod( + workspace_root: &Path, + pod_name: &str, + runtime_command: PodRuntimeCommand, +) -> Result<(), client::SpawnError> { + let config = SpawnConfig { + runtime_command, + pod_name: pod_name.to_string(), + profile: None, + cwd: workspace_root.to_path_buf(), + resume_from: None, + resume_by_pod_name: true, + }; + spawn_pod(config, |_| {}).await.map(|_| ()) +} + +async fn spawn_orchestrator_pod( + workspace_root: &Path, + pod_name: &str, + runtime_command: PodRuntimeCommand, +) -> Result { + let mut context = + TicketRoleLaunchContext::new(workspace_root.to_path_buf(), TicketRole::Orchestrator); + context.pod_name = Some(pod_name.to_string()); + context.user_instruction = Some( + "Workspace panel opened for this Ticket-enabled workspace. Coordinate Ticket routing and wait for explicit follow-up before spawning role Pods." + .to_string(), + ); + let result = launch_ticket_role_pod(context, runtime_command, |_| {}).await?; + Ok(result.plan.profile) +} + fn current_workspace_root() -> PathBuf { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } -async fn load_pod_list(selected_name: Option) -> Result { +async fn load_exact_pod_presence(pod_name: &str) -> Result { + let list = load_pod_list(Some(pod_name.to_string()), usize::MAX).await?; + Ok(orchestrator_pod_presence(pod_name, &list)) +} + +async fn load_pod_list( + selected_name: Option, + max_entries: usize, +) -> Result { let store_dir = default_store_dir()?; let store = FsStore::new(&store_dir)?; let pod_store = FsPodStore::new(default_pod_store_dir()?).map_err(io::Error::other)?; @@ -616,7 +862,7 @@ async fn load_pod_list(selected_name: Option) -> Result, app: &mut MultiPodApp) { .apply_cursor_viewport(&mut input_render, input_height); let layout = multi_pod_layout(area, input_height); - draw_title(frame, layout.title); + draw_title(frame, app, layout.title); draw_list(frame, app, layout.list); draw_separator(frame, layout.boundary); draw_target_status(frame, app, layout.target_status); @@ -975,20 +1221,42 @@ fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) - needed.clamp(1, cap) } -fn draw_title(frame: &mut Frame<'_>, area: Rect) { - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled( - "workspace dashboard", - Style::default().add_modifier(Modifier::BOLD), - ), - Span::styled( - " Ticket actions are display-only · Enter sends to selected idle Pod · o open/attach · r refresh", - Style::default().fg(Color::DarkGray), - ), - ])), - area, - ); +fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { + let guidance = if app.panel.header.ticket_configured { + " Ticket actions are display-only · Enter sends to selected idle Pod · o open/attach · r refresh" + } else { + " Pod-centric view · Enter sends to selected idle Pod · o open/attach · r refresh" + }; + let mut spans = vec![ + Span::styled( + "workspace dashboard", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled(guidance, Style::default().fg(Color::DarkGray)), + ]; + if let Some(orchestrator) = &app.panel.header.orchestrator { + spans.push(Span::styled( + " · orchestrator ", + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::styled( + orchestrator.status.label(), + orchestrator_status_style(orchestrator.status), + )); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn orchestrator_status_style(status: OrchestratorPanelStatus) -> Style { + match status { + OrchestratorPanelStatus::Live + | OrchestratorPanelStatus::Restored + | OrchestratorPanelStatus::Spawned => Style::default().fg(Color::Green), + OrchestratorPanelStatus::Stopped | OrchestratorPanelStatus::Missing => { + Style::default().fg(Color::Yellow) + } + OrchestratorPanelStatus::Unavailable => Style::default().fg(Color::Red), + } } fn draw_list(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { @@ -1002,6 +1270,7 @@ fn draw_list(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) { fn list_lines(app: &MultiPodApp, width: u16, height: u16) -> Vec> { let sections = sectioned_entries(&app.list); let selected = app.selected_row.as_ref(); + let diagnostic_lines = panel_diagnostic_lines(&app.panel, width); let action_lines = panel_action_lines(&app.panel, selected, width); let live_lines = sections .iter() @@ -1015,15 +1284,18 @@ fn list_lines(app: &MultiPodApp, width: u16, height: u16) -> Vec> .unwrap_or_default(); let available = height as usize; - let action_len = action_lines.len().min(available); - let remaining_after_actions = available.saturating_sub(action_len); + let diagnostic_len = diagnostic_lines.len().min(available); + let remaining_after_diagnostics = available.saturating_sub(diagnostic_len); + let action_len = action_lines.len().min(remaining_after_diagnostics); + let remaining_after_actions = remaining_after_diagnostics.saturating_sub(action_len); let closed_len = closed_lines.len().min(remaining_after_actions); let live_len = live_lines .len() .min(remaining_after_actions.saturating_sub(closed_len)); - let spacer_len = available.saturating_sub(action_len + live_len + closed_len); + let spacer_len = available.saturating_sub(diagnostic_len + action_len + live_len + closed_len); let mut lines = Vec::with_capacity(available); + lines.extend(diagnostic_lines.into_iter().take(diagnostic_len)); lines.extend(action_lines.into_iter().take(action_len)); lines.extend(live_lines.into_iter().take(live_len)); lines.extend(std::iter::repeat_with(|| Line::from(Span::raw(""))).take(spacer_len)); @@ -1031,6 +1303,23 @@ fn list_lines(app: &MultiPodApp, width: u16, height: u16) -> Vec> lines } +fn panel_diagnostic_lines(panel: &WorkspacePanelViewModel, width: u16) -> Vec> { + panel + .header + .diagnostics + .iter() + .map(|diagnostic| { + Line::from(vec![ + Span::styled("⚠ ", Style::default().fg(Color::Yellow)), + Span::styled( + truncate_with_ellipsis(diagnostic, width.saturating_sub(2) as usize), + Style::default().fg(Color::Yellow), + ), + ]) + }) + .collect() +} + fn panel_action_lines( panel: &WorkspacePanelViewModel, selected: Option<&PanelRowKey>, @@ -1673,6 +1962,77 @@ mod tests { assert_eq!(app.list.selected_entry().unwrap().name, "closed-2"); } + #[test] + fn multi_selection_does_not_default_to_orchestrator_only_row() { + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info("test-orchestrator", PodStatus::Idle)], + None, + 10, + ); + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.header.orchestrator = Some(OrchestratorPanelState::new( + "test-orchestrator", + OrchestratorPanelStatus::Live, + None, + )); + let app = app_with_panel(list, panel); + + assert!(app.selected_row.is_none()); + assert!(app.list.selected_name.is_none()); + assert_eq!(app.selected_send_eligibility(), SendEligibility::Disabled); + } + + #[test] + fn multi_selection_prefers_non_orchestrator_pod_by_default() { + let list = PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![ + live_info_with_updated_at("test-orchestrator", PodStatus::Idle, 80), + live_info_with_updated_at("worker", PodStatus::Idle, 70), + ], + None, + 10, + ); + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel.header.orchestrator = Some(OrchestratorPanelState::new( + "test-orchestrator", + OrchestratorPanelStatus::Live, + None, + )); + let app = app_with_panel(list, panel); + + assert_eq!(app.list.selected_entry().unwrap().name, "worker"); + } + + #[test] + fn multi_list_renders_workspace_diagnostics_before_rows() { + let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); + panel + .header + .diagnostics + .push("Ticket config is unusable".to_string()); + let app = app_with_panel( + PodList::from_sources( + PodVisibilitySource::ResumePicker, + vec![], + vec![live_info("idle", PodStatus::Idle)], + None, + 10, + ), + panel, + ); + let lines = list_lines(&app, 80, 4) + .into_iter() + .map(|line| plain_line(&line)) + .collect::>(); + + assert!(lines[0].contains("Ticket config is unusable")); + assert!(lines.iter().any(|line| line.contains("idle"))); + } + #[test] fn multi_list_pins_closed_section_below_live_flexible_area() { let list = PodList::from_sources( diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index e14574fb..9d4ab2a9 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -187,7 +187,7 @@ pub(crate) async fn run_resume( pub(crate) async fn run_panel( runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { - let mut app = multi_pod::load_app().await?; + let mut app = multi_pod::load_app(runtime_command.clone()).await?; let mut terminal = enter_fullscreen()?; loop { diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index b407e0e0..e7a8c09e 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -26,6 +26,8 @@ impl WorkspacePanelViewModel { .to_string(), ticket_root: workspace_root .join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH), + ticket_configured: false, + orchestrator: None, diagnostics: Vec::new(), }, rows: Vec::new(), @@ -41,9 +43,55 @@ impl WorkspacePanelViewModel { pub(crate) struct WorkspacePanelHeader { pub(crate) workspace_label: String, pub(crate) ticket_root: PathBuf, + pub(crate) ticket_configured: bool, + pub(crate) orchestrator: Option, pub(crate) diagnostics: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct OrchestratorPanelState { + pub(crate) pod_name: String, + pub(crate) status: OrchestratorPanelStatus, + pub(crate) detail: Option, +} + +impl OrchestratorPanelState { + pub(crate) fn new( + pod_name: impl Into, + status: OrchestratorPanelStatus, + detail: Option, + ) -> Self { + Self { + pod_name: pod_name.into(), + status, + detail, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OrchestratorPanelStatus { + Live, + Restored, + Spawned, + Stopped, + Missing, + Unavailable, +} + +impl OrchestratorPanelStatus { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Live => "live", + Self::Restored => "restored", + Self::Spawned => "spawned", + Self::Stopped => "stopped/restorable", + Self::Missing => "missing", + Self::Unavailable => "unavailable", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(crate) enum PanelRowKey { Ticket(String), @@ -185,20 +233,192 @@ impl PanelRow { } } +const MAX_POD_NAME_CHARS: usize = 80; +const ORCHESTRATOR_SUFFIX: &str = "-orchestrator"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TicketConfigAvailability { + Absent, + Usable, + Unusable(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum OrchestratorPodPresence { + Live, + Restorable, + Missing, + Unavailable(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum OrchestratorLifecyclePlan { + SkipNoTicketConfig, + ReportLive, + Restore, + Spawn, + Unavailable(String), +} + +pub(crate) fn workspace_orchestrator_pod_name(workspace_root: &Path) -> String { + let seed = workspace_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace"); + let max_component_chars = MAX_POD_NAME_CHARS.saturating_sub(ORCHESTRATOR_SUFFIX.len()); + let component = sanitise_pod_name_component(seed, max_component_chars) + .filter(|component| !component.is_empty()) + .unwrap_or_else(|| "workspace".to_string()); + format!("{component}{ORCHESTRATOR_SUFFIX}") +} + +fn sanitise_pod_name_component(value: &str, max_chars: usize) -> Option { + let mut out = String::new(); + let mut last_was_dash = false; + for ch in value.trim().chars() { + let mapped = if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.') { + ch.to_ascii_lowercase() + } else { + '-' + }; + if mapped == '-' { + if !last_was_dash && !out.is_empty() { + out.push(mapped); + } + last_was_dash = true; + } else { + out.push(mapped); + last_was_dash = false; + } + } + let trimmed = out.trim_matches(|ch| matches!(ch, '-' | '_' | '.')); + if trimmed.is_empty() { + None + } else { + Some(trimmed.chars().take(max_chars).collect()) + } +} + +pub(crate) fn orchestrator_pod_presence(pod_name: &str, pods: &PodList) -> OrchestratorPodPresence { + let Some(entry) = pods.entries.iter().find(|entry| entry.name == pod_name) else { + return OrchestratorPodPresence::Missing; + }; + if entry.live.as_ref().is_some_and(|live| live.reachable) { + return OrchestratorPodPresence::Live; + } + if entry.actions.can_restore { + return OrchestratorPodPresence::Restorable; + } + let reason = entry + .actions + .disabled_reason + .clone() + .or_else(|| { + entry + .diagnostics + .first() + .map(|diagnostic| diagnostic.message.clone()) + }) + .unwrap_or_else(|| "pod state is not live, restorable, or spawn-safe".to_string()); + OrchestratorPodPresence::Unavailable(reason) +} + +pub(crate) fn decide_orchestrator_lifecycle( + config: &TicketConfigAvailability, + presence: &OrchestratorPodPresence, +) -> OrchestratorLifecyclePlan { + match config { + TicketConfigAvailability::Absent => OrchestratorLifecyclePlan::SkipNoTicketConfig, + TicketConfigAvailability::Unusable(message) => OrchestratorLifecyclePlan::Unavailable( + format!("Ticket config is unusable; workspace Orchestrator not started: {message}"), + ), + TicketConfigAvailability::Usable => match presence { + OrchestratorPodPresence::Live => OrchestratorLifecyclePlan::ReportLive, + OrchestratorPodPresence::Restorable => OrchestratorLifecyclePlan::Restore, + OrchestratorPodPresence::Missing => OrchestratorLifecyclePlan::Spawn, + OrchestratorPodPresence::Unavailable(message) => { + OrchestratorLifecyclePlan::Unavailable(format!( + "Workspace Orchestrator Pod state is unusable: {message}" + )) + } + }, + } +} + +pub(crate) fn ticket_config_availability(workspace_root: &Path) -> TicketConfigAvailability { + let config_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); + match config_path.symlink_metadata() { + Ok(metadata) if !metadata.is_file() => TicketConfigAvailability::Unusable(format!( + "{} exists but is not a regular file", + TICKET_CONFIG_RELATIVE_PATH + )), + Ok(_) => match TicketConfig::load_workspace(workspace_root) { + Ok(_) => TicketConfigAvailability::Usable, + Err(error) => TicketConfigAvailability::Unusable(error.to_string()), + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + TicketConfigAvailability::Absent + } + Err(error) => TicketConfigAvailability::Unusable(format!( + "could not inspect {}: {error}", + TICKET_CONFIG_RELATIVE_PATH + )), + } +} + +pub(crate) fn bounded_panel_diagnostic(message: impl AsRef) -> String { + let collapsed = message + .as_ref() + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" "); + excerpt(&collapsed, 180).unwrap_or_else(|| "unknown diagnostic".to_string()) +} + pub(crate) fn build_workspace_panel( workspace_root: &Path, pods: &PodList, ) -> WorkspacePanelViewModel { let mut model = WorkspacePanelViewModel::empty(workspace_root); - let ticket_config_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); - if ticket_config_path.is_file() { - if let Ok(config) = TicketConfig::load_workspace(workspace_root) { - model.header.ticket_root = config.backend_root().to_path_buf(); - let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()); - if let Ok(rows) = build_ticket_rows(&backend, pods) { - model.rows.extend(rows); + match ticket_config_availability(workspace_root) { + TicketConfigAvailability::Absent => {} + TicketConfigAvailability::Usable => { + model.header.ticket_configured = true; + match TicketConfig::load_workspace(workspace_root) { + Ok(config) => { + model.header.ticket_root = config.backend_root().to_path_buf(); + let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()); + match build_ticket_rows(&backend, pods) { + Ok(rows) => model.rows.extend(rows), + Err(error) => { + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Ticket rows unavailable: {error}" + ))) + } + } + } + Err(error) => model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Ticket config is unusable: {error}" + ))), } } + TicketConfigAvailability::Unusable(message) => { + model.header.ticket_configured = true; + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Ticket config is unusable: {message}" + ))); + } } model.rows.extend(pod_rows(pods)); @@ -863,4 +1083,142 @@ mod tests { assert_eq!(close.priority, ActionPriority::Decision); assert_eq!(close.next_action, Some(NextUserAction::Close)); } + + #[test] + fn workspace_orchestrator_pod_name_is_stable_and_safe() { + assert_eq!( + workspace_orchestrator_pod_name(Path::new("/tmp/Yoi Workspace")), + "yoi-workspace-orchestrator" + ); + assert_eq!( + workspace_orchestrator_pod_name(Path::new("/tmp/.strange_日本語!!")), + "strange-orchestrator" + ); + assert_eq!( + workspace_orchestrator_pod_name(Path::new("/tmp/___")), + "workspace-orchestrator" + ); + let long = "a".repeat(120); + let name = workspace_orchestrator_pod_name(&PathBuf::from(format!("/tmp/{long}"))); + assert_eq!(name.chars().count(), 80); + assert!(name.ends_with("-orchestrator")); + } + + #[test] + fn orchestrator_lifecycle_decisions_follow_ticket_gate_and_pod_state() { + assert_eq!( + decide_orchestrator_lifecycle( + &TicketConfigAvailability::Absent, + &OrchestratorPodPresence::Missing, + ), + OrchestratorLifecyclePlan::SkipNoTicketConfig + ); + assert!(matches!( + decide_orchestrator_lifecycle( + &TicketConfigAvailability::Unusable("bad config".to_string()), + &OrchestratorPodPresence::Missing, + ), + OrchestratorLifecyclePlan::Unavailable(message) if message.contains("bad config") + )); + assert_eq!( + decide_orchestrator_lifecycle( + &TicketConfigAvailability::Usable, + &OrchestratorPodPresence::Live, + ), + OrchestratorLifecyclePlan::ReportLive + ); + assert_eq!( + decide_orchestrator_lifecycle( + &TicketConfigAvailability::Usable, + &OrchestratorPodPresence::Restorable, + ), + OrchestratorLifecyclePlan::Restore + ); + assert_eq!( + decide_orchestrator_lifecycle( + &TicketConfigAvailability::Usable, + &OrchestratorPodPresence::Missing, + ), + OrchestratorLifecyclePlan::Spawn + ); + assert!(matches!( + decide_orchestrator_lifecycle( + &TicketConfigAvailability::Usable, + &OrchestratorPodPresence::Unavailable("corrupt metadata".to_string()), + ), + OrchestratorLifecyclePlan::Unavailable(message) if message.contains("corrupt metadata") + )); + } + + #[test] + fn existing_non_file_ticket_config_is_unusable_not_absent() { + let temp = TempDir::new().unwrap(); + let config_parent = temp.path().join(".yoi"); + fs::create_dir_all(&config_parent).unwrap(); + fs::create_dir(config_parent.join("ticket.config.toml")).unwrap(); + + assert!(matches!( + ticket_config_availability(temp.path()), + TicketConfigAvailability::Unusable(message) if message.contains("not a regular file") + )); + let model = build_workspace_panel(temp.path(), &empty_pods()); + + assert!(model.header.ticket_configured); + assert!(model.header.diagnostics.iter().any(|diagnostic| { + diagnostic.contains("Ticket config is unusable") + && diagnostic.contains("not a regular file") + })); + } + + #[test] + fn orchestrator_presence_can_be_decided_from_untruncated_authority() { + let live = (0..60) + .map(|index| LivePodInfo { + pod_name: format!("pod-{index:02}"), + socket_path: PathBuf::from(format!("/tmp/pod-{index:02}.sock")), + status: Some(PodStatus::Idle), + reachable: true, + segment_id: None, + summary: PodEntrySummary::default(), + }) + .chain(std::iter::once(LivePodInfo { + pod_name: "zz-workspace-orchestrator".to_string(), + socket_path: PathBuf::from("/tmp/zz-workspace-orchestrator.sock"), + status: Some(PodStatus::Idle), + reachable: true, + segment_id: None, + summary: PodEntrySummary::default(), + })) + .collect::>(); + let visible = PodList::from_sources( + crate::pod_list::PodVisibilitySource::ResumePicker, + vec![], + live.clone(), + None, + 50, + ); + let authority = PodList::from_sources( + crate::pod_list::PodVisibilitySource::ResumePicker, + vec![], + live, + None, + usize::MAX, + ); + + assert_eq!( + orchestrator_pod_presence("zz-workspace-orchestrator", &visible), + OrchestratorPodPresence::Missing + ); + assert_eq!( + orchestrator_pod_presence("zz-workspace-orchestrator", &authority), + OrchestratorPodPresence::Live + ); + assert_eq!( + decide_orchestrator_lifecycle( + &TicketConfigAvailability::Usable, + &orchestrator_pod_presence("zz-workspace-orchestrator", &authority), + ), + OrchestratorLifecyclePlan::ReportLive + ); + } }