use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use argonauts_core::events::run::RunMode;
use argonauts_core::events::workflow::StepEnd;
use argonauts_core::names::WorkflowId;
use argonauts_core::paths::Paths;
use argonauts_core::settings::crew::CrewRole;
use argonauts_core::settings::workflows::AdaptWith;
use argonauts_core::workflow::{Workflow, WorkflowRef};
use argonauts_runner::{
Library, LibraryError, Limits, Loaded, Required, RunInput, Script, ScriptEnd, WorkflowFile,
};
use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
use crate::captain;
use crate::engine::host::Host;
use crate::error::{EngineError, EngineResult};
use crate::state::RunState;
use crate::understand::round;
use crate::workflow::runner::run_mode;
use crate::workflow::{StepCall, StepDone, naming, yaml};
use crate::workflow_size;
use crate::{claude_codes, ultra};
mod bridge;
pub mod goal;
mod journal;
mod plan;
pub mod required;
mod tasks;
pub(crate) mod write;
use bridge::Bridge;
const PHASES: &str = include_str!("phases.yaml");
pub const PHASES_NAME: &str = "script";
pub fn library(host: &Host) -> Library {
Library {
project: Some(host.repo().root.join(".argonauts/workflows")),
yours: Paths::from_env()
.ok()
.map(|paths| paths.home().join("workflows")),
waiting: crate::prompts::files::waiting(host),
}
}
pub fn for_run(host: &Host) -> EngineResult<Option<Loaded>> {
if host.read(run_mode) == Some(RunMode::Captain) {
return Ok(None);
}
let settings = host.settings();
let loaded = match library(host).load(&settings.workflow) {
Ok(loaded) => loaded,
Err(LibraryError::Unknown(_) | LibraryError::NoSteps { .. }) => return Ok(None),
Err(err) => return Err(EngineError::Refused(err.to_string())),
};
let runs = match &loaded {
Loaded::Script(_) => true,
Loaded::Steps(file) => !file.adapt || settings.workflows.adapt_with == AdaptWith::Script,
};
Ok(runs.then_some(loaded))
}
pub fn runs(state: &RunState, id: &WorkflowId) -> bool {
state
.workflows
.by_id
.get(id)
.and_then(|record| record.from.as_ref())
.is_some_and(|from| from.as_str() == PHASES_NAME)
}
fn workflow_named(host: &Host, name: &str) -> Result<Script, String> {
match library(host).load(name) {
Ok(Loaded::Script(script)) => Ok(script),
Ok(Loaded::Steps(file)) => Ok(Script::from_steps(&file)),
Err(err) => Err(err.to_string()),
}
}
pub fn phases(host: &Host) -> EngineResult<(WorkflowRef, Workflow)> {
let workflow = yaml::parse(PHASES).map_err(EngineError::Refused)?;
let from = WorkflowRef::new(PHASES_NAME);
Ok((from.clone(), naming::as_run(host, Some(&from), workflow)))
}
pub async fn script_step(host: &Host, call: &StepCall) -> EngineResult<StepDone> {
captain::prompt_is_the_goal(host).await?;
let loaded = for_run(host)?.ok_or_else(|| {
EngineError::Refused(format!(
"the workflow in use, {}, has no steps to run",
host.settings().workflow
))
})?;
let request = host.read(round::request)?;
if let Loaded::Steps(file) = &loaded
&& ultra::plans_and_delegates(file)
&& let Some(crew) =
ultra::claude_codes_own(host.settings(), &host.services().workers.available())
{
return claude_codes::run(host, call, &request, &crew).await;
}
let required = required::for_run(host, &request);
let (script, roles) = loop {
let notes = plan::sent_back(host, call);
let (script, roles, wrote) =
steps_of(host, call, &loaded, &required, &request, ¬es).await?;
match plan::approval(host, call, &script, &wrote, ¬es).await? {
plan::Verdict::Approved => break (script, roles),
plan::Verdict::SentBack => {}
plan::Verdict::Ended(done) => return Ok(done),
}
};
let stop = CancellationToken::new();
let mut bridge = Bridge::new(host, call, stop.clone());
if roles.is_some() {
bridge.size_asked = workflow_size::asked(&request).map(|asked| asked.said());
}
if let Loaded::Steps(file) = &loaded {
bridge.max_rounds = max_rounds(file);
bridge.own_workflow_in = file
.steps
.iter()
.filter(|step| step.own_workflow)
.map(|step| step.name.clone())
.collect();
}
if roles.is_none() {
bridge.workflow_words = Some(script.meta.name.clone());
}
let input = RunInput {
id: call.workflow().to_string(),
args: args(host, &request, &required.tasks),
limits: limits(host),
replay: journal::read(host, call)?,
roles,
token_limit: None,
};
let ended = tokio::select! {
ended = argonauts_runner::run(&script, input, &bridge) => ended,
() = stop.cancelled() => return Err(bridge.take_error()),
};
if let Some(err) = bridge.error() {
return Err(err);
}
match ended.result {
Ok(result) => {
let never_ran = bridge.never_ran(&required::shared(&required).steps);
let lacked = (!never_ran.is_empty()).then(|| {
let names: Vec<String> =
never_ran.iter().map(|name| format!("\"{name}\"")).collect();
format!("It never ran {}, which you require.", names.join(", "))
});
if let Some(line) = &lacked {
claude_codes::note_left(host, call, line.clone())?;
}
let merged = tasks::finish(&bridge, &call.at).await?;
let mut said = summary(&script, &result, merged);
if let Some(line) = lacked {
said.push_str(&format!(" · {line}"));
}
Ok(StepDone::done(said))
}
Err(ScriptEnd::Stopped) => Err(EngineError::Interrupted),
Err(why) => Ok(StepDone {
end: StepEnd::Failed,
..StepDone::done(format!("{} stopped: {why}", script.meta.name))
}),
}
}
fn limits(host: &Host) -> Limits {
let settings = host.settings();
Limits {
at_once: settings.concurrency.max_sessions.max(1) as usize,
nested: settings.workflows.max_depth > 0,
..Limits::default()
}
}
async fn steps_of(
host: &Host,
call: &StepCall,
loaded: &Loaded,
required: &Required,
request: &str,
notes: &[String],
) -> EngineResult<(Script, Option<BTreeSet<CrewRole>>, plan::Wrote)> {
match loaded {
Loaded::Script(script) => {
let missing = required.missing_from(&script.meta);
if !missing.is_empty() {
return Err(EngineError::Refused(format!(
"{} has no phase for a step you require: {}",
script.meta.name,
missing.join(", ")
)));
}
Ok((script.clone(), None, plan::Wrote::File))
}
Loaded::Steps(file) if file.adapt => {
let file = required.added_to(file);
let (written, wrote) =
write::script_for(host, call, &file, required, request, notes).await?;
let roles = matches!(wrote, plan::Wrote::Orchestrator(_)).then(|| file.roles());
Ok((written, roles, wrote))
}
Loaded::Steps(file) => {
let mut file = required.added_to(file);
if file.goal.is_none() {
file.goal = crate::goal::until::for_run(host);
}
Ok((Script::from_steps(&file), None, plan::Wrote::File))
}
}
}
fn max_rounds(file: &WorkflowFile) -> BTreeMap<Option<String>, u32> {
let steps = file
.steps
.iter()
.filter_map(|step| Some((Some(step.name.clone()), step.max_rounds?)));
file.max_rounds
.map(|rounds| (None, rounds))
.into_iter()
.chain(steps)
.collect()
}
fn args(host: &Host, request: &str, tasks: &[String]) -> Value {
let settings = host.settings();
let crew: serde_json::Map<String, Value> = CrewRole::ALL
.into_iter()
.map(|role| {
let models = crate::roles::candidates(settings, role.fine_role()).len();
(role.as_str().to_owned(), json!(models))
})
.collect();
json!({"request": request, "crew": crew, "tasks": tasks})
}
fn summary(script: &Script, result: &Value, merged: usize) -> String {
let tasks = match merged {
0 => "nothing to merge".to_owned(),
1 => "1 task merged into the run".to_owned(),
n => format!("{n} tasks merged into the run"),
};
let notes: Vec<&str> = result
.get("notes")
.and_then(Value::as_array)
.map(|notes| notes.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
if notes.is_empty() {
format!("{} ran to its end · {tasks}", script.meta.name)
} else {
format!(
"{} ran to its end · {tasks} · {}",
script.meta.name,
notes.join(" · ")
)
}
}
fn folder(host: &Host) -> PathBuf {
host.repo().run_dir.join("workflow")
}
fn named(file: &WorkflowFile) -> &str {
if file.name.is_empty() {
"the workflow"
} else {
&file.name
}
}