use std::ffi::OsString;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
use tokio::sync::watch;
use rho_tools::cancellation::RunCancellation;
use crate::run_artifacts::RunArtifactIdentity;
use crate::subagent::RunStatus;
use super::drain::{self, DrainEnd, DrainInput, StreamLineMapper};
use super::line_decoder::MAX_NDJSON_LINE_BYTES;
use super::status_sink::{RateLimitRecorder, RuntimeLabel, StatusSink};
use super::stream_effect::TerminalResult;
use super::terminal::TerminalOutcome;
use super::{read_log_tail, CliExecutable, OwnedChild};
#[derive(Default)]
pub(crate) struct CliSessionOverrides {
pub(crate) executable: Option<CliExecutable>,
pub(crate) frozen_argv: Option<Vec<String>>,
pub(crate) live_title: Option<crate::run_artifacts::LiveRunTitle>,
pub(crate) before_spawn: Option<BeforeSpawn>,
}
pub(crate) type BeforeSpawn =
Box<dyn Fn(&mut tokio::process::Command) -> std::io::Result<()> + Send + Sync>;
pub(crate) struct CliSessionRequest {
pub(crate) identity: RunArtifactIdentity,
pub(crate) prompt: String,
pub(crate) output_file: PathBuf,
pub(crate) cancellation: RunCancellation,
pub(crate) status_tx: Option<watch::Sender<RunStatus>>,
pub(crate) started_status: Option<RunStatus>,
pub(crate) overrides: CliSessionOverrides,
}
pub(crate) trait CliSessionPolicy: Send {
type Mapper: StreamLineMapper;
fn label(&self) -> RuntimeLabel;
fn preflight(
&mut self,
sink: &mut StatusSink,
) -> impl Future<Output = Result<(), String>> + Send;
fn resolve_executable(&self) -> Result<CliExecutable, String>;
fn spawn_args(
&mut self,
output_file: &Path,
frozen: Option<Vec<String>>,
) -> Result<(Vec<OsString>, PathBuf), String>;
fn log_path(&self, output_file: &Path) -> PathBuf;
fn drain_input(&mut self) -> DrainInput;
fn mapper(&self) -> Self::Mapper;
fn assess_exit(
&self,
pending: Option<TerminalResult>,
status: ExitStatus,
log_tail: &str,
) -> TerminalOutcome;
fn rate_limit_recorder(&self) -> Option<Box<dyn RateLimitRecorder>>;
}
pub(crate) async fn run_session<P: CliSessionPolicy>(
mut request: CliSessionRequest,
mut policy: P,
) -> anyhow::Result<()> {
let mut sink = match request.started_status.take() {
Some(status) => StatusSink::continue_from(
request.output_file.clone(),
status,
&request.prompt,
request.status_tx.take(),
request.overrides.live_title.clone(),
policy.rate_limit_recorder(),
policy.label(),
)?,
None => StatusSink::new(
request.output_file.clone(),
&request.identity,
&request.prompt,
request.status_tx.take(),
policy.rate_limit_recorder(),
policy.label(),
)?,
};
let outcome = drive_session(&mut request, &mut policy, &mut sink).await;
settle(&policy, sink, outcome).await;
Ok(())
}
enum SessionOutcome {
Cancelled {
reason: &'static str,
pending: Option<Box<TerminalResult>>,
},
Failed(String),
Exited {
pending: Option<Box<TerminalResult>>,
status: ExitStatus,
log_tail: String,
},
}
async fn settle<P: CliSessionPolicy>(policy: &P, mut sink: StatusSink, outcome: SessionOutcome) {
match outcome {
SessionOutcome::Cancelled { reason, pending } => {
sink.stop(reason, pending.as_deref()).await
}
SessionOutcome::Failed(error) => sink.fail(error).await,
SessionOutcome::Exited {
pending,
status,
log_tail,
} => {
if !sink.status().state.is_terminal() {
let pending = pending.map(|terminal| *terminal);
match policy.assess_exit(pending, status, &log_tail) {
TerminalOutcome::Success(terminal) => {
sink.finalize_success_from_stream(&terminal).await;
}
TerminalOutcome::Failure {
terminal,
detail,
prefer_detail,
} => {
sink.finalize_failure_from_stream(terminal.as_ref(), detail, prefer_detail)
.await;
}
}
}
}
}
}
async fn drive_session<P: CliSessionPolicy>(
request: &mut CliSessionRequest,
policy: &mut P,
sink: &mut StatusSink,
) -> SessionOutcome {
if request.cancellation.is_cancelled() {
return SessionOutcome::Cancelled {
reason: "cancelled before execution",
pending: None,
};
}
match prepare_launch(request, policy, sink).await {
Ok(launch) => run_child(request, policy, sink, launch).await,
Err(error) => SessionOutcome::Failed(error),
}
}
struct Launch {
executable: CliExecutable,
cwd: PathBuf,
spawn_args: Vec<OsString>,
log_path: PathBuf,
log_file: std::fs::File,
}
async fn prepare_launch<P: CliSessionPolicy>(
request: &mut CliSessionRequest,
policy: &mut P,
sink: &mut StatusSink,
) -> Result<Launch, String> {
policy.preflight(sink).await?;
let executable = match request.overrides.executable.take() {
Some(executable) => executable,
None => policy.resolve_executable()?,
};
let frozen = request.overrides.frozen_argv.take();
let (spawn_args, cwd) = policy.spawn_args(&request.output_file, frozen)?;
let log_path = policy.log_path(&request.output_file);
let program = policy.label().program;
let log_file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.await
.map_err(|error| format!("{program}: could not open log file: {error}"))?
.into_std()
.await;
Ok(Launch {
executable,
cwd,
spawn_args,
log_path,
log_file,
})
}
async fn run_child<P: CliSessionPolicy>(
request: &mut CliSessionRequest,
policy: &mut P,
sink: &mut StatusSink,
launch: Launch,
) -> SessionOutcome {
let Launch {
executable,
cwd,
spawn_args,
log_path,
log_file,
} = launch;
let program = policy.label().program;
let mut command = match executable.try_command(&spawn_args) {
Ok(command) => command,
Err(error) => return SessionOutcome::Failed(format!("{program}: {error}")),
};
command
.current_dir(&cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(log_file)
.kill_on_drop(true);
if let Some(before_spawn) = request.overrides.before_spawn.as_ref() {
if let Err(error) = before_spawn(&mut command) {
return SessionOutcome::Failed(format!(
"{program}: frozen executable changed before spawn: {error}"
));
}
}
let mut child = match OwnedChild::spawn(command) {
Ok(child) => child,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return SessionOutcome::Failed(format!("{program}: binary not found on PATH"));
}
Err(error) => {
return SessionOutcome::Failed(format!(
"{program}: failed to spawn `{}`: {error}",
executable.display()
));
}
};
let outcome = drain_child(request, policy, sink, &mut child, &log_path).await;
if !matches!(outcome, SessionOutcome::Exited { .. }) {
child.terminate().await;
}
outcome
}
async fn drain_child<P: CliSessionPolicy>(
request: &mut CliSessionRequest,
policy: &mut P,
sink: &mut StatusSink,
child: &mut OwnedChild,
log_path: &Path,
) -> SessionOutcome {
sink.mark_running();
let program = policy.label().program;
let input = policy.drain_input();
let drained = {
let mut mapper = policy.mapper();
let mut on_effect = |effect| sink.apply_effect(effect);
drain::drain_child(
child,
drain::DrainConfig {
program_label: program,
max_line_bytes: MAX_NDJSON_LINE_BYTES,
},
&mut mapper,
input,
&request.cancellation,
&mut on_effect,
)
.await
};
let pending = drained.terminal.map(Box::new);
match drained.end {
DrainEnd::Cancelled => SessionOutcome::Cancelled {
reason: "cancelled",
pending,
},
DrainEnd::StdinFailed(error) | DrainEnd::StreamFailed(error) => {
SessionOutcome::Failed(error)
}
DrainEnd::Exited(Ok(status)) => SessionOutcome::Exited {
pending,
status,
log_tail: read_log_tail(log_path).await,
},
DrainEnd::Exited(Err(error)) => {
SessionOutcome::Failed(format!("{program}: failed waiting for child: {error}"))
}
}
}