use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, bail, Context, Result};
use chrono::Utc;
use tokio::task::JoinHandle;
use crate::agent::dry_run::{DryRunAgent, DryRunFinal};
use crate::agent::{self, Agent, AgentEvent};
use crate::config;
use crate::deferred::{self, DeferredDoc};
use crate::git::{self, Git, PrSummary, ShellGit};
use crate::plan::{self, Plan};
use crate::runner::{self, RunSummary, Runner};
use crate::state::{self, TokenUsage};
use crate::tui;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartMode {
Fresh,
Resume,
}
pub async fn run(workspace: PathBuf, tui: bool, pr: bool, dry_run: bool) -> Result<()> {
execute(workspace, tui, pr, dry_run, StartMode::Fresh).await
}
pub async fn execute(
workspace: PathBuf,
tui: bool,
pr_flag: bool,
dry_run: bool,
mode: StartMode,
) -> Result<()> {
let config = config::load(&workspace)
.with_context(|| format!("run: loading config in {:?}", workspace))?;
if dry_run {
execute_with_agent(workspace, config, tui, false, mode, dry_run_agent()).await
} else {
let agent = agent::build_agent(&config)?;
execute_with_agent(workspace, config, tui, pr_flag, mode, agent).await
}
}
async fn execute_with_agent<A: Agent + 'static>(
workspace: PathBuf,
config: config::Config,
tui: bool,
pr_flag: bool,
mode: StartMode,
agent: A,
) -> Result<()> {
let dry_run = is_dry_run_agent(&agent);
let plan = load_plan(&workspace)?;
let deferred = load_deferred(&workspace)?;
let existing_state = state::load(&workspace)
.with_context(|| format!("run: loading state in {:?}", workspace))?;
let git = ShellGit::new(workspace.clone());
let (state, is_fresh_run) = match (existing_state, mode) {
(Some(s), _) => {
if s.aborted {
bail!(
"state.json marks run {} as aborted; remove .pitboss/state.json to start over",
s.run_id
);
}
(s, false)
}
(None, StartMode::Fresh) => {
let original_branch = git.current_branch().await.ok();
let mut s = runner::fresh_run_state(&plan, &config, Utc::now());
s.original_branch = original_branch;
(s, true)
}
(None, StartMode::Resume) => {
bail!(
"no run to resume: .pitboss/state.json is empty; use `pitboss run` to start a fresh run"
);
}
};
if is_fresh_run {
git.create_branch(&state.branch).await.with_context(|| {
format!(
"run: creating per-run branch {:?} (workspace must already be a git repo)",
state.branch
)
})?;
}
git.checkout(&state.branch)
.await
.with_context(|| format!("run: checking out {:?}", state.branch))?;
state::save(&workspace, Some(&state))
.with_context(|| format!("run: persisting initial state in {:?}", workspace))?;
let want_pr = pr_flag || config.git.create_pr;
let mut runner =
Runner::new(workspace, config, plan, deferred, state, agent, git).skip_tests(dry_run);
let summary = if tui {
tui::run(&mut runner).await?
} else {
let logger = spawn_logger(&runner);
let result = runner.run().await;
let _ = logger.await;
Some(result?)
};
match summary {
None => Ok(()),
Some(RunSummary::Finished) => {
if want_pr {
use crate::style::{self, col};
match open_post_run_pr(&runner).await {
Ok(url) => {
let c = style::use_color_stdout();
let stdout = std::io::stdout();
let mut h = stdout.lock();
let _ = writeln!(
h,
"{} opened PR: {}",
col(c, style::BOLD_CYAN, "[pitboss]"),
col(c, style::CYAN, &url)
);
}
Err(e) => {
let c = style::use_color_stderr();
eprintln!(
"{} PR creation failed: {e:#}",
col(c, style::BOLD_RED, "[pitboss]")
);
}
}
}
Ok(())
}
Some(RunSummary::Halted { phase_id, reason }) => {
Err(anyhow!("run halted at phase {phase_id}: {reason}"))
}
}
}
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
}
pub async fn open_post_run_pr<A, G>(runner: &Runner<A, G>) -> Result<String>
where
A: crate::agent::Agent,
G: Git,
{
let summary = PrSummary {
plan: runner.plan(),
state: runner.state(),
deferred: runner.deferred(),
};
let title = git::pr_title(&summary);
let body = git::pr_body(&summary);
let url = runner
.git_handle()
.open_pr(&title, &body)
.await
.context("opening PR via gh pr create")?;
Ok(url)
}
fn load_plan(workspace: &Path) -> Result<Plan> {
let path = workspace.join("plan.md");
let text = fs::read_to_string(&path).with_context(|| format!("run: reading {:?}", path))?;
plan::parse(&text).with_context(|| format!("run: parsing {:?}", path))
}
fn load_deferred(workspace: &Path) -> Result<DeferredDoc> {
let path = workspace.join("deferred.md");
match fs::read_to_string(&path) {
Ok(text) => {
if text.trim().is_empty() {
Ok(DeferredDoc::empty())
} else {
deferred::parse(&text).with_context(|| format!("run: parsing {:?}", path))
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DeferredDoc::empty()),
Err(e) => Err(anyhow::Error::new(e).context(format!("run: reading {:?}", path))),
}
}
fn spawn_logger<A, G>(runner: &Runner<A, G>) -> JoinHandle<()>
where
A: crate::agent::Agent + 'static,
G: Git + 'static,
{
let rx = runner.subscribe();
tokio::spawn(runner::log_events(rx))
}