diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 9da64b63..e753db71 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -18,7 +18,8 @@ pub use runtime_command::PodRuntimeCommand; pub use pod_client::PodClient; pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod}; pub use ticket_role::{ - TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchPlan, - TicketRoleLaunchResult, launch_ticket_role_pod, plan_ticket_role_launch, + TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions, + TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning, launch_ticket_role_pod, + launch_ticket_role_pod_with_options, plan_ticket_role_launch, plan_ticket_role_launch_with_config, }; diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index a7afc59e..0f283666 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -18,6 +18,7 @@ use crate::{PodClient, PodRuntimeCommand, SpawnConfig, SpawnError, SpawnReady, s const MAX_FIELD_CHARS: usize = 8_000; const MAX_POD_NAME_CHARS: usize = 80; const RUN_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(10); +const PRE_RUN_ACTION_TIMEOUT: Duration = Duration::from_secs(5); /// Ticket identifier carried by a role launch request. #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -71,6 +72,31 @@ impl TicketRef { } } +/// Auditable panel handoff target included in a Ticket Intake launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketIntakeHandoff { + pub orchestrator_pod: String, + pub workspace_label: String, +} + +impl TicketIntakeHandoff { + pub fn new(orchestrator_pod: impl Into, workspace_label: impl Into) -> Self { + Self { + orchestrator_pod: orchestrator_pod.into(), + workspace_label: workspace_label.into(), + } + } + + fn append_prompt_lines(&self, out: &mut String) { + out.push_str("\nPanel handoff:\n"); + push_bounded_bullet(out, "workspace", &self.workspace_label); + push_bounded_bullet(out, "workspace_orchestrator_pod", &self.orchestrator_pod); + out.push_str("- When Intake has clarified the request and created/updated the Ticket, notify/report readiness to this Orchestrator.\n"); + out.push_str("- Handoff report fields: created_or_updated_ticket_id_or_slug, readiness, needs_preflight, risk_flags, user_go_required, intake_summary.\n"); + out.push_str("- Do not start implementation automatically; wait for Orchestrator routing/preflight and human Go gates.\n"); + } +} + /// Typed input for constructing a Ticket role launch. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketRoleLaunchContext { @@ -79,6 +105,7 @@ pub struct TicketRoleLaunchContext { pub pod_name: Option, pub ticket: Option, pub user_instruction: Option, + pub intake_handoff: Option, pub intent_packet: Option, pub worktree_path: Option, pub branch: Option, @@ -94,6 +121,7 @@ impl TicketRoleLaunchContext { pod_name: None, ticket: None, user_instruction: None, + intake_handoff: None, intent_packet: None, worktree_path: None, branch: None, @@ -145,6 +173,26 @@ impl TicketRoleLaunchPlan { pub struct TicketRoleLaunchResult { pub plan: TicketRoleLaunchPlan, pub ready: SpawnReady, + pub pre_run_warnings: Vec, +} + +/// Non-fatal diagnostic produced by bounded pre-run launch actions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketRolePreRunWarning { + pub message: String, +} + +/// Optional bounded actions executed after spawn readiness and before the first Run. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TicketRoleLaunchOptions { + pub pre_run_peer_registrations: Vec, +} + +impl TicketRoleLaunchOptions { + pub fn with_pre_run_peer_registration(mut self, pod_name: impl Into) -> Self { + self.pre_run_peer_registrations.push(pod_name.into()); + self + } } #[derive(Debug, Error)] @@ -228,6 +276,26 @@ pub async fn launch_ticket_role_pod( runtime_command: PodRuntimeCommand, progress: F, ) -> Result +where + F: FnMut(&str), +{ + launch_ticket_role_pod_with_options( + context, + runtime_command, + progress, + TicketRoleLaunchOptions::default(), + ) + .await +} + +/// Spawn the Pod, run bounded pre-run launch options while it is still idle, +/// then send the first `Method::Run` input and wait for acceptance evidence. +pub async fn launch_ticket_role_pod_with_options( + context: TicketRoleLaunchContext, + runtime_command: PodRuntimeCommand, + progress: F, + options: TicketRoleLaunchOptions, +) -> Result where F: FnMut(&str), { @@ -239,12 +307,95 @@ where socket_path: ready.socket_path.clone(), source, })?; + let pre_run_warnings = run_pre_run_options_then_send_run(&mut client, &plan, &options).await?; + wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?; + Ok(TicketRoleLaunchResult { + plan, + ready, + pre_run_warnings, + }) +} + +async fn run_pre_run_options_then_send_run( + client: &mut PodClient, + plan: &TicketRoleLaunchPlan, + options: &TicketRoleLaunchOptions, +) -> Result, TicketRoleLaunchError> { + let pre_run_warnings = perform_pre_run_peer_registrations( + client, + &options.pre_run_peer_registrations, + PRE_RUN_ACTION_TIMEOUT, + ) + .await; client .send(&plan.run_method()) .await .map_err(|source| TicketRoleLaunchError::SendRun { source })?; - wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?; - Ok(TicketRoleLaunchResult { plan, ready }) + Ok(pre_run_warnings) +} + +async fn perform_pre_run_peer_registrations( + client: &mut PodClient, + peer_names: &[String], + timeout: Duration, +) -> Vec { + let mut warnings = Vec::new(); + for peer_name in peer_names { + if peer_name.trim().is_empty() { + warnings.push(TicketRolePreRunWarning { + message: "pre-run peer registration skipped: peer Pod name is empty".to_string(), + }); + continue; + } + if let Err(message) = pre_run_register_peer(client, peer_name, timeout).await { + warnings.push(TicketRolePreRunWarning { message }); + } + } + warnings +} + +async fn pre_run_register_peer( + client: &mut PodClient, + peer_name: &str, + timeout: Duration, +) -> Result<(), String> { + if let Err(source) = client + .send(&Method::RegisterPeer { + name: peer_name.to_string(), + }) + .await + { + return Err(format!( + "pre-run peer registration for {peer_name} failed while sending request: {source}" + )); + } + + let wait = async { + loop { + let Some(event) = client.next_event().await else { + return Err(format!( + "pre-run peer registration for {peer_name} failed: connection closed before response" + )); + }; + match event { + Event::PeerRegistered { .. } => return Ok(()), + Event::Error { code, message } => { + return Err(format!( + "pre-run peer registration for {peer_name} failed with {code:?}: {message}" + )); + } + _ => {} + } + } + }; + + tokio::time::timeout(timeout, wait) + .await + .unwrap_or_else(|_| { + Err(format!( + "pre-run peer registration for {peer_name} timed out before first Run" + )) + }) } async fn wait_for_run_acceptance( @@ -309,6 +460,10 @@ fn build_launch_prompt( None => out.push_str("\nUser/action instruction: not specified\n"), } + if let Some(handoff) = &context.intake_handoff { + handoff.append_prompt_lines(&mut out); + } + if let Some(intent_packet) = non_empty(context.intent_packet.as_deref()) { push_bounded_section(&mut out, "Intent packet", intent_packet); } @@ -431,7 +586,10 @@ fn non_empty(value: Option<&str>) -> Option<&str> { #[cfg(test)] mod tests { use super::*; + use protocol::{Greeting, PodStatus}; use tempfile::TempDir; + use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; + use tokio::net::UnixListener; fn write_config(workspace: &std::path::Path, content: &str) { let dir = workspace.join(".yoi"); @@ -446,6 +604,152 @@ mod tests { } } + async fn write_test_event(writer: &mut W, event: Event) + where + W: AsyncWrite + Unpin, + { + writer + .write_all(serde_json::to_string(&event).unwrap().as_bytes()) + .await + .unwrap(); + writer.write_all(b"\n").await.unwrap(); + } + + fn test_snapshot() -> Event { + Event::Snapshot { + entries: vec![], + greeting: Greeting { + pod_name: "ticket-intake".to_string(), + cwd: "/tmp".to_string(), + provider: "test".to_string(), + model: "test".to_string(), + scope_summary: "test".to_string(), + tools: vec![], + context_window: 0, + context_tokens: 0, + }, + status: PodStatus::Idle, + } + } + + fn test_launch_plan(workspace: &std::path::Path) -> TicketRoleLaunchPlan { + TicketRoleLaunchPlan { + workspace_root: workspace.to_path_buf(), + role: TicketRole::Intake, + pod_name: "ticket-intake".to_string(), + profile: "project:intake".to_string(), + workflow: "ticket-intake-workflow".to_string(), + launch_prompt_ref: None, + run_segments: vec![Segment::Text { + content: "intake request".to_string(), + }], + } + } + + #[tokio::test] + async fn pre_run_peer_registration_is_sent_before_first_run_submission() { + let temp = TempDir::new().unwrap(); + let socket_path = temp.path().join("pod.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (reader, mut writer) = stream.into_split(); + write_test_event(&mut writer, test_snapshot()).await; + let mut reader = BufReader::new(reader); + + let mut first = String::new(); + reader.read_line(&mut first).await.unwrap(); + match serde_json::from_str::(&first).unwrap() { + Method::RegisterPeer { name } => assert_eq!(name, "workspace-orchestrator"), + method => panic!("expected RegisterPeer before Run, got {method:?}"), + } + write_test_event( + &mut writer, + Event::PeerRegistered { + result: serde_json::json!({"peer": "workspace-orchestrator"}), + }, + ) + .await; + + let mut second = String::new(); + reader.read_line(&mut second).await.unwrap(); + match serde_json::from_str::(&second).unwrap() { + Method::Run { input } => { + assert_eq!( + input, + test_launch_plan(std::path::Path::new("/tmp")).run_segments + ) + } + method => panic!("expected Run after pre-run RegisterPeer, got {method:?}"), + } + }); + + let mut client = PodClient::connect(&socket_path).await.unwrap(); + let options = TicketRoleLaunchOptions::default() + .with_pre_run_peer_registration("workspace-orchestrator"); + let warnings = run_pre_run_options_then_send_run( + &mut client, + &test_launch_plan(std::path::Path::new("/tmp")), + &options, + ) + .await + .unwrap(); + + server.await.unwrap(); + assert!(warnings.is_empty()); + } + + #[tokio::test] + async fn pre_run_peer_registration_failure_warns_but_still_sends_run() { + let temp = TempDir::new().unwrap(); + let socket_path = temp.path().join("pod.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (reader, mut writer) = stream.into_split(); + write_test_event(&mut writer, test_snapshot()).await; + let mut reader = BufReader::new(reader); + + let mut first = String::new(); + reader.read_line(&mut first).await.unwrap(); + assert!(matches!( + serde_json::from_str::(&first).unwrap(), + Method::RegisterPeer { .. } + )); + write_test_event( + &mut writer, + Event::Error { + code: protocol::ErrorCode::InvalidRequest, + message: "peer metadata unavailable".to_string(), + }, + ) + .await; + + let mut second = String::new(); + reader.read_line(&mut second).await.unwrap(); + assert!(matches!( + serde_json::from_str::(&second).unwrap(), + Method::Run { .. } + )); + }); + + let mut client = PodClient::connect(&socket_path).await.unwrap(); + let options = TicketRoleLaunchOptions::default() + .with_pre_run_peer_registration("workspace-orchestrator"); + let warnings = run_pre_run_options_then_send_run( + &mut client, + &test_launch_plan(std::path::Path::new("/tmp")), + &options, + ) + .await + .unwrap(); + + server.await.unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].message.contains("InvalidRequest")); + assert!(warnings[0].message.contains("workspace-orchestrator")); + } + #[test] fn default_config_role_launch_plan_uses_defaults() { let temp = TempDir::new().unwrap(); @@ -534,6 +838,20 @@ workflow = "ticket-review-workflow" assert!(intake_text.contains("Clarify and materialize")); assert!(intake_text.contains("Workflow: ticket-intake-workflow")); + let mut handoff_intake = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); + handoff_intake.intake_handoff = Some(TicketIntakeHandoff::new( + "panel-orchestrator-demo", + "Demo workspace", + )); + let handoff_plan = plan_ticket_role_launch(handoff_intake).unwrap(); + let handoff_text = text_segment(&handoff_plan); + assert!(handoff_text.contains("Panel handoff:")); + assert!(handoff_text.contains("workspace_orchestrator_pod: panel-orchestrator-demo")); + assert!(handoff_text.contains("workspace: Demo workspace")); + assert!(handoff_text.contains("created_or_updated_ticket_id_or_slug")); + assert!(handoff_text.contains("Do not start implementation automatically")); + assert!(handoff_text.contains("human Go gates")); + let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator); orchestrator.ticket = Some(TicketRef::slug("launcher")); orchestrator.intent_packet = Some("Route to implementation after preflight.".into()); diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index da595883..0dc0feb7 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -3,8 +3,9 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use client::ticket_role::{ - TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchResult, - launch_ticket_role_pod, + TicketIntakeHandoff, TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError, + TicketRoleLaunchOptions, TicketRoleLaunchResult, launch_ticket_role_pod, + launch_ticket_role_pod_with_options, }; use client::{PodRuntimeCommand, SpawnConfig, spawn_pod}; use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read}; @@ -147,9 +148,7 @@ pub(crate) async fn run( MultiPodAction::LaunchIntake(request) => { pending_reload.abort(); terminal.draw(|f| draw(f, app))?; - let result = - launch_ticket_role_pod(request.context, request.runtime_command, |_| {}) - .await; + let result = launch_intake_with_handoff(request).await; app.finish_intake_launch(result); app.reload_or_notice().await; next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL; @@ -259,6 +258,81 @@ pub(crate) struct DirectSendRequest { pub(crate) struct IntakeLaunchRequest { context: TicketRoleLaunchContext, runtime_command: PodRuntimeCommand, + peer_registration: IntakePeerRegistrationRequest, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum IntakePeerRegistrationRequest { + Register { orchestrator_pod: String }, + Skip { reason: String }, +} + +#[derive(Debug, Clone)] +pub(crate) struct IntakeLaunchOutcome { + launch: TicketRoleLaunchResult, + peer_registration: IntakePeerRegistrationStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum IntakePeerRegistrationStatus { + Registered { orchestrator_pod: String }, + Warning { message: String }, +} + +impl IntakePeerRegistrationStatus { + fn warning(message: impl Into) -> Self { + Self::Warning { + message: bounded_panel_diagnostic(message.into()), + } + } +} + +pub(crate) type IntakeLaunchResult = Result; + +pub(crate) async fn launch_intake_with_handoff(request: IntakeLaunchRequest) -> IntakeLaunchResult { + let (options, orchestrator_pod, skip_warning) = match request.peer_registration.clone() { + IntakePeerRegistrationRequest::Register { orchestrator_pod } => ( + TicketRoleLaunchOptions::default() + .with_pre_run_peer_registration(orchestrator_pod.clone()), + Some(orchestrator_pod), + None, + ), + IntakePeerRegistrationRequest::Skip { reason } => ( + TicketRoleLaunchOptions::default(), + None, + Some(IntakePeerRegistrationStatus::warning(format!( + "handoff peer registration skipped: {reason}" + ))), + ), + }; + let launch = launch_ticket_role_pod_with_options( + request.context, + request.runtime_command, + |_| {}, + options, + ) + .await?; + let peer_registration = match (orchestrator_pod, skip_warning) { + (_, Some(warning)) => warning, + (Some(orchestrator_pod), None) if launch.pre_run_warnings.is_empty() => { + IntakePeerRegistrationStatus::Registered { orchestrator_pod } + } + (Some(_), None) => IntakePeerRegistrationStatus::warning( + launch + .pre_run_warnings + .iter() + .map(|warning| warning.message.as_str()) + .collect::>() + .join("; "), + ), + (None, None) => IntakePeerRegistrationStatus::warning( + "handoff peer registration skipped: no Orchestrator target", + ), + }; + Ok(IntakeLaunchOutcome { + launch, + peer_registration, + }) } pub(crate) struct MultiPodApp { @@ -619,25 +693,55 @@ impl MultiPodApp { 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) => { + context.intake_handoff = Some(TicketIntakeHandoff::new( + orchestrator.pod_name.clone(), + self.panel.header.workspace_label.clone(), + )); + if orchestrator_status_is_peer_reachable(orchestrator.status) { + IntakePeerRegistrationRequest::Register { + orchestrator_pod: orchestrator.pod_name.clone(), + } + } else { + IntakePeerRegistrationRequest::Skip { + reason: format!( + "workspace Orchestrator {} is {}; launch input still carries the auditable handoff target", + orchestrator.pod_name, + orchestrator.status.label() + ), + } + } + } + None => IntakePeerRegistrationRequest::Skip { + reason: "workspace Orchestrator is not configured for this panel".to_string(), + }, + }; self.sending = true; self.notice = Some("Launching Ticket Intake…".to_string()); Some(IntakeLaunchRequest { context, runtime_command: self.runtime_command.clone(), + peer_registration, }) } - pub(crate) fn finish_intake_launch( - &mut self, - result: Result, - ) { + pub(crate) fn finish_intake_launch(&mut self, result: IntakeLaunchResult) { self.sending = false; match result { Ok(result) => { - let pod_name = result.plan.pod_name; + let pod_name = result.launch.plan.pod_name; self.input.clear(); + let peer_notice = match result.peer_registration { + IntakePeerRegistrationStatus::Registered { orchestrator_pod } => { + format!(" Handoff peer registered with {orchestrator_pod}.") + } + IntakePeerRegistrationStatus::Warning { message } => { + format!(" Handoff warning: {message}") + } + }; self.notice = Some(bounded_panel_diagnostic(format!( - "Launched Ticket Intake Pod {pod_name}." + "Launched Ticket Intake Pod {pod_name}.{peer_notice}" ))); } Err(error) => { @@ -955,6 +1059,15 @@ fn current_workspace_root() -> PathBuf { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } +fn orchestrator_status_is_peer_reachable(status: OrchestratorPanelStatus) -> bool { + matches!( + status, + OrchestratorPanelStatus::Live + | OrchestratorPanelStatus::Restored + | OrchestratorPanelStatus::Spawned + ) +} + async fn load_exact_pod_presence(pod_name: &str) -> Result { let list = load_pod_list(Some(pod_name.to_string()), usize::MAX).await?; Ok(orchestrator_pod_presence(pod_name, &list)) @@ -2421,11 +2534,48 @@ mod tests { Some("please intake this work") ); assert_eq!(request.runtime_command.program(), Path::new("/tmp/yoi")); + assert_eq!( + request.context.intake_handoff, + Some(TicketIntakeHandoff::new("test-orchestrator", "test")) + ); + assert_eq!( + request.peer_registration, + IntakePeerRegistrationRequest::Register { + orchestrator_pod: "test-orchestrator".to_string() + } + ); assert!(app.sending); assert!(app.notice.as_deref().unwrap().contains("Launching")); assert_eq!(input_text(&app), "please intake this work"); } + #[test] + fn multi_ticket_intake_handoff_skips_peer_registration_when_orchestrator_not_live() { + let mut app = ticket_enabled_app_with_orchestrator( + vec![live_info("idle", PodStatus::Idle)], + OrchestratorPanelStatus::Unavailable, + ); + app.cycle_composer_target(); + app.input.insert_str("please intake this work"); + + let request = match app.handle_key(key(KeyCode::Enter)) { + MultiPodAction::LaunchIntake(request) => request, + _ => panic!("Ticket Intake target should launch Intake"), + }; + + assert_eq!( + request.context.intake_handoff, + Some(TicketIntakeHandoff::new("test-orchestrator", "test")) + ); + match request.peer_registration { + IntakePeerRegistrationRequest::Skip { reason } => { + assert!(reason.contains("test-orchestrator")); + assert!(reason.contains("unavailable")); + } + other => panic!("expected peer registration skip, got {other:?}"), + } + } + #[test] fn multi_ticket_intake_finish_success_clears_composer_and_reports_pod() { let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]); @@ -2433,25 +2583,33 @@ mod tests { app.input.insert_str("please intake this work"); app.sending = true; - app.finish_intake_launch(Ok(TicketRoleLaunchResult { - plan: client::ticket_role::TicketRoleLaunchPlan { - workspace_root: PathBuf::from("/tmp/workspace"), - role: TicketRole::Intake, - pod_name: "intake-pod".to_string(), - profile: "builtin:default".to_string(), - workflow: "ticket-intake-workflow".to_string(), - launch_prompt_ref: None, - run_segments: vec![], + app.finish_intake_launch(Ok(IntakeLaunchOutcome { + launch: TicketRoleLaunchResult { + plan: client::ticket_role::TicketRoleLaunchPlan { + workspace_root: PathBuf::from("/tmp/workspace"), + role: TicketRole::Intake, + pod_name: "intake-pod".to_string(), + profile: "builtin:default".to_string(), + workflow: "ticket-intake-workflow".to_string(), + launch_prompt_ref: None, + run_segments: vec![], + }, + ready: client::SpawnReady { + pod_name: "intake-pod".to_string(), + socket_path: PathBuf::from("/tmp/intake.sock"), + }, + pre_run_warnings: vec![], }, - ready: client::SpawnReady { - pod_name: "intake-pod".to_string(), - socket_path: PathBuf::from("/tmp/intake.sock"), + peer_registration: IntakePeerRegistrationStatus::Registered { + orchestrator_pod: "test-orchestrator".to_string(), }, })); assert!(!app.sending); assert_eq!(input_text(&app), ""); - assert!(app.notice.as_deref().unwrap().contains("intake-pod")); + let notice = app.notice.as_deref().unwrap(); + assert!(notice.contains("intake-pod")); + assert!(notice.contains("Handoff peer registered")); } #[test] @@ -2499,8 +2657,20 @@ mod tests { } fn ticket_enabled_app(live: Vec) -> MultiPodApp { + ticket_enabled_app_with_orchestrator(live, OrchestratorPanelStatus::Live) + } + + fn ticket_enabled_app_with_orchestrator( + live: Vec, + orchestrator_status: OrchestratorPanelStatus, + ) -> MultiPodApp { let mut panel = WorkspacePanelViewModel::empty(Path::new("test")); panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled(); + panel.header.orchestrator = Some(OrchestratorPanelState::new( + "test-orchestrator", + orchestrator_status, + None, + )); app_with_panel( PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10), panel,