feat: add ticket intake handoff

This commit is contained in:
Keisuke Hirata 2026-06-06 14:05:39 +09:00
parent f9745dd7a6
commit adbf3a1e34
No known key found for this signature in database
3 changed files with 517 additions and 28 deletions

View File

@ -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,
};

View File

@ -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<String>, workspace_label: impl Into<String>) -> 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<String>,
pub ticket: Option<TicketRef>,
pub user_instruction: Option<String>,
pub intake_handoff: Option<TicketIntakeHandoff>,
pub intent_packet: Option<String>,
pub worktree_path: Option<PathBuf>,
pub branch: Option<String>,
@ -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<TicketRolePreRunWarning>,
}
/// 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<String>,
}
impl TicketRoleLaunchOptions {
pub fn with_pre_run_peer_registration(mut self, pod_name: impl Into<String>) -> 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<F>(
runtime_command: PodRuntimeCommand,
progress: F,
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
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<F>(
context: TicketRoleLaunchContext,
runtime_command: PodRuntimeCommand,
progress: F,
options: TicketRoleLaunchOptions,
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
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<Vec<TicketRolePreRunWarning>, 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<TicketRolePreRunWarning> {
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<W>(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::<Method>(&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::<Method>(&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::<Method>(&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::<Method>(&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());

View File

@ -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<String>) -> Self {
Self::Warning {
message: bounded_panel_diagnostic(message.into()),
}
}
}
pub(crate) type IntakeLaunchResult = Result<IntakeLaunchOutcome, TicketRoleLaunchError>;
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::<Vec<_>>()
.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<TicketRoleLaunchResult, TicketRoleLaunchError>,
) {
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<OrchestratorPodPresence, MultiPodError> {
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<LivePodInfo>) -> MultiPodApp {
ticket_enabled_app_with_orchestrator(live, OrchestratorPanelStatus::Live)
}
fn ticket_enabled_app_with_orchestrator(
live: Vec<LivePodInfo>,
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,