From 489059019d49838d344af7d4b01d14242415122f Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 7 Jun 2026 11:02:30 +0900 Subject: [PATCH] tui: add panel role session registry --- crates/tui/src/lib.rs | 1 + crates/tui/src/multi_pod.rs | 257 ++++++++++++-- crates/tui/src/role_session_registry.rs | 436 ++++++++++++++++++++++++ crates/tui/src/workspace_panel.rs | 198 +++++++++-- 4 files changed, 843 insertions(+), 49 deletions(-) create mode 100644 crates/tui/src/role_session_registry.rs diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 349545ac..feb19fb8 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -8,6 +8,7 @@ mod markdown; mod multi_pod; mod picker; mod pod_list; +mod role_session_registry; mod scroll; mod single_pod; mod spawn; diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 394e371d..d117462b 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -1,11 +1,11 @@ use std::io; use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use client::ticket_role::{ - TicketIntakeHandoff, TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError, + TicketIntakeHandoff, TicketRef, TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions, TicketRoleLaunchResult, launch_ticket_role_pod, - launch_ticket_role_pod_with_options, + launch_ticket_role_pod_with_options, plan_ticket_role_launch, }; use client::{PodRuntimeCommand, SpawnConfig, spawn_pod}; use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read}; @@ -33,12 +33,14 @@ use crate::pod_list::{ PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, read_reachable_live_pod_infos, read_stored_pod_infos, }; +use crate::role_session_registry::{PanelRegistryStore, TicketClaimResult}; use crate::workspace_panel::{ ActionPriority, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan, OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow, - PanelRowKey, TicketConfigAvailability, WorkspacePanelViewModel, bounded_panel_diagnostic, - build_current_ticket_row, build_workspace_panel, decide_orchestrator_lifecycle, - orchestrator_pod_presence, ticket_config_availability, workspace_orchestrator_pod_name, + PanelRowKey, TicketConfigAvailability, TicketLocalClaimStatus, WorkspacePanelViewModel, + bounded_panel_diagnostic, build_current_ticket_row, build_workspace_panel, + decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence, + ticket_config_availability, workspace_orchestrator_pod_name, }; const MAX_ENTRIES: usize = 50; @@ -272,6 +274,21 @@ pub(crate) struct IntakeLaunchRequest { context: TicketRoleLaunchContext, runtime_command: PodRuntimeCommand, peer_registration: IntakePeerRegistrationRequest, + registry_update: IntakeRegistryUpdate, +} + +#[derive(Debug, Clone)] +pub(crate) enum IntakeRegistryUpdate { + RecordSession { + registry_root: PathBuf, + pod_name: String, + ticket_ids: Vec, + }, + ClaimedTicket { + registry_root: PathBuf, + ticket_id: String, + pod_name: String, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -318,13 +335,43 @@ pub(crate) async fn launch_intake_with_handoff(request: IntakeLaunchRequest) -> ))), ), }; - let launch = launch_ticket_role_pod_with_options( + let launch = match launch_ticket_role_pod_with_options( request.context, request.runtime_command, |_| {}, options, ) - .await?; + .await + { + Ok(launch) => launch, + Err(error) => { + if let IntakeRegistryUpdate::ClaimedTicket { + registry_root, + ticket_id, + pod_name, + } = &request.registry_update + { + let _ = PanelRegistryStore::from_root(registry_root.clone()) + .release_ticket_claim(ticket_id, pod_name); + } + return Err(error); + } + }; + match &request.registry_update { + IntakeRegistryUpdate::RecordSession { + registry_root, + pod_name, + ticket_ids, + } => { + let _ = PanelRegistryStore::from_root(registry_root.clone()).record_session( + pod_name.clone(), + TicketRole::Intake.as_str().to_string(), + None, + ticket_ids.clone(), + ); + } + IntakeRegistryUpdate::ClaimedTicket { .. } => {} + }; let peer_registration = match (orchestrator_pod, skip_warning) { (_, Some(warning)) => warning, (Some(orchestrator_pod), None) if launch.pre_run_warnings.is_empty() => { @@ -742,27 +789,11 @@ impl MultiPodApp { }); } - pub(crate) fn prepare_intake_launch(&mut self) -> Option { - if !self - .panel - .composer - .is_available(ComposerTarget::TicketIntake) - { - self.composer_target = ComposerTarget::Companion; - self.notice = Some( - "Ticket Intake target is unavailable without usable Ticket config.".to_string(), - ); - return None; - } - let body = Segment::flatten_to_text(&self.input.submit_segments()); - if body.trim().is_empty() { - self.notice = Some("Ticket Intake input is empty; type a request first.".to_string()); - return None; - } - let mut context = - TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake); - context.user_instruction = Some(body); - let peer_registration = match self.panel.header.orchestrator.as_ref() { + fn prepare_intake_peer_registration( + &self, + context: &mut TicketRoleLaunchContext, + ) -> IntakePeerRegistrationRequest { + match self.panel.header.orchestrator.as_ref() { Some(orchestrator) => { context.intake_handoff = Some(TicketIntakeHandoff::new( orchestrator.pod_name.clone(), @@ -785,13 +816,148 @@ impl MultiPodApp { None => IntakePeerRegistrationRequest::Skip { reason: "workspace Orchestrator is not configured for this panel".to_string(), }, + } + } + + pub(crate) fn prepare_intake_launch(&mut self) -> Option { + if !self + .panel + .composer + .is_available(ComposerTarget::TicketIntake) + { + self.composer_target = ComposerTarget::Companion; + self.notice = Some( + "Ticket Intake target is unavailable without usable Ticket config.".to_string(), + ); + return None; + } + let body = Segment::flatten_to_text(&self.input.submit_segments()); + if body.trim().is_empty() { + self.notice = Some("Ticket Intake input is empty; type a request first.".to_string()); + return None; + } + let mut context = + TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake); + let pod_name = unique_preticket_intake_pod_name(); + context.pod_name = Some(pod_name.clone()); + context.user_instruction = Some(body); + let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) { + Ok(store) => store, + Err(error) => { + self.notice = Some(format!("Ticket Intake registry unavailable: {error}")); + return None; + } }; + let registry_update = IntakeRegistryUpdate::RecordSession { + registry_root: store.root().to_path_buf(), + pod_name, + ticket_ids: Vec::new(), + }; + let peer_registration = self.prepare_intake_peer_registration(&mut context); self.sending = true; self.notice = Some("Launching Ticket Intake…".to_string()); Some(IntakeLaunchRequest { context, runtime_command: self.runtime_command.clone(), peer_registration, + registry_update, + }) + } + + pub(crate) fn prepare_existing_ticket_intake_launch(&mut self) -> Option { + let row = match self.selected_panel_row() { + Some(row) if row.is_ticket_action() => row, + Some(row) if row.ticket.is_some() => { + self.notice = Some("Selected Ticket row has no Intake action.".to_string()); + return None; + } + _ => { + self.notice = Some("No Ticket Intake action is selected.".to_string()); + return None; + } + }; + let Some(action) = row.next_action else { + self.notice = Some("Selected Ticket row has no Intake action.".to_string()); + return None; + }; + if action != NextUserAction::Clarify { + self.notice = Some(format!( + "{} is not handled by Ticket Intake launch.", + action.label() + )); + return None; + } + let Some(ticket) = row.ticket.as_ref() else { + self.notice = Some("No Ticket Intake action is selected.".to_string()); + return None; + }; + let ticket_id = ticket.id.clone(); + let ticket_slug = ticket.slug.clone(); + let mut context = + TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake); + context.ticket = Some(TicketRef { + id: Some(ticket_id.clone()), + slug: Some(ticket_slug.clone()), + }); + context.user_instruction = Some(format!( + "Continue Intake for existing Ticket {ticket_id} ({ticket_slug}). Do not create a duplicate Ticket unless the user explicitly requests one." + )); + let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) { + Ok(store) => store, + Err(error) => { + self.notice = Some(format!("Ticket Intake registry unavailable: {error}")); + return None; + } + }; + match store.claim_for_ticket(&ticket_id) { + Ok(Some(claim)) => { + let status = local_claim_status_for_pod(&claim.pod_name, &self.list); + self.notice = Some(existing_ticket_claim_notice( + &ticket_id, + &claim.pod_name, + status, + )); + return None; + } + Ok(None) => {} + Err(error) => { + self.notice = Some(format!("Ticket claim diagnostic required: {error}")); + return None; + } + } + let planned = match plan_ticket_role_launch(context.clone()) { + Ok(plan) => plan, + Err(error) => { + self.notice = Some(format!( + "Ticket Intake launch plan failed; no claim written: {}", + bounded_panel_diagnostic(error.to_string()) + )); + return None; + } + }; + context.pod_name = Some(planned.pod_name.clone()); + match store.claim_ticket(&ticket_id, &planned.pod_name, TicketRole::Intake.as_str()) { + Ok(TicketClaimResult::Claimed) | Ok(TicketClaimResult::AlreadyOwned(_)) => {} + Err(error) => { + self.notice = Some(format!("Ticket claim diagnostic required: {error}")); + return None; + } + } + let peer_registration = self.prepare_intake_peer_registration(&mut context); + self.sending = true; + self.notice = Some(format!( + "Launching Ticket Intake for {} as {}…", + ticket_slug, planned.pod_name + )); + Some(IntakeLaunchRequest { + context, + runtime_command: self.runtime_command.clone(), + peer_registration, + registry_update: IntakeRegistryUpdate::ClaimedTicket { + registry_root: store.root().to_path_buf(), + ticket_id, + pod_name: planned.pod_name, + }, }) } @@ -855,6 +1021,14 @@ impl MultiPodApp { self.input.insert_newline(); MultiPodAction::None } + KeyCode::Enter + if self.composer_is_blank() + && self.selected_ticket_action() == Some(NextUserAction::Clarify) => + { + self.prepare_existing_ticket_intake_launch() + .map(MultiPodAction::LaunchIntake) + .unwrap_or(MultiPodAction::None) + } KeyCode::Enter if self.composer_is_blank() && self.selected_ticket_action().is_some() => { @@ -1145,6 +1319,30 @@ fn orchestrator_status_is_peer_reachable(status: OrchestratorPanelStatus) -> boo ) } +fn unique_preticket_intake_pod_name() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("ticket-intake-{nanos:x}") +} + +fn existing_ticket_claim_notice( + ticket_id: &str, + pod_name: &str, + status: TicketLocalClaimStatus, +) -> String { + match status { + TicketLocalClaimStatus::Live | TicketLocalClaimStatus::Restorable => format!( + "Ticket {ticket_id} is already claimed by local Intake Pod {pod_name} ({}); select/open that Pod instead of starting a second Intake.", + status.label() + ), + TicketLocalClaimStatus::Stale => format!( + "Ticket {ticket_id} has a stale local Intake claim for {pod_name}; explicit reclaim/diagnostic is required before starting a replacement." + ), + } +} + 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)) @@ -3477,6 +3675,7 @@ mod tests { latest_event_excerpt: Some("latest event stays out of the primary row".to_string()), blocked_reason: None, related_pods: Vec::new(), + local_claim: None, }; PanelRow { key: PanelRowKey::Ticket(ticket.id.clone()), diff --git a/crates/tui/src/role_session_registry.rs b/crates/tui/src/role_session_registry.rs new file mode 100644 index 00000000..e97ec844 --- /dev/null +++ b/crates/tui/src/role_session_registry.rs @@ -0,0 +1,436 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +const REGISTRY_VERSION: u32 = 1; +const REGISTRY_FILE: &str = "role-sessions.json"; +const CLAIMS_DIR: &str = "ticket-claims"; + +#[derive(Debug, Clone)] +pub(crate) struct PanelRegistryStore { + root: PathBuf, + workspace_root: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct RoleSessionRegistry { + pub version: u32, + pub workspace_root: String, + pub sessions: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct RoleSessionRecord { + pub pod_name: String, + pub role: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default)] + pub ticket_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct TicketClaim { + pub ticket_id: String, + pub pod_name: String, + pub role: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PanelRegistrySnapshot { + pub sessions: Vec, + pub claims: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TicketClaimResult { + Claimed, + AlreadyOwned(TicketClaim), +} + +#[derive(Debug)] +pub(crate) enum PanelRegistryError { + Io(io::Error), + Json(serde_json::Error), + TicketAlreadyClaimed(TicketClaim), +} + +impl std::fmt::Display for PanelRegistryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(error) => write!(f, "local role session registry I/O error: {error}"), + Self::Json(error) => write!(f, "local role session registry JSON error: {error}"), + Self::TicketAlreadyClaimed(claim) => write!( + f, + "Ticket {} is already claimed locally by {} ({})", + claim.ticket_id, claim.pod_name, claim.role + ), + } + } +} + +impl std::error::Error for PanelRegistryError {} + +impl From for PanelRegistryError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for PanelRegistryError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +impl PanelRegistryStore { + pub(crate) fn default_for_workspace(workspace_root: &Path) -> Result { + let data_dir = manifest::paths::data_dir().ok_or_else(|| { + PanelRegistryError::Io(io::Error::other("failed to resolve yoi data directory")) + })?; + Ok(Self::for_data_dir(data_dir, workspace_root)) + } + + pub(crate) fn for_data_dir(data_dir: impl AsRef, workspace_root: &Path) -> Self { + let workspace_root = normalized_workspace_key(workspace_root); + let leaf = workspace_leaf(&workspace_root); + let digest = fnv1a64_hex(workspace_root.as_bytes()); + Self { + root: data_dir + .as_ref() + .join("panel") + .join("workspaces") + .join(format!("{leaf}-{digest}")), + workspace_root: Some(workspace_root), + } + } + + pub(crate) fn from_root(root: impl Into) -> Self { + Self { + root: root.into(), + workspace_root: None, + } + } + + pub(crate) fn root(&self) -> &Path { + &self.root + } + + pub(crate) fn snapshot(&self) -> Result { + let registry = self.load_registry()?; + let claims = self.load_claims()?; + Ok(PanelRegistrySnapshot { + sessions: registry.sessions.into_values().collect(), + claims, + }) + } + + pub(crate) fn load_registry(&self) -> Result { + match fs::read(self.registry_path()) { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(RoleSessionRegistry { + version: REGISTRY_VERSION, + workspace_root: self.workspace_root.clone().unwrap_or_default(), + sessions: BTreeMap::new(), + }), + Err(error) => Err(error.into()), + } + } + + pub(crate) fn record_session( + &self, + pod_name: impl Into, + role: impl Into, + session_id: Option, + ticket_ids: impl IntoIterator, + ) -> Result<(), PanelRegistryError> { + let pod_name = pod_name.into(); + let role = role.into(); + let mut registry = self.load_registry()?; + registry.version = REGISTRY_VERSION; + if let Some(workspace_root) = self.workspace_root.as_ref() { + registry.workspace_root = workspace_root.clone(); + } + let mut tickets: BTreeSet = registry + .sessions + .get(&pod_name) + .map(|record| record.ticket_ids.iter().cloned().collect()) + .unwrap_or_default(); + tickets.extend(ticket_ids); + registry.sessions.insert( + pod_name.clone(), + RoleSessionRecord { + pod_name, + role, + session_id, + ticket_ids: tickets.into_iter().collect(), + }, + ); + self.save_registry(®istry) + } + + pub(crate) fn claim_ticket( + &self, + ticket_id: &str, + pod_name: &str, + role: &str, + ) -> Result { + fs::create_dir_all(self.claims_dir())?; + let claim_path = self.claim_path(ticket_id); + let claim = TicketClaim { + ticket_id: ticket_id.to_string(), + pod_name: pod_name.to_string(), + role: role.to_string(), + }; + let bytes = serde_json::to_vec_pretty(&claim)?; + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&claim_path) + { + Ok(mut file) => { + if let Err(error) = file.write_all(&bytes).and_then(|()| file.write_all(b"\n")) { + let _ = fs::remove_file(&claim_path); + return Err(error.into()); + } + if let Err(error) = self.record_session( + pod_name.to_string(), + role.to_string(), + None, + [ticket_id.to_string()], + ) { + let _ = fs::remove_file(&claim_path); + return Err(error); + } + Ok(TicketClaimResult::Claimed) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let existing = self.load_claim(ticket_id)?; + if existing.pod_name == pod_name && existing.role == role { + Ok(TicketClaimResult::AlreadyOwned(existing)) + } else { + Err(PanelRegistryError::TicketAlreadyClaimed(existing)) + } + } + Err(error) => Err(error.into()), + } + } + + pub(crate) fn load_claim(&self, ticket_id: &str) -> Result { + let bytes = fs::read(self.claim_path(ticket_id))?; + Ok(serde_json::from_slice(&bytes)?) + } + + pub(crate) fn claim_for_ticket( + &self, + ticket_id: &str, + ) -> Result, PanelRegistryError> { + match self.load_claim(ticket_id) { + Ok(claim) => Ok(Some(claim)), + Err(PanelRegistryError::Io(error)) if error.kind() == io::ErrorKind::NotFound => { + Ok(None) + } + Err(error) => Err(error), + } + } + + pub(crate) fn release_ticket_claim( + &self, + ticket_id: &str, + pod_name: &str, + ) -> Result<(), PanelRegistryError> { + match self.load_claim(ticket_id) { + Ok(claim) if claim.pod_name == pod_name => { + fs::remove_file(self.claim_path(ticket_id))?; + Ok(()) + } + Ok(_) => Ok(()), + Err(PanelRegistryError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } + } + + fn save_registry(&self, registry: &RoleSessionRegistry) -> Result<(), PanelRegistryError> { + fs::create_dir_all(&self.root)?; + let path = self.registry_path(); + let temp_path = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec_pretty(registry)?; + fs::write(&temp_path, [&bytes[..], b"\n"].concat())?; + fs::rename(temp_path, path)?; + Ok(()) + } + + fn load_claims(&self) -> Result, PanelRegistryError> { + let mut claims: Vec = Vec::new(); + match fs::read_dir(self.claims_dir()) { + Ok(entries) => { + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_file() { + let bytes = fs::read(entry.path())?; + claims.push(serde_json::from_slice(&bytes)?); + } + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + claims.sort_by(|left, right| left.ticket_id.cmp(&right.ticket_id)); + Ok(claims) + } + + fn registry_path(&self) -> PathBuf { + self.root.join(REGISTRY_FILE) + } + + fn claims_dir(&self) -> PathBuf { + self.root.join(CLAIMS_DIR) + } + + fn claim_path(&self, ticket_id: &str) -> PathBuf { + self.claims_dir() + .join(format!("{}.json", encode_path_component(ticket_id))) + } +} + +impl PanelRegistrySnapshot { + pub(crate) fn empty() -> Self { + Self { + sessions: Vec::new(), + claims: Vec::new(), + } + } + + pub(crate) fn claim_for_ticket(&self, ticket_id: &str) -> Option<&TicketClaim> { + self.claims + .iter() + .find(|claim| claim.ticket_id == ticket_id) + } +} + +fn normalized_workspace_key(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn workspace_leaf(workspace_root: &str) -> String { + let leaf = workspace_root + .rsplit('/') + .find(|part| !part.is_empty()) + .unwrap_or("workspace"); + let sanitized = leaf + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string(); + if sanitized.is_empty() { + "workspace".to_string() + } else { + sanitized + } +} + +fn fnv1a64_hex(bytes: &[u8]) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +fn encode_path_component(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' => encoded.push(byte as char), + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn registry_path_is_workspace_scoped_under_data_dir() { + let data_dir = TempDir::new().unwrap(); + let store = PanelRegistryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi")); + let other = PanelRegistryStore::for_data_dir(data_dir.path(), Path::new("/repo/other")); + + assert!(store.root().starts_with(data_dir.path())); + let root = store.root().to_string_lossy(); + assert!(root.contains("panel/workspaces/yoi-")); + assert_ne!(store.root(), other.root()); + + store + .record_session("ticket-intake-preticket", "intake", None, []) + .unwrap(); + assert_eq!(store.load_registry().unwrap().workspace_root, "/repo/yoi"); + } + + #[test] + fn claim_ticket_rejects_second_active_local_pod() { + let temp = TempDir::new().unwrap(); + let store = PanelRegistryStore::from_root(temp.path().join("registry")); + + assert!(matches!( + store.claim_ticket("T-1", "ticket-one-intake", "intake"), + Ok(TicketClaimResult::Claimed) + )); + + let error = store + .claim_ticket("T-1", "ticket-two-intake", "intake") + .unwrap_err(); + assert!(matches!(error, PanelRegistryError::TicketAlreadyClaimed(_))); + assert_eq!( + store.claim_for_ticket("T-1").unwrap().unwrap().pod_name, + "ticket-one-intake" + ); + } + + #[test] + fn intake_session_relation_is_not_one_to_one_with_tickets() { + let temp = TempDir::new().unwrap(); + let store = PanelRegistryStore::from_root(temp.path().join("registry")); + + store + .record_session("ticket-intake-preticket", "intake", None, []) + .unwrap(); + store + .record_session( + "ticket-intake-shared", + "intake", + None, + ["T-1".to_string(), "T-2".to_string()], + ) + .unwrap(); + + let snapshot = store.snapshot().unwrap(); + let preticket = snapshot + .sessions + .iter() + .find(|session| session.pod_name == "ticket-intake-preticket") + .unwrap(); + let shared = snapshot + .sessions + .iter() + .find(|session| session.pod_name == "ticket-intake-shared") + .unwrap(); + + assert!(preticket.ticket_ids.is_empty()); + assert_eq!(shared.ticket_ids, vec!["T-1", "T-2"]); + } +} diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index c6babc78..c52ef9d8 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -8,6 +8,7 @@ use ticket::{ }; use crate::pod_list::{PodList, PodListEntry, StoredMetadataState}; +use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore}; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WorkspacePanelViewModel { @@ -202,6 +203,31 @@ pub(crate) struct TicketPanelEntry { pub(crate) latest_event_excerpt: Option, pub(crate) blocked_reason: Option, pub(crate) related_pods: Vec, + pub(crate) local_claim: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TicketLocalClaimEntry { + pub(crate) pod_name: String, + pub(crate) role: String, + pub(crate) status: TicketLocalClaimStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TicketLocalClaimStatus { + Live, + Restorable, + Stale, +} + +impl TicketLocalClaimStatus { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Live => "live", + Self::Restorable => "restorable", + Self::Stale => "stale", + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -373,7 +399,49 @@ pub(crate) fn build_workspace_panel( workspace_root: &Path, pods: &PodList, ) -> WorkspacePanelViewModel { - let mut model = WorkspacePanelViewModel::empty(workspace_root); + let registry = match PanelRegistryStore::default_for_workspace(workspace_root) + .and_then(|store| store.snapshot()) + { + Ok(snapshot) => snapshot, + Err(error) => { + let mut snapshot = PanelRegistrySnapshot::empty(); + // Keep panel rendering available even when the local registry is corrupt or + // unavailable; the diagnostic is attached below after the header is initialized. + snapshot.claims.clear(); + snapshot.sessions.clear(); + let mut model = WorkspacePanelViewModel::empty(workspace_root); + model + .header + .diagnostics + .push(bounded_panel_diagnostic(format!( + "Panel local role registry unavailable: {error}" + ))); + return build_workspace_panel_with_registry_model( + model, + workspace_root, + pods, + &snapshot, + ); + } + }; + build_workspace_panel_with_registry(workspace_root, pods, ®istry) +} + +fn build_workspace_panel_with_registry( + workspace_root: &Path, + pods: &PodList, + registry: &PanelRegistrySnapshot, +) -> WorkspacePanelViewModel { + let model = WorkspacePanelViewModel::empty(workspace_root); + build_workspace_panel_with_registry_model(model, workspace_root, pods, registry) +} + +fn build_workspace_panel_with_registry_model( + mut model: WorkspacePanelViewModel, + workspace_root: &Path, + pods: &PodList, + registry: &PanelRegistrySnapshot, +) -> WorkspacePanelViewModel { match ticket_config_availability(workspace_root) { TicketConfigAvailability::Absent => {} TicketConfigAvailability::Usable => { @@ -383,7 +451,7 @@ pub(crate) fn build_workspace_panel( 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) { + match build_ticket_rows(&backend, pods, registry) { Ok(rows) => model.rows.extend(rows), Err(error) => { model @@ -436,7 +504,8 @@ pub(crate) fn build_current_ticket_row( ))); } let summary = ticket_summary_from_meta(&ticket.meta); - Ok(ticket_row(summary, &ticket.events, pods)) + let registry = PanelRegistrySnapshot::empty(); + Ok(ticket_row(summary, &ticket.events, pods, ®istry)) } fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary { @@ -463,6 +532,7 @@ fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary { fn build_ticket_rows( backend: &LocalTicketBackend, pods: &PodList, + registry: &PanelRegistrySnapshot, ) -> ticket::Result> { let mut rows = Vec::new(); for summary in backend.list(TicketFilter::all())? { @@ -470,13 +540,19 @@ fn build_ticket_rows( continue; } let ticket = backend.show(TicketIdOrSlug::Query(summary.slug.clone()))?; - rows.push(ticket_row(summary, &ticket.events, pods)); + rows.push(ticket_row(summary, &ticket.events, pods, registry)); } Ok(rows) } -fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> PanelRow { - let related_pods = related_pods_for_ticket(&summary, pods); +fn ticket_row( + summary: TicketSummary, + events: &[TicketEvent], + pods: &PodList, + registry: &PanelRegistrySnapshot, +) -> PanelRow { + let local_claim = local_claim_for_ticket(&summary, pods, registry); + let related_pods = related_pods_for_ticket(&summary, pods, registry); let derived = derive_ticket_state(&summary); let latest_event = events.last(); let entry = TicketPanelEntry { @@ -496,6 +572,7 @@ fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)), blocked_reason: derived.blocked_reason.clone(), related_pods: related_pods.clone(), + local_claim, }; let subtitle = ticket_subtitle(&entry); PanelRow { @@ -610,22 +687,60 @@ fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState { } } -fn related_pods_for_ticket(summary: &TicketSummary, pods: &PodList) -> Vec { +fn related_pods_for_ticket( + summary: &TicketSummary, + pods: &PodList, + registry: &PanelRegistrySnapshot, +) -> Vec { let slug = lowercase(&summary.slug); let id = lowercase(&summary.id); - pods.entries - .iter() - .filter_map(|pod| { - let name = lowercase(&pod.name); - if (!slug.is_empty() && name.contains(&slug)) || (!id.is_empty() && name.contains(&id)) - { - Some(pod.name.clone()) - } else { - None - } - }) - .take(5) - .collect() + let mut names = Vec::new(); + if let Some(claim) = registry.claim_for_ticket(&summary.id) { + names.push(claim.pod_name.clone()); + } + for pod in pods.entries.iter().filter_map(|pod| { + let name = lowercase(&pod.name); + if (!slug.is_empty() && name.contains(&slug)) || (!id.is_empty() && name.contains(&id)) { + Some(pod.name.clone()) + } else { + None + } + }) { + if !names.iter().any(|existing| existing == &pod) { + names.push(pod); + } + if names.len() >= 5 { + break; + } + } + names +} + +fn local_claim_for_ticket( + summary: &TicketSummary, + pods: &PodList, + registry: &PanelRegistrySnapshot, +) -> Option { + let claim = registry.claim_for_ticket(&summary.id)?; + let status = local_claim_status_for_pod(&claim.pod_name, pods); + Some(TicketLocalClaimEntry { + pod_name: claim.pod_name.clone(), + role: claim.role.clone(), + status, + }) +} + +pub(crate) fn local_claim_status_for_pod(pod_name: &str, pods: &PodList) -> TicketLocalClaimStatus { + let Some(entry) = pods.entries.iter().find(|entry| entry.name == pod_name) else { + return TicketLocalClaimStatus::Stale; + }; + if entry.live.as_ref().is_some_and(|live| live.reachable) { + return TicketLocalClaimStatus::Live; + } + if entry.actions.can_restore { + return TicketLocalClaimStatus::Restorable; + } + TicketLocalClaimStatus::Stale } fn ticket_subtitle(entry: &TicketPanelEntry) -> Option { @@ -637,6 +752,13 @@ fn ticket_subtitle(entry: &TicketPanelEntry) -> Option { if let Some(reason) = entry.attention_required.as_deref() { parts.push(format!("attention: {reason}")); } + if let Some(claim) = entry.local_claim.as_ref() { + parts.push(format!( + "claim: {} ({})", + claim.pod_name, + claim.status.label() + )); + } if !entry.related_pods.is_empty() { parts.push(format!("pods: {}", entry.related_pods.join(", "))); } @@ -946,6 +1068,42 @@ mod tests { assert_eq!(done.next_action, Some(NextUserAction::Close)); } + #[test] + fn workspace_panel_displays_local_ticket_claim_status() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + create_ticket(&backend, "Claimed Intake", "claimed-intake", |_| {}); + let summary = backend.list(TicketFilter::all()).unwrap().remove(0); + let store = PanelRegistryStore::from_root(temp.path().join("local-registry")); + store + .claim_ticket(&summary.id, "ticket-claimed-intake", "intake") + .unwrap(); + let registry = store.snapshot().unwrap(); + + let model = build_workspace_panel_with_registry( + temp.path(), + &live_pods(&["ticket-claimed-intake"]), + ®istry, + ); + let row = model + .rows + .iter() + .find(|row| row.title == "Claimed Intake") + .unwrap(); + let claim = row.ticket.as_ref().unwrap().local_claim.as_ref().unwrap(); + + assert_eq!(claim.pod_name, "ticket-claimed-intake"); + assert_eq!(claim.status, TicketLocalClaimStatus::Live); + assert_eq!(row.related_pods, vec!["ticket-claimed-intake"]); + assert!( + row.subtitle + .as_deref() + .unwrap() + .contains("claim: ticket-claimed-intake (live)") + ); + } + #[test] fn workspace_orchestrator_pod_name_is_stable_and_safe() { assert_eq!(