fix: tolerate invalid ticket rows in panel
This commit is contained in:
@@ -958,6 +958,10 @@ fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey {
|
||||
kind: "ticket",
|
||||
id: id.clone(),
|
||||
},
|
||||
PanelRowKey::InvalidTicket(label) => PanelE2eRowKey {
|
||||
kind: "invalid_ticket",
|
||||
id: label.clone(),
|
||||
},
|
||||
PanelRowKey::TicketIntakePod {
|
||||
ticket_id,
|
||||
pod_name,
|
||||
@@ -1359,7 +1363,9 @@ impl MultiPodApp {
|
||||
),
|
||||
None => match &hit.key {
|
||||
PanelRowKey::Pod(name) => (name.clone(), None, None),
|
||||
PanelRowKey::Ticket(id) => (id.clone(), None, None),
|
||||
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => {
|
||||
(id.clone(), None, None)
|
||||
}
|
||||
PanelRowKey::TicketIntakePod { pod_name, .. } => {
|
||||
(pod_name.clone(), None, None)
|
||||
}
|
||||
@@ -1415,7 +1421,9 @@ impl MultiPodApp {
|
||||
}
|
||||
if let Some(key) = visible.iter().find(|key| match key {
|
||||
PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name,
|
||||
PanelRowKey::Ticket(_) | PanelRowKey::TicketIntakePod { .. } => true,
|
||||
PanelRowKey::Ticket(_)
|
||||
| PanelRowKey::InvalidTicket(_)
|
||||
| PanelRowKey::TicketIntakePod { .. } => true,
|
||||
}) {
|
||||
self.select_panel_key(key.clone());
|
||||
return;
|
||||
@@ -4693,6 +4701,11 @@ fn selected_ticket_notice(row: Option<&PanelRow>) -> String {
|
||||
.unwrap_or_else(|| {
|
||||
"Open/attach this Ticket's Intake Pod from the associated row.".to_string()
|
||||
}),
|
||||
Some(row) if row.kind == PanelRowKind::InvalidTicket => row
|
||||
.disabled_reason
|
||||
.clone()
|
||||
.or_else(|| row.key_hint.clone())
|
||||
.unwrap_or_else(|| "Invalid Ticket record placeholder has no actions.".to_string()),
|
||||
_ => "No Pod is selected.".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -5262,6 +5275,14 @@ fn push_ticket_marker_span(spans: &mut Vec<Span<'static>>, selected: bool, remai
|
||||
}
|
||||
|
||||
fn panel_ticket_detail(row: &PanelRow) -> String {
|
||||
if row.kind == PanelRowKind::InvalidTicket {
|
||||
let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()];
|
||||
if let Some(reason) = panel_ticket_reason(row) {
|
||||
parts.push(format!("Reason: {reason}"));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
if row.kind == PanelRowKind::TicketIntakePod {
|
||||
let mut parts = row
|
||||
.subtitle
|
||||
@@ -5320,6 +5341,9 @@ fn panel_ticket_reason(row: &PanelRow) -> Option<&str> {
|
||||
}
|
||||
|
||||
fn ticket_detail_style(row: &PanelRow) -> Style {
|
||||
if row.kind == PanelRowKind::InvalidTicket {
|
||||
return Style::default().fg(Color::Yellow);
|
||||
}
|
||||
if row
|
||||
.ticket
|
||||
.as_ref()
|
||||
@@ -5337,7 +5361,7 @@ fn panel_ticket_reference(row: &PanelRow) -> String {
|
||||
.as_ref()
|
||||
.map(|ticket| ticket.id.clone())
|
||||
.unwrap_or_else(|| match &row.key {
|
||||
PanelRowKey::Ticket(id) => id.clone(),
|
||||
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
|
||||
PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(),
|
||||
PanelRowKey::Pod(name) => name.clone(),
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ use protocol::PodStatus;
|
||||
use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig};
|
||||
use ticket::{
|
||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug,
|
||||
TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
||||
TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState,
|
||||
};
|
||||
|
||||
use crate::pod_list::{PodList, PodListEntry, StoredMetadataState};
|
||||
@@ -182,6 +182,7 @@ impl OrchestratorPanelStatus {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub(crate) enum PanelRowKey {
|
||||
Ticket(String),
|
||||
InvalidTicket(String),
|
||||
TicketIntakePod { ticket_id: String, pod_name: String },
|
||||
Pod(String),
|
||||
}
|
||||
@@ -190,7 +191,7 @@ impl PanelRowKey {
|
||||
pub(crate) fn pod_name(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Pod(name) | Self::TicketIntakePod { pod_name: name, .. } => Some(name),
|
||||
Self::Ticket(_) => None,
|
||||
Self::Ticket(_) | Self::InvalidTicket(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,6 +204,7 @@ pub(crate) enum PanelRowKind {
|
||||
ActiveWork,
|
||||
TicketIntakePod,
|
||||
Pod,
|
||||
InvalidTicket,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
@@ -324,12 +326,17 @@ impl PanelRow {
|
||||
}
|
||||
|
||||
pub(crate) fn is_ticket_section_row(&self) -> bool {
|
||||
self.is_ticket_action() || matches!(self.kind, PanelRowKind::TicketIntakePod)
|
||||
self.is_ticket_action()
|
||||
|| matches!(
|
||||
self.kind,
|
||||
PanelRowKind::TicketIntakePod | PanelRowKind::InvalidTicket
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_POD_NAME_CHARS: usize = 80;
|
||||
const MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET: usize = 3;
|
||||
const MAX_INVALID_TICKET_PLACEHOLDER_ROWS: usize = 5;
|
||||
const ORCHESTRATOR_SUFFIX: &str = "-orchestrator";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -589,7 +596,10 @@ fn build_workspace_panel_with_registry_model(
|
||||
let backend = LocalTicketBackend::new(config.backend_root().to_path_buf())
|
||||
.with_record_language(config.ticket_record_language());
|
||||
match build_ticket_rows(&backend, pods, registry) {
|
||||
Ok(rows) => model.rows.extend(rows),
|
||||
Ok(ticket_rows) => {
|
||||
model.rows.extend(ticket_rows.rows);
|
||||
model.header.diagnostics.extend(ticket_rows.diagnostics);
|
||||
}
|
||||
Err(error) => {
|
||||
model
|
||||
.header
|
||||
@@ -663,24 +673,40 @@ fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct TicketRowsBuild {
|
||||
rows: Vec<PanelRow>,
|
||||
diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
fn build_ticket_rows(
|
||||
backend: &LocalTicketBackend,
|
||||
pods: &PodList,
|
||||
registry: &PanelRegistrySnapshot,
|
||||
) -> ticket::Result<Vec<PanelRow>> {
|
||||
) -> ticket::Result<TicketRowsBuild> {
|
||||
let partial = backend.list_partial(TicketFilter::all())?;
|
||||
let mut ticket_rows = Vec::new();
|
||||
for summary in backend.list(TicketFilter::all())? {
|
||||
let mut invalid_records = partial.invalid_records;
|
||||
for summary in partial.tickets {
|
||||
if summary.workflow_state == TicketWorkflowState::Closed {
|
||||
continue;
|
||||
}
|
||||
let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?;
|
||||
ticket_rows.push(ticket_row(
|
||||
summary,
|
||||
&ticket.events,
|
||||
&ticket.relations.blockers,
|
||||
pods,
|
||||
registry,
|
||||
));
|
||||
match backend.show_partial(TicketIdOrSlug::Query(summary.id.clone())) {
|
||||
Ok(ticket) => {
|
||||
invalid_records.extend(ticket.invalid_records);
|
||||
ticket_rows.push(ticket_row(
|
||||
summary,
|
||||
&ticket.ticket.events,
|
||||
&ticket.ticket.relations.blockers,
|
||||
pods,
|
||||
registry,
|
||||
));
|
||||
}
|
||||
Err(_) => invalid_records.push(TicketInvalidRecord {
|
||||
label: summary.id,
|
||||
reason: "could not load ticket detail".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
ticket_rows.sort_by(|a, b| {
|
||||
a.priority
|
||||
@@ -695,7 +721,72 @@ fn build_ticket_rows(
|
||||
rows.push(row);
|
||||
rows.extend(intake_rows);
|
||||
}
|
||||
Ok(rows)
|
||||
|
||||
let invalid_records = dedupe_invalid_ticket_records(invalid_records);
|
||||
let diagnostics = invalid_ticket_diagnostics(invalid_records.len());
|
||||
rows.extend(invalid_ticket_rows(&invalid_records));
|
||||
|
||||
Ok(TicketRowsBuild { rows, diagnostics })
|
||||
}
|
||||
|
||||
fn dedupe_invalid_ticket_records(records: Vec<TicketInvalidRecord>) -> Vec<TicketInvalidRecord> {
|
||||
let mut deduped = Vec::new();
|
||||
for record in records {
|
||||
if deduped
|
||||
.iter()
|
||||
.any(|existing: &TicketInvalidRecord| existing.label == record.label)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
deduped.push(record);
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn invalid_ticket_diagnostics(invalid_count: usize) -> Vec<String> {
|
||||
if invalid_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let suffix = if invalid_count > MAX_INVALID_TICKET_PLACEHOLDER_ROWS {
|
||||
format!(
|
||||
"; showing first {} placeholder rows",
|
||||
MAX_INVALID_TICKET_PLACEHOLDER_ROWS
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
vec![bounded_panel_diagnostic(format!(
|
||||
"Ticket records partially loaded: {invalid_count} invalid record(s) unavailable for actions{suffix}."
|
||||
))]
|
||||
}
|
||||
|
||||
fn invalid_ticket_rows(records: &[TicketInvalidRecord]) -> Vec<PanelRow> {
|
||||
records
|
||||
.iter()
|
||||
.take(MAX_INVALID_TICKET_PLACEHOLDER_ROWS)
|
||||
.map(invalid_ticket_row)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn invalid_ticket_row(record: &TicketInvalidRecord) -> PanelRow {
|
||||
PanelRow {
|
||||
key: PanelRowKey::InvalidTicket(record.label.clone()),
|
||||
kind: PanelRowKind::InvalidTicket,
|
||||
title: format!("Invalid Ticket record: {}", record.label),
|
||||
subtitle: Some(record.reason.clone()),
|
||||
status: "invalid".to_string(),
|
||||
priority: ActionPriority::Background,
|
||||
next_action: None,
|
||||
ticket: None,
|
||||
related_pods: Vec::new(),
|
||||
disabled_reason: Some(
|
||||
"Invalid Ticket record is diagnostics-only; lifecycle actions are disabled."
|
||||
.to_string(),
|
||||
),
|
||||
key_hint: Some(
|
||||
"Actions unavailable until the Ticket record is repaired manually.".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_row(
|
||||
@@ -1210,6 +1301,126 @@ mod tests {
|
||||
assert_eq!(row.next_action, Some(NextUserAction::Queue));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_panel_keeps_valid_ticket_actions_with_invalid_records() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_ticket_config(temp.path());
|
||||
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
|
||||
let mut ready_input = NewTicket::new("Ready Still Queueable");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let ready = backend.create(ready_input).unwrap();
|
||||
backend
|
||||
.create(NewTicket::new("Planning Still Clarifies"))
|
||||
.unwrap();
|
||||
|
||||
for index in 0..6 {
|
||||
let ticket = backend
|
||||
.create(NewTicket::new(format!("Leaked Secret Invalid {index}")))
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path()
|
||||
.join(".yoi/tickets")
|
||||
.join(&ticket.id)
|
||||
.join("item.md"),
|
||||
format!(
|
||||
"---\ntitle: Leaked Secret Invalid {index}\nstate: super-secret-invalid-{index}\n---\nbody\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let registry = PanelRegistryStore::from_root(temp.path().join("registry"));
|
||||
registry
|
||||
.claim_ticket(&ready.id, None, "ready-intake", "intake")
|
||||
.unwrap();
|
||||
let model = build_workspace_panel_with_registry(
|
||||
temp.path(),
|
||||
&live_pods(&["ready-intake"]),
|
||||
®istry.snapshot().unwrap(),
|
||||
);
|
||||
|
||||
let ready_index = model
|
||||
.rows
|
||||
.iter()
|
||||
.position(|row| row.title == "Ready Still Queueable")
|
||||
.unwrap();
|
||||
let ready_row = &model.rows[ready_index];
|
||||
assert_eq!(ready_row.next_action, Some(NextUserAction::Queue));
|
||||
assert!(ready_row.is_ticket_action());
|
||||
assert_eq!(
|
||||
model.rows[ready_index + 1].key,
|
||||
PanelRowKey::TicketIntakePod {
|
||||
ticket_id: ready.id.clone(),
|
||||
pod_name: "ready-intake".to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
let planning = model
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.title == "Planning Still Clarifies")
|
||||
.unwrap();
|
||||
assert_eq!(planning.next_action, Some(NextUserAction::Clarify));
|
||||
assert!(planning.is_ticket_action());
|
||||
|
||||
let invalid_rows = model
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| row.kind == PanelRowKind::InvalidTicket)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(invalid_rows.len(), MAX_INVALID_TICKET_PLACEHOLDER_ROWS);
|
||||
for row in invalid_rows {
|
||||
assert_eq!(row.status, "invalid");
|
||||
assert!(row.ticket.is_none());
|
||||
assert_eq!(row.next_action, None);
|
||||
assert!(!row.is_ticket_action());
|
||||
assert!(row.disabled_reason.as_deref().unwrap().contains("disabled"));
|
||||
}
|
||||
|
||||
let diagnostics = model.header.diagnostics.join("\n");
|
||||
assert!(diagnostics.contains("Ticket records partially loaded: 6 invalid record"));
|
||||
assert!(diagnostics.contains("showing first 5"));
|
||||
assert!(!diagnostics.contains("super-secret-invalid"));
|
||||
assert!(
|
||||
!model
|
||||
.rows
|
||||
.iter()
|
||||
.any(|row| row.title.contains("Leaked Secret Invalid"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_panel_keeps_backend_config_unusable_as_whole_ticket_degradation() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let config_dir = temp.path().join(".yoi");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::write(
|
||||
config_dir.join("ticket.config.toml"),
|
||||
"[backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let model = build_workspace_panel(temp.path(), &live_pods(&["idle"]));
|
||||
|
||||
let diagnostics = model.header.diagnostics.join("\n");
|
||||
assert!(diagnostics.contains("Ticket config is unusable"));
|
||||
assert!(
|
||||
model
|
||||
.rows
|
||||
.iter()
|
||||
.all(|row| row.kind != PanelRowKind::InvalidTicket)
|
||||
);
|
||||
assert_eq!(
|
||||
model.composer.available_targets,
|
||||
vec![ComposerTarget::Companion]
|
||||
);
|
||||
assert!(
|
||||
model
|
||||
.rows
|
||||
.iter()
|
||||
.any(|row| row.key == PanelRowKey::Pod("idle".to_string()))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn workspace_panel_does_not_infer_workflow_state_from_readiness_or_title() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user