use std::path::Path;
use argonauts_core::TaskId;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::goal::{GoalChecked, GoalEvent, GoalRun, GoalStatus, GoalVerdict};
use argonauts_core::events::run::{Party, RunMode};
use argonauts_core::goal::keep::{self, Limits, Next};
use argonauts_core::goal::{CheckMeans, CheckWith, GoalDefault, GoalFrom, GoalSpec, RoleGoal};
use argonauts_core::names::RoleName;
use crate::engine::host::Host;
use crate::error::{EngineError, EngineResult};
use crate::merge::final_check::run_base;
use crate::workflow::{StepAt, runner};
use super::check::{self, Place};
use super::pursue;
use super::task::{self, AfterTry};
#[derive(Debug, Clone, Copy)]
pub struct Asked<'a> {
pub condition: &'a str,
pub commands: &'a [String],
pub step: Option<&'a str>,
pub task: Option<&'a TaskId>,
pub kept_by: Option<&'a str>,
pub try_number: Option<u32>,
pub round: Option<u32>,
pub max_rounds: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Held {
pub holds: bool,
pub failed: String,
pub stop: bool,
}
impl Held {
fn holds() -> Self {
Self {
holds: true,
failed: String::new(),
stop: false,
}
}
fn not_yet(failed: String) -> Self {
Self {
holds: false,
failed,
stop: false,
}
}
fn stopped(why: String) -> Self {
Self {
holds: false,
failed: why,
stop: true,
}
}
}
pub fn for_run(host: &Host) -> Option<String> {
let settings = host.settings();
host.read(|state| {
if runner::run_mode(state) == Some(RunMode::Captain) {
return None;
}
if let Some(goal) = &state.goal.pursuit {
return Some(goal.objective.clone());
}
if let Some(goal) = pursue::configured(settings) {
return Some(goal.condition);
}
let from_request = settings.goal.default == GoalDefault::FromRequest;
Some(pursue::objective(state)).filter(|request| from_request && !request.trim().is_empty())
})
}
pub fn around(host: &Host, at: &StepAt, asked: &Asked<'_>) -> EngineResult<()> {
let has_one = host.read(|state| state.goal.pursuit.is_some());
if has_one || pursue::take_goal(host, at.step.phase)? {
return Ok(());
}
let spec = spec(host, asked, GoalFrom::WorkflowFile)?;
pursue::around(host, &spec, at.step.phase)?;
Ok(())
}
pub fn work_as(host: &Host, asked: &Asked<'_>) -> EngineResult<()> {
let Some(task) = asked.task else {
return Ok(());
};
let spec = spec(host, asked, GoalFrom::Step)?;
task::work_as(host, task, &spec, keeper(host, asked.kept_by)?)
}
pub async fn held(
host: &Host,
at: &StepAt,
asked: &Asked<'_>,
workdir: &Path,
) -> EngineResult<Held> {
match (asked.step, asked.task, asked.round) {
(Some(_), Some(task), _) => {
work_as(host, asked)?;
of_task(host, at, task).await
}
(None, None, Some(round)) => {
around(host, at, asked)?;
if host.read(|state| state.goal.pursuit.is_some()) {
of_run(host, at, round, workdir).await
} else {
unrecorded(host, at, asked, workdir).await
}
}
_ => unrecorded(host, at, asked, workdir).await,
}
}
async fn of_task(host: &Host, at: &StepAt, task: &TaskId) -> EngineResult<Held> {
let status = host.read(|state| state.goal.goal_of(Some(task)).map(|goal| goal.status));
match status {
Some(GoalStatus::Met) => Ok(Held::holds()),
Some(GoalStatus::Pursuing) => match task::after_try(host, task, at, true).await? {
AfterTry::Holds => Ok(Held::holds()),
AfterTry::Again(told) => Ok(Held::not_yet(told.text)),
AfterTry::GaveUp => Ok(stopped(host, Some(task))),
},
_ => Ok(stopped(host, Some(task))),
}
}
async fn of_run(host: &Host, at: &StepAt, round: u32, workdir: &Path) -> EngineResult<Held> {
let Some(goal) = host.read(|state| state.goal.pursuit.clone()) else {
return Ok(stopped(host, None));
};
let newest = goal.tries.last().map_or(1, |a_try| a_try.n);
let recorded = |round: u32| {
host.read(|state| {
state
.goal
.checks
.iter()
.rev()
.find(|checked| checked.try_number == round && checked.task.is_none())
.cloned()
})
};
if round < newest
&& let Some(found) = recorded(round)
{
return Ok(found_again(&goal, &found));
}
match goal.status {
GoalStatus::Met => return Ok(Held::holds()),
GoalStatus::Pursuing | GoalStatus::Paused => {}
_ => return Ok(stopped(host, None)),
}
let checked = match recorded(newest) {
Some(found) => found,
None => {
let place = Place {
base: run_base(host),
at: Some(at.clone()),
authors: host.read(|state| check::authors(state, newest)),
..Place::new(workdir, newest)
};
check::goal(host, &goal.spec(), &place).await?
}
};
if checked.overall == GoalVerdict::Met {
pursue::status_changed(host, GoalStatus::Met, Party::ArgonautsCode, None)?;
return Ok(Held::holds());
}
let pursued = host.read(|state| {
state
.goal
.pursuit
.as_ref()
.is_some_and(|goal| goal.status == GoalStatus::Pursuing)
});
if !pursued {
return Ok(stopped(host, None));
}
let settings = host.settings();
let next = host.read(|state| {
keep::next(
&state.goal.checks_of(None),
pursue::limits(state, settings),
state.goal.approach_changed(None),
)
});
let change = match next {
Next::Met => return Ok(Held::holds()),
Next::Stop { why } => {
pursue::status_changed(
host,
GoalStatus::Stuck,
Party::ArgonautsCode,
Some(why.clone()),
)?;
pursue::note(host, format!("The goal is stuck: {why}"))?;
return Ok(Held::stopped(why));
}
Next::Again => None,
Next::ChangeApproach { why } => {
host.record(
None,
None,
RunEventKind::Goal(GoalEvent::ApproachChanged {
after: newest,
why: why.clone(),
task: None,
}),
)?;
pursue::note(
host,
format!("The goal is stuck, {why}, so the next round takes another approach"),
)?;
Some(why)
}
};
let round = newest + 1;
pursue::try_started(host, round, format!("Round {round}"), at.step.phase)?;
Ok(Held::not_yet(keep::send_back(
&goal.criteria,
&checked,
change.as_deref(),
)))
}
fn found_again(goal: &GoalRun, found: &GoalChecked) -> Held {
if found.overall == GoalVerdict::Met {
Held::holds()
} else {
Held::not_yet(keep::send_back(&goal.criteria, found, None))
}
}
async fn unrecorded(
host: &Host,
at: &StepAt,
asked: &Asked<'_>,
workdir: &Path,
) -> EngineResult<Held> {
let spec = spec(host, asked, GoalFrom::Step)?;
let try_number = asked.try_number.unwrap_or(1).max(1);
let place = Place {
base: run_base(host),
at: Some(at.clone()),
..Place::new(workdir, try_number)
};
let checked = check::unrecorded(host, &spec, &place).await?;
let limits = Limits {
stuck_after: host.settings().goal.stuck_after,
max_rounds: spec.max_rounds,
};
let next = match asked.step {
Some(_) => with_no_record(&checked, try_number, limits),
None if checked.overall == GoalVerdict::Met => Next::Met,
None => Next::Again,
};
Ok(match next {
Next::Met => Held::holds(),
Next::Stop { why } => Held::stopped(why),
Next::Again => Held::not_yet(keep::send_back(&spec.lines, &checked, None)),
Next::ChangeApproach { why } => {
Held::not_yet(keep::send_back(&spec.lines, &checked, Some(&why)))
}
})
}
fn with_no_record(checked: &GoalChecked, try_number: u32, limits: Limits) -> Next {
match checked.overall {
GoalVerdict::Met => return Next::Met,
GoalVerdict::Impossible => {
return Next::Stop {
why: format!("the check says it can't be met: {}", checked.reason),
};
}
GoalVerdict::NotYet => {}
}
let tries = |n: u32| if n == 1 { "try" } else { "tries" };
if let Some(max) = limits.max_rounds
&& try_number >= max.max(1)
{
return Next::Stop {
why: format!("it didn't hold after the {max} {} you allow", tries(max)),
};
}
let same = limits.stuck_after.max(1);
if try_number > same {
Next::Stop {
why: format!(
"it didn't hold in {try_number} {}, after a change of approach",
tries(try_number)
),
}
} else if try_number == same {
Next::ChangeApproach {
why: format!("{same} {} without it holding", tries(same)),
}
} else {
Next::Again
}
}
fn stopped(host: &Host, task: Option<&TaskId>) -> Held {
let (status, why) = host.read(|state| {
let status = state.goal.goal_of(task).map(|goal| goal.status);
let why = match task {
Some(task) => state
.goal
.tasks
.get(task)
.and_then(|kept| kept.ended_because.clone()),
None => state.goal.ended_because.clone(),
};
(status, why)
});
Held::stopped(why.unwrap_or_else(|| {
match status {
Some(GoalStatus::Paused) => "its goal is paused",
Some(GoalStatus::Cleared) => "you cleared its goal",
_ => "its goal stopped being kept",
}
.to_owned()
}))
}
fn spec(host: &Host, asked: &Asked<'_>, from: GoalFrom) -> EngineResult<GoalSpec> {
let settings = host.settings();
let means = CheckMeans {
project_checks: !settings.checks.run.is_empty(),
};
let mut spec = GoalSpec::from_words(asked.condition, settings.goal.check, means, from)
.or_else(|_| GoalSpec::from_words(asked.condition, CheckWith::Auto, means, from))
.map_err(EngineError::Refused)?;
spec.commands = match from {
GoalFrom::WorkflowFile if asked.commands.is_empty() => {
commands_in_file(host, asked.condition)
}
_ => asked.commands.to_vec(),
};
spec.max_rounds = asked.max_rounds.or(settings.goal.max_rounds);
Ok(spec)
}
fn commands_in_file(host: &Host, condition: &str) -> Vec<String> {
let name = host.settings().workflow.as_str();
if Path::new(name).file_name().is_none_or(|file| file != name) {
return Vec::new();
}
let waiting = crate::prompts::files::waiting(host);
runner::workflow_dirs(host)
.into_iter()
.map(|dir| dir.join(format!("{name}.toml")))
.filter(|path| !waiting.contains(path))
.find_map(|path| std::fs::read_to_string(path).ok())
.map(|file| argonauts_core::goal::checks_beside(&file, condition))
.unwrap_or_default()
}
fn keeper(host: &Host, kept_by: Option<&str>) -> EngineResult<RoleGoal> {
match kept_by.map(str::trim) {
Some("cli") => Ok(RoleGoal::Cli),
Some("argonauts") => Ok(RoleGoal::Argonauts),
Some(other) => Err(EngineError::Refused(format!(
"kept_by is cli or argonauts, not {other}"
))),
None => {
let role = host.settings().roles.get(&RoleName::Worker);
Ok(match role.and_then(|role| role.goal) {
Some(RoleGoal::Cli) => RoleGoal::Cli,
_ => RoleGoal::Argonauts,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn checked(overall: GoalVerdict) -> GoalChecked {
GoalChecked {
try_number: 1,
verdicts: Vec::new(),
overall,
reason: "the API it needs is gone".to_owned(),
task: None,
checks: Vec::new(),
}
}
#[test]
fn an_unrecorded_condition_changes_approach_once_then_stops() {
let limits = Limits {
stuck_after: 3,
max_rounds: None,
};
let not_yet = checked(GoalVerdict::NotYet);
let after = |try_number| with_no_record(¬_yet, try_number, limits);
assert_eq!(after(1), Next::Again);
assert_eq!(after(2), Next::Again);
assert_eq!(
after(3),
Next::ChangeApproach {
why: "3 tries without it holding".to_owned()
}
);
assert_eq!(
after(4),
Next::Stop {
why: "it didn't hold in 4 tries, after a change of approach".to_owned()
}
);
assert_eq!(
with_no_record(&checked(GoalVerdict::Met), 9, limits),
Next::Met
);
assert!(matches!(
with_no_record(&checked(GoalVerdict::Impossible), 1, limits),
Next::Stop { why } if why.contains("can't be met")
));
let capped = Limits {
stuck_after: 3,
max_rounds: Some(2),
};
assert_eq!(
with_no_record(¬_yet, 2, capped),
Next::Stop {
why: "it didn't hold after the 2 tries you allow".to_owned()
}
);
}
}