feat: add ticket intake handoff

This commit is contained in:
2026-06-06 14:21:37 +09:00
parent f9745dd7a6
commit adbf3a1e34
3 changed files with 517 additions and 28 deletions
+194 -24
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,