diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 52db77f8..6210dc00 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -3211,7 +3211,22 @@ fn derive_orchestrator_work_set( .iter() .filter_map(|row| { let ticket = row.ticket.as_ref()?; - if ticket.workflow_state == TicketWorkflowState::InProgress { + if let Some(overlay) = ticket + .orchestration_overlay + .as_ref() + .filter(|overlay| overlay.workflow_state == TicketWorkflowState::InProgress) + { + Some(OrchestratorActiveWorkItem { + id: ticket.id.clone(), + title: ticket.title.clone(), + status: format!( + "local: {} · {}: {}", + ticket.workflow_state.as_str(), + overlay.source, + overlay.workflow_state.as_str() + ), + }) + } else if ticket.workflow_state == TicketWorkflowState::InProgress { Some(OrchestratorActiveWorkItem { id: ticket.id.clone(), title: ticket.title.clone(), @@ -3297,6 +3312,13 @@ fn queued_duplicate_guard( guards.push(format!("related pod/worktree {pod}")); } } + if let Some(overlay) = ticket.orchestration_overlay.as_ref() { + guards.push(format!( + "{} worktree overlay shows state {}", + overlay.source, + overlay.workflow_state.as_str() + )); + } if guards.is_empty() { None } else { @@ -8893,6 +8915,7 @@ branch = "orchestration/custom-panel" workflow_state: TicketWorkflowState::parse(state) .unwrap_or(TicketWorkflowState::Planning), workflow_state_explicit: true, + orchestration_overlay: None, next_action: Some(next_action), updated_at: None, latest_event_kind: Some("implementation_report".to_string()), diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index aee3fc22..d5e9082c 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -1,7 +1,12 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::process::Command; use protocol::PodStatus; -use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig}; +use ticket::config::{ + DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, + TicketOrchestrationConfig, +}; use ticket::{ LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState, @@ -242,6 +247,7 @@ pub(crate) struct TicketPanelEntry { pub(crate) priority: String, pub(crate) workflow_state: TicketWorkflowState, pub(crate) workflow_state_explicit: bool, + pub(crate) orchestration_overlay: Option, pub(crate) next_action: Option, pub(crate) updated_at: Option, pub(crate) latest_event_kind: Option, @@ -252,6 +258,12 @@ pub(crate) struct TicketPanelEntry { pub(crate) intake_pods: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TicketStateOverlay { + pub(crate) source: String, + pub(crate) workflow_state: TicketWorkflowState, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TicketAssociatedIntakeEntry { pub(crate) ticket_id: String, @@ -379,6 +391,27 @@ pub(crate) enum OrchestratorLifecyclePlan { Unavailable(String), } +#[derive(Debug, Clone, PartialEq, Eq)] +struct OrchestrationTicketOverlay { + states: BTreeMap, + diagnostics: Vec, +} + +impl OrchestrationTicketOverlay { + fn empty() -> Self { + Self { + states: BTreeMap::new(), + diagnostics: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OrchestrationWorktreeLayout { + path: PathBuf, + branch: String, +} + pub(crate) fn workspace_companion_pod_name(workspace_root: &Path) -> String { let seed = workspace_root .file_name() @@ -543,6 +576,161 @@ pub(crate) fn bounded_panel_diagnostic(message: impl AsRef) -> String { excerpt(&collapsed, 180).unwrap_or_else(|| "unknown diagnostic".to_string()) } +fn load_orchestration_ticket_overlay( + workspace_root: &Path, + config: &TicketConfig, +) -> OrchestrationTicketOverlay { + let layout = orchestration_worktree_layout(workspace_root, &config.orchestration); + if !layout.path.exists() { + return OrchestrationTicketOverlay::empty(); + } + match validate_orchestration_overlay_source(workspace_root, &layout) { + Ok(()) => { + load_orchestration_ticket_overlay_states(&layout.path, config.ticket_record_language()) + .unwrap_or_else(|message| OrchestrationTicketOverlay { + states: BTreeMap::new(), + diagnostics: vec![bounded_panel_diagnostic(format!( + "Orchestration Ticket overlay unavailable: {message}" + ))], + }) + } + Err(message) => OrchestrationTicketOverlay { + states: BTreeMap::new(), + diagnostics: vec![bounded_panel_diagnostic(format!( + "Orchestration Ticket overlay unavailable: {message}" + ))], + }, + } +} + +fn orchestration_worktree_layout( + workspace_root: &Path, + config: &TicketOrchestrationConfig, +) -> OrchestrationWorktreeLayout { + OrchestrationWorktreeLayout { + path: workspace_root + .join(config.worktree_dir()) + .join(config.worktree_name()), + branch: config.effective_branch_name().to_string(), + } +} + +fn load_orchestration_ticket_overlay_states( + worktree_root: &Path, + record_language: Option<&str>, +) -> Result { + let ticket_root = worktree_root.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH); + if !ticket_root.is_dir() { + return Ok(OrchestrationTicketOverlay { + states: BTreeMap::new(), + diagnostics: vec![bounded_panel_diagnostic(format!( + "Orchestration worktree has no {} directory", + DEFAULT_TICKET_BACKEND_RELATIVE_PATH + ))], + }); + } + let backend = LocalTicketBackend::new(ticket_root).with_record_language(record_language); + let partial = backend + .list_partial(TicketFilter::all()) + .map_err(|error| error.to_string())?; + let mut states = BTreeMap::new(); + for summary in partial.tickets { + states.insert( + summary.id, + TicketStateOverlay { + source: "orchestration".to_string(), + workflow_state: summary.workflow_state, + }, + ); + } + let diagnostics = invalid_ticket_diagnostics(partial.invalid_records.len()); + Ok(OrchestrationTicketOverlay { + states, + diagnostics, + }) +} + +fn validate_orchestration_overlay_source( + workspace_root: &Path, + layout: &OrchestrationWorktreeLayout, +) -> Result<(), String> { + if !layout.path.exists() { + return Err(format!( + "expected worktree {} does not exist", + layout.path.display() + )); + } + if !layout.path.is_dir() { + return Err(format!( + "expected worktree {} is not a directory", + layout.path.display() + )); + } + let expected_path = layout + .path + .canonicalize() + .map_err(|error| format!("could not canonicalize expected worktree: {error}"))?; + let overlay_top = git_output(&layout.path, &["rev-parse", "--show-toplevel"])?; + let overlay_top = PathBuf::from(overlay_top); + let overlay_top = overlay_top + .canonicalize() + .map_err(|error| format!("could not canonicalize overlay git top-level: {error}"))?; + if overlay_top != expected_path { + return Err(format!( + "overlay git top-level {} does not match expected path {}", + overlay_top.display(), + expected_path.display() + )); + } + + let current_common_dir = git_common_dir(workspace_root)?; + let overlay_common_dir = git_common_dir(&layout.path)?; + if current_common_dir != overlay_common_dir { + return Err("expected worktree is from a different git common-dir".to_string()); + } + + let overlay_branch = git_output(&layout.path, &["branch", "--show-current"])?; + if overlay_branch != layout.branch { + return Err(format!( + "expected branch {} but worktree is on {}", + layout.branch, overlay_branch + )); + } + + Ok(()) +} + +fn git_common_dir(worktree_root: &Path) -> Result { + let common_dir = git_output(worktree_root, &["rev-parse", "--git-common-dir"])?; + let common_dir_path = PathBuf::from(common_dir); + let absolute = if common_dir_path.is_absolute() { + common_dir_path + } else { + worktree_root.join(common_dir_path) + }; + absolute + .canonicalize() + .map_err(|error| format!("could not canonicalize git common-dir: {error}")) +} + +fn git_output(worktree_root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(worktree_root) + .output() + .map_err(|error| format!("could not run git {}: {error}", args.join(" ")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + format!("git {} exited with {}", args.join(" "), output.status) + } else { + format!("git {} failed: {stderr}", args.join(" ")) + }; + return Err(message); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + pub(crate) fn build_workspace_panel( workspace_root: &Path, pods: &PodList, @@ -595,10 +783,17 @@ fn build_workspace_panel_with_registry_model( model.header.ticket_root = config.backend_root().to_path_buf(); let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()) .with_record_language(config.ticket_record_language()); - match build_ticket_rows(&backend, pods, registry) { + let orchestration_overlay = + load_orchestration_ticket_overlay(workspace_root, &config); + match build_ticket_rows(&backend, pods, registry, &orchestration_overlay.states) + { Ok(ticket_rows) => { model.rows.extend(ticket_rows.rows); model.header.diagnostics.extend(ticket_rows.diagnostics); + model + .header + .diagnostics + .extend(orchestration_overlay.diagnostics); } Err(error) => { model @@ -652,6 +847,7 @@ pub(crate) fn build_current_ticket_row( &ticket.relations.blockers, pods, ®istry, + None, )) } @@ -683,6 +879,7 @@ fn build_ticket_rows( backend: &LocalTicketBackend, pods: &PodList, registry: &PanelRegistrySnapshot, + orchestration_overlay: &BTreeMap, ) -> ticket::Result { let partial = backend.list_partial(TicketFilter::all())?; let mut ticket_rows = Vec::new(); @@ -701,12 +898,14 @@ fn build_ticket_rows( if current_ticket_invalid { continue; } + let overlay = orchestration_overlay.get(&summary.id); ticket_rows.push(ticket_row( summary, &ticket.ticket.events, &ticket.ticket.relations.blockers, pods, registry, + overlay, )); } Err(_) => invalid_records.push(TicketInvalidRecord { @@ -802,6 +1001,7 @@ fn ticket_row( relation_blockers: &[TicketRelationBlocker], pods: &PodList, registry: &PanelRegistrySnapshot, + orchestration_overlay: Option<&TicketStateOverlay>, ) -> PanelRow { let local_claim = local_claim_for_ticket(&summary, pods, registry); let intake_pods = @@ -815,14 +1015,24 @@ fn ticket_row( related_pods.push(pod_name); } } - let derived = derive_ticket_state(&summary, relation_blockers); + let visible_overlay = orchestration_overlay + .filter(|overlay| { + overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state) + }) + .cloned(); + let mut derived = derive_ticket_state(&summary, relation_blockers); + if let Some(overlay) = visible_overlay.as_ref() { + apply_orchestration_overlay_to_derived(&mut derived, summary.workflow_state, overlay); + } let latest_event = events.last(); + let state_display = ticket_state_display(summary.workflow_state, visible_overlay.as_ref()); let entry = TicketPanelEntry { id: summary.id.clone(), title: summary.title.clone(), priority: summary.priority.clone(), workflow_state: summary.workflow_state, workflow_state_explicit: summary.workflow_state_explicit, + orchestration_overlay: visible_overlay, next_action: derived.action, updated_at: summary.updated_at.clone(), latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()), @@ -838,7 +1048,7 @@ fn ticket_row( kind: derived.kind, title: summary.title, subtitle, - status: summary.workflow_state.as_str().to_string(), + status: state_display, priority: derived.priority, next_action: derived.action, ticket: Some(entry), @@ -858,6 +1068,76 @@ struct DerivedTicketState { blocked_reason: Option, } +fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 { + match state { + TicketWorkflowState::Planning => 0, + TicketWorkflowState::Ready => 1, + TicketWorkflowState::Queued => 2, + TicketWorkflowState::InProgress => 3, + TicketWorkflowState::Done => 4, + TicketWorkflowState::Closed => 5, + } +} + +fn overlay_state_has_progressed(local: TicketWorkflowState, overlay: TicketWorkflowState) -> bool { + workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local) +} + +fn ticket_state_display( + local: TicketWorkflowState, + overlay: Option<&TicketStateOverlay>, +) -> String { + match overlay { + Some(overlay) => format!( + "local: {} · {}: {}", + local.as_str(), + overlay.source, + overlay.workflow_state.as_str() + ), + None => local.as_str().to_string(), + } +} + +fn apply_orchestration_overlay_to_derived( + derived: &mut DerivedTicketState, + local: TicketWorkflowState, + overlay: &TicketStateOverlay, +) { + derived.action = Some(NextUserAction::Wait); + let overlay_state = overlay.workflow_state.as_str(); + match overlay.workflow_state { + TicketWorkflowState::Done | TicketWorkflowState::Closed => { + derived.kind = PanelRowKind::Review; + derived.priority = ActionPriority::Background; + derived.disabled_reason = Some(format!( + "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.", + overlay.source, + local.as_str() + )); + derived.key_hint = Some(format!( + "Merge pending: local: {} · {}: {overlay_state}", + local.as_str(), + overlay.source + )); + } + TicketWorkflowState::InProgress | TicketWorkflowState::Queued => { + derived.kind = PanelRowKind::ActiveWork; + derived.priority = ActionPriority::ActiveWork; + derived.disabled_reason = Some(format!( + "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.", + overlay.source, + local.as_str() + )); + derived.key_hint = Some(format!( + "Progress overlay: local: {} · {}: {overlay_state}", + local.as_str(), + overlay.source + )); + } + TicketWorkflowState::Planning | TicketWorkflowState::Ready => {} + } +} + fn derive_ticket_state( summary: &TicketSummary, relation_blockers: &[TicketRelationBlocker], @@ -1099,7 +1379,11 @@ pub(crate) fn local_claim_status_for_pod(pod_name: &str, pods: &PodList) -> Tick } fn ticket_subtitle(entry: &TicketPanelEntry) -> Option { - let mut parts = vec![format!("{} · {}", entry.id, entry.workflow_state.as_str())]; + let mut parts = vec![format!( + "{} · {}", + entry.id, + ticket_state_display(entry.workflow_state, entry.orchestration_overlay.as_ref()) + )]; if let Some(claim) = entry.local_claim.as_ref() { parts.push(format!( "claim: {} ({})", @@ -1212,7 +1496,10 @@ mod tests { use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; - use ticket::{NewTicket, NewTicketRelation, TicketRelationKind, TicketWorkflowState}; + use ticket::{ + NewTicket, NewTicketRelation, TicketIdOrSlug, TicketRelationKind, TicketStateChange, + TicketWorkflowState, + }; fn empty_pods() -> PodList { PodList::from_sources( @@ -1229,9 +1516,17 @@ mod tests { title: &str, configure: impl FnOnce(&mut NewTicket), ) { + create_ticket_with_id(backend, title, configure); + } + + fn create_ticket_with_id( + backend: &LocalTicketBackend, + title: &str, + configure: impl FnOnce(&mut NewTicket), + ) -> String { let mut input = NewTicket::new(title); configure(&mut input); - backend.create(input).unwrap(); + backend.create(input).unwrap().id } fn write_ticket_config(workspace_root: &Path) { @@ -1244,6 +1539,111 @@ mod tests { .unwrap(); } + fn run_git(workspace_root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(workspace_root) + .output() + .unwrap_or_else(|error| panic!("failed to run git {args:?}: {error}")); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn init_git_repo(workspace_root: &Path) { + run_git(workspace_root, &["init", "--initial-branch", "main"]); + run_git(workspace_root, &["config", "user.name", "Panel Test"]); + run_git( + workspace_root, + &["config", "user.email", "panel-test@example.invalid"], + ); + fs::write(workspace_root.join("README.md"), "panel test\n").unwrap(); + run_git(workspace_root, &["add", "README.md"]); + run_git(workspace_root, &["commit", "-m", "init"]); + } + + fn add_orchestration_worktree(workspace_root: &Path, branch: &str) -> PathBuf { + let orchestration_root = workspace_root.join(".worktree/orchestration"); + fs::create_dir_all(orchestration_root.parent().unwrap()).unwrap(); + run_git( + workspace_root, + &[ + "worktree", + "add", + "-b", + branch, + orchestration_root.to_str().unwrap(), + "HEAD", + ], + ); + orchestration_root + } + + fn copy_ticket_to_overlay(workspace_root: &Path, orchestration_root: &Path, id: &str) { + let local_ticket_dir = workspace_root.join(".yoi/tickets").join(id); + let overlay_ticket_dir = orchestration_root.join(".yoi/tickets").join(id); + fs::create_dir_all(overlay_ticket_dir.parent().unwrap()).unwrap(); + fs::create_dir_all(&overlay_ticket_dir).unwrap(); + fs::copy( + local_ticket_dir.join("item.md"), + overlay_ticket_dir.join("item.md"), + ) + .unwrap(); + let local_thread = local_ticket_dir.join("thread.md"); + if local_thread.exists() { + fs::copy(local_thread, overlay_ticket_dir.join("thread.md")).unwrap(); + } + } + + fn set_ticket_state(backend: &LocalTicketBackend, id: &str, state: TicketWorkflowState) { + loop { + let ticket = backend.show(TicketIdOrSlug::Id(id.to_string())).unwrap(); + if ticket.meta.workflow_state == state { + break; + } + let next = match (ticket.meta.workflow_state, state) { + (TicketWorkflowState::Queued, TicketWorkflowState::InProgress) + | (TicketWorkflowState::Queued, TicketWorkflowState::Done) => { + TicketWorkflowState::InProgress + } + (TicketWorkflowState::InProgress, TicketWorkflowState::Done) => { + TicketWorkflowState::Done + } + (from, to) => panic!("unsupported test transition {from} -> {to}"), + }; + backend + .set_workflow_state( + TicketIdOrSlug::Id(id.to_string()), + TicketStateChange::new( + ticket.meta.workflow_state.as_str(), + next.as_str(), + "test", + format!("test state -> {}", next.as_str()), + ), + ) + .unwrap(); + } + } + + fn ticket_row_by_title<'a>(model: &'a WorkspacePanelViewModel, title: &str) -> &'a PanelRow { + model + .rows + .iter() + .find(|row| row.title == title) + .unwrap_or_else(|| panic!("missing row for {title}")) + } + + fn status_contains(row: &PanelRow, needle: &str) { + assert!( + row.status.contains(needle), + "status {:?} did not contain {:?}", + row.status, + needle + ); + } + fn live_pods(names: &[&str]) -> PodList { PodList::from_sources( crate::pod_list::PodVisibilitySource::ResumePicker, @@ -1308,6 +1708,151 @@ mod tests { assert_eq!(row.next_action, Some(NextUserAction::Queue)); } + #[test] + fn workspace_panel_joins_orchestration_overlay_by_ticket_id() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()); + write_ticket_config(temp.path()); + let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration"); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let id = create_ticket_with_id(&backend, "Overlay Match", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + create_ticket(&backend, "Local Only", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); + let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); + set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress); + create_ticket(&overlay_backend, "Overlay Only", |input| { + input.workflow_state = Some(TicketWorkflowState::Done); + }); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let matched = ticket_row_by_title(&model, "Overlay Match"); + status_contains(matched, "local: queued"); + status_contains(matched, "orchestration: inprogress"); + assert_eq!( + matched.ticket.as_ref().unwrap().workflow_state, + TicketWorkflowState::Queued + ); + assert_eq!( + matched + .ticket + .as_ref() + .unwrap() + .orchestration_overlay + .as_ref() + .unwrap() + .workflow_state, + TicketWorkflowState::InProgress + ); + assert_eq!(ticket_row_by_title(&model, "Local Only").status, "queued"); + assert!(model.rows.iter().all(|row| row.title != "Overlay Only")); + } + + #[test] + fn workspace_panel_displays_queued_plus_orchestration_inprogress_without_mutating_local_ticket() + { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()); + write_ticket_config(temp.path()); + let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration"); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let id = create_ticket_with_id(&backend, "Overlay In Progress", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); + let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); + set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress); + let local_item = temp.path().join(".yoi/tickets").join(&id).join("item.md"); + let before = fs::read_to_string(&local_item).unwrap(); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let row = ticket_row_by_title(&model, "Overlay In Progress"); + status_contains(row, "local: queued"); + status_contains(row, "orchestration: inprogress"); + assert_eq!(row.next_action, Some(NextUserAction::Wait)); + assert_eq!(row.kind, PanelRowKind::ActiveWork); + assert_eq!(fs::read_to_string(&local_item).unwrap(), before); + let local_ticket = backend.show(TicketIdOrSlug::Id(id)).unwrap(); + assert_eq!( + local_ticket.meta.workflow_state, + TicketWorkflowState::Queued + ); + } + + #[test] + fn workspace_panel_displays_queued_plus_orchestration_done_as_merge_pending_without_queue() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()); + write_ticket_config(temp.path()); + let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration"); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let id = create_ticket_with_id(&backend, "Overlay Done", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); + let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); + set_ticket_state(&overlay_backend, &id, TicketWorkflowState::Done); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let row = ticket_row_by_title(&model, "Overlay Done"); + status_contains(row, "local: queued"); + status_contains(row, "orchestration: done"); + assert_eq!(row.kind, PanelRowKind::Review); + assert_eq!(row.next_action, Some(NextUserAction::Wait)); + assert_ne!(row.next_action, Some(NextUserAction::Queue)); + assert!( + row.disabled_reason + .as_deref() + .unwrap() + .contains("merge/review/close authority") + ); + } + + #[test] + fn workspace_panel_ignores_orchestration_overlay_on_branch_mismatch() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()); + write_ticket_config(temp.path()); + let orchestration_root = add_orchestration_worktree(temp.path(), "other-branch"); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let id = create_ticket_with_id(&backend, "Branch Mismatch", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); + let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); + set_ticket_state(&overlay_backend, &id, TicketWorkflowState::Done); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let row = ticket_row_by_title(&model, "Branch Mismatch"); + assert_eq!(row.status, "queued"); + assert!(row.ticket.as_ref().unwrap().orchestration_overlay.is_none()); + let diagnostics = model.header.diagnostics.join("\n"); + assert!(diagnostics.contains("expected branch orchestration")); + } + + #[test] + fn workspace_panel_falls_back_when_orchestration_worktree_is_missing() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + create_ticket(&backend, "Missing Overlay", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let row = ticket_row_by_title(&model, "Missing Overlay"); + assert_eq!(row.status, "queued"); + assert!(row.ticket.as_ref().unwrap().orchestration_overlay.is_none()); + } + #[test] fn workspace_panel_keeps_valid_ticket_actions_with_invalid_records() { let temp = TempDir::new().unwrap();