use anyhow::{bail, Context, Result};
use clap::Subcommand;
use edda_conductor::agent::budget::BudgetTracker;
use edda_conductor::agent::launcher::{phase_session_id, ClaudeCodeLauncher};
use edda_conductor::check::engine::CheckEngine;
use edda_conductor::plan::parser::load_plan;
use edda_conductor::runner::notify::StdoutNotifier;
use edda_conductor::runner::sequential::{run_plan, RunContext};
use edda_conductor::state::machine::{PhaseStatus, PlanState, PlanStatus};
use edda_conductor::state::persist::{load_state, save_state};
use edda_conductor::tmux::TmuxSession;
use std::path::Path;
use tokio_util::sync::CancellationToken;
#[derive(Subcommand)]
pub enum ConductCmd {
Run {
plan_file: String,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
quiet: bool,
#[arg(long)]
json: bool,
#[arg(long)]
tmux: bool,
},
Status {
plan_name: Option<String>,
#[arg(long)]
json: bool,
},
Retry {
phase_id: String,
#[arg(long)]
plan: Option<String>,
},
Skip {
phase_id: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
plan: Option<String>,
},
Abort {
plan_name: Option<String>,
},
}
pub fn run_cmd(cmd: ConductCmd, repo_root: &Path) -> Result<()> {
match cmd {
ConductCmd::Run {
plan_file,
cwd,
dry_run,
quiet,
json,
tmux,
} => run(
Path::new(&plan_file),
cwd.as_deref().map(Path::new),
dry_run,
!quiet,
json,
tmux,
),
ConductCmd::Status { plan_name, json } => status(repo_root, plan_name.as_deref(), json),
ConductCmd::Retry { phase_id, plan } => retry(repo_root, &phase_id, plan.as_deref()),
ConductCmd::Skip {
phase_id,
reason,
plan,
} => skip(repo_root, &phase_id, reason.as_deref(), plan.as_deref()),
ConductCmd::Abort { plan_name } => abort(repo_root, plan_name.as_deref()),
}
}
pub fn run(
plan_file: &Path,
cwd_override: Option<&Path>,
dry_run: bool,
verbose: bool,
json_events: bool,
tmux: bool,
) -> Result<()> {
let plan = load_plan(plan_file)?;
let cwd = cwd_override
.map(|p| p.to_path_buf())
.or_else(|| {
plan.cwd
.as_ref()
.map(|p| plan_file.parent().unwrap_or(Path::new(".")).join(p))
})
.unwrap_or_else(|| plan_file.parent().unwrap_or(Path::new(".")).to_path_buf());
let cwd = if cwd.is_relative() {
std::env::current_dir()?.join(&cwd)
} else {
cwd
};
let verbose = if json_events { false } else { verbose };
let use_tmux = if tmux {
if TmuxSession::is_available() {
true
} else {
eprintln!(
"Warning: --tmux requested but tmux is not installed. \
Falling back to normal mode."
);
false
}
} else {
false
};
let mut state = match load_state(&cwd, &plan.name)? {
Some(s) => {
if !json_events {
println!("Resuming plan \"{}\"", plan.name);
}
s
}
None => {
if !json_events {
println!(
"Starting plan \"{}\" ({} phases)",
plan.name,
plan.phases.len()
);
}
PlanState::from_plan(&plan, &plan_file.display().to_string())
}
};
let order = edda_conductor::plan::topo::topo_sort(&plan)?;
if dry_run {
println!("\n[dry-run] Plan: {}", plan.name);
println!(" Phases: {}", plan.phases.len());
println!(
" Budget: {}",
plan.budget_usd
.map_or("unlimited".into(), |b| format!("${b:.2}"))
);
println!(" Max attempts: {}", plan.max_attempts);
println!(" On fail: {:?}", plan.on_fail);
println!("\n Phase order:");
for (i, id) in order.iter().enumerate() {
let phase = plan
.phases
.iter()
.find(|p| p.id == *id)
.context("phase referenced in topo order not found in plan")?;
let checks = if phase.check.is_empty() {
String::new()
} else {
format!(" ({} checks)", phase.check.len())
};
println!(" {}. {}{}", i + 1, id, checks);
}
println!("\n Session IDs:");
for id in &order {
println!(" {} \u{2192} {}", id, phase_session_id(&plan.name, id));
}
if use_tmux {
TmuxSession::print_layout_preview(&plan.name, &order);
}
return Ok(());
}
let transcript_dir = cwd
.join(".edda")
.join("conductor")
.join(&plan.name)
.join("transcripts");
let mut launcher = ClaudeCodeLauncher::new().with_verbose(verbose);
launcher.transcript_dir = Some(transcript_dir.clone());
launcher.verify_available()?;
let engine = CheckEngine::new(cwd.clone());
let notifier = StdoutNotifier;
let mut budget = BudgetTracker::new(plan.budget_usd);
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
ctrlc_cancel(cancel_clone);
let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin());
let tmux_session = if use_tmux {
match TmuxSession::create(&plan.name, &order, &transcript_dir) {
Ok(session) => {
println!("Tmux session created: {}", session.session_name);
println!(" Attach: tmux attach -t {}", session.session_name);
Some(session)
}
Err(e) => {
eprintln!(
"Warning: failed to create tmux session: {e}. \
Continuing without tmux."
);
None
}
}
} else {
None
};
let rt = tokio::runtime::Runtime::new()?;
let result = rt.block_on(run_plan(
&plan,
&mut state,
RunContext {
launcher: &launcher,
check_engine: &engine,
notifier: ¬ifier,
budget: &mut budget,
cancel,
cwd: &cwd,
interactive,
json_events,
tmux_session: tmux_session.as_ref(),
},
));
if let Some(ref session) = tmux_session {
println!(
"\nTmux session still active: tmux attach -t {}",
session.session_name
);
println!(" Destroy: tmux kill-session -t {}", session.session_name);
}
result
}
pub fn status(repo_root: &Path, plan_name: Option<&str>, json: bool) -> Result<()> {
let conductor_dir = repo_root.join(".edda").join("conductor");
if !conductor_dir.exists() {
if json {
println!("[]");
} else {
println!("No conductor state found.");
}
return Ok(());
}
let plans: Vec<String> = if let Some(name) = plan_name {
vec![name.to_string()]
} else {
let mut names = Vec::new();
for entry in std::fs::read_dir(&conductor_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
}
names.sort();
names
};
if plans.is_empty() {
if json {
println!("[]");
} else {
println!("No plans found.");
}
return Ok(());
}
if json {
let states: Vec<PlanState> = plans
.iter()
.filter_map(|name| load_state(repo_root, name).ok().flatten())
.collect();
if plan_name.is_some() {
if let Some(s) = states.into_iter().next() {
println!("{}", serde_json::to_string_pretty(&s)?);
} else {
println!("null");
}
} else {
println!("{}", serde_json::to_string_pretty(&states)?);
}
} else {
for name in &plans {
let state = load_state(repo_root, name)?;
match state {
Some(s) => print_status(&s),
None => println!("Plan \"{name}\": no state file found"),
}
}
}
Ok(())
}
pub fn retry(repo_root: &Path, phase_id: &str, plan_name: Option<&str>) -> Result<()> {
let name = resolve_plan_name(repo_root, plan_name)?;
let mut state = load_state(repo_root, &name)?
.ok_or_else(|| anyhow::anyhow!("no state for plan \"{name}\""))?;
let current_status = {
let ps = state.get_phase_mut(phase_id)?;
if ps.status != PhaseStatus::Failed && ps.status != PhaseStatus::Stale {
bail!(
"Phase \"{}\" is {:?}, not Failed or Stale. Cannot retry.",
phase_id,
ps.status
);
}
ps.status
};
edda_conductor::state::machine::transition(
&mut state,
phase_id,
current_status,
PhaseStatus::Pending,
None,
)?;
if state.plan_status == PlanStatus::Blocked {
state.plan_status = PlanStatus::Running;
}
save_state(repo_root, &state)?;
println!("Phase \"{phase_id}\" reset to Pending. Run `edda conduct run` to resume.");
Ok(())
}
pub fn skip(
repo_root: &Path,
phase_id: &str,
reason: Option<&str>,
plan_name: Option<&str>,
) -> Result<()> {
let name = resolve_plan_name(repo_root, plan_name)?;
let mut state = load_state(repo_root, &name)?
.ok_or_else(|| anyhow::anyhow!("no state for plan \"{name}\""))?;
let ps = state.get_phase_mut(phase_id)?;
if ps.status != PhaseStatus::Failed
&& ps.status != PhaseStatus::Stale
&& ps.status != PhaseStatus::Pending
{
bail!(
"Phase \"{}\" is {:?}. Can only skip Failed, Stale, or Pending phases.",
phase_id,
ps.status
);
}
ps.status = PhaseStatus::Skipped;
ps.skip_reason = Some(reason.unwrap_or("manually skipped").to_string());
if state.plan_status == PlanStatus::Blocked {
state.plan_status = PlanStatus::Running;
}
save_state(repo_root, &state)?;
println!("Phase \"{phase_id}\" skipped.");
Ok(())
}
pub fn abort(repo_root: &Path, plan_name: Option<&str>) -> Result<()> {
let name = resolve_plan_name(repo_root, plan_name)?;
let mut state = load_state(repo_root, &name)?
.ok_or_else(|| anyhow::anyhow!("no state for plan \"{name}\""))?;
if state.plan_status == PlanStatus::Completed || state.plan_status == PlanStatus::Aborted {
bail!("Plan \"{}\" is already {:?}.", name, state.plan_status);
}
state.plan_status = PlanStatus::Aborted;
state.aborted_at = Some(now_rfc3339());
save_state(repo_root, &state)?;
println!("Plan \"{name}\" aborted.");
Ok(())
}
fn resolve_plan_name(repo_root: &Path, explicit: Option<&str>) -> Result<String> {
if let Some(name) = explicit {
return Ok(name.to_string());
}
let conductor_dir = repo_root.join(".edda").join("conductor");
if !conductor_dir.exists() {
bail!("No conductor state found. Specify --plan <name>.");
}
let mut names = Vec::new();
for entry in std::fs::read_dir(&conductor_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(n) = entry.file_name().to_str() {
names.push(n.to_string());
}
}
}
match names.len() {
0 => bail!("No plans found."),
1 => Ok(names
.into_iter()
.next()
.context("expected exactly one plan")?),
_ => bail!(
"Multiple plans found: {}. Use --plan to specify.",
names.join(", ")
),
}
}
fn print_status(state: &PlanState) {
println!("\nPlan: {} ({:?})", state.plan_name, state.plan_status);
if !state.plan_file.is_empty() {
println!(" File: {}", state.plan_file);
}
println!(" Cost: ${:.2}", state.total_cost_usd);
println!();
for ps in &state.phases {
let icon = match ps.status {
PhaseStatus::Passed => "\u{2713}", PhaseStatus::Failed => "\u{2717}", PhaseStatus::Running | PhaseStatus::Checking => "\u{25B6}", PhaseStatus::Skipped => "\u{2298}", PhaseStatus::Stale => "\u{23F0}", PhaseStatus::Pending => "\u{25CB}", };
let detail = match ps.status {
PhaseStatus::Passed => format!("(attempt {})", ps.attempts),
PhaseStatus::Failed => {
let err = ps
.error
.as_ref()
.map(|e| e.message.as_str())
.unwrap_or("unknown");
format!("(attempt {}, {})", ps.attempts, err)
}
PhaseStatus::Skipped => {
let reason = ps.skip_reason.as_deref().unwrap_or("");
format!("({})", reason)
}
_ => String::new(),
};
println!(" {icon} {:<24} {:?} {detail}", ps.id, ps.status);
}
println!();
}
fn now_rfc3339() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default()
}
fn ctrlc_cancel(cancel: CancellationToken) {
let _ = ctrlc::set_handler(move || {
cancel.cancel();
});
}