use argonauts_core::events::approval::{
AnswerAction, AnswerOption, ApprovalKind, ApprovalPacket, FreeText, PlanPacket, PlanWorkflow,
};
use argonauts_core::events::plan::{LimitPlan, TaskGraph};
use argonauts_core::events::run::RunOutcome;
use argonauts_core::events::workflow::StepEnd;
use argonauts_core::names::{ModelRef, WorkflowId};
use argonauts_core::policy::{EvidenceDocument, EvidenceFacts};
use argonauts_core::settings::limit::LimitUnit;
use argonauts_core::steps::StepName;
use argonauts_core::steps::design::DesignOutput;
use argonauts_core::workflow::{StepKind, WorkflowRef};
use crate::approvals::{self, ApprovalRequest, options};
use crate::engine::host::Host;
use crate::error::{EngineError, EngineResult};
use crate::state::RunState;
use crate::workflow::{Flow, StepCall, StepDone, runner, shape, yaml};
pub fn drafts_plan(state: &RunState, id: &WorkflowId) -> bool {
let drafted = !state.plan.drafts.is_empty() || !state.plan.orchestrated.is_empty();
drafted
|| state.workflows.by_id.get(id).is_none_or(|record| {
record
.workflow
.phases
.iter()
.any(|phase| shape::has_kind(&phase.steps, StepKind::Draft))
})
}
pub async fn run(host: &Host, call: &StepCall) -> EngineResult<StepDone> {
let decided = approvals::open_and_wait(host, request(host, call).await?).await?;
match decided.action {
AnswerAction::Approve => Ok(StepDone::done("You approved the workflow")),
AnswerAction::SwitchToCaptain => Ok(StepDone {
end: StepEnd::Done,
summary: Some("You chose to run it as Captain".to_owned()),
flow: Flow::Switch(WorkflowRef::new(runner::captain_workflow(host.settings()))),
}),
AnswerAction::Abandon => Ok(StepDone {
end: StepEnd::Done,
summary: Some("You abandoned the run".to_owned()),
flow: Flow::Finish(RunOutcome::Abandoned),
}),
other => Err(EngineError::Refused(format!(
"the plan approval of a workflow with no plan doesn't offer {other:?}"
))),
}
}
async fn request(host: &Host, call: &StepCall) -> EngineResult<ApprovalRequest> {
let workflow = host.read(plan_workflow);
let summary = match &workflow {
Some(shown) => shown
.why
.clone()
.unwrap_or_else(|| format!("The run follows the {} workflow", shown.workflow.name)),
None => "The run follows its workflow".to_owned(),
};
let documents = match &workflow {
Some(shown) => vec![EvidenceDocument {
label: "workflow".to_owned(),
text: yaml::print(&shown.workflow).map_err(EngineError::Refused)?,
path: None,
sha256: None,
}],
None => Vec::new(),
};
let shown = Shown {
workflow,
summary,
documents,
options: answer_options(),
free_text: FreeText::None,
authors: host.read(designer).into_iter().collect(),
};
Ok(request_showing(host, call, shown).await)
}
pub struct Shown {
pub workflow: Option<PlanWorkflow>,
pub summary: String,
pub documents: Vec<EvidenceDocument>,
pub options: Vec<AnswerOption>,
pub free_text: FreeText,
pub authors: Vec<ModelRef>,
}
pub async fn request_showing(host: &Host, call: &StepCall, shown: Shown) -> ApprovalRequest {
let limit = super::limit_plan(host.settings());
let review = super::plan_facts::review(host);
let facts = EvidenceFacts {
subject: super::plan_approval::subject(host).await,
estimate_usd: estimate_usd(&limit),
..review.facts()
};
let packet = PlanPacket {
summary: shown.summary,
judging: None,
near_tie: false,
decisions: host.read(|state| state.plan.decisions.clone()),
graph: TaskGraph {
tasks: Vec::new(),
critical_path: Vec::new(),
},
acceptance_tests: Vec::new(),
limit,
new_dependencies: Vec::new(),
flags: review.flags(),
workflow: shown.workflow,
};
ApprovalRequest {
at: Some(call.at.clone()),
kind: ApprovalKind::Plan,
title: "Approve this plan?".to_owned(),
task: None,
packet: ApprovalPacket::Plan(Box::new(packet)),
facts,
agent_outputs: Vec::new(),
documents: shown.documents,
options: Some(shown.options),
free_text: shown.free_text,
authors: shown.authors,
}
}
fn estimate_usd(limit: &LimitPlan) -> Option<f64> {
(limit.total.unit == LimitUnit::Usd && limit.estimate_high > 0.0).then_some(limit.estimate_high)
}
fn designer(state: &RunState) -> Option<ModelRef> {
state
.sessions
.in_order()
.into_iter()
.rev()
.find(|session| session.started.step == Some(StepName::DesignWorkflow))
.map(|session| session.started.worker.clone())
}
pub fn answer_options() -> Vec<AnswerOption> {
options::standard(ApprovalKind::Plan)
.into_iter()
.filter(|option| {
!matches!(
option.action,
AnswerAction::ApproveWithEdits | AnswerAction::SendBack
)
})
.collect()
}
pub fn plan_workflow(state: &RunState) -> Option<PlanWorkflow> {
let root = state.workflows.root.as_ref()?;
let record = state.workflows.by_id.get(root)?;
Some(PlanWorkflow {
from: record.from.clone(),
why: design_why(state, record.from.as_ref()),
workflow: record.workflow.clone(),
})
}
fn design_why(state: &RunState, from: Option<&WorkflowRef>) -> Option<String> {
state
.sessions
.in_order()
.into_iter()
.rev()
.find_map(|session| {
let started = &session.started;
if started.step != Some(StepName::DesignWorkflow) || started.workflow_step.is_some() {
return None;
}
let structured = session.end.as_ref()?.structured.clone()?;
let output: DesignOutput = serde_json::from_value(structured).ok()?;
let why = output.why.trim();
let named = match from {
Some(from) => output.based_on_names(from.as_str()),
None => output.based_on.is_none(),
};
(named && !why.is_empty()).then(|| why.to_owned())
})
}
#[cfg(test)]
mod tests {
use super::*;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::session::{EndReason, SessionEvent};
use argonauts_core::names::RoleName;
use crate::state::tests::{end, envelope, session_started};
#[test]
fn the_main_agents_why_follows_the_template_it_spelled() {
let mut state = RunState::default();
let mut started =
session_started(&ModelRef::new("claude-code", None), RoleName::TieBreaker);
started.step = Some(StepName::DesignWorkflow);
let kind = RunEventKind::Session(SessionEvent::Started(Box::new(started)));
state.apply(&envelope(1, None, Some("s-001"), kind));
let mut ended = end(EndReason::Completed, Some("v-1"));
ended.structured = Some(serde_json::json!({
"name": "fix-it", "description": "Fix it", "based_on": "Council",
"why": "each bug has one clear fix", "done_when": [],
}));
let kind = RunEventKind::Session(SessionEvent::Ended(Box::new(ended)));
state.apply(&envelope(2, None, Some("s-001"), kind));
assert_eq!(
design_why(&state, Some(&WorkflowRef::new("council"))).as_deref(),
Some("each bug has one clear fix")
);
assert_eq!(design_why(&state, Some(&WorkflowRef::new("captain"))), None);
assert_eq!(design_why(&state, None), None);
}
#[test]
fn a_workflow_plan_offers_no_edits_or_send_back() {
let actions: Vec<AnswerAction> = answer_options()
.into_iter()
.map(|option| option.action)
.collect();
assert_eq!(
actions,
[
AnswerAction::Approve,
AnswerAction::SwitchToCaptain,
AnswerAction::Abandon,
AnswerAction::ShowDiff
]
);
}
}