use agentplane::core::{Decided, Decision, Justification, Operator, Tainted, TaskSpec};
use serde_json::{Value, json};
use super::{Answer, Decision as Said, PresentContext, Presentation, Request, Scope, Who};
pub const DEADLINE: &str = "approval";
pub const APPROVAL: &str = "approval";
pub const QUESTION: &str = "question";
#[must_use]
pub fn approval_task(
request: &Request,
grant: Option<&crate::tools::policy::Grant>,
log: &[Value],
) -> TaskSpec {
TaskSpec::new(
APPROVAL,
Justification::new(
Tainted::trusted(format!("{} — {}", request.task, request.covers)),
json!({
"tool": request.tool,
"arguments": request.arguments,
"task": request.task,
"grant": grant,
"request": request,
"log": log,
}),
),
DEADLINE,
)
}
#[must_use]
pub fn question_task(task: &str, question: &str) -> TaskSpec {
TaskSpec::new(
QUESTION,
Justification::new(
Tainted::trusted(format!("{task} asks")),
json!({ "task": task, "question": question }),
),
DEADLINE,
)
}
#[must_use]
pub fn request_of(proposed_action: &Value) -> Option<Request> {
serde_json::from_value(proposed_action["request"].clone()).ok()
}
#[must_use]
pub fn presentation_of(proposed_action: &Value, context: &PresentContext) -> Option<Presentation> {
request_of(proposed_action).map(|r| super::present(&r, context))
}
#[must_use]
pub fn decision_for(answer: &Answer, by: &Who, text: Option<&str>) -> Option<Decision> {
let operator: Operator = by.operator()?;
let mut amendment = json!({ "scope": answer.scope.name(), "via": by.via });
if let Some(note) = &answer.note {
amendment["note"] = json!(note);
}
if let Some(text) = text {
amendment["answer"] = json!(text);
}
Some(
match answer.decision {
Said::Approve => Decision::approve(operator, answer.note.clone().unwrap_or_default()),
Said::Reject | Said::Stop => Decision::reject(
operator,
answer
.reason
.clone()
.unwrap_or_else(|| "no reason given".to_owned()),
),
}
.amend(amendment),
)
}
#[must_use]
pub fn ask(
approver: &dyn super::Approver,
task: &agentplane::core::Task,
context: &PresentContext,
) -> Option<Decision> {
let action = &task.justification.proposed_action;
match task.kind.as_str() {
APPROVAL => {
let answer = approver.ask(&presentation_of(action, context)?);
if answer.decision == Said::Stop {
return None;
}
decision_for(&answer, &named(answer.by.clone(), approver, context), None)
}
QUESTION => {
let by = named(None, approver, context);
match approver.answer(action["question"].as_str()?) {
Some(text) => decision_for(
&Answer::approve(Scope::Once, None, Some(by.clone())),
&by,
Some(&text),
),
None => decision_for(
&Answer::reject("declined to answer", Some(by.clone())),
&by,
None,
),
}
}
_ => None,
}
}
fn named(by: Option<Who>, approver: &dyn super::Approver, context: &PresentContext) -> Who {
let mut by = by.unwrap_or_else(|| Who::local(&approver.via()));
if let Some(root) = &context.root {
by.name = super::who_in(root);
}
by
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Read {
pub answer: Answer,
pub by: Option<Who>,
pub expired: bool,
pub text: Option<String>,
}
#[must_use]
pub fn read(d: &Decision) -> Read {
let a = &d.amendment;
let scope = match a["scope"].as_str() {
Some("once") => Scope::Once,
Some("run") => Scope::Run,
_ => Scope::Task,
};
let by = d.decided.operator().map(|op| Who {
name: op.actor().to_owned(),
via: a["via"].as_str().unwrap_or("cli").to_owned(),
basis: op.basis().to_string(),
});
let expired = matches!(d.decided, Decided::OnExpiry(_));
let answer = if d.approved {
Answer::approve(scope, a["note"].as_str().map(str::to_owned), by.clone())
} else {
Answer::reject(
if expired {
"not approved before its deadline"
} else {
d.reason.as_str()
},
by.clone(),
)
};
Read {
answer,
by,
expired,
text: a["answer"].as_str().map(str::to_owned),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn who() -> Who {
Who {
name: "dana".to_owned(),
via: "terminal".to_owned(),
basis: "asserted".to_owned(),
}
}
#[test]
fn every_scope_and_note_survive_the_record() {
for scope in [Scope::Once, Scope::Task, Scope::Run] {
let answer = Answer::approve(scope, Some("keep it small".to_owned()), Some(who()));
let d = decision_for(&answer, &who(), None).unwrap();
let back = read(&d);
assert_eq!(back.answer.scope, scope);
assert_eq!(back.answer.note.as_deref(), Some("keep it small"));
assert_eq!(back.by, Some(who()));
assert!(!back.expired);
}
let rejected = Answer::reject("not that file", Some(who()));
let back = read(&decision_for(&rejected, &who(), None).unwrap());
assert_eq!(back.answer.decision, Said::Reject);
assert_eq!(back.answer.reason.as_deref(), Some("not that file"));
}
#[test]
fn an_expiry_is_a_refusal_nobody_made() {
let d = Decision::expired(agentplane::core::OnExpiry::Deny);
let back = read(&d);
assert!(back.expired && back.by.is_none());
assert_eq!(back.answer.decision, Said::Reject);
}
#[test]
fn the_task_carries_the_exact_call() {
let request = super::super::Request {
task: "001-x/T001".to_owned(),
task_title: "t".to_owned(),
turn: 1,
tool: "write_file".to_owned(),
arguments: json!({ "path": "src/a.rs", "content": "fn a() {}\n" }),
kind: "write".to_owned(),
reason: "r".to_owned(),
covers: "write `src/a.rs`".to_owned(),
risk: "low".to_owned(),
claim: None,
options: vec!["once".to_owned()],
};
let spec = approval_task(&request, None, &[]);
assert_eq!(spec.kind, APPROVAL);
let action = &spec.justification.proposed_action;
assert_eq!(action["arguments"]["content"], "fn a() {}\n");
assert_eq!(request_of(action), Some(request.clone()));
assert_eq!(
spec.justification.digest(),
approval_task(&request, None, &[]).justification.digest()
);
}
}