merge: companion pod lifecycle
This commit is contained in:
commit
f7c5b56eb0
|
|
@ -1,3 +1,4 @@
|
|||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
|
@ -37,12 +38,14 @@ use crate::role_session_registry::{
|
|||
PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin, TicketClaimResult,
|
||||
};
|
||||
use crate::workspace_panel::{
|
||||
ActionPriority, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
|
||||
ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus,
|
||||
CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan,
|
||||
OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow,
|
||||
PanelRowKey, TicketConfigAvailability, TicketLocalClaimStatus, WorkspacePanelViewModel,
|
||||
bounded_panel_diagnostic, build_current_ticket_row, build_workspace_panel,
|
||||
decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence,
|
||||
ticket_config_availability, workspace_orchestrator_pod_name,
|
||||
companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle,
|
||||
local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability,
|
||||
workspace_companion_pod_name, workspace_orchestrator_pod_name,
|
||||
};
|
||||
|
||||
const MAX_ENTRIES: usize = 50;
|
||||
|
|
@ -162,6 +165,14 @@ pub(crate) async fn run(
|
|||
app.reload_or_notice().await;
|
||||
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
||||
}
|
||||
MultiPodAction::SendCompanion(request) => {
|
||||
pending_reload.abort();
|
||||
terminal.draw(|f| draw(f, app))?;
|
||||
let result = dispatch_companion_message(request).await;
|
||||
app.finish_companion_send(result);
|
||||
app.reload_or_notice().await;
|
||||
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
||||
}
|
||||
},
|
||||
TermEvent::Paste(text) => app.input.insert_paste(text),
|
||||
TermEvent::Resize(_, _) => {}
|
||||
|
|
@ -305,7 +316,105 @@ impl IntakePeerRegistrationStatus {
|
|||
|
||||
pub(crate) type IntakeLaunchResult = Result<IntakeLaunchOutcome, TicketRoleLaunchError>;
|
||||
|
||||
pub(crate) async fn launch_intake_with_handoff(request: IntakeLaunchRequest) -> IntakeLaunchResult {
|
||||
pub(crate) async fn dispatch_companion_message(
|
||||
request: CompanionSendRequest,
|
||||
) -> Result<CompanionSendOutcome, CompanionSendError> {
|
||||
let stream = tokio::time::timeout(SOCKET_OP_TIMEOUT, UnixStream::connect(&request.socket_path))
|
||||
.await
|
||||
.map_err(|_| CompanionSendError::Rejected {
|
||||
pod_name: request.pod_name.clone(),
|
||||
message: "connect timed out".to_string(),
|
||||
})?
|
||||
.map_err(|source| CompanionSendError::Connect {
|
||||
pod_name: request.pod_name.clone(),
|
||||
source,
|
||||
})?;
|
||||
let (read_half, write_half) = stream.into_split();
|
||||
let mut reader = JsonLineReader::new(read_half);
|
||||
let mut writer = JsonLineWriter::new(write_half);
|
||||
|
||||
loop {
|
||||
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
|
||||
.await
|
||||
.map_err(|_| CompanionSendError::Rejected {
|
||||
pod_name: request.pod_name.clone(),
|
||||
message: "initial Snapshot timed out".to_string(),
|
||||
})?
|
||||
.map_err(|source| CompanionSendError::Read {
|
||||
pod_name: request.pod_name.clone(),
|
||||
source,
|
||||
})?;
|
||||
match event {
|
||||
Some(Event::Snapshot { .. }) => break,
|
||||
Some(Event::Alert(_)) => continue,
|
||||
Some(Event::Error { message, .. }) => {
|
||||
return Err(CompanionSendError::Rejected {
|
||||
pod_name: request.pod_name,
|
||||
message,
|
||||
});
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => {
|
||||
return Err(CompanionSendError::Closed {
|
||||
pod_name: request.pod_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::timeout(
|
||||
SOCKET_OP_TIMEOUT,
|
||||
writer.write(&Method::Run {
|
||||
input: request.segments,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| CompanionSendError::Rejected {
|
||||
pod_name: request.pod_name.clone(),
|
||||
message: "write timed out".to_string(),
|
||||
})?
|
||||
.map_err(|source| CompanionSendError::Write {
|
||||
pod_name: request.pod_name.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
loop {
|
||||
match tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>()).await {
|
||||
Ok(Ok(Some(Event::UserMessage { .. }))) => {
|
||||
return Ok(CompanionSendOutcome {
|
||||
notice: format!("Sent to Companion {}.", request.pod_name),
|
||||
});
|
||||
}
|
||||
Ok(Ok(Some(Event::Error { message, .. }))) => {
|
||||
return Err(CompanionSendError::Rejected {
|
||||
pod_name: request.pod_name,
|
||||
message,
|
||||
});
|
||||
}
|
||||
Ok(Ok(Some(Event::Snapshot { .. } | Event::Alert(_)))) => continue,
|
||||
Ok(Ok(Some(_))) => continue,
|
||||
Ok(Ok(None)) => {
|
||||
return Err(CompanionSendError::Closed {
|
||||
pod_name: request.pod_name,
|
||||
});
|
||||
}
|
||||
Ok(Err(source)) => {
|
||||
return Err(CompanionSendError::Read {
|
||||
pod_name: request.pod_name,
|
||||
source,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(CompanionSendError::Rejected {
|
||||
pod_name: request.pod_name,
|
||||
message: "acceptance read timed out".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
@ -383,6 +492,7 @@ pub(crate) struct MultiPodApp {
|
|||
notice: Option<String>,
|
||||
sending: bool,
|
||||
runtime_command: PodRuntimeCommand,
|
||||
last_companion_lifecycle_failure: Option<CompanionPanelState>,
|
||||
last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>,
|
||||
}
|
||||
|
||||
|
|
@ -398,6 +508,8 @@ impl MultiPodApp {
|
|||
},
|
||||
)
|
||||
.await?;
|
||||
let last_companion_lifecycle_failure =
|
||||
companion_lifecycle_failure_from_panel(&snapshot.panel);
|
||||
let last_orchestrator_lifecycle_failure =
|
||||
orchestrator_lifecycle_failure_from_panel(&snapshot.panel);
|
||||
let mut app = Self {
|
||||
|
|
@ -409,6 +521,7 @@ impl MultiPodApp {
|
|||
notice: None,
|
||||
sending: false,
|
||||
runtime_command,
|
||||
last_companion_lifecycle_failure,
|
||||
last_orchestrator_lifecycle_failure,
|
||||
};
|
||||
app.ensure_selection_visible();
|
||||
|
|
@ -443,6 +556,7 @@ impl MultiPodApp {
|
|||
}
|
||||
|
||||
fn apply_reloaded_snapshot(&mut self, mut snapshot: MultiPodSnapshot) {
|
||||
self.apply_companion_lifecycle_memory(&mut snapshot.panel);
|
||||
self.apply_orchestrator_lifecycle_memory(&mut snapshot.panel);
|
||||
let previous_selected_pod = self.list.selected_name.clone();
|
||||
snapshot.list.selected_name = previous_selected_pod
|
||||
|
|
@ -468,6 +582,35 @@ impl MultiPodApp {
|
|||
self.ensure_composer_target_available();
|
||||
}
|
||||
|
||||
fn apply_companion_lifecycle_memory(&mut self, panel: &mut WorkspacePanelViewModel) {
|
||||
let Some(state) = panel.header.companion.as_ref() else {
|
||||
self.last_companion_lifecycle_failure = None;
|
||||
return;
|
||||
};
|
||||
|
||||
match state.status {
|
||||
CompanionPanelStatus::Unavailable => {
|
||||
self.last_companion_lifecycle_failure =
|
||||
companion_lifecycle_failure_from_panel(panel);
|
||||
}
|
||||
CompanionPanelStatus::Live
|
||||
| CompanionPanelStatus::Spawned
|
||||
| CompanionPanelStatus::Restored => {
|
||||
self.last_companion_lifecycle_failure = None;
|
||||
}
|
||||
CompanionPanelStatus::Missing | CompanionPanelStatus::Stopped => {
|
||||
if let Some(previous) = self.last_companion_lifecycle_failure.clone() {
|
||||
if previous.pod_name == state.pod_name {
|
||||
panel.header.companion = Some(previous.clone());
|
||||
append_unique_diagnostic(panel, previous.detail.as_deref());
|
||||
} else {
|
||||
self.last_companion_lifecycle_failure = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_orchestrator_lifecycle_memory(&mut self, panel: &mut WorkspacePanelViewModel) {
|
||||
let Some(state) = panel.header.orchestrator.as_ref() else {
|
||||
self.last_orchestrator_lifecycle_failure = None;
|
||||
|
|
@ -704,16 +847,82 @@ impl MultiPodApp {
|
|||
segments_are_blank(&self.input.submit_segments())
|
||||
}
|
||||
|
||||
pub(crate) fn reject_companion_submit(&mut self) {
|
||||
pub(crate) fn prepare_companion_send(&mut self) -> Option<CompanionSendRequest> {
|
||||
let segments = self.input.submit_segments();
|
||||
if segments_are_blank(&segments) {
|
||||
self.notice = Some("Composer is empty.".to_string());
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let Some(companion) = self.panel.header.companion.as_ref() else {
|
||||
self.notice = Some("Workspace Companion is unavailable; draft kept.".to_string());
|
||||
return None;
|
||||
};
|
||||
if matches!(
|
||||
companion.status,
|
||||
CompanionPanelStatus::Unavailable
|
||||
| CompanionPanelStatus::Missing
|
||||
| CompanionPanelStatus::Stopped
|
||||
) {
|
||||
let detail = companion
|
||||
.detail
|
||||
.as_deref()
|
||||
.unwrap_or("workspace Companion is not live yet");
|
||||
self.notice = Some(bounded_panel_diagnostic(format!(
|
||||
"Companion {} is {}: {detail}; draft kept.",
|
||||
companion.pod_name,
|
||||
companion.status.label()
|
||||
)));
|
||||
return None;
|
||||
}
|
||||
let Some(entry) = self
|
||||
.list
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.name == companion.pod_name)
|
||||
else {
|
||||
self.notice = Some(format!(
|
||||
"Companion {} is not in the current Pod list; refresh and retry. Draft kept.",
|
||||
companion.pod_name
|
||||
));
|
||||
return None;
|
||||
};
|
||||
let Some(live) = entry.live.as_ref().filter(|live| live.reachable) else {
|
||||
self.notice = Some(format!(
|
||||
"Companion {} is not reachable; refresh and retry. Draft kept.",
|
||||
companion.pod_name
|
||||
));
|
||||
return None;
|
||||
};
|
||||
if live.status == Some(PodStatus::Running) {
|
||||
self.notice = Some(format!(
|
||||
"Companion {} is busy; wait for it to become idle or open it for inspection. Draft kept.",
|
||||
companion.pod_name
|
||||
));
|
||||
return None;
|
||||
}
|
||||
self.sending = true;
|
||||
self.notice = Some(format!("Sending to Companion {}…", companion.pod_name));
|
||||
Some(CompanionSendRequest {
|
||||
pod_name: companion.pod_name.clone(),
|
||||
socket_path: live.socket_path.clone(),
|
||||
segments,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn finish_companion_send(
|
||||
&mut self,
|
||||
result: Result<CompanionSendOutcome, CompanionSendError>,
|
||||
) {
|
||||
self.sending = false;
|
||||
self.notice = Some(bounded_panel_diagnostic(
|
||||
"Companion composer is not wired to a Companion Pod yet; draft kept. Press o or empty Enter to open/attach the selected Pod.",
|
||||
));
|
||||
match result {
|
||||
Ok(outcome) => {
|
||||
self.input.clear();
|
||||
self.notice = Some(outcome.notice);
|
||||
}
|
||||
Err(error) => {
|
||||
self.notice = Some(bounded_panel_diagnostic(error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_ticket_action_dispatch(&mut self) -> Option<TicketActionRequest> {
|
||||
|
|
@ -1022,10 +1231,10 @@ impl MultiPodApp {
|
|||
.map(MultiPodAction::LaunchIntake)
|
||||
.unwrap_or(MultiPodAction::None),
|
||||
KeyCode::Enter if self.composer_is_blank() => MultiPodAction::Open,
|
||||
KeyCode::Enter => {
|
||||
self.reject_companion_submit();
|
||||
MultiPodAction::None
|
||||
}
|
||||
KeyCode::Enter => self
|
||||
.prepare_companion_send()
|
||||
.map(MultiPodAction::SendCompanion)
|
||||
.unwrap_or(MultiPodAction::None),
|
||||
KeyCode::Backspace => {
|
||||
self.input.delete_before();
|
||||
MultiPodAction::None
|
||||
|
|
@ -1066,6 +1275,7 @@ enum MultiPodAction {
|
|||
Refresh,
|
||||
DispatchTicketAction(TicketActionRequest),
|
||||
LaunchIntake(IntakeLaunchRequest),
|
||||
SendCompanion(CompanionSendRequest),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -1074,6 +1284,17 @@ struct MultiPodSnapshot {
|
|||
panel: WorkspacePanelViewModel,
|
||||
}
|
||||
|
||||
fn companion_lifecycle_failure_from_panel(
|
||||
panel: &WorkspacePanelViewModel,
|
||||
) -> Option<CompanionPanelState> {
|
||||
let state = panel.header.companion.as_ref()?;
|
||||
if state.status == CompanionPanelStatus::Unavailable && state.detail.is_some() {
|
||||
Some(state.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn orchestrator_lifecycle_failure_from_panel(
|
||||
panel: &WorkspacePanelViewModel,
|
||||
) -> Option<OrchestratorPanelState> {
|
||||
|
|
@ -1110,7 +1331,29 @@ async fn load_multi_pod_snapshot(
|
|||
lifecycle_mode: OrchestratorLifecycleMode,
|
||||
) -> Result<MultiPodSnapshot, MultiPodError> {
|
||||
let workspace_root = current_workspace_root();
|
||||
let mut list = load_pod_list(selected_name.clone(), MAX_ENTRIES).await?;
|
||||
let companion_pod_name = workspace_companion_pod_name(&workspace_root);
|
||||
let list_selected_name = selected_name
|
||||
.clone()
|
||||
.or_else(|| Some(companion_pod_name.clone()));
|
||||
let mut list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?;
|
||||
let companion_presence = load_exact_companion_pod_presence(&companion_pod_name).await?;
|
||||
let companion = match lifecycle_mode.clone() {
|
||||
OrchestratorLifecycleMode::Ensure { runtime_command } => {
|
||||
ensure_workspace_companion(
|
||||
&workspace_root,
|
||||
companion_pod_name,
|
||||
companion_presence,
|
||||
runtime_command,
|
||||
)
|
||||
.await
|
||||
}
|
||||
OrchestratorLifecycleMode::Observe => {
|
||||
observe_workspace_companion(companion_pod_name, companion_presence)
|
||||
}
|
||||
};
|
||||
if companion.reload_pods {
|
||||
list = load_pod_list(list_selected_name.clone(), MAX_ENTRIES).await?;
|
||||
}
|
||||
let config = ticket_config_availability(&workspace_root);
|
||||
let orchestrator_pod_name = workspace_orchestrator_pod_name(&workspace_root);
|
||||
let orchestrator_presence = match &config {
|
||||
|
|
@ -1135,14 +1378,121 @@ async fn load_multi_pod_snapshot(
|
|||
}
|
||||
};
|
||||
if orchestrator.reload_pods {
|
||||
list = load_pod_list(selected_name, MAX_ENTRIES).await?;
|
||||
list = load_pod_list(list_selected_name, MAX_ENTRIES).await?;
|
||||
}
|
||||
let mut panel = build_workspace_panel(&workspace_root, &list);
|
||||
panel.header.companion = companion.state;
|
||||
panel.header.diagnostics.extend(companion.diagnostics);
|
||||
panel.header.orchestrator = orchestrator.state;
|
||||
panel.header.diagnostics.extend(orchestrator.diagnostics);
|
||||
Ok(MultiPodSnapshot { list, panel })
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CompanionLifecycleReport {
|
||||
state: Option<CompanionPanelState>,
|
||||
diagnostics: Vec<String>,
|
||||
reload_pods: bool,
|
||||
}
|
||||
|
||||
impl CompanionLifecycleReport {
|
||||
fn with_state(state: CompanionPanelState) -> Self {
|
||||
Self {
|
||||
state: Some(state),
|
||||
diagnostics: Vec::new(),
|
||||
reload_pods: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable(pod_name: String, detail: String) -> Self {
|
||||
let detail = bounded_panel_diagnostic(detail);
|
||||
Self {
|
||||
state: Some(CompanionPanelState::new(
|
||||
pod_name,
|
||||
CompanionPanelStatus::Unavailable,
|
||||
Some(detail.clone()),
|
||||
)),
|
||||
diagnostics: vec![detail],
|
||||
reload_pods: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_reload(mut self) -> Self {
|
||||
self.reload_pods = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_workspace_companion(
|
||||
workspace_root: &Path,
|
||||
pod_name: String,
|
||||
presence: CompanionPodPresence,
|
||||
runtime_command: PodRuntimeCommand,
|
||||
) -> CompanionLifecycleReport {
|
||||
match decide_companion_lifecycle(&presence) {
|
||||
CompanionLifecyclePlan::ReportLive => CompanionLifecycleReport::with_state(
|
||||
CompanionPanelState::new(pod_name, CompanionPanelStatus::Live, None),
|
||||
),
|
||||
CompanionLifecyclePlan::Restore => {
|
||||
match restore_workspace_companion_pod(
|
||||
workspace_root,
|
||||
&pod_name,
|
||||
runtime_command.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => CompanionLifecycleReport::with_state(CompanionPanelState::new(
|
||||
pod_name,
|
||||
CompanionPanelStatus::Restored,
|
||||
Some("restored existing Pod state".to_string()),
|
||||
))
|
||||
.mark_reload(),
|
||||
Err(error) => CompanionLifecycleReport::unavailable(
|
||||
pod_name,
|
||||
format!("could not restore workspace Companion: {error}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
CompanionLifecyclePlan::Spawn => {
|
||||
match spawn_workspace_companion_pod(workspace_root, &pod_name, runtime_command).await {
|
||||
Ok(()) => CompanionLifecycleReport::with_state(CompanionPanelState::new(
|
||||
pod_name,
|
||||
CompanionPanelStatus::Spawned,
|
||||
Some("launched with default Companion profile".to_string()),
|
||||
))
|
||||
.mark_reload(),
|
||||
Err(error) => CompanionLifecycleReport::unavailable(
|
||||
pod_name,
|
||||
format!("could not spawn workspace Companion: {error}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
CompanionLifecyclePlan::Unavailable(message) => {
|
||||
CompanionLifecycleReport::unavailable(pod_name, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_workspace_companion(
|
||||
pod_name: String,
|
||||
presence: CompanionPodPresence,
|
||||
) -> CompanionLifecycleReport {
|
||||
match presence {
|
||||
CompanionPodPresence::Live => CompanionLifecycleReport::with_state(
|
||||
CompanionPanelState::new(pod_name, CompanionPanelStatus::Live, None),
|
||||
),
|
||||
CompanionPodPresence::Restorable => CompanionLifecycleReport::with_state(
|
||||
CompanionPanelState::new(pod_name, CompanionPanelStatus::Stopped, None),
|
||||
),
|
||||
CompanionPodPresence::Missing => CompanionLifecycleReport::with_state(
|
||||
CompanionPanelState::new(pod_name, CompanionPanelStatus::Missing, None),
|
||||
),
|
||||
CompanionPodPresence::Unavailable(message) => {
|
||||
CompanionLifecycleReport::unavailable(pod_name, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OrchestratorLifecycleReport {
|
||||
state: Option<OrchestratorPanelState>,
|
||||
|
|
@ -1279,6 +1629,38 @@ async fn orchestrator_lifecycle(
|
|||
}
|
||||
}
|
||||
|
||||
async fn restore_workspace_companion_pod(
|
||||
workspace_root: &Path,
|
||||
pod_name: &str,
|
||||
runtime_command: PodRuntimeCommand,
|
||||
) -> Result<(), client::SpawnError> {
|
||||
let config = SpawnConfig {
|
||||
runtime_command,
|
||||
pod_name: pod_name.to_string(),
|
||||
profile: None,
|
||||
cwd: workspace_root.to_path_buf(),
|
||||
resume_from: None,
|
||||
resume_by_pod_name: true,
|
||||
};
|
||||
spawn_pod(config, |_| {}).await.map(|_| ())
|
||||
}
|
||||
|
||||
async fn spawn_workspace_companion_pod(
|
||||
workspace_root: &Path,
|
||||
pod_name: &str,
|
||||
runtime_command: PodRuntimeCommand,
|
||||
) -> Result<(), client::SpawnError> {
|
||||
let config = SpawnConfig {
|
||||
runtime_command,
|
||||
pod_name: pod_name.to_string(),
|
||||
profile: None,
|
||||
cwd: workspace_root.to_path_buf(),
|
||||
resume_from: None,
|
||||
resume_by_pod_name: false,
|
||||
};
|
||||
spawn_pod(config, |_| {}).await.map(|_| ())
|
||||
}
|
||||
|
||||
async fn restore_orchestrator_pod(
|
||||
workspace_root: &Path,
|
||||
pod_name: &str,
|
||||
|
|
@ -1348,6 +1730,13 @@ fn existing_ticket_claim_notice(
|
|||
}
|
||||
}
|
||||
|
||||
async fn load_exact_companion_pod_presence(
|
||||
pod_name: &str,
|
||||
) -> Result<CompanionPodPresence, MultiPodError> {
|
||||
let list = load_pod_list(Some(pod_name.to_string()), usize::MAX).await?;
|
||||
Ok(companion_pod_presence(pod_name, &list))
|
||||
}
|
||||
|
||||
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))
|
||||
|
|
@ -1373,6 +1762,68 @@ async fn load_pod_list(
|
|||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CompanionSendRequest {
|
||||
pub(crate) pod_name: String,
|
||||
pub(crate) socket_path: PathBuf,
|
||||
pub(crate) segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CompanionSendOutcome {
|
||||
pub(crate) notice: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum CompanionSendError {
|
||||
Connect {
|
||||
pod_name: String,
|
||||
source: std::io::Error,
|
||||
},
|
||||
Write {
|
||||
pod_name: String,
|
||||
source: std::io::Error,
|
||||
},
|
||||
Read {
|
||||
pod_name: String,
|
||||
source: std::io::Error,
|
||||
},
|
||||
Rejected {
|
||||
pod_name: String,
|
||||
message: String,
|
||||
},
|
||||
Closed {
|
||||
pod_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for CompanionSendError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Connect { pod_name, source } => {
|
||||
write!(f, "Companion {pod_name} is unreachable: {source}")
|
||||
}
|
||||
Self::Write { pod_name, source } => {
|
||||
write!(f, "Failed to send to Companion {pod_name}: {source}")
|
||||
}
|
||||
Self::Read { pod_name, source } => {
|
||||
write!(f, "Failed while waiting for Companion {pod_name}: {source}")
|
||||
}
|
||||
Self::Rejected { pod_name, message } => {
|
||||
write!(f, "Companion {pod_name} rejected the message: {message}")
|
||||
}
|
||||
Self::Closed { pod_name } => {
|
||||
write!(
|
||||
f,
|
||||
"Companion {pod_name} closed the socket before accepting the message"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CompanionSendError {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct TicketActionRequest {
|
||||
workspace_root: PathBuf,
|
||||
|
|
@ -1969,6 +2420,16 @@ fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
|||
),
|
||||
Span::styled(guidance, Style::default().fg(Color::DarkGray)),
|
||||
];
|
||||
if let Some(companion) = &app.panel.header.companion {
|
||||
spans.push(Span::styled(
|
||||
" · companion ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
companion.status.label(),
|
||||
companion_status_style(companion.status),
|
||||
));
|
||||
}
|
||||
if let Some(orchestrator) = &app.panel.header.orchestrator {
|
||||
spans.push(Span::styled(
|
||||
" · orchestrator ",
|
||||
|
|
@ -1982,6 +2443,18 @@ fn draw_title(frame: &mut Frame<'_>, app: &MultiPodApp, area: Rect) {
|
|||
frame.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
fn companion_status_style(status: CompanionPanelStatus) -> Style {
|
||||
match status {
|
||||
CompanionPanelStatus::Live
|
||||
| CompanionPanelStatus::Restored
|
||||
| CompanionPanelStatus::Spawned => Style::default().fg(Color::Green),
|
||||
CompanionPanelStatus::Stopped | CompanionPanelStatus::Missing => {
|
||||
Style::default().fg(Color::Yellow)
|
||||
}
|
||||
CompanionPanelStatus::Unavailable => Style::default().fg(Color::Red),
|
||||
}
|
||||
}
|
||||
|
||||
fn orchestrator_status_style(status: OrchestratorPanelStatus) -> Style {
|
||||
match status {
|
||||
OrchestratorPanelStatus::Live
|
||||
|
|
@ -2780,7 +3253,7 @@ mod tests {
|
|||
app.notice
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("Companion composer is not wired")
|
||||
.contains("Workspace Companion is unavailable")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3388,34 +3861,83 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn multi_companion_submit_keeps_composer_contents() {
|
||||
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||
fn multi_companion_submit_routes_to_workspace_companion_not_selected_pod() {
|
||||
let mut app = companion_app(
|
||||
vec![
|
||||
live_info("alpha", PodStatus::Idle),
|
||||
live_info("yoi", PodStatus::Idle),
|
||||
],
|
||||
CompanionPanelStatus::Live,
|
||||
);
|
||||
let alpha_index = app
|
||||
.list
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.name == "alpha")
|
||||
.unwrap();
|
||||
app.list.select_index(alpha_index);
|
||||
app.input.insert_str("send to companion");
|
||||
|
||||
let request = match app.handle_key(key(KeyCode::Enter)) {
|
||||
MultiPodAction::SendCompanion(request) => request,
|
||||
_ => panic!("Companion target should send to the workspace Companion"),
|
||||
};
|
||||
|
||||
assert_eq!(request.pod_name, "yoi");
|
||||
assert_eq!(request.socket_path, PathBuf::from("/tmp/yoi.sock"));
|
||||
assert!(app.sending);
|
||||
assert_eq!(input_text(&app), "send to companion");
|
||||
assert!(app.notice.as_deref().unwrap().contains("Companion yoi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_companion_submit_unavailable_keeps_composer_contents() {
|
||||
let mut app = companion_app(vec![], CompanionPanelStatus::Missing);
|
||||
app.input.insert_str("keep me");
|
||||
let before = input_text(&app);
|
||||
|
||||
app.reject_companion_submit();
|
||||
assert!(matches!(
|
||||
app.handle_key(key(KeyCode::Enter)),
|
||||
MultiPodAction::None
|
||||
));
|
||||
|
||||
assert_eq!(input_text(&app), before);
|
||||
assert!(!app.sending);
|
||||
assert!(
|
||||
app.notice
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("Companion composer is not wired")
|
||||
);
|
||||
assert!(app.notice.as_deref().unwrap().contains("draft kept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_companion_submit_empty_reports_empty_composer() {
|
||||
let mut app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||
let mut app = companion_app(
|
||||
vec![live_info("yoi", PodStatus::Idle)],
|
||||
CompanionPanelStatus::Live,
|
||||
);
|
||||
|
||||
app.reject_companion_submit();
|
||||
assert!(app.prepare_companion_send().is_none());
|
||||
|
||||
assert_eq!(input_text(&app), "");
|
||||
assert!(!app.sending);
|
||||
assert_eq!(app.notice.as_deref(), Some("Composer is empty."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_companion_finish_success_clears_composer() {
|
||||
let mut app = companion_app(
|
||||
vec![live_info("yoi", PodStatus::Idle)],
|
||||
CompanionPanelStatus::Live,
|
||||
);
|
||||
app.input.insert_str("done");
|
||||
app.sending = true;
|
||||
|
||||
app.finish_companion_send(Ok(CompanionSendOutcome {
|
||||
notice: "Sent to Companion yoi.".to_string(),
|
||||
}));
|
||||
|
||||
assert_eq!(input_text(&app), "");
|
||||
assert!(!app.sending);
|
||||
assert_eq!(app.notice.as_deref(), Some("Sent to Companion yoi."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_open_request_keeps_dashboard_state_for_nested_single_pod() {
|
||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||
|
|
@ -3513,7 +4035,7 @@ mod tests {
|
|||
app.notice
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("Companion composer is not wired")
|
||||
.contains("Workspace Companion is unavailable")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3712,6 +4234,15 @@ mod tests {
|
|||
))
|
||||
}
|
||||
|
||||
fn companion_app(live: Vec<LivePodInfo>, status: CompanionPanelStatus) -> MultiPodApp {
|
||||
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
||||
panel.header.companion = Some(CompanionPanelState::new("yoi", status, None));
|
||||
app_with_panel(
|
||||
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10),
|
||||
panel,
|
||||
)
|
||||
}
|
||||
|
||||
fn ticket_enabled_app(live: Vec<LivePodInfo>) -> MultiPodApp {
|
||||
ticket_enabled_app_with_orchestrator(live, OrchestratorPanelStatus::Live)
|
||||
}
|
||||
|
|
@ -3722,6 +4253,11 @@ mod tests {
|
|||
) -> MultiPodApp {
|
||||
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
||||
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
|
||||
panel.header.companion = Some(CompanionPanelState::new(
|
||||
"yoi",
|
||||
CompanionPanelStatus::Live,
|
||||
None,
|
||||
));
|
||||
panel.header.orchestrator = Some(OrchestratorPanelState::new(
|
||||
"test-orchestrator",
|
||||
orchestrator_status,
|
||||
|
|
@ -3758,6 +4294,7 @@ mod tests {
|
|||
}
|
||||
|
||||
fn app_with_panel(list: PodList, panel: WorkspacePanelViewModel) -> MultiPodApp {
|
||||
let last_companion_lifecycle_failure = companion_lifecycle_failure_from_panel(&panel);
|
||||
let last_orchestrator_lifecycle_failure = orchestrator_lifecycle_failure_from_panel(&panel);
|
||||
let mut app = MultiPodApp {
|
||||
list,
|
||||
|
|
@ -3768,6 +4305,7 @@ mod tests {
|
|||
notice: None,
|
||||
sending: false,
|
||||
runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"),
|
||||
last_companion_lifecycle_failure,
|
||||
last_orchestrator_lifecycle_failure,
|
||||
};
|
||||
app.ensure_selection_visible();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ impl WorkspacePanelViewModel {
|
|||
ticket_root: workspace_root
|
||||
.join(ticket::config::DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
|
||||
ticket_configured: false,
|
||||
companion: None,
|
||||
orchestrator: None,
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
|
|
@ -47,6 +48,7 @@ pub(crate) struct WorkspacePanelHeader {
|
|||
pub(crate) workspace_label: String,
|
||||
pub(crate) ticket_root: PathBuf,
|
||||
pub(crate) ticket_configured: bool,
|
||||
pub(crate) companion: Option<CompanionPanelState>,
|
||||
pub(crate) orchestrator: Option<OrchestratorPanelState>,
|
||||
pub(crate) diagnostics: Vec<String>,
|
||||
}
|
||||
|
|
@ -89,6 +91,50 @@ impl WorkspacePanelComposer {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct CompanionPanelState {
|
||||
pub(crate) pod_name: String,
|
||||
pub(crate) status: CompanionPanelStatus,
|
||||
pub(crate) detail: Option<String>,
|
||||
}
|
||||
|
||||
impl CompanionPanelState {
|
||||
pub(crate) fn new(
|
||||
pod_name: impl Into<String>,
|
||||
status: CompanionPanelStatus,
|
||||
detail: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pod_name: pod_name.into(),
|
||||
status,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CompanionPanelStatus {
|
||||
Live,
|
||||
Restored,
|
||||
Spawned,
|
||||
Stopped,
|
||||
Missing,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
impl CompanionPanelStatus {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Live => "live",
|
||||
Self::Restored => "restored",
|
||||
Self::Spawned => "spawned",
|
||||
Self::Stopped => "stopped/restorable",
|
||||
Self::Missing => "missing",
|
||||
Self::Unavailable => "unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct OrchestratorPanelState {
|
||||
pub(crate) pod_name: String,
|
||||
|
|
@ -259,6 +305,22 @@ pub(crate) enum TicketConfigAvailability {
|
|||
Unusable(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum CompanionPodPresence {
|
||||
Live,
|
||||
Restorable,
|
||||
Missing,
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum CompanionLifecyclePlan {
|
||||
ReportLive,
|
||||
Restore,
|
||||
Spawn,
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum OrchestratorPodPresence {
|
||||
Live,
|
||||
|
|
@ -276,6 +338,16 @@ pub(crate) enum OrchestratorLifecyclePlan {
|
|||
Unavailable(String),
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_companion_pod_name(workspace_root: &Path) -> String {
|
||||
let seed = workspace_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("workspace");
|
||||
sanitise_pod_name_component(seed, MAX_POD_NAME_CHARS)
|
||||
.filter(|component| !component.is_empty())
|
||||
.unwrap_or_else(|| "workspace".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_orchestrator_pod_name(workspace_root: &Path) -> String {
|
||||
let seed = workspace_root
|
||||
.file_name()
|
||||
|
|
@ -315,6 +387,43 @@ fn sanitise_pod_name_component(value: &str, max_chars: usize) -> Option<String>
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn companion_pod_presence(pod_name: &str, pods: &PodList) -> CompanionPodPresence {
|
||||
let Some(entry) = pods.entries.iter().find(|entry| entry.name == pod_name) else {
|
||||
return CompanionPodPresence::Missing;
|
||||
};
|
||||
if entry.live.as_ref().is_some_and(|live| live.reachable) {
|
||||
return CompanionPodPresence::Live;
|
||||
}
|
||||
if entry.actions.can_restore {
|
||||
return CompanionPodPresence::Restorable;
|
||||
}
|
||||
let reason = entry
|
||||
.actions
|
||||
.disabled_reason
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
entry
|
||||
.diagnostics
|
||||
.first()
|
||||
.map(|diagnostic| diagnostic.message.clone())
|
||||
})
|
||||
.unwrap_or_else(|| "pod state is not live, restorable, or spawn-safe".to_string());
|
||||
CompanionPodPresence::Unavailable(reason)
|
||||
}
|
||||
|
||||
pub(crate) fn decide_companion_lifecycle(
|
||||
presence: &CompanionPodPresence,
|
||||
) -> CompanionLifecyclePlan {
|
||||
match presence {
|
||||
CompanionPodPresence::Live => CompanionLifecyclePlan::ReportLive,
|
||||
CompanionPodPresence::Restorable => CompanionLifecyclePlan::Restore,
|
||||
CompanionPodPresence::Missing => CompanionLifecyclePlan::Spawn,
|
||||
CompanionPodPresence::Unavailable(message) => CompanionLifecyclePlan::Unavailable(format!(
|
||||
"Workspace Companion Pod state is unusable: {message}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn orchestrator_pod_presence(pod_name: &str, pods: &PodList) -> OrchestratorPodPresence {
|
||||
let Some(entry) = pods.entries.iter().find(|entry| entry.name == pod_name) else {
|
||||
return OrchestratorPodPresence::Missing;
|
||||
|
|
@ -1095,6 +1204,52 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_companion_pod_name_is_workspace_basename_without_suffix() {
|
||||
assert_eq!(
|
||||
workspace_companion_pod_name(Path::new("/home/hare/Projects/yoi")),
|
||||
"yoi"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_companion_pod_name(Path::new("/tmp/Yoi Workspace")),
|
||||
"yoi-workspace"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_companion_pod_name(Path::new("/tmp/.strange_日本語!!")),
|
||||
"strange"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_companion_pod_name(Path::new("/tmp/___")),
|
||||
"workspace"
|
||||
);
|
||||
let long = "a".repeat(120);
|
||||
let name = workspace_companion_pod_name(&PathBuf::from(format!("/tmp/{long}")));
|
||||
assert_eq!(name.chars().count(), 80);
|
||||
assert!(!name.ends_with("-companion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_lifecycle_decisions_follow_pod_state_without_ticket_gate() {
|
||||
assert_eq!(
|
||||
decide_companion_lifecycle(&CompanionPodPresence::Live),
|
||||
CompanionLifecyclePlan::ReportLive
|
||||
);
|
||||
assert_eq!(
|
||||
decide_companion_lifecycle(&CompanionPodPresence::Restorable),
|
||||
CompanionLifecyclePlan::Restore
|
||||
);
|
||||
assert_eq!(
|
||||
decide_companion_lifecycle(&CompanionPodPresence::Missing),
|
||||
CompanionLifecyclePlan::Spawn
|
||||
);
|
||||
assert!(matches!(
|
||||
decide_companion_lifecycle(&CompanionPodPresence::Unavailable(
|
||||
"corrupt metadata".to_string()
|
||||
)),
|
||||
CompanionLifecyclePlan::Unavailable(message) if message.contains("corrupt metadata")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_orchestrator_pod_name_is_stable_and_safe() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user