use super::super::*;
use super::{
ExportWorker, ExportWorkerDelivery, ExportWorkerOutcome, MissionControlApp, PendingExport,
};
const MAX_EXPORT_DIAGNOSTIC_CHARS: usize = 500;
fn bounded_export_text(message: impl std::fmt::Display, limit: usize) -> String {
let sanitized = crate::output::sanitize_display_text(&message.to_string());
let normalized = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
let mut chars = normalized.chars();
let mut bounded = chars.by_ref().take(limit).collect::<String>();
if chars.next().is_some() {
bounded.pop();
bounded.push('…');
}
bounded
}
fn bounded_export_diagnostic(error: impl std::fmt::Display) -> String {
bounded_export_text(error, MAX_EXPORT_DIAGNOSTIC_CHARS)
}
fn format_export_status(report: &crate::sessions::SessionExportReport) -> String {
let partial = !report.warnings.is_empty();
let completeness = if partial { "partial" } else { "complete" };
let completeness_prefix = if partial { "PARTIAL — " } else { "" };
let warnings = report
.warnings
.iter()
.take(8)
.cloned()
.collect::<Vec<_>>()
.join("; ");
let path = report
.destination
.display()
.to_string()
.chars()
.take(240)
.collect::<String>();
let status = format!(
"{completeness_prefix}UNENCRYPTED — NOT SAFE TO SHARE; session export {completeness}: {path} ({} members, {} source bytes); raw potentially sensitive session/project/tool data{}",
report.member_count,
report.source_bytes,
if warnings.is_empty() {
String::new()
} else {
format!("; warnings: {warnings}")
}
);
bounded_export_text(status, 600)
}
fn run_export_job(
paths: crate::config::McPaths,
manager: crate::sessions::SessionManager,
session: crate::sessions::Session,
cancel: Arc<AtomicBool>,
) -> Option<Result<TuiExportWorkerResult, String>> {
if cancel.load(Ordering::SeqCst) {
return None;
}
let cancellation = crate::cancellation::AgentCancellation::new(Arc::clone(&cancel));
let destination = match crate::sessions::export_destination(&paths.root, session.id()) {
Ok(path) => path,
Err(error) => return Some(Err(bounded_export_diagnostic(error))),
};
let result = crate::sessions::export_session_with_cancellation(
&manager,
&session,
&destination,
&cancellation,
)
.map(|report| TuiExportWorkerResult { report })
.map_err(bounded_export_diagnostic);
Some(result)
}
impl MissionControlApp {
pub(super) fn start_export(
&mut self,
arg: Option<&str>,
ui_state: &mut state::MissionControlState,
) {
if arg.is_some() {
ui_state.status = "usage: /export".to_string();
return;
}
if self.pending_session_switch.is_some() {
ui_state.status = "session switch loading; export blocked".to_string();
return;
}
let Some(session) = self.state.current_session.as_ref() else {
ui_state.status = "/export requires an active persisted session".to_string();
return;
};
match std::fs::symlink_metadata(session.path()) {
Ok(metadata) if metadata.file_type().is_file() => {}
Ok(_) => {
ui_state.status = "/export requires an active persisted session".to_string();
return;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
ui_state.status = "/export requires an active persisted session".to_string();
return;
}
Err(error) => {
ui_state.status = format!("export failed: {}", bounded_export_diagnostic(error));
return;
}
}
if self.pending_export.is_some() {
ui_state.status = "export request already in progress".to_string();
return;
}
self.next_export_request_id = self.next_export_request_id.saturating_add(1);
let request_id = self.next_export_request_id;
let session_id = session.id().to_string();
let generation = self.session_generation;
self.pending_export = Some(PendingExport {
request_id,
session_id: session_id.clone(),
generation,
});
ui_state.status = "exporting session…".to_string();
let paths = self.config.paths.clone();
let manager = self.state.session_manager.clone();
let worker_session = session.clone();
let worker_session_id = session_id.clone();
let sender = self.events.clone();
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = Arc::clone(&cancel);
let handle = thread::Builder::new()
.name("magi-export".to_string())
.spawn(move || {
let Some(result) =
run_export_job(paths, manager, worker_session, worker_cancel.clone())
else {
return ExportWorkerOutcome {
result: None,
delivery: ExportWorkerDelivery::Canceled,
};
};
let delivery = match send_tui_event(
&sender,
TuiEvent::ExportFinished {
request_id,
session_id: worker_session_id,
generation,
result: Box::new(result.clone()),
},
) {
Ok(()) => ExportWorkerDelivery::Delivered,
Err(_) => ExportWorkerDelivery::Failed,
};
ExportWorkerOutcome {
result: Some(result),
delivery,
}
});
match handle {
Ok(handle) => self.export_workers.push(ExportWorker {
request_id,
cancel,
handle,
}),
Err(error) => {
self.pending_export = None;
ui_state.status = format!(
"export failed: could not start worker: {}",
bounded_export_diagnostic(error)
);
}
}
}
pub(in crate::tui) fn handle_export_drain(
&mut self,
ui_state: &mut state::MissionControlState,
drain_result: &DrainResult,
) -> bool {
let mut changed = false;
for (request_id, session_id, generation, result) in &drain_result.export_finished {
let Some(pending) = self.pending_export.clone() else {
continue;
};
if pending.request_id != *request_id
|| pending.session_id != *session_id
|| pending.generation != *generation
{
continue;
}
self.pending_export = None;
if self.session_generation != *generation
|| self.state.active_session_id() != Some(session_id.as_str())
{
ui_state.status = "export result ignored; session changed".to_string();
changed = true;
continue;
}
match result {
Ok(worker_result) => {
ui_state.status = format_export_status(&worker_result.report);
}
Err(error) => {
ui_state.status =
format!("export failed: {}", bounded_export_diagnostic(error));
}
}
changed = true;
}
changed
}
pub(in crate::tui) fn reap_export_workers(
&mut self,
ui_state: &mut state::MissionControlState,
) -> bool {
let mut changed = false;
let mut active = Vec::with_capacity(self.export_workers.len());
for worker in std::mem::take(&mut self.export_workers) {
if !worker.handle.is_finished() {
active.push(worker);
continue;
}
match worker.handle.join() {
Ok(outcome) => match outcome.delivery {
ExportWorkerDelivery::Delivered => {}
ExportWorkerDelivery::Canceled => {
if self
.pending_export
.as_ref()
.is_some_and(|pending| pending.request_id == worker.request_id)
{
self.pending_export = None;
ui_state.status = "export request cancelled".to_string();
changed = true;
}
}
ExportWorkerDelivery::Failed => {
if let Some(result) = outcome.result {
let (session_id, generation) = self
.pending_export
.as_ref()
.filter(|pending| pending.request_id == worker.request_id)
.map(|pending| (pending.session_id.clone(), pending.generation))
.unwrap_or_default();
let mut drain = DrainResult::default();
drain.export_finished.push((
worker.request_id,
session_id,
generation,
result,
));
changed |= self.handle_export_drain(ui_state, &drain);
}
}
},
Err(_) => {
if self
.pending_export
.as_ref()
.is_some_and(|pending| pending.request_id == worker.request_id)
{
self.pending_export = None;
ui_state.status = "export worker panicked".to_string();
changed = true;
}
}
}
}
self.export_workers = active;
changed
}
pub(super) fn cancel_export_workers(&self) {
for worker in &self.export_workers {
worker.cancel.store(true, Ordering::SeqCst);
}
}
pub(in crate::tui) fn join_export_workers_for_cleanup(&mut self) -> Vec<String> {
let mut errors = Vec::new();
for worker in std::mem::take(&mut self.export_workers) {
if worker.handle.join().is_err() {
errors.push(format!("export worker {} panicked", worker.request_id));
}
}
self.pending_export = None;
errors
}
}