From b83b9e4e9e6f5ac40cfad82cfccf90e14b693625 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:21:04 +0900 Subject: [PATCH] fix: tolerate invalid ticket rows in panel --- crates/ticket/src/lib.rs | 266 +++++++++++++++++++++++++++--- crates/tui/src/multi_pod.rs | 30 +++- crates/tui/src/workspace_panel.rs | 241 +++++++++++++++++++++++++-- 3 files changed, 496 insertions(+), 41 deletions(-) diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 0cd9cf33..b9cc6d50 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -765,6 +765,24 @@ pub struct TicketSummary { pub updated_at: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketInvalidRecord { + pub label: String, + pub reason: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TicketPartialList { + pub tickets: Vec, + pub invalid_records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketPartial { + pub ticket: Ticket, + pub invalid_records: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketDocument { pub body: MarkdownText, @@ -932,6 +950,49 @@ impl LocalTicketBackend { } } + pub fn list_partial(&self, filter: TicketFilter) -> Result { + let mut output = TicketPartialList::default(); + let mut invalid_seen = BTreeSet::new(); + for dir in self.iter_ticket_dirs(TicketFilter::all())? { + let item = dir.join("item.md"); + if !item.exists() { + continue; + } + match read_item_file(&item) + .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) + { + Ok(meta) => { + if filter + .state + .is_some_and(|state| meta.workflow_state != state) + { + continue; + } + output.tickets.push(ticket_summary_from_meta(meta)); + } + Err(error) => push_invalid_ticket_record( + &mut output.invalid_records, + &mut invalid_seen, + &dir, + &error, + ), + } + } + Ok(output) + } + + pub fn show_partial(&self, id: TicketIdOrSlug) -> Result { + let dir = self.find_ticket_dir(&id)?; + let mut invalid_records = Vec::new(); + let mut invalid_seen = BTreeSet::new(); + let ticket = + self.ticket_from_dir_tolerant(&dir, &mut invalid_records, &mut invalid_seen)?; + Ok(TicketPartial { + ticket, + invalid_records, + }) + } + fn generated_heading(&self, default: &'static str, japanese: &'static str) -> &'static str { if is_japanese_record_language(self.record_language()) { japanese @@ -1045,6 +1106,27 @@ impl LocalTicketBackend { } fn ticket_from_dir(&self, dir: &Path) -> Result { + self.ticket_from_dir_with_relations(dir, |backend, meta| { + backend.relation_view_for_meta(meta) + }) + } + + fn ticket_from_dir_tolerant( + &self, + dir: &Path, + invalid_records: &mut Vec, + invalid_seen: &mut BTreeSet, + ) -> Result { + self.ticket_from_dir_with_relations(dir, |backend, meta| { + backend.relation_view_for_meta_tolerant(meta, invalid_records, invalid_seen) + }) + } + + fn ticket_from_dir_with_relations( + &self, + dir: &Path, + relation_view: impl FnOnce(&Self, &TicketMeta) -> Result, + ) -> Result { let item_path = dir.join("item.md"); let parsed = read_item_file(&item_path)?; let meta = ticket_meta_for_dir(dir, parsed.frontmatter.clone())?; @@ -1059,7 +1141,7 @@ impl LocalTicketBackend { Vec::new() }; let artifacts = collect_artifacts(&dir.join("artifacts"))?; - let relations = self.relation_view_for_meta(&meta)?; + let relations = relation_view(self, &meta)?; let resolution_path = dir.join("resolution.md"); let resolution = if resolution_path.exists() { Some(MarkdownText::new( @@ -1223,13 +1305,25 @@ impl LocalTicketBackend { for dir in self.iter_ticket_dirs(TicketFilter::all())? { relations.extend(self.read_ticket_relations_for_dir(&dir)?); } - relations.sort_by(|a, b| { - a.ticket_id - .cmp(&b.ticket_id) - .then_with(|| a.kind.cmp(&b.kind)) - .then_with(|| a.target.cmp(&b.target)) - .then_with(|| a.at.cmp(&b.at)) - }); + sort_ticket_relations(&mut relations); + Ok(relations) + } + + fn all_ticket_relation_records_tolerant( + &self, + invalid_records: &mut Vec, + invalid_seen: &mut BTreeSet, + ) -> Result> { + let mut relations = Vec::new(); + for dir in self.iter_ticket_dirs(TicketFilter::all())? { + match self.read_ticket_relations_for_dir(&dir) { + Ok(records) => relations.extend(records), + Err(error) => { + push_invalid_ticket_record(invalid_records, invalid_seen, &dir, &error) + } + } + } + sort_ticket_relations(&mut relations); Ok(relations) } @@ -1239,6 +1333,17 @@ impl LocalTicketBackend { Ok(relation_view_from_records(meta, &all, &states)) } + fn relation_view_for_meta_tolerant( + &self, + meta: &TicketMeta, + invalid_records: &mut Vec, + invalid_seen: &mut BTreeSet, + ) -> Result { + let states = self.ticket_state_index_tolerant(invalid_records, invalid_seen)?; + let all = self.all_ticket_relation_records_tolerant(invalid_records, invalid_seen)?; + Ok(relation_view_from_records(meta, &all, &states)) + } + fn ticket_state_index(&self) -> Result> { let mut states = HashMap::new(); for dir in self.iter_ticket_dirs(TicketFilter::all())? { @@ -1249,6 +1354,28 @@ impl LocalTicketBackend { Ok(states) } + fn ticket_state_index_tolerant( + &self, + invalid_records: &mut Vec, + invalid_seen: &mut BTreeSet, + ) -> Result> { + let mut states = HashMap::new(); + for dir in self.iter_ticket_dirs(TicketFilter::all())? { + let item = dir.join("item.md"); + match read_item_file(&item) + .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) + { + Ok(meta) => { + states.insert(meta.id, meta.workflow_state); + } + Err(error) => { + push_invalid_ticket_record(invalid_records, invalid_seen, &dir, &error) + } + } + } + Ok(states) + } + fn relation_blockers_for_meta(&self, meta: &TicketMeta) -> Result> { Ok(self.relation_view_for_meta(meta)?.blockers) } @@ -1274,21 +1401,7 @@ impl TicketBackend for LocalTicketBackend { } let parsed = read_item_file(&item)?; let meta = ticket_meta_for_dir(&dir, parsed.frontmatter)?; - tickets.push(TicketSummary { - id: meta.id, - slug: meta.slug, - title: meta.title, - status: meta.status, - kind: meta.kind, - priority: meta.priority, - labels: meta.labels, - readiness: meta.readiness, - workflow_state: meta.workflow_state, - workflow_state_explicit: meta.workflow_state_explicit, - queued_by: meta.queued_by, - queued_at: meta.queued_at, - updated_at: meta.updated_at, - }); + tickets.push(ticket_summary_from_meta(meta)); } Ok(tickets) } @@ -2224,6 +2337,72 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta { } } +fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary { + TicketSummary { + id: meta.id, + slug: meta.slug, + title: meta.title, + status: meta.status, + kind: meta.kind, + priority: meta.priority, + labels: meta.labels, + readiness: meta.readiness, + workflow_state: meta.workflow_state, + workflow_state_explicit: meta.workflow_state_explicit, + queued_by: meta.queued_by, + queued_at: meta.queued_at, + updated_at: meta.updated_at, + } +} + +fn sort_ticket_relations(relations: &mut [TicketRelation]) { + relations.sort_by(|a, b| { + a.ticket_id + .cmp(&b.ticket_id) + .then_with(|| a.kind.cmp(&b.kind)) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.at.cmp(&b.at)) + }); +} + +fn invalid_ticket_record_label(dir: &Path) -> String { + dir.file_name() + .and_then(|name| name.to_str()) + .filter(|name| validate_record_id(name).is_ok()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "invalid ticket record".to_string()) +} + +fn invalid_ticket_record_reason(error: &TicketError) -> &'static str { + match error { + TicketError::Io { .. } => "could not read ticket record", + TicketError::Parse { .. } => "invalid ticket record schema", + TicketError::InvalidPathComponent(_) | TicketError::PathEscapesRoot { .. } => { + "invalid ticket record identity" + } + TicketError::Locked { .. } => "ticket backend is locked", + TicketError::NotFound(_) => "ticket record is missing", + TicketError::Ambiguous { .. } | TicketError::Conflict(_) => { + "invalid ticket record metadata" + } + } +} + +fn push_invalid_ticket_record( + invalid_records: &mut Vec, + invalid_seen: &mut BTreeSet, + dir: &Path, + error: &TicketError, +) { + let label = invalid_ticket_record_label(dir); + if invalid_seen.insert(label.clone()) { + invalid_records.push(TicketInvalidRecord { + label, + reason: invalid_ticket_record_reason(error).to_string(), + }); + } +} + fn trim_owned(value: String) -> String { value.trim().to_string() } @@ -3633,6 +3812,47 @@ state: planning assert!(report.is_ok(), "{:?}", report.diagnostics); } + #[test] + fn partial_list_and_show_keep_valid_tickets_when_peer_record_is_invalid() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut ready = NewTicket::new("Ready Valid"); + ready.workflow_state = Some(TicketWorkflowState::Ready); + let valid = backend.create(ready).unwrap(); + let invalid = backend + .create(NewTicket::new("Invalid Secret Title")) + .unwrap(); + fs::write( + backend.root().join(&invalid.id).join("item.md"), + "---\ntitle: Invalid Secret Title\nstate: super-secret-invalid\n---\nbody\n", + ) + .unwrap(); + + assert!(backend.list(TicketFilter::all()).is_err()); + + let partial = backend.list_partial(TicketFilter::all()).unwrap(); + assert_eq!(partial.tickets.len(), 1); + assert_eq!(partial.tickets[0].id, valid.id); + assert_eq!(partial.invalid_records.len(), 1); + assert_eq!(partial.invalid_records[0].label, invalid.id); + assert_eq!( + partial.invalid_records[0].reason, + "invalid ticket record schema" + ); + assert!( + !partial.invalid_records[0] + .reason + .contains("super-secret-invalid") + ); + + let detail = backend + .show_partial(TicketIdOrSlug::Id(valid.id.clone())) + .unwrap(); + assert_eq!(detail.ticket.meta.title, "Ready Valid"); + assert_eq!(detail.invalid_records.len(), 1); + assert_eq!(detail.invalid_records[0].label, invalid.id); + } + #[test] fn create_uses_configured_japanese_record_language_for_generated_defaults() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index bdce4d9e..63c0da1c 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -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>, 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(), }) diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 58e11b0a..6597f81b 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -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, + diagnostics: Vec, +} + fn build_ticket_rows( backend: &LocalTicketBackend, pods: &PodList, registry: &PanelRegistrySnapshot, -) -> ticket::Result> { +) -> ticket::Result { + 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) -> Vec { + 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 { + 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 { + 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::>(); + 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();