use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use clap::Parser;
use crate::agent::dry_run::{DryRunAgent, DryRunFinal};
use crate::agent::{self, Agent, AgentEvent};
use crate::cli::ExitCode;
use crate::config;
use crate::deferred::{self, DeferredDoc};
use crate::git::{Git, ShellGit};
use crate::plan::{self, PhaseId, Plan};
use crate::runner::{self, PhaseResult, Runner};
use crate::state::{self, TokenUsage};
use crate::util::paths;
#[derive(Debug, Parser)]
pub struct SweepArgs {
#[arg(long = "max-items")]
pub max_items: Option<usize>,
#[arg(long = "audit", conflicts_with = "no_audit")]
pub audit: bool,
#[arg(long = "no-audit")]
pub no_audit: bool,
#[arg(long = "dry-run")]
pub dry_run: bool,
#[arg(long = "after")]
pub after: Option<String>,
}
pub async fn run(workspace: PathBuf, args: SweepArgs) -> Result<ExitCode> {
let config = config::load(&workspace)
.with_context(|| format!("sweep: loading config in {:?}", workspace))?;
if args.dry_run {
execute_with_agent(workspace, config, args, dry_run_agent()).await
} else {
let agent = agent::build_agent(&config)?;
execute_with_agent(workspace, config, args, agent).await
}
}
async fn execute_with_agent<A: Agent + 'static>(
workspace: PathBuf,
mut config: config::Config,
args: SweepArgs,
agent: A,
) -> Result<ExitCode> {
if args.audit {
config.sweep.audit_enabled = true;
} else if args.no_audit {
config.sweep.audit_enabled = false;
}
let plan_obj = load_plan(&workspace)?;
let deferred_doc = load_deferred(&workspace)?;
let after_override = args
.after
.as_deref()
.map(|s| {
PhaseId::parse(s).map_err(|e| anyhow!("sweep: invalid --after phase id {s:?}: {e}"))
})
.transpose()?;
let existing_state = state::load(&workspace)
.with_context(|| format!("sweep: loading state in {:?}", workspace))?;
let state_existed = existing_state.is_some();
let after = after_override.or_else(|| {
existing_state
.as_ref()
.and_then(|s| s.completed.last().cloned())
});
let dry_run = is_dry_run_agent(&agent);
let git = ShellGit::new(workspace.clone());
let state = match existing_state {
Some(s) => {
if s.aborted {
anyhow::bail!(
"run {} was folded; remove .pitboss/play/state.json to start over",
s.run_id
);
}
s
}
None => {
let mut s = runner::fresh_run_state(&plan_obj, &config, Utc::now());
s.branch = git
.current_branch()
.await
.context("sweep: resolving current branch for standalone sweep state")?;
s
}
};
if state_existed {
git.checkout(&state.branch)
.await
.with_context(|| format!("sweep: checking out {:?}", state.branch))?;
}
let mut runner = Runner::new(
workspace.clone(),
config,
plan_obj,
deferred_doc,
state,
agent,
git,
)
.skip_tests(dry_run);
let logger = spawn_logger(&runner);
let outcome = runner
.run_standalone_sweep(after, args.max_items, state_existed)
.await;
let result = match outcome {
Ok(PhaseResult::Halted { phase_id, reason }) => {
if state_existed {
if let Err(e) = state::save(&workspace, Some(runner.state())) {
eprintln!("[pitboss] failed to persist state.json after sweep: {e:#}");
}
}
eprintln!("[pitboss] sweep halted at phase {phase_id}: {reason}");
Ok(ExitCode::MixedFailures)
}
Ok(PhaseResult::Advanced { .. }) => Ok(ExitCode::Success),
Err(e) => Err(e),
};
drop(runner);
let _ = logger.await;
result
}
const DRY_RUN_AGENT_NAME: &str = "pitboss-dry-run";
fn dry_run_agent() -> DryRunAgent {
DryRunAgent::new(DRY_RUN_AGENT_NAME)
.emit(AgentEvent::Stdout(
"[dry-run] no-op agent dispatched; making no edits".to_string(),
))
.finish(DryRunFinal::Success {
exit_code: 0,
tokens: TokenUsage::default(),
})
}
fn is_dry_run_agent<A: Agent>(agent: &A) -> bool {
agent.name() == DRY_RUN_AGENT_NAME
}
fn load_plan(workspace: &Path) -> Result<Plan> {
let path = paths::plan_path(workspace);
let text = fs::read_to_string(&path).with_context(|| format!("sweep: reading {:?}", path))?;
plan::parse(&text).with_context(|| format!("sweep: parsing {:?}", path))
}
fn load_deferred(workspace: &Path) -> Result<DeferredDoc> {
let path = paths::deferred_path(workspace);
match fs::read_to_string(&path) {
Ok(text) => {
if text.trim().is_empty() {
Ok(DeferredDoc::empty())
} else {
deferred::parse(&text).with_context(|| format!("sweep: parsing {:?}", path))
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DeferredDoc::empty()),
Err(e) => Err(anyhow::Error::new(e).context(format!("sweep: reading {:?}", path))),
}
}
fn spawn_logger<A, G>(runner: &Runner<A, G>) -> tokio::task::JoinHandle<()>
where
A: Agent + 'static,
G: crate::git::Git + 'static,
{
let rx = runner.subscribe();
tokio::spawn(runner::log_events(rx))
}