tui: add panel role session registry
This commit is contained in:
parent
5db02079d0
commit
489059019d
|
|
@ -8,6 +8,7 @@ mod markdown;
|
||||||
mod multi_pod;
|
mod multi_pod;
|
||||||
mod picker;
|
mod picker;
|
||||||
mod pod_list;
|
mod pod_list;
|
||||||
|
mod role_session_registry;
|
||||||
mod scroll;
|
mod scroll;
|
||||||
mod single_pod;
|
mod single_pod;
|
||||||
mod spawn;
|
mod spawn;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use client::ticket_role::{
|
use client::ticket_role::{
|
||||||
TicketIntakeHandoff, TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError,
|
TicketIntakeHandoff, TicketRef, TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError,
|
||||||
TicketRoleLaunchOptions, TicketRoleLaunchResult, launch_ticket_role_pod,
|
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 client::{PodRuntimeCommand, SpawnConfig, spawn_pod};
|
||||||
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
|
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,
|
PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, read_reachable_live_pod_infos,
|
||||||
read_stored_pod_infos,
|
read_stored_pod_infos,
|
||||||
};
|
};
|
||||||
|
use crate::role_session_registry::{PanelRegistryStore, TicketClaimResult};
|
||||||
use crate::workspace_panel::{
|
use crate::workspace_panel::{
|
||||||
ActionPriority, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
|
ActionPriority, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
|
||||||
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
|
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
|
||||||
PanelRowKey, TicketConfigAvailability, WorkspacePanelViewModel, bounded_panel_diagnostic,
|
PanelRowKey, TicketConfigAvailability, TicketLocalClaimStatus, WorkspacePanelViewModel,
|
||||||
build_current_ticket_row, build_workspace_panel, decide_orchestrator_lifecycle,
|
bounded_panel_diagnostic, build_current_ticket_row, build_workspace_panel,
|
||||||
orchestrator_pod_presence, ticket_config_availability, workspace_orchestrator_pod_name,
|
decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence,
|
||||||
|
ticket_config_availability, workspace_orchestrator_pod_name,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_ENTRIES: usize = 50;
|
const MAX_ENTRIES: usize = 50;
|
||||||
|
|
@ -272,6 +274,21 @@ pub(crate) struct IntakeLaunchRequest {
|
||||||
context: TicketRoleLaunchContext,
|
context: TicketRoleLaunchContext,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
peer_registration: IntakePeerRegistrationRequest,
|
peer_registration: IntakePeerRegistrationRequest,
|
||||||
|
registry_update: IntakeRegistryUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) enum IntakeRegistryUpdate {
|
||||||
|
RecordSession {
|
||||||
|
registry_root: PathBuf,
|
||||||
|
pod_name: String,
|
||||||
|
ticket_ids: Vec<String>,
|
||||||
|
},
|
||||||
|
ClaimedTicket {
|
||||||
|
registry_root: PathBuf,
|
||||||
|
ticket_id: String,
|
||||||
|
pod_name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[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.context,
|
||||||
request.runtime_command,
|
request.runtime_command,
|
||||||
|_| {},
|
|_| {},
|
||||||
options,
|
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) {
|
let peer_registration = match (orchestrator_pod, skip_warning) {
|
||||||
(_, Some(warning)) => warning,
|
(_, Some(warning)) => warning,
|
||||||
(Some(orchestrator_pod), None) if launch.pre_run_warnings.is_empty() => {
|
(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<IntakeLaunchRequest> {
|
fn prepare_intake_peer_registration(
|
||||||
if !self
|
&self,
|
||||||
.panel
|
context: &mut TicketRoleLaunchContext,
|
||||||
.composer
|
) -> IntakePeerRegistrationRequest {
|
||||||
.is_available(ComposerTarget::TicketIntake)
|
match self.panel.header.orchestrator.as_ref() {
|
||||||
{
|
|
||||||
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() {
|
|
||||||
Some(orchestrator) => {
|
Some(orchestrator) => {
|
||||||
context.intake_handoff = Some(TicketIntakeHandoff::new(
|
context.intake_handoff = Some(TicketIntakeHandoff::new(
|
||||||
orchestrator.pod_name.clone(),
|
orchestrator.pod_name.clone(),
|
||||||
|
|
@ -785,13 +816,148 @@ impl MultiPodApp {
|
||||||
None => IntakePeerRegistrationRequest::Skip {
|
None => IntakePeerRegistrationRequest::Skip {
|
||||||
reason: "workspace Orchestrator is not configured for this panel".to_string(),
|
reason: "workspace Orchestrator is not configured for this panel".to_string(),
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare_intake_launch(&mut self) -> Option<IntakeLaunchRequest> {
|
||||||
|
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.sending = true;
|
||||||
self.notice = Some("Launching Ticket Intake…".to_string());
|
self.notice = Some("Launching Ticket Intake…".to_string());
|
||||||
Some(IntakeLaunchRequest {
|
Some(IntakeLaunchRequest {
|
||||||
context,
|
context,
|
||||||
runtime_command: self.runtime_command.clone(),
|
runtime_command: self.runtime_command.clone(),
|
||||||
peer_registration,
|
peer_registration,
|
||||||
|
registry_update,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare_existing_ticket_intake_launch(&mut self) -> Option<IntakeLaunchRequest> {
|
||||||
|
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();
|
self.input.insert_newline();
|
||||||
MultiPodAction::None
|
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
|
KeyCode::Enter
|
||||||
if self.composer_is_blank() && self.selected_ticket_action().is_some() =>
|
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<OrchestratorPodPresence, 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?;
|
let list = load_pod_list(Some(pod_name.to_string()), usize::MAX).await?;
|
||||||
Ok(orchestrator_pod_presence(pod_name, &list))
|
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()),
|
latest_event_excerpt: Some("latest event stays out of the primary row".to_string()),
|
||||||
blocked_reason: None,
|
blocked_reason: None,
|
||||||
related_pods: Vec::new(),
|
related_pods: Vec::new(),
|
||||||
|
local_claim: None,
|
||||||
};
|
};
|
||||||
PanelRow {
|
PanelRow {
|
||||||
key: PanelRowKey::Ticket(ticket.id.clone()),
|
key: PanelRowKey::Ticket(ticket.id.clone()),
|
||||||
|
|
|
||||||
436
crates/tui/src/role_session_registry.rs
Normal file
436
crates/tui/src/role_session_registry.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub(crate) struct RoleSessionRegistry {
|
||||||
|
pub version: u32,
|
||||||
|
pub workspace_root: String,
|
||||||
|
pub sessions: BTreeMap<String, RoleSessionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ticket_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<RoleSessionRecord>,
|
||||||
|
pub claims: Vec<TicketClaim>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<io::Error> for PanelRegistryError {
|
||||||
|
fn from(error: io::Error) -> Self {
|
||||||
|
Self::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for PanelRegistryError {
|
||||||
|
fn from(error: serde_json::Error) -> Self {
|
||||||
|
Self::Json(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanelRegistryStore {
|
||||||
|
pub(crate) fn default_for_workspace(workspace_root: &Path) -> Result<Self, PanelRegistryError> {
|
||||||
|
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<Path>, 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<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
root: root.into(),
|
||||||
|
workspace_root: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn root(&self) -> &Path {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn snapshot(&self) -> Result<PanelRegistrySnapshot, PanelRegistryError> {
|
||||||
|
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<RoleSessionRegistry, PanelRegistryError> {
|
||||||
|
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<String>,
|
||||||
|
role: impl Into<String>,
|
||||||
|
session_id: Option<String>,
|
||||||
|
ticket_ids: impl IntoIterator<Item = String>,
|
||||||
|
) -> 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<String> = 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<TicketClaimResult, PanelRegistryError> {
|
||||||
|
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<TicketClaim, PanelRegistryError> {
|
||||||
|
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<Option<TicketClaim>, 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<Vec<TicketClaim>, PanelRegistryError> {
|
||||||
|
let mut claims: Vec<TicketClaim> = 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::<String>()
|
||||||
|
.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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ use ticket::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
||||||
|
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct WorkspacePanelViewModel {
|
pub(crate) struct WorkspacePanelViewModel {
|
||||||
|
|
@ -202,6 +203,31 @@ pub(crate) struct TicketPanelEntry {
|
||||||
pub(crate) latest_event_excerpt: Option<String>,
|
pub(crate) latest_event_excerpt: Option<String>,
|
||||||
pub(crate) blocked_reason: Option<String>,
|
pub(crate) blocked_reason: Option<String>,
|
||||||
pub(crate) related_pods: Vec<String>,
|
pub(crate) related_pods: Vec<String>,
|
||||||
|
pub(crate) local_claim: Option<TicketLocalClaimEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -373,7 +399,49 @@ 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 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) {
|
match ticket_config_availability(workspace_root) {
|
||||||
TicketConfigAvailability::Absent => {}
|
TicketConfigAvailability::Absent => {}
|
||||||
TicketConfigAvailability::Usable => {
|
TicketConfigAvailability::Usable => {
|
||||||
|
|
@ -383,7 +451,7 @@ pub(crate) fn build_workspace_panel(
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
model.header.ticket_root = config.backend_root().to_path_buf();
|
model.header.ticket_root = config.backend_root().to_path_buf();
|
||||||
let backend = LocalTicketBackend::new(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),
|
Ok(rows) => model.rows.extend(rows),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
model
|
model
|
||||||
|
|
@ -436,7 +504,8 @@ pub(crate) fn build_current_ticket_row(
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let summary = ticket_summary_from_meta(&ticket.meta);
|
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 {
|
fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
|
||||||
|
|
@ -463,6 +532,7 @@ fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
|
||||||
fn build_ticket_rows(
|
fn build_ticket_rows(
|
||||||
backend: &LocalTicketBackend,
|
backend: &LocalTicketBackend,
|
||||||
pods: &PodList,
|
pods: &PodList,
|
||||||
|
registry: &PanelRegistrySnapshot,
|
||||||
) -> ticket::Result<Vec<PanelRow>> {
|
) -> ticket::Result<Vec<PanelRow>> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for summary in backend.list(TicketFilter::all())? {
|
for summary in backend.list(TicketFilter::all())? {
|
||||||
|
|
@ -470,13 +540,19 @@ fn build_ticket_rows(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let ticket = backend.show(TicketIdOrSlug::Query(summary.slug.clone()))?;
|
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)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_row(summary: TicketSummary, events: &[TicketEvent], pods: &PodList) -> PanelRow {
|
fn ticket_row(
|
||||||
let related_pods = related_pods_for_ticket(&summary, pods);
|
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 derived = derive_ticket_state(&summary);
|
||||||
let latest_event = events.last();
|
let latest_event = events.last();
|
||||||
let entry = TicketPanelEntry {
|
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)),
|
latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)),
|
||||||
blocked_reason: derived.blocked_reason.clone(),
|
blocked_reason: derived.blocked_reason.clone(),
|
||||||
related_pods: related_pods.clone(),
|
related_pods: related_pods.clone(),
|
||||||
|
local_claim,
|
||||||
};
|
};
|
||||||
let subtitle = ticket_subtitle(&entry);
|
let subtitle = ticket_subtitle(&entry);
|
||||||
PanelRow {
|
PanelRow {
|
||||||
|
|
@ -610,22 +687,60 @@ fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn related_pods_for_ticket(summary: &TicketSummary, pods: &PodList) -> Vec<String> {
|
fn related_pods_for_ticket(
|
||||||
|
summary: &TicketSummary,
|
||||||
|
pods: &PodList,
|
||||||
|
registry: &PanelRegistrySnapshot,
|
||||||
|
) -> Vec<String> {
|
||||||
let slug = lowercase(&summary.slug);
|
let slug = lowercase(&summary.slug);
|
||||||
let id = lowercase(&summary.id);
|
let id = lowercase(&summary.id);
|
||||||
pods.entries
|
let mut names = Vec::new();
|
||||||
.iter()
|
if let Some(claim) = registry.claim_for_ticket(&summary.id) {
|
||||||
.filter_map(|pod| {
|
names.push(claim.pod_name.clone());
|
||||||
let name = lowercase(&pod.name);
|
}
|
||||||
if (!slug.is_empty() && name.contains(&slug)) || (!id.is_empty() && name.contains(&id))
|
for pod in pods.entries.iter().filter_map(|pod| {
|
||||||
{
|
let name = lowercase(&pod.name);
|
||||||
Some(pod.name.clone())
|
if (!slug.is_empty() && name.contains(&slug)) || (!id.is_empty() && name.contains(&id)) {
|
||||||
} else {
|
Some(pod.name.clone())
|
||||||
None
|
} else {
|
||||||
}
|
None
|
||||||
})
|
}
|
||||||
.take(5)
|
}) {
|
||||||
.collect()
|
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<TicketLocalClaimEntry> {
|
||||||
|
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<String> {
|
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
|
||||||
|
|
@ -637,6 +752,13 @@ fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
|
||||||
if let Some(reason) = entry.attention_required.as_deref() {
|
if let Some(reason) = entry.attention_required.as_deref() {
|
||||||
parts.push(format!("attention: {reason}"));
|
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() {
|
if !entry.related_pods.is_empty() {
|
||||||
parts.push(format!("pods: {}", entry.related_pods.join(", ")));
|
parts.push(format!("pods: {}", entry.related_pods.join(", ")));
|
||||||
}
|
}
|
||||||
|
|
@ -946,6 +1068,42 @@ mod tests {
|
||||||
assert_eq!(done.next_action, Some(NextUserAction::Close));
|
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]
|
#[test]
|
||||||
fn workspace_orchestrator_pod_name_is_stable_and_safe() {
|
fn workspace_orchestrator_pod_name_is_stable_and_safe() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user