use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Mutex, MutexGuard, PoisonError};
use tokio::sync::oneshot;
use argonauts_core::api::ApprovalAnswer;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::approval::{
AnswerAction, AnswerOption, ApprovalDecided, ApprovalEvent, ApprovalKind, ApprovalOpened,
ApprovalPacket, FreeText, PolicyCheck,
};
use argonauts_core::events::run::Party;
use argonauts_core::events::shared::BlackboardEvent;
use argonauts_core::names::{ApprovalId, ModelRef, RoleName};
use argonauts_core::policy::{
AutoPreviewRecord, DecisionPoint, EvidenceDocument, EvidenceFacts, EvidencePacket,
EvidenceSubject, PolicyOutcome, UserDecision, Verdict, Violation,
};
use argonauts_core::settings::Settings;
use argonauts_core::settings::approvals::{
Answerer, ApprovalMode, AtHardLimit, DecideWith, Otherwise,
};
use argonauts_core::{Note, NoteLevel, TaskId};
use crate::engine::host::Host;
use crate::error::{EngineError, EngineResult};
use crate::state::RunState;
use crate::state::approvals::ApprovalRecord;
use crate::state::ids::IdKind;
use crate::workflow::{StepAt, change, kinds};
pub mod autopilot;
pub mod decline;
pub mod evidence;
pub mod options;
#[derive(Debug, Clone)]
pub struct ApprovalRequest {
pub at: Option<StepAt>,
pub kind: ApprovalKind,
pub title: String,
pub task: Option<TaskId>,
pub packet: ApprovalPacket,
pub facts: EvidenceFacts,
pub agent_outputs: Vec<String>,
pub documents: Vec<EvidenceDocument>,
pub options: Option<Vec<AnswerOption>>,
pub free_text: FreeText,
pub authors: Vec<ModelRef>,
}
#[derive(Debug, Default)]
pub struct Desk {
waiters: Mutex<BTreeMap<ApprovalId, Vec<oneshot::Sender<ApprovalDecided>>>>,
points: Mutex<BTreeMap<ApprovalId, DecisionPoint>>,
because: Mutex<BTreeMap<ApprovalId, String>>,
evaluating: Mutex<BTreeSet<ApprovalId>>,
spot_checked: Mutex<BTreeSet<ApprovalId>>,
spot_check_once_merged: Mutex<BTreeSet<ApprovalId>>,
autopilot: Mutex<BTreeSet<ApprovalId>>,
deciding: Mutex<()>,
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
impl Desk {
pub fn wake(&self, decided: &ApprovalDecided) {
for waiter in lock(&self.waiters).remove(&decided.id).unwrap_or_default() {
let _ = waiter.send(decided.clone());
}
}
fn wait(&self, id: &ApprovalId) -> oneshot::Receiver<ApprovalDecided> {
let (sender, receiver) = oneshot::channel();
lock(&self.waiters)
.entry(id.clone())
.or_default()
.push(sender);
receiver
}
fn release(&self, id: &ApprovalId) {
lock(&self.waiters).remove(id);
}
fn point(&self, id: &ApprovalId) -> Option<DecisionPoint> {
lock(&self.points).get(id).cloned()
}
pub fn weighing(&self) -> usize {
lock(&self.evaluating).len()
}
}
pub async fn open_and_wait(host: &Host, request: ApprovalRequest) -> EngineResult<ApprovalDecided> {
open(host, request)?.decided(host).await
}
pub async fn open_and_wait_because(
host: &Host,
request: ApprovalRequest,
because: Option<String>,
) -> EngineResult<ApprovalDecided> {
open_because(host, request, because)?.decided(host).await
}
pub async fn open_and_wait_yours(
host: &Host,
request: ApprovalRequest,
because: String,
) -> EngineResult<ApprovalDecided> {
open_yours(host, request, because)?.decided(host).await
}
pub fn open_yours(host: &Host, request: ApprovalRequest, because: String) -> EngineResult<Opening> {
open_with(host, request, Some(because), false)
}
pub const GO_AHEAD_TOPIC: &str = "go-ahead";
pub const CHANGE_TOPIC: &str = "workflow change";
pub fn asks_you_about(settings: &Settings, topics: &[&str]) -> bool {
settings.approvals.ask_you_about.iter().any(|asked| {
let asked = asked.trim();
!asked.is_empty()
&& topics
.iter()
.any(|topic| topic.trim().eq_ignore_ascii_case(asked))
})
}
pub fn keeps_yours(host: &Host, kind: ApprovalKind, topics: &[&str]) -> bool {
!autopilot::applies_now(host, kind) && asks_you_about(host.settings(), topics)
}
pub fn yours_because(topic: &str) -> String {
format!("your /approvals settings keep {topic} yours")
}
pub enum Opening {
Decided(Box<ApprovalDecided>),
Waiting {
id: ApprovalId,
receiver: oneshot::Receiver<ApprovalDecided>,
},
}
impl Opening {
pub fn id(&self) -> &ApprovalId {
match self {
Self::Decided(decided) => &decided.id,
Self::Waiting { id, .. } => id,
}
}
pub async fn decided(self, host: &Host) -> EngineResult<ApprovalDecided> {
let (id, receiver) = match self {
Self::Decided(decided) => return Ok(*decided),
Self::Waiting { id, receiver } => (id, receiver),
};
tokio::select! {
decided = receiver => decided.map_err(|_| {
let reason = host
.read(|state| {
state.approvals.by_id.get(&id).and_then(|record| record.withdrawn.clone())
})
.unwrap_or_else(|| "the run stopped".to_owned());
EngineError::Refused(format!("approval {id} was withdrawn: {reason}"))
}),
() = host.interrupted().cancelled() => Err(EngineError::Interrupted),
}
}
pub async fn decided_or_withdrawn(
self,
host: &Host,
) -> EngineResult<Result<ApprovalDecided, String>> {
let id = self.id().clone();
match self.decided(host).await {
Ok(decided) => Ok(Ok(decided)),
Err(EngineError::Refused(refused)) => {
let withdrawn = host.read(|state| {
state
.approvals
.by_id
.get(&id)
.and_then(|record| record.withdrawn.clone())
});
withdrawn.map(Err).ok_or(EngineError::Refused(refused))
}
Err(err) => Err(err),
}
}
}
pub fn open(host: &Host, request: ApprovalRequest) -> EngineResult<Opening> {
open_because(host, request, None)
}
pub fn open_because(
host: &Host,
request: ApprovalRequest,
because: Option<String>,
) -> EngineResult<Opening> {
open_with(host, request, because, true)
}
fn open_with(
host: &Host,
request: ApprovalRequest,
because: Option<String>,
policy: bool,
) -> EngineResult<Opening> {
let evidence = evidence::packet(
host,
request.kind,
request.facts.clone(),
request.agent_outputs.clone(),
request.documents.clone(),
)?;
let sha256 = evidence.sha256();
let desk = &host.shared().desk;
let kept_yours = !policy;
let policy = policy && !only_yours(&request.packet);
let (id, receiver, evaluate) = {
let _deciding = lock(&desk.deciding);
match host.read(|state| find(state, &request, &sha256)) {
Found::Decided(decided) => return Ok(Opening::Decided(decided)),
Found::Open { id, policy_done } => {
let receiver = desk.wait(&id);
let evaluate = policy && !policy_done && lock(&desk.evaluating).insert(id.clone());
(id, receiver, evaluate)
}
Found::Stale(stale) => {
withdraw_locked(host, &stale, "the evidence changed")?;
let id = record_opened(host, &request, &evidence, &sha256, !policy)?;
if policy {
lock(&desk.evaluating).insert(id.clone());
}
(id.clone(), desk.wait(&id), policy)
}
Found::None => {
let id = record_opened(host, &request, &evidence, &sha256, !policy)?;
if policy {
lock(&desk.evaluating).insert(id.clone());
}
(id.clone(), desk.wait(&id), policy)
}
}
};
let point = decision_point(host, &id, &request);
lock(&desk.points).insert(id.clone(), point.clone());
if let Some(because) = because.filter(|because| !because.trim().is_empty()) {
lock(&desk.because).insert(id.clone(), because);
}
if evaluate {
if autopilot::chosen(point.mode_override, host.settings(), request.kind) {
lock(&desk.autopilot).insert(id.clone());
}
spawn_policy(host, point, evidence);
} else if autopilot::applies_now(host, request.kind) {
if kept_yours {
lock(&desk.autopilot).insert(id.clone());
}
autopilot::settle_open(host)?;
}
Ok(Opening::Waiting { id, receiver })
}
pub async fn wait_again(host: &Host, id: &ApprovalId) -> EngineResult<ApprovalDecided> {
let desk = &host.shared().desk;
let (receiver, kind) = {
let _deciding = lock(&desk.deciding);
let record = host
.read(|state| state.approvals.by_id.get(id).cloned())
.ok_or_else(|| EngineError::Refused(format!("There's no approval {id}")))?;
if let Some(decided) = record.decided {
return Ok(decided);
}
if desk.point(id).is_none() {
let point = recorded_point(host, &record);
lock(&desk.points).insert(id.clone(), point);
}
(desk.wait(id), record.opened.kind)
};
if autopilot::applies_now(host, kind) {
autopilot::settle_open(host)?;
}
Opening::Waiting {
id: id.clone(),
receiver,
}
.decided(host)
.await
}
fn ask_again(host: &Host, record: &ApprovalRecord) -> EngineResult<()> {
let desk = &host.shared().desk;
let id = &record.opened.id;
let point = match desk.point(id) {
Some(point) => point,
None if record.opened.kind == ApprovalKind::Question => recorded_point(host, record),
None => return Ok(()),
};
if !lock(&desk.evaluating).insert(id.clone()) {
return Ok(());
}
let evidence = match evidence_of(host, &record.opened) {
Ok(evidence) => evidence,
Err(err) => {
lock(&desk.evaluating).remove(id);
return Err(err);
}
};
let point = host.read(|state| DecisionPoint {
mode_override: state.run.approval_mode,
main_agent: Some(tie_breaker_of(state, host.settings())),
..point
});
lock(&desk.points).insert(id.clone(), point.clone());
lock(&desk.autopilot).insert(id.clone());
spawn_policy(host, point, evidence);
Ok(())
}
enum Found {
Decided(Box<ApprovalDecided>),
Open {
id: ApprovalId,
policy_done: bool,
},
Stale(ApprovalId),
None,
}
fn find(state: &RunState, request: &ApprovalRequest, sha256: &str) -> Found {
if is_workflow_change(request.kind, request.task.as_ref()) {
return find_workflow_change(state, request, sha256);
}
if let Some(open) = state
.approvals
.open_for(request.kind, request.task.as_ref())
{
if open.opened.evidence_sha256 != sha256 {
return Found::Stale(open.opened.id.clone());
}
return Found::Open {
id: open.opened.id.clone(),
policy_done: open.policy_check().is_some(),
};
}
let Some(round_since) = request.at.as_ref().and_then(|at| round_since(state, at)) else {
return Found::None;
};
state
.approvals
.by_id
.values()
.filter(|record| {
record.since > round_since
&& record.withdrawn.is_none()
&& record.opened.kind == request.kind
&& record.opened.task == request.task
&& record.opened.title == request.title
&& record.opened.evidence_sha256 == sha256
})
.max_by_key(|record| record.since)
.and_then(|record| record.decided.clone())
.map_or(Found::None, |decided| Found::Decided(Box::new(decided)))
}
fn round_since(state: &RunState, at: &StepAt) -> Option<u64> {
state
.workflows
.progress(&at.step)
.filter(|progress| progress.round == at.round && progress.end.is_none())
.map(|progress| progress.since)
}
pub fn decided_in_round(
host: &Host,
at: &StepAt,
kind: ApprovalKind,
title: &str,
) -> EngineResult<Option<(ApprovalDecided, Option<EvidenceSubject>)>> {
let decided = host.read(|state| {
let since = round_since(state, at)?;
state
.approvals
.by_id
.values()
.filter(|record| {
record.since > since
&& record.withdrawn.is_none()
&& record.opened.kind == kind
&& record.opened.task.is_none()
&& record.opened.title == title
})
.max_by_key(|record| record.since)
.and_then(|record| record.decided.clone())
});
let Some(decided) = decided else {
return Ok(None);
};
let subject = evidence::read(host, &decided.id)?.and_then(|packet| packet.facts.subject);
Ok(Some((decided, subject)))
}
pub fn only_yours(packet: &ApprovalPacket) -> bool {
matches!(packet, ApprovalPacket::Merge(merge) if merge.waits_on_you())
}
fn why_only_yours(packet: &ApprovalPacket) -> Option<String> {
let ApprovalPacket::Merge(merge) = packet else {
return None;
};
if !merge.both_changed.is_empty() {
Some("only you can pick between your version and the run's".to_owned())
} else if merge.asks_which_branch {
Some("only you can name the branch".to_owned())
} else {
None
}
}
pub fn builders_provider_reviewed(host: &Host, opened: &ApprovalOpened) -> Option<String> {
let ApprovalPacket::Merge(merge) = &opened.packet else {
return None;
};
let registry = &host.services().registry;
host.read(|state| {
merge.rows.iter().find_map(|row| {
let record = state.tasks.get(&row.task)?;
let builder = registry.provider(&crate::fallback::rules::worker(record)?);
let fallback = state.sessions.by_key.values().find(|session| {
matches!(session.started.role, RoleName::Reviewer | RoleName::Judge)
&& session.asked.is_some()
&& session.task.as_ref().is_none_or(|task| *task == row.task)
&& registry.provider(&session.started.worker) == builder
})?;
Some(format!(
"{}, a fallback from its builder's own provider, reviewed {}",
crate::roles::display_name(&fallback.started.worker),
row.task
))
})
})
}
pub fn merged(host: &Host, id: &ApprovalId) -> EngineResult<()> {
if lock(&host.shared().desk.spot_check_once_merged).remove(id) {
host.record(
None,
None,
RunEventKind::Approval(ApprovalEvent::SpotCheckQueued { id: id.clone() }),
)?;
}
Ok(())
}
pub fn landing_refused(host: &Host, id: &ApprovalId) {
let Some(record) = host.read(|state| state.approvals.by_id.get(id).cloned()) else {
return;
};
let auto = record
.decided
.as_ref()
.is_some_and(|decided| matches!(decided.by, Party::Rules | Party::Judge { .. }));
if auto {
let point = recorded_point(host, &record);
host.services().policy.landing_refused(&point);
}
lock(&host.shared().desk.spot_check_once_merged).remove(id);
}
pub fn is_workflow_change(kind: ApprovalKind, task: Option<&TaskId>) -> bool {
kind == ApprovalKind::ChangeRequest && task.is_none()
}
fn find_workflow_change(state: &RunState, request: &ApprovalRequest, sha256: &str) -> Found {
let same = state
.approvals
.open()
.filter(|record| {
is_workflow_change(record.opened.kind, record.opened.task.as_ref())
&& record.opened.title == request.title
})
.max_by_key(|record| record.since);
match same {
Some(open) if open.opened.evidence_sha256 == sha256 => Found::Open {
id: open.opened.id.clone(),
policy_done: open.policy_check().is_some(),
},
Some(open) if request.at.is_some() => Found::Stale(open.opened.id.clone()),
_ => Found::None,
}
}
pub fn evidence_of(host: &Host, opened: &ApprovalOpened) -> EngineResult<EvidencePacket> {
let path = host
.repo()
.run_dir
.join("evidence")
.join(format!("{}.json", opened.id));
let packet: EvidencePacket = serde_json::from_slice(&std::fs::read(&path)?)?;
if packet.sha256() != opened.evidence_sha256 {
return Err(EngineError::Refused(format!(
"the evidence of approval {} changed since it opened",
opened.id
)));
}
Ok(packet)
}
fn record_opened(
host: &Host,
request: &ApprovalRequest,
evidence: &EvidencePacket,
sha256: &str,
yours: bool,
) -> EngineResult<ApprovalId> {
let id = ApprovalId::numbered(host.allocate(IdKind::Approval)?);
evidence::write(host, &id, evidence)?;
let (override_mode, last_round) = host.read(|state| {
let last_round = request
.at
.as_ref()
.is_some_and(|at| kinds::in_last_loop_round(state, at));
(state.run.approval_mode, last_round)
});
let configured = configured(host.settings(), request.kind);
let mode = if yours {
ApprovalMode::Supervised
} else {
effective_mode(
host,
&decision_point(host, &id, request),
override_mode,
configured.mode,
)
};
let in_autopilot = host.read(|state| autopilot::applies(state, host.settings(), request.kind));
let mut options = request
.options
.clone()
.unwrap_or_else(|| options::standard(request.kind));
if last_round {
options.retain(|option| option.action != AnswerAction::SendBack);
}
let opened = ApprovalOpened {
id: id.clone(),
kind: request.kind,
title: request.title.clone(),
task: request.task.clone(),
packet: request.packet.clone(),
evidence_sha256: sha256.to_owned(),
mode,
decide_with: configured.decide_with,
options,
free_text: request.free_text,
needs_you: (!mode.goes_ahead() || !policy_may_settle(host.settings(), request.kind))
&& !in_autopilot,
};
host.record(
request.task.as_ref(),
None,
RunEventKind::Approval(ApprovalEvent::Opened(Box::new(opened))),
)?;
Ok(id)
}
fn effective_mode(
host: &Host,
point: &DecisionPoint,
chosen: Option<ApprovalMode>,
configured: ApprovalMode,
) -> ApprovalMode {
host.services()
.policy
.effective_mode(point, chosen.unwrap_or(configured))
}
pub fn mode_now(host: &Host, kind: ApprovalKind) -> ApprovalMode {
let (mode_override, learned_workflow) =
host.read(|state| (state.run.approval_mode, learned_workflow_of(state)));
let point = DecisionPoint {
run: host.run_id().clone(),
approval: ApprovalId::numbered(0),
kind,
learned_workflow,
authors: Vec::new(),
mode_override,
main_agent: None,
};
let configured = configured(host.settings(), kind).mode;
effective_mode(host, &point, mode_override, configured)
}
pub fn answerer(host: &Host, kind: ApprovalKind) -> Answerer {
let approvals = &host.settings().approvals;
if autopilot::applies_now(host, kind) {
return approvals.answerer(ApprovalMode::Autopilot);
}
if host.read(crate::understand::approval::main_agent_answers_for_you) {
return Answerer::Main;
}
approvals.answerer(mode_now(host, kind))
}
fn decision_point(host: &Host, id: &ApprovalId, request: &ApprovalRequest) -> DecisionPoint {
let (mode_override, learned_workflow, tie_breaker) = host.read(|state| {
(
state.run.approval_mode,
learned_workflow_of(state),
tie_breaker_of(state, host.settings()),
)
});
DecisionPoint {
run: host.run_id().clone(),
approval: id.clone(),
kind: request.kind,
learned_workflow,
authors: request.authors.clone(),
mode_override,
main_agent: Some(tie_breaker),
}
}
fn tie_breaker_of(state: &RunState, settings: &Settings) -> ModelRef {
crate::tie_breaker::tie_breaker(state, settings).unwrap_or_else(|| settings.crew.main.clone())
}
fn learned_workflow_of(state: &RunState) -> Option<String> {
state
.run
.started
.as_ref()
.and_then(|started| started.captain_or_crew.as_ref())
.and_then(|captain_or_crew| captain_or_crew.rule.strip_prefix("learned:"))
.map(str::to_owned)
}
fn spawn_policy(host: &Host, point: DecisionPoint, evidence: EvidencePacket) {
let host = host.clone();
let tasks = host.shared().tasks.clone();
tasks.spawn(async move {
let policy = host.services().policy.clone();
let shared = host.shared();
let outcome = tokio::select! {
outcome = policy.evaluate(&point, &evidence) => outcome,
() = shared.shutdown.cancelled() => {
lock(&shared.desk.evaluating).remove(&point.approval);
return;
}
() = shared.halt.cancelled() => {
lock(&shared.desk.evaluating).remove(&point.approval);
return;
}
};
lock(&host.shared().desk.evaluating).remove(&point.approval);
if let Err(err) = policy_outcome(&host, &point.approval, outcome) {
let note = Note {
level: NoteLevel::Error,
text: format!(
"the policy's outcome on {} wasn't recorded: {err}",
point.approval
),
};
if let Err(also) = host.record(None, None, RunEventKind::Note(note)) {
tracing::warn!(approval = %point.approval, "the policy's outcome wasn't recorded: {err}; nor was the note: {also}");
}
}
});
}
pub fn answer(host: &Host, answer: ApprovalAnswer) -> Result<(), String> {
if answer.action == AnswerAction::CarryOn {
return decline::left(host, &answer.id);
}
let desk = &host.shared().desk;
let (decided, point, yours) = {
let _deciding = lock(&desk.deciding);
let record = host
.read(|state| state.approvals.by_id.get(&answer.id).cloned())
.ok_or_else(|| format!("no approval {}", answer.id))?;
if record.decided.is_some() {
return Err("already decided".to_owned());
}
if let Some(reason) = &record.withdrawn {
return Err(format!("withdrawn: {reason}"));
}
if record.opened.evidence_sha256 != answer.evidence_sha256 {
return Err(
"the evidence changed since the question was shown; look at it again and answer"
.to_owned(),
);
}
check_offered(&record.opened, &answer)?;
let decided = ApprovalDecided {
id: answer.id.clone(),
action: answer.action.clone(),
text: answer.text.clone(),
by: Party::User,
evidence_sha256: answer.evidence_sha256.clone(),
auto_preview: auto_preview_of(&record),
tasks: answer.tasks.clone(),
};
host.record(
record.opened.task.as_ref(),
None,
RunEventKind::Approval(ApprovalEvent::Decided(decided.clone())),
)
.map_err(|err| err.to_string())?;
if crate::run_review::reversals::rejects(record.opened.kind, &decided.action) {
let what =
crate::run_review::reversals::at_approval(record.opened.kind, &decided.action);
crate::run_review::reversals::note(host, record.opened.task.as_ref(), what);
}
change::on_decided(host, &record.opened, &decided);
crate::fallback::down::on_decided(host, &record.opened, &decided);
desk.wake(&decided);
let yours = only_yours(&record.opened.packet);
(decided, recorded_point(host, &record), yours)
};
if yours {
return Ok(());
}
host.services().policy.user_decided(&UserDecision {
point,
edited: matches!(decided.action, AnswerAction::ApproveWithEdits),
action: decided.action,
auto_preview: decided.auto_preview,
});
Ok(())
}
pub fn spot_check(host: &Host, id: &ApprovalId, correct: bool) -> Result<(), String> {
let desk = &host.shared().desk;
let record = host
.read(|state| state.approvals.by_id.get(id).cloned())
.ok_or_else(|| format!("no approval {id}"))?;
if !record.spot_check {
return Err(format!("{id} isn't waiting for a spot check"));
}
if !lock(&desk.spot_checked).insert(id.clone()) {
return Err(format!("{id} was already spot-checked"));
}
let point = recorded_point(host, &record);
host.services().policy.spot_checked(&point, correct);
if !correct {
let what = format!(
"your spot check found the auto-approval at the {} approval wrong",
serde_json::to_value(record.opened.kind)
.ok()
.and_then(|kind| kind.as_str().map(|kind| kind.replace('_', " ")))
.unwrap_or_default()
);
crate::run_review::reversals::note(host, record.opened.task.as_ref(), what);
}
Ok(())
}
fn recorded_point(host: &Host, record: &ApprovalRecord) -> DecisionPoint {
let id = &record.opened.id;
if let Some(point) = host.shared().desk.point(id) {
return point;
}
let (mode_override, learned_workflow, tie_breaker) = host.read(|state| {
(
state.run.approval_mode,
learned_workflow_of(state),
tie_breaker_of(state, host.settings()),
)
});
DecisionPoint {
run: host.run_id().clone(),
approval: id.clone(),
kind: record.opened.kind,
learned_workflow,
authors: Vec::new(),
mode_override,
main_agent: Some(tie_breaker),
}
}
fn check_offered(opened: &ApprovalOpened, answer: &ApprovalAnswer) -> Result<(), String> {
if matches!(answer.action, AnswerAction::ShowDiff) {
return Err("showing the details doesn't answer the question".to_owned());
}
let offered = opened.options.iter().any(|option| option.action == answer.action)
|| (matches!(answer.action, AnswerAction::Candidate { .. })
&& opened
.options
.iter()
.any(|option| option.action == AnswerAction::OtherCandidates));
if !offered {
return Err(format!(
"that answer isn't one the question offered ({:?})",
answer.action
));
}
let needs_text = matches!(answer.action, AnswerAction::Answer);
if needs_text
&& answer
.text
.as_deref()
.is_none_or(|text| text.trim().is_empty())
{
return Err("type your answer first".to_owned());
}
Ok(())
}
fn auto_preview_of(record: &ApprovalRecord) -> Option<AutoPreviewRecord> {
record
.checks
.iter()
.find(|check| check.auto_preview)
.and_then(|check| {
Some(AutoPreviewRecord {
would: check.verdict?,
by: check.by.unwrap_or_default(),
reasons: check.reasons.clone(),
})
})
}
pub fn policy_outcome(host: &Host, id: &ApprovalId, outcome: PolicyOutcome) -> EngineResult<()> {
let desk = &host.shared().desk;
let _deciding = lock(&desk.deciding);
let record = host
.read(|state| state.approvals.by_id.get(id).cloned())
.ok_or_else(|| EngineError::Refused(format!("no approval {id}")))?;
let task = record.opened.task.clone();
let checked = |check: PolicyCheck| {
host.record(
task.as_ref(),
None,
RunEventKind::Approval(ApprovalEvent::Checked(check)),
)
};
let asked_again = record.policy_check().is_some()
&& record.is_open()
&& autopilot::asked_in_autopilot(host, &record)
&& record.opened.mode != ApprovalMode::Autopilot;
let late = !record.is_open() || (record.policy_check().is_some() && !asked_again);
let in_autopilot = !late && autopilot::applies_now(host, record.opened.kind);
let not_independent = builders_provider_reviewed(host, &record.opened);
let outcome = match outcome {
PolicyOutcome::Decided { .. } if not_independent.is_some() => PolicyOutcome::AskUser {
violations: Vec::new(),
auto_preview: None,
},
outcome => outcome,
};
match outcome {
PolicyOutcome::AskUser {
violations,
auto_preview,
} => {
let because = || {
lock(&desk.because)
.get(id)
.cloned()
.or_else(|| why_asked(&violations, auto_preview.as_ref()))
};
let auto_preview_check = |auto_preview: AutoPreviewRecord| PolicyCheck {
id: id.clone(),
violations: Vec::new(),
verdict: Some(auto_preview.would),
by: Some(auto_preview.by),
judge: None,
reasons: auto_preview.reasons,
auto_preview: true,
};
if in_autopilot && !autopilot::asked_in_autopilot(host, &record) {
if let Some(auto_preview) = auto_preview {
checked(auto_preview_check(auto_preview))?;
}
return ask_again(host, &record);
}
let held = match &auto_preview {
_ if !violations.is_empty() => {
autopilot::Held::HardLimit(autopilot::at_hard_limit(host, &record, &violations))
}
_ if not_independent.is_some()
|| autopilot::kept_for_you(host.settings(), record.opened.kind) =>
{
autopilot::Held::Kept
}
Some(auto_preview) if auto_preview.would == Verdict::Reject => {
autopilot::Held::Rejected
}
_ => autopilot::Held::Undecided,
};
let not_checked = auto_preview.as_ref().and_then(|auto_preview| {
let rules_alone = auto_preview.by == DecideWith::Rules;
unchecked_question(record.opened.kind, rules_alone, &auto_preview.reasons)
});
let why = not_independent.or(not_checked).or_else(because);
let plan_in_auto = !late
&& autopilot::settles_a_plan_in_auto(
&record.opened,
auto_preview.is_some(),
!violations.is_empty(),
);
if in_autopilot || plan_in_auto {
autopilot::decide(host, &record, held, why.clone(), &violations)?;
}
if !late {
let says_why = in_autopilot
|| (record.opened.mode.goes_ahead()
&& policy_may_settle(host.settings(), record.opened.kind));
checked(PolicyCheck {
id: id.clone(),
violations,
verdict: None,
by: None,
judge: None,
reasons: says_why.then_some(why).flatten().into_iter().collect(),
auto_preview: false,
})?;
}
if let Some(auto_preview) = auto_preview {
checked(auto_preview_check(auto_preview))?;
}
Ok(())
}
PolicyOutcome::Decided {
verdict,
by,
judge,
reasons,
spot_check,
} => {
let unchecked = in_autopilot
&& by == DecideWith::Rules
&& configured(host.settings(), record.opened.kind).decide_with != DecideWith::Rules;
let no_model = reasons.last().cloned().filter(|_| unchecked);
let not_checked = unchecked_question(record.opened.kind, judge.is_none(), &reasons);
checked(PolicyCheck {
id: id.clone(),
violations: Vec::new(),
verdict: Some(verdict),
by: Some(by),
judge: judge.clone(),
reasons,
auto_preview: late,
})?;
if late {
return Ok(());
}
if let Some(why) = no_model {
autopilot::rules_alone(host, &record.opened, &why)?;
}
let otherwise = configured(host.settings(), record.opened.kind).otherwise;
let Some(action) = action_for(&record.opened.options, verdict, otherwise) else {
let plan_in_auto = autopilot::settles_a_plan_in_auto(&record.opened, true, false);
if in_autopilot || plan_in_auto {
let checked = host.read(|state| state.approvals.by_id.get(id).cloned());
let record = checked.unwrap_or(record);
let held = match verdict {
Verdict::Reject => autopilot::Held::Rejected,
Verdict::Approve | Verdict::AskYou => autopilot::Held::Undecided,
};
let why = not_checked.or_else(|| why_asked_of(&record));
autopilot::decide(host, &record, held, why, &[])?;
}
return Ok(());
};
let decided = ApprovalDecided {
id: id.clone(),
action,
text: None,
by: match (by, judge) {
(DecideWith::Rules, _) | (_, None) => Party::Rules,
(_, Some(model)) => Party::Judge { model },
},
evidence_sha256: record.opened.evidence_sha256.clone(),
auto_preview: None,
tasks: Vec::new(),
};
host.record(
task.as_ref(),
None,
RunEventKind::Approval(ApprovalEvent::Decided(decided.clone())),
)?;
if in_autopilot && record.opened.kind == ApprovalKind::Question {
let taken = record
.opened
.options
.iter()
.find(|option| option.action == decided.action)
.map(|option| option.label.clone());
let judge = match &decided.by {
Party::Judge { model } => Some(model.clone()),
_ => None,
};
let why = host
.read(|state| state.approvals.by_id.get(id).cloned())
.and_then(|checked| why_asked_of(&checked));
if let Some(answered) = taken.and_then(|taken| {
autopilot::asked_by_a_worker(
host,
&record.opened,
&taken,
why.as_deref(),
judge,
)
}) {
host.record(
task.as_ref(),
None,
RunEventKind::Blackboard(BlackboardEvent::AnsweredForYou(answered)),
)?;
}
}
if spot_check {
if record.opened.kind == ApprovalKind::Merge {
lock(&desk.spot_check_once_merged).insert(id.clone());
} else {
host.record(
task.as_ref(),
None,
RunEventKind::Approval(ApprovalEvent::SpotCheckQueued { id: id.clone() }),
)?;
}
}
change::on_decided(host, &record.opened, &decided);
desk.wake(&decided);
Ok(())
}
}
}
fn why_asked(violations: &[Violation], auto_preview: Option<&AutoPreviewRecord>) -> Option<String> {
if let Some(first) = violations.first() {
return Some(format!("a hard limit, {}", first.detail.trim()));
}
let auto_preview = auto_preview?;
let reason = match auto_preview.would {
Verdict::Approve => auto_preview.reasons.last(),
Verdict::Reject | Verdict::AskYou => auto_preview.reasons.first(),
};
reason
.map(|reason| reason.trim().to_owned())
.filter(|reason| !reason.is_empty())
}
fn unchecked_question(kind: ApprovalKind, rules_alone: bool, reasons: &[String]) -> Option<String> {
(kind == ApprovalKind::Question && rules_alone)
.then(|| reasons.last().cloned())
.flatten()
}
fn why_asked_of(record: &ApprovalRecord) -> Option<String> {
let check = record.policy_check()?;
check
.reasons
.first()
.cloned()
.or_else(|| why_asked(&check.violations, None))
}
fn action_for(
options: &[AnswerOption],
verdict: Verdict,
otherwise: Otherwise,
) -> Option<AnswerAction> {
let wanted: &[AnswerAction] = match (verdict, otherwise) {
(Verdict::Approve, _) => &[
AnswerAction::Approve,
AnswerAction::Merge,
AnswerAction::RaiseLimit,
AnswerAction::Fallback,
],
(Verdict::Reject, Otherwise::SendBack) => {
&[AnswerAction::SendBack, AnswerAction::RequestChanges]
}
(Verdict::Reject, Otherwise::Stop) => &[AnswerAction::Stop, AnswerAction::Abandon],
(Verdict::Reject, Otherwise::OpenPullRequest) => &[AnswerAction::PullRequest],
(Verdict::Reject, Otherwise::Ask) | (Verdict::AskYou, _) => &[],
};
wanted
.iter()
.find(|action| options.iter().any(|option| option.action == **action))
.cloned()
}
pub fn withdraw(host: &Host, id: &ApprovalId, reason: &str) -> EngineResult<()> {
let _deciding = lock(&host.shared().desk.deciding);
withdraw_locked(host, id, reason)
}
fn withdraw_locked(host: &Host, id: &ApprovalId, reason: &str) -> EngineResult<()> {
let task = host.read(|state| {
state
.approvals
.by_id
.get(id)
.filter(|record| record.is_open())
.map(|record| record.opened.task.clone())
});
let Some(task) = task else {
return Err(EngineError::Refused(format!(
"approval {id} isn't open, so it can't be withdrawn"
)));
};
host.record(
task.as_ref(),
None,
RunEventKind::Approval(ApprovalEvent::Withdrawn {
id: id.clone(),
reason: reason.to_owned(),
}),
)?;
host.shared().desk.release(id);
Ok(())
}
pub struct Configured {
pub mode: ApprovalMode,
pub decide_with: DecideWith,
pub otherwise: Otherwise,
pub at_hard_limit: AtHardLimit,
}
pub fn configured(settings: &Settings, kind: ApprovalKind) -> Configured {
own_settings(settings, kind).unwrap_or(Configured {
mode: settings.approvals.mode(),
decide_with: DecideWith::default(),
otherwise: Otherwise::Ask,
at_hard_limit: AtHardLimit::default(),
})
}
fn policy_may_settle(settings: &Settings, kind: ApprovalKind) -> bool {
own_settings(settings, kind).is_some()
}
fn own_settings(settings: &Settings, kind: ApprovalKind) -> Option<Configured> {
let approvals = &settings.approvals;
macro_rules! from {
($point:expr) => {
Configured {
mode: approvals.run_mode($point.mode),
decide_with: $point.decide_with,
otherwise: $point.otherwise,
at_hard_limit: $point.at_hard_limit,
}
};
}
Some(match kind {
ApprovalKind::Understand => from!(approvals.understand),
ApprovalKind::Plan => from!(approvals.plan),
ApprovalKind::Merge => from!(approvals.merge),
ApprovalKind::WorkflowPromotion => Configured {
mode: approvals.workflow_promotion.mode,
decide_with: approvals.workflow_promotion.decide_with,
otherwise: approvals.workflow_promotion.otherwise,
at_hard_limit: AtHardLimit::default(),
},
ApprovalKind::Stuck => from!(approvals.stuck),
ApprovalKind::Limit => from!(approvals.limit),
ApprovalKind::ChangeRequest => from!(approvals.change_request),
ApprovalKind::Fallback
| ApprovalKind::AgentDown
| ApprovalKind::GoalChange
| ApprovalKind::TakeOverReturn
| ApprovalKind::Question
| ApprovalKind::OriginalReturned => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_approval_takes_the_questions_approving_option() {
let merge = options::standard(ApprovalKind::Merge);
assert_eq!(
action_for(&merge, Verdict::Approve, Otherwise::Ask),
Some(AnswerAction::Merge)
);
assert_eq!(
action_for(&merge, Verdict::Reject, Otherwise::OpenPullRequest),
Some(AnswerAction::PullRequest)
);
assert_eq!(action_for(&merge, Verdict::Reject, Otherwise::Ask), None);
assert_eq!(action_for(&merge, Verdict::AskYou, Otherwise::Stop), None);
let plan = options::standard(ApprovalKind::Plan);
assert_eq!(
action_for(&plan, Verdict::Reject, Otherwise::SendBack),
Some(AnswerAction::SendBack)
);
assert_eq!(
action_for(&plan, Verdict::Reject, Otherwise::Stop),
Some(AnswerAction::Abandon)
);
let stuck = options::standard(ApprovalKind::Stuck);
assert_eq!(
action_for(&stuck, Verdict::Approve, Otherwise::Ask),
Some(AnswerAction::Fallback)
);
let limit = options::standard(ApprovalKind::Limit);
assert_eq!(
action_for(&limit, Verdict::Approve, Otherwise::Ask),
Some(AnswerAction::RaiseLimit)
);
}
#[test]
fn a_kind_without_settings_of_its_own_opens_in_the_shared_mode() {
let mut settings = Settings::default();
let approvals = &mut settings.approvals;
for point in [
&mut approvals.understand,
&mut approvals.plan,
&mut approvals.merge,
&mut approvals.stuck,
&mut approvals.limit,
&mut approvals.change_request,
] {
point.mode = ApprovalMode::Auto;
}
assert_eq!(settings.approvals.mode(), ApprovalMode::Auto);
for kind in [ApprovalKind::Question, ApprovalKind::Fallback] {
assert_eq!(configured(&settings, kind).mode, ApprovalMode::Auto);
assert!(!policy_may_settle(&settings, kind));
}
assert!(policy_may_settle(&settings, ApprovalKind::Merge));
settings.approvals.autopilot = true;
assert_eq!(
configured(&settings, ApprovalKind::Question).mode,
ApprovalMode::Autopilot
);
}
}