use crate::workspace::Workspace;
use ostraka_core::identity::ActorId;
use ostraka_core::task::TaskSpec;
use ostraka_runtime::index::{self, RunSummary};
use ostraka_runtime::orchestrator::{Places, RunReport};
use ostraka_runtime::progress::Watcher;
use ostraka_runtime::{orchestrator, route};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT_RUN: AtomicUsize = AtomicUsize::new(1);
pub const AUTHOR: &str = "author";
pub const REVIEWER: &str = "reviewer";
pub const BASE_REF: &str = "HEAD";
#[derive(Clone)]
pub struct Args {
pub prompt: String,
pub repository: Option<String>,
pub author: String,
pub reviewer: String,
pub adapter: Option<String>,
pub review_adapter: Option<String>,
pub base_ref: String,
pub from: Option<String>,
pub model: Option<String>,
}
impl Args {
pub fn for_task(prompt: String) -> Self {
Self {
prompt,
repository: None,
author: AUTHOR.to_string(),
reviewer: REVIEWER.to_string(),
adapter: None,
review_adapter: None,
base_ref: BASE_REF.to_string(),
from: None,
model: None,
}
}
}
fn finished_run(
workspace: &Workspace,
run_id: &str,
) -> Result<RunSummary, Box<dyn std::error::Error>> {
let runs = index::list(&workspace.records())?;
let Some(run) = runs.into_iter().find(|r| r.run_id == run_id) else {
return Err(format!("no run {run_id:?} is recorded in this workspace").into());
};
if !run.approved() {
let said = match &run.outcome {
Some(outcome) => format!("{outcome:?}").to_lowercase(),
None => "never finished".to_string(),
};
return Err(format!(
"run {run_id} was {said}, so there is nothing to continue from — \
a change the gate would not take is not a base to build on"
)
.into());
}
Ok(run)
}
fn continue_from(repo: &Path, run_id: &str) -> Result<String, Box<dyn std::error::Error>> {
match index::commit_branch(repo, run_id)? {
Some(branch) => Ok(branch),
None => Err(format!(
"run {run_id} was approved but its commit is not on a branch here; \
the branch it was on has been deleted"
)
.into()),
}
}
type Outcome = Result<bool, Box<dyn std::error::Error>>;
pub fn execute(
workspace: &Workspace,
args: &Args,
watcher: Option<Box<dyn Watcher>>,
stop: &ostraka_adapter::interrupt::Stop,
) -> Result<RunReport, Box<dyn std::error::Error>> {
let continued = args.from.as_deref().map(|id| finished_run(workspace, id));
let continued = continued.transpose()?;
let named = continued
.as_ref()
.map(|r| r.repository.clone())
.or_else(|| args.repository.clone());
if let (Some(run), Some(asked)) = (continued.as_ref(), args.repository.as_deref()) {
if run.repository != asked {
return Err(format!(
"run {} was made in {:?}, not in {asked:?}",
run.run_id, run.repository
)
.into());
}
}
let repo = workspace.repository(named.as_deref())?;
let config = workspace.config_for(&repo)?;
config.validate()?;
let profiles = workspace.profiles()?;
let vendor_home = workspace.ostraka().join("vendor-home");
let routing = route::select_until(
&profiles,
args.adapter.as_deref(),
args.review_adapter.as_deref(),
&vendor_home,
config
.policy
.timeout_secs
.map(std::time::Duration::from_secs),
stop,
);
let routing = match routing {
Ok(routing) => routing,
Err(e) => {
let ids: Vec<String> = profiles.iter().map(|p| p.id.clone()).collect();
return Err(Box::new(crate::discover::NoAdapter {
said: e.to_string(),
found: crate::discover::unconfigured(&ids),
}));
}
};
let task = TaskSpec {
id: format!(
"t{}-{}",
std::process::id(),
NEXT_RUN.fetch_add(1, Ordering::Relaxed)
),
prompt: args.prompt.clone(),
adapter: routing_author_id(&routing),
author: ActorId::new(&args.author),
base_ref: match continued.as_ref() {
Some(run) => continue_from(&repo.path, &run.run_id)?,
None => args.base_ref.clone(),
},
model: args.model.clone(),
};
let worktrees = workspace.worktrees(&config);
let notes = workspace.notes_if_present();
let skills = workspace.skills_if_present();
let records = workspace.records();
let places = Places {
repo: &repo.path,
worktrees: &worktrees,
records: &records,
name: &repo.name,
notes: notes.as_deref(),
skills: skills.as_deref(),
};
Ok(orchestrator::run_task_until(
&places,
&config,
&routing,
&task,
&ActorId::new(&args.reviewer),
watcher,
stop,
)?)
}
pub fn run(workspace: &Workspace, args: &Args, json: bool) -> Outcome {
run_with(workspace, args, json, |_| {})
}
fn run_with(
workspace: &Workspace,
args: &Args,
json: bool,
seen: impl FnOnce(&crate::run::RunReport),
) -> Outcome {
let interrupts = std::sync::atomic::AtomicUsize::new(0);
let _ = ctrlc::set_handler(move || {
if interrupts.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
eprintln!("\nstopping the run — press Ctrl-C again to give up on it");
ostraka_adapter::interrupt::request();
} else {
std::process::exit(130);
}
});
let report = match execute(
workspace,
args,
None,
&ostraka_adapter::interrupt::Stop::new(),
) {
Ok(report) => report,
Err(e) => {
let Some(problem) = e.downcast_ref::<crate::discover::NoAdapter>() else {
return Err(e);
};
let choice = crate::offer::profiles(
workspace,
problem,
&mut std::io::stdin().lock(),
&mut std::io::stderr(),
!json && crate::offer::at_a_terminal(),
)?;
match choice {
crate::offer::Choice::Wrote => execute(
workspace,
args,
None,
&ostraka_adapter::interrupt::Stop::new(),
)?,
crate::offer::Choice::Declined | crate::offer::Choice::NotAsked => return Err(e),
}
}
};
if json {
let out = serde_json::json!({
"run_id": report.record.run_id,
"approved": report.approved(),
"outcome": report.record.outcome,
"checks": report.record.checks.iter().map(|c| serde_json::json!({
"name": c.name,
"passed": c.passed(),
"exit_code": c.exit_code,
"duration_ms": c.duration_ms,
})).collect::<Vec<_>>(),
"refusal": report.refusal.as_ref().map(|r| format!("{r:?}")),
});
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
println!("run {}", report.record.run_id);
for c in &report.record.checks {
let mark = if c.passed() { "pass" } else { "FAIL" };
println!(" {mark} {:<8} {}ms", c.name, c.duration_ms);
}
match (&report.token, &report.refusal) {
(Some(token), _) => println!(
"approved — written by {}, reviewed by {}",
token.author(),
token.reviewer()
),
(None, Some(refusal)) => println!("rejected — {}", describe(refusal)),
(None, None) => println!("rejected"),
}
println!("record: {}", workspace.records().join("runs").display());
}
seen(&report);
Ok(report.approved())
}
pub fn run_reporting(
workspace: &Workspace,
args: &Args,
json: bool,
) -> Result<(bool, String), Box<dyn std::error::Error>> {
let mut id = String::new();
let approved = run_with(workspace, args, json, |report| {
id = report.record.run_id.clone();
})?;
Ok((approved, id))
}
fn routing_author_id(routing: &route::Routing) -> String {
ostraka_adapter::VendorAdapter::id(routing.author.as_ref()).to_string()
}
pub fn describe(refusal: &ostraka_runtime::gate::Refusal) -> String {
use ostraka_runtime::gate::Refusal;
match refusal {
Refusal::ChecksFailed { failed, .. } => {
format!("checks failed: {}", failed.join(", "))
}
Refusal::Rejected { reason } => format!("reviewer rejected: {reason}"),
Refusal::AuthorFailed { code, diagnostics } => match diagnostics {
Some(d) => format!("the author could not run (exit {code}): {d}"),
None => format!("the author could not run (exit {code}), and said nothing"),
},
Refusal::PolicyViolation { reason } => reason.clone(),
Refusal::SetupFailed { step, reason } => {
format!("the worktree could not be prepared ({step}): {reason}")
}
Refusal::Interrupted => "stopped by the operator".to_string(),
Refusal::TimedOut { after_secs } => {
format!("the author was still running after {after_secs}s and was stopped")
}
Refusal::NoChange => {
"the author ran cleanly and changed nothing; there is nothing to review".to_string()
}
Refusal::SelfApproval { actor } => {
format!("{actor} cannot approve a change {actor} wrote")
}
}
}