use argonauts_core::events::RunEventKind;
use argonauts_core::events::approval::{
AnswerAction, ApprovalKind, ApprovalPacket, FreeText, GoalChangePacket,
};
use argonauts_core::events::goal::{GoalChangeResolution, GoalEvent, GoalFile};
use argonauts_core::events::run::Party;
use argonauts_core::log::NoteLevel;
use argonauts_core::names::MessageId;
use argonauts_core::policy::{EvidenceDocument, EvidenceFacts};
use argonauts_core::settings::approvals::GoalChanges;
use crate::approvals::{self, ApprovalRequest};
use crate::engine::host::Host;
use crate::error::{EngineError, EngineResult};
use crate::state::ids::IdKind;
use super::approval::approve;
use super::materiality::one_line;
use super::{draft, file, round};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Route {
Record,
Refuse,
AskYou,
}
pub fn route(setting: GoalChanges, clarification: bool) -> Route {
match setting {
GoalChanges::Deny => Route::Refuse,
GoalChanges::AllowClarifications if clarification => Route::Record,
GoalChanges::AllowClarifications | GoalChanges::Ask => Route::AskYou,
}
}
pub async fn request(
host: &Host,
from: Party,
text: String,
clarification: bool,
) -> EngineResult<GoalChangeResolution> {
let text = text.trim().to_owned();
if text.is_empty() {
return Err(EngineError::Refused(
"a goal change needs some text".to_owned(),
));
}
let (approved, earlier) = host.read(|state| {
let earlier = state
.goal
.changes
.iter()
.rev()
.find(|change| {
change.text == text && change.from == from && change.clarification == clarification
})
.cloned();
(state.goal.approved.clone(), earlier)
});
let approved = approved.ok_or_else(|| {
EngineError::Refused("there is no approved goal to change yet".to_owned())
})?;
let (id, asked) = match earlier {
Some(change) => match change.resolved() {
Some(resolution) => return Ok(resolution),
None => {
let asked = change.resolutions.contains(&GoalChangeResolution::AskedYou);
(change.id, asked)
}
},
None => {
let id = MessageId::numbered(host.allocate(IdKind::Message)?);
host.record(
None,
None,
RunEventKind::Goal(GoalEvent::ChangeRequested {
id: id.clone(),
from: from.clone(),
text: text.clone(),
clarification,
}),
)?;
(id, false)
}
};
match route(host.settings().approvals.goal_changes, clarification) {
Route::Refuse => resolve(host, &id, GoalChangeResolution::Refused, None),
Route::Record => resolve(host, &id, GoalChangeResolution::Recorded, None),
Route::AskYou => {
if !asked {
resolve(host, &id, GoalChangeResolution::AskedYou, None)?;
}
ask_you(host, &id, from, &text, clarification, approved).await
}
}
}
async fn ask_you(
host: &Host,
id: &MessageId,
from: Party,
text: &str,
clarification: bool,
approved: GoalFile,
) -> EngineResult<GoalChangeResolution> {
let changed = changed_goal(&approved, text);
let request = ApprovalRequest {
at: None,
kind: ApprovalKind::GoalChange,
title: "Change the goal?".to_owned(),
task: None,
packet: ApprovalPacket::GoalChange(GoalChangePacket {
from,
text: text.to_owned(),
clarification,
goal: approved.clone(),
}),
facts: facts(&approved, &changed, clarification),
agent_outputs: Vec::new(),
documents: vec![
EvidenceDocument {
label: "approved goal".to_owned(),
text: file::render(&approved),
path: Some(approved.path.clone()),
sha256: Some(approved.sha256.clone()),
},
EvidenceDocument {
label: "goal with the change".to_owned(),
text: file::render(&changed),
path: None,
sha256: Some(changed.sha256.clone()),
},
],
options: None,
free_text: FreeText::None,
authors: Vec::new(),
};
let decided = approvals::open_and_wait(host, request).await?;
let accepted = decided.action == AnswerAction::Approve;
if accepted && decided.by != Party::User {
round::note(
host,
NoteLevel::Warn,
"Only you can change the goal, so the change was refused.".to_owned(),
)?;
return resolve(host, id, GoalChangeResolution::Refused, None);
}
if !accepted {
return resolve(host, id, GoalChangeResolution::Refused, None);
}
let written = file::write(host, changed).await?;
resolve(
host,
id,
GoalChangeResolution::Changed,
Some(written.sha256.clone()),
)?;
approve(host, written, decided.by)?;
Ok(GoalChangeResolution::Changed)
}
fn facts(approved: &GoalFile, changed: &GoalFile, clarification: bool) -> EvidenceFacts {
EvidenceFacts {
open_questions: Some(1),
criteria_unchanged: Some(clarification && changed.criteria == approved.criteria),
..EvidenceFacts::default()
}
}
pub fn changed_goal(approved: &GoalFile, text: &str) -> GoalFile {
let mut changed = draft::with_answers(approved, &[format!("Goal change: {}", one_line(text))]);
changed.version = approved.version + 1;
file::hashed(changed)
}
fn resolve(
host: &Host,
id: &MessageId,
resolution: GoalChangeResolution,
new_sha256: Option<String>,
) -> EngineResult<GoalChangeResolution> {
host.record(
None,
None,
RunEventKind::Goal(GoalEvent::ChangeResolved {
id: id.clone(),
resolution,
new_sha256,
}),
)?;
Ok(resolution)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use argonauts_core::RunId;
use argonauts_core::events::goal::{Criterion, GoalSource};
use super::*;
#[test]
fn the_setting_routes_each_request() {
let table = [
(GoalChanges::Ask, false, Route::AskYou),
(GoalChanges::Ask, true, Route::AskYou),
(GoalChanges::AllowClarifications, true, Route::Record),
(GoalChanges::AllowClarifications, false, Route::AskYou),
(GoalChanges::Deny, true, Route::Refuse),
(GoalChanges::Deny, false, Route::Refuse),
];
for (setting, clarification, expected) in table {
assert_eq!(route(setting, clarification), expected, "{setting:?}");
}
}
#[test]
fn a_clarification_keeps_the_criteria_and_nothing_else_claims_to() {
let approved = file::hashed(GoalFile {
run: RunId::new("run-01").unwrap(),
version: 1,
summary: "s".to_owned(),
criteria: vec![Criterion::new("G1", "Requests over the limit get a 429")],
in_scope: Vec::new(),
out_of_scope: Vec::new(),
assumptions: Vec::new(),
answers: Vec::new(),
source: GoalSource::Readings,
path: PathBuf::new(),
sha256: String::new(),
});
let changed = changed_goal(&approved, "the limit is per key");
assert_eq!(
facts(&approved, &changed, true).criteria_unchanged,
Some(true)
);
assert_eq!(
facts(&approved, &changed, false).criteria_unchanged,
Some(false)
);
let mut rewritten = changed.clone();
rewritten.criteria.clear();
assert_eq!(
facts(&approved, &rewritten, true).criteria_unchanged,
Some(false)
);
assert_eq!(facts(&approved, &changed, true).open_questions, Some(1));
}
#[test]
fn a_change_is_the_next_version() {
let approved = file::hashed(GoalFile {
run: RunId::new("run-01").unwrap(),
version: 1,
summary: "s".to_owned(),
criteria: Vec::new(),
in_scope: Vec::new(),
out_of_scope: Vec::new(),
assumptions: Vec::new(),
answers: Vec::new(),
source: GoalSource::Readings,
path: PathBuf::new(),
sha256: String::new(),
});
let changed = changed_goal(&approved, "also cover\nthe CLI");
assert_eq!(changed.version, 2);
assert_eq!(changed.answers, ["Goal change: also cover the CLI"]);
assert_ne!(changed.sha256, approved.sha256);
assert_eq!(changed.sha256, file::sha256(&changed));
}
}