speccoder_runtime/approvals/
tasks.rs1use agentplane::core::{Decided, Decision, Justification, Operator, Tainted, TaskSpec};
10use serde_json::{Value, json};
11
12use super::{Answer, Decision as Said, PresentContext, Presentation, Request, Scope, Who};
13
14pub const DEADLINE: &str = "approval";
16
17pub const APPROVAL: &str = "approval";
19
20pub const QUESTION: &str = "question";
22
23#[must_use]
29pub fn approval_task(
30 request: &Request,
31 grant: Option<&crate::tools::policy::Grant>,
32 log: &[Value],
33) -> TaskSpec {
34 TaskSpec::new(
35 APPROVAL,
36 Justification::new(
37 Tainted::trusted(format!("{} — {}", request.task, request.covers)),
38 json!({
39 "tool": request.tool,
40 "arguments": request.arguments,
41 "task": request.task,
42 "grant": grant,
43 "request": request,
44 "log": log,
46 }),
47 ),
48 DEADLINE,
49 )
50}
51
52#[must_use]
54pub fn question_task(task: &str, question: &str) -> TaskSpec {
55 TaskSpec::new(
56 QUESTION,
57 Justification::new(
58 Tainted::trusted(format!("{task} asks")),
59 json!({ "task": task, "question": question }),
60 ),
61 DEADLINE,
62 )
63}
64
65#[must_use]
67pub fn request_of(proposed_action: &Value) -> Option<Request> {
68 serde_json::from_value(proposed_action["request"].clone()).ok()
69}
70
71#[must_use]
73pub fn presentation_of(proposed_action: &Value, context: &PresentContext) -> Option<Presentation> {
74 request_of(proposed_action).map(|r| super::present(&r, context))
75}
76
77#[must_use]
80pub fn decision_for(answer: &Answer, by: &Who, text: Option<&str>) -> Option<Decision> {
81 let operator: Operator = by.operator()?;
82 let mut amendment = json!({ "scope": answer.scope.name(), "via": by.via });
83 if let Some(note) = &answer.note {
84 amendment["note"] = json!(note);
85 }
86 if let Some(text) = text {
87 amendment["answer"] = json!(text);
88 }
89 Some(
90 match answer.decision {
91 Said::Approve => Decision::approve(operator, answer.note.clone().unwrap_or_default()),
92 Said::Reject | Said::Stop => Decision::reject(
93 operator,
94 answer
95 .reason
96 .clone()
97 .unwrap_or_else(|| "no reason given".to_owned()),
98 ),
99 }
100 .amend(amendment),
101 )
102}
103
104#[must_use]
107pub fn ask(
108 approver: &dyn super::Approver,
109 task: &agentplane::core::Task,
110 context: &PresentContext,
111) -> Option<Decision> {
112 let action = &task.justification.proposed_action;
113 match task.kind.as_str() {
114 APPROVAL => {
115 let answer = approver.ask(&presentation_of(action, context)?);
116 if answer.decision == Said::Stop {
117 return None;
118 }
119 decision_for(&answer, &named(answer.by.clone(), approver, context), None)
120 }
121 QUESTION => {
122 let by = named(None, approver, context);
123 match approver.answer(action["question"].as_str()?) {
124 Some(text) => decision_for(
125 &Answer::approve(Scope::Once, None, Some(by.clone())),
126 &by,
127 Some(&text),
128 ),
129 None => decision_for(
130 &Answer::reject("declined to answer", Some(by.clone())),
131 &by,
132 None,
133 ),
134 }
135 }
136 _ => None,
137 }
138}
139
140fn named(by: Option<Who>, approver: &dyn super::Approver, context: &PresentContext) -> Who {
142 let mut by = by.unwrap_or_else(|| Who::local(&approver.via()));
143 if let Some(root) = &context.root {
144 by.name = super::who_in(root);
145 }
146 by
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Read {
153 pub answer: Answer,
155 pub by: Option<Who>,
157 pub expired: bool,
159 pub text: Option<String>,
161}
162
163#[must_use]
165pub fn read(d: &Decision) -> Read {
166 let a = &d.amendment;
167 let scope = match a["scope"].as_str() {
168 Some("once") => Scope::Once,
169 Some("run") => Scope::Run,
170 _ => Scope::Task,
171 };
172 let by = d.decided.operator().map(|op| Who {
173 name: op.actor().to_owned(),
174 via: a["via"].as_str().unwrap_or("cli").to_owned(),
175 basis: op.basis().to_string(),
176 });
177 let expired = matches!(d.decided, Decided::OnExpiry(_));
178 let answer = if d.approved {
179 Answer::approve(scope, a["note"].as_str().map(str::to_owned), by.clone())
180 } else {
181 Answer::reject(
182 if expired {
183 "not approved before its deadline"
184 } else {
185 d.reason.as_str()
186 },
187 by.clone(),
188 )
189 };
190 Read {
191 answer,
192 by,
193 expired,
194 text: a["answer"].as_str().map(str::to_owned),
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 fn who() -> Who {
203 Who {
204 name: "dana".to_owned(),
205 via: "terminal".to_owned(),
206 basis: "asserted".to_owned(),
207 }
208 }
209
210 #[test]
211 fn every_scope_and_note_survive_the_record() {
212 for scope in [Scope::Once, Scope::Task, Scope::Run] {
213 let answer = Answer::approve(scope, Some("keep it small".to_owned()), Some(who()));
214 let d = decision_for(&answer, &who(), None).unwrap();
215 let back = read(&d);
216 assert_eq!(back.answer.scope, scope);
217 assert_eq!(back.answer.note.as_deref(), Some("keep it small"));
218 assert_eq!(back.by, Some(who()));
219 assert!(!back.expired);
220 }
221 let rejected = Answer::reject("not that file", Some(who()));
222 let back = read(&decision_for(&rejected, &who(), None).unwrap());
223 assert_eq!(back.answer.decision, Said::Reject);
224 assert_eq!(back.answer.reason.as_deref(), Some("not that file"));
225 }
226
227 #[test]
228 fn an_expiry_is_a_refusal_nobody_made() {
229 let d = Decision::expired(agentplane::core::OnExpiry::Deny);
230 let back = read(&d);
231 assert!(back.expired && back.by.is_none());
232 assert_eq!(back.answer.decision, Said::Reject);
233 }
234
235 #[test]
236 fn the_task_carries_the_exact_call() {
237 let request = super::super::Request {
238 task: "001-x/T001".to_owned(),
239 task_title: "t".to_owned(),
240 turn: 1,
241 tool: "write_file".to_owned(),
242 arguments: json!({ "path": "src/a.rs", "content": "fn a() {}\n" }),
243 kind: "write".to_owned(),
244 reason: "r".to_owned(),
245 covers: "write `src/a.rs`".to_owned(),
246 risk: "low".to_owned(),
247 claim: None,
248 options: vec!["once".to_owned()],
249 };
250 let spec = approval_task(&request, None, &[]);
251 assert_eq!(spec.kind, APPROVAL);
252 let action = &spec.justification.proposed_action;
253 assert_eq!(action["arguments"]["content"], "fn a() {}\n");
254 assert_eq!(request_of(action), Some(request.clone()));
255 assert_eq!(
257 spec.justification.digest(),
258 approval_task(&request, None, &[]).justification.digest()
259 );
260 }
261}