feat: add workspace orchestrator panel lifecycle

This commit is contained in:
Keisuke Hirata 2026-06-06 08:55:40 +09:00
parent 17970feb21
commit 7b29cd6817
No known key found for this signature in database
3 changed files with 761 additions and 43 deletions

View File

@ -2,6 +2,8 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, Instant}; 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 crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
use pod_store::FsPodStore; use pod_store::FsPodStore;
use protocol::stream::{JsonLineReader, JsonLineWriter}; use protocol::stream::{JsonLineReader, JsonLineWriter};
@ -23,8 +25,11 @@ use crate::pod_list::{
read_stored_pod_infos, read_stored_pod_infos,
}; };
use crate::workspace_panel::{ use crate::workspace_panel::{
ActionPriority, NextUserAction, PanelRow, PanelRowKey, WorkspacePanelViewModel, ActionPriority, NextUserAction, OrchestratorLifecyclePlan, OrchestratorPanelState,
build_workspace_panel, 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; const MAX_ENTRIES: usize = 50;
@ -78,15 +83,17 @@ pub(crate) struct OpenPodRequest {
pub(crate) socket_override: Option<PathBuf>, pub(crate) socket_override: Option<PathBuf>,
} }
pub(crate) async fn load_app() -> Result<MultiPodApp, MultiPodError> { pub(crate) async fn load_app(
MultiPodApp::load(None).await runtime_command: PodRuntimeCommand,
) -> Result<MultiPodApp, MultiPodError> {
MultiPodApp::load(None, runtime_command).await
} }
pub(crate) async fn run( pub(crate) async fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut MultiPodApp, app: &mut MultiPodApp,
) -> Result<MultiPodOutcome, MultiPodError> { ) -> Result<MultiPodOutcome, MultiPodError> {
if app.panel.rows.is_empty() { if app.panel.rows.is_empty() && app.panel.header.diagnostics.is_empty() {
return Err(MultiPodError::NoPods); return Err(MultiPodError::NoPods);
} }
@ -151,7 +158,9 @@ impl PendingReload {
if self.handle.is_some() { if self.handle.is_some() {
return false; 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 true
} }
@ -243,8 +252,15 @@ pub(crate) struct MultiPodApp {
} }
impl MultiPodApp { impl MultiPodApp {
async fn load(selected_name: Option<String>) -> Result<Self, MultiPodError> { async fn load(
let snapshot = load_multi_pod_snapshot(selected_name).await?; selected_name: Option<String>,
runtime_command: PodRuntimeCommand,
) -> Result<Self, MultiPodError> {
let snapshot = load_multi_pod_snapshot(
selected_name,
OrchestratorLifecycleMode::Ensure { runtime_command },
)
.await?;
let mut app = Self { let mut app = Self {
list: snapshot.list, list: snapshot.list,
panel: snapshot.panel, panel: snapshot.panel,
@ -258,7 +274,7 @@ impl MultiPodApp {
} }
pub(crate) async fn reload_or_notice(&mut self) { 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); self.apply_reload_result(result);
} }
@ -395,14 +411,32 @@ impl MultiPodApp {
.is_some_and(|key| visible.iter().any(|visible_key| visible_key == key)); .is_some_and(|key| visible.iter().any(|visible_key| visible_key == key));
if !selected_visible { if !selected_visible {
let has_action_rows = self.panel.rows.iter().any(|row| row.is_ticket_action()); 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 !has_action_rows {
if let Some(selected_name) = self.list.selected_name.as_ref() { if let Some(selected_name) = self.list.selected_name.as_ref() {
let key = PanelRowKey::Pod(selected_name.clone()); if Some(selected_name.as_str()) != orchestrator_pod_name {
if visible.iter().any(|visible_key| visible_key == &key) { let key = PanelRowKey::Pod(selected_name.clone());
self.select_panel_key(key); if visible.iter().any(|visible_key| visible_key == &key) {
return; 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()); self.select_panel_key(visible[0].clone());
} else if let Some(PanelRowKey::Pod(name)) = self.selected_row.as_ref() { } else if let Some(PanelRowKey::Pod(name)) = self.selected_row.as_ref() {
@ -591,19 +625,231 @@ struct MultiPodSnapshot {
panel: WorkspacePanelViewModel, panel: WorkspacePanelViewModel,
} }
#[derive(Debug, Clone)]
enum OrchestratorLifecycleMode {
Ensure { runtime_command: PodRuntimeCommand },
Observe,
}
async fn load_multi_pod_snapshot( async fn load_multi_pod_snapshot(
selected_name: Option<String>, selected_name: Option<String>,
lifecycle_mode: OrchestratorLifecycleMode,
) -> Result<MultiPodSnapshot, MultiPodError> { ) -> Result<MultiPodSnapshot, MultiPodError> {
let list = load_pod_list(selected_name).await?; let workspace_root = current_workspace_root();
let panel = build_workspace_panel(&current_workspace_root(), &list); 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 }) Ok(MultiPodSnapshot { list, panel })
} }
#[derive(Debug, Clone)]
struct OrchestratorLifecycleReport {
state: Option<OrchestratorPanelState>,
diagnostics: Vec<String>,
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<OrchestratorPodPresence>,
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<OrchestratorPodPresence>,
) -> 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<OrchestratorPodPresence>,
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<String, client::TicketRoleLaunchError> {
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 { fn current_workspace_root() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
} }
async fn load_pod_list(selected_name: Option<String>) -> Result<PodList, MultiPodError> { async fn load_exact_pod_presence(pod_name: &str) -> Result<OrchestratorPodPresence, MultiPodError> {
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<String>,
max_entries: usize,
) -> Result<PodList, MultiPodError> {
let store_dir = default_store_dir()?; let store_dir = default_store_dir()?;
let store = FsStore::new(&store_dir)?; let store = FsStore::new(&store_dir)?;
let pod_store = FsPodStore::new(default_pod_store_dir()?).map_err(io::Error::other)?; 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<String>) -> Result<PodList, MultiPo
stored, stored,
live, live,
selected_name, selected_name,
MAX_ENTRIES, max_entries,
)) ))
} }
@ -961,7 +1207,7 @@ fn draw(frame: &mut Frame<'_>, app: &mut MultiPodApp) {
.apply_cursor_viewport(&mut input_render, input_height); .apply_cursor_viewport(&mut input_render, input_height);
let layout = multi_pod_layout(area, 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_list(frame, app, layout.list);
draw_separator(frame, layout.boundary); draw_separator(frame, layout.boundary);
draw_target_status(frame, app, layout.target_status); 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) needed.clamp(1, cap)
} }
fn draw_title(frame: &mut Frame<'_>, area: Rect) { fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
frame.render_widget( let guidance = if app.panel.header.ticket_configured {
Paragraph::new(Line::from(vec![ " Ticket actions are display-only · Enter sends to selected idle Pod · o open/attach · r refresh"
Span::styled( } else {
"workspace dashboard", " Pod-centric view · Enter sends to selected idle Pod · o open/attach · r refresh"
Style::default().add_modifier(Modifier::BOLD), };
), let mut spans = vec![
Span::styled( Span::styled(
" Ticket actions are display-only · Enter sends to selected idle Pod · o open/attach · r refresh", "workspace dashboard",
Style::default().fg(Color::DarkGray), Style::default().add_modifier(Modifier::BOLD),
), ),
])), Span::styled(guidance, Style::default().fg(Color::DarkGray)),
area, ];
); 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) { 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<Line<'static>> { fn list_lines(app: &MultiPodApp, width: u16, height: u16) -> Vec<Line<'static>> {
let sections = sectioned_entries(&app.list); let sections = sectioned_entries(&app.list);
let selected = app.selected_row.as_ref(); 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 action_lines = panel_action_lines(&app.panel, selected, width);
let live_lines = sections let live_lines = sections
.iter() .iter()
@ -1015,15 +1284,18 @@ fn list_lines(app: &MultiPodApp, width: u16, height: u16) -> Vec<Line<'static>>
.unwrap_or_default(); .unwrap_or_default();
let available = height as usize; let available = height as usize;
let action_len = action_lines.len().min(available); let diagnostic_len = diagnostic_lines.len().min(available);
let remaining_after_actions = available.saturating_sub(action_len); 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 closed_len = closed_lines.len().min(remaining_after_actions);
let live_len = live_lines let live_len = live_lines
.len() .len()
.min(remaining_after_actions.saturating_sub(closed_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); 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(action_lines.into_iter().take(action_len));
lines.extend(live_lines.into_iter().take(live_len)); lines.extend(live_lines.into_iter().take(live_len));
lines.extend(std::iter::repeat_with(|| Line::from(Span::raw(""))).take(spacer_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<Line<'static>>
lines lines
} }
fn panel_diagnostic_lines(panel: &WorkspacePanelViewModel, width: u16) -> Vec<Line<'static>> {
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( fn panel_action_lines(
panel: &WorkspacePanelViewModel, panel: &WorkspacePanelViewModel,
selected: Option<&PanelRowKey>, selected: Option<&PanelRowKey>,
@ -1673,6 +1962,77 @@ mod tests {
assert_eq!(app.list.selected_entry().unwrap().name, "closed-2"); 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::<Vec<_>>();
assert!(lines[0].contains("Ticket config is unusable"));
assert!(lines.iter().any(|line| line.contains("idle")));
}
#[test] #[test]
fn multi_list_pins_closed_section_below_live_flexible_area() { fn multi_list_pins_closed_section_below_live_flexible_area() {
let list = PodList::from_sources( let list = PodList::from_sources(

View File

@ -187,7 +187,7 @@ pub(crate) async fn run_resume(
pub(crate) async fn run_panel( pub(crate) async fn run_panel(
runtime_command: PodRuntimeCommand, runtime_command: PodRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut app = multi_pod::load_app().await?; let mut app = multi_pod::load_app(runtime_command.clone()).await?;
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
loop { loop {

View File

@ -26,6 +26,8 @@ impl WorkspacePanelViewModel {
.to_string(), .to_string(),
ticket_root: workspace_root ticket_root: workspace_root
.join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH), .join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
ticket_configured: false,
orchestrator: None,
diagnostics: Vec::new(), diagnostics: Vec::new(),
}, },
rows: Vec::new(), rows: Vec::new(),
@ -41,9 +43,55 @@ impl WorkspacePanelViewModel {
pub(crate) struct WorkspacePanelHeader { pub(crate) struct WorkspacePanelHeader {
pub(crate) workspace_label: String, pub(crate) workspace_label: String,
pub(crate) ticket_root: PathBuf, pub(crate) ticket_root: PathBuf,
pub(crate) ticket_configured: bool,
pub(crate) orchestrator: Option<OrchestratorPanelState>,
pub(crate) diagnostics: Vec<String>, pub(crate) diagnostics: Vec<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OrchestratorPanelState {
pub(crate) pod_name: String,
pub(crate) status: OrchestratorPanelStatus,
pub(crate) detail: Option<String>,
}
impl OrchestratorPanelState {
pub(crate) fn new(
pod_name: impl Into<String>,
status: OrchestratorPanelStatus,
detail: Option<String>,
) -> 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)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum PanelRowKey { pub(crate) enum PanelRowKey {
Ticket(String), 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<String> {
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<str>) -> String {
let collapsed = message
.as_ref()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ");
excerpt(&collapsed, 180).unwrap_or_else(|| "unknown diagnostic".to_string())
}
pub(crate) fn build_workspace_panel( pub(crate) fn build_workspace_panel(
workspace_root: &Path, workspace_root: &Path,
pods: &PodList, pods: &PodList,
) -> WorkspacePanelViewModel { ) -> WorkspacePanelViewModel {
let mut model = WorkspacePanelViewModel::empty(workspace_root); let mut model = WorkspacePanelViewModel::empty(workspace_root);
let ticket_config_path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); match ticket_config_availability(workspace_root) {
if ticket_config_path.is_file() { TicketConfigAvailability::Absent => {}
if let Ok(config) = TicketConfig::load_workspace(workspace_root) { TicketConfigAvailability::Usable => {
model.header.ticket_root = config.backend_root().to_path_buf(); model.header.ticket_configured = true;
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()); match TicketConfig::load_workspace(workspace_root) {
if let Ok(rows) = build_ticket_rows(&backend, pods) { Ok(config) => {
model.rows.extend(rows); 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)); model.rows.extend(pod_rows(pods));
@ -863,4 +1083,142 @@ mod tests {
assert_eq!(close.priority, ActionPriority::Decision); assert_eq!(close.priority, ActionPriority::Decision);
assert_eq!(close.next_action, Some(NextUserAction::Close)); 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::<Vec<_>>();
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
);
}
} }