feat: add workspace orchestrator panel lifecycle
This commit is contained in:
parent
17970feb21
commit
7b29cd6817
|
|
@ -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(¤t_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(
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user