1use std::collections::BTreeMap;
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18use speccoder_core::actions::Via;
19use speccoder_core::{
20 ApprovalsReport, CommandData, CommandResult, CommandStatus, DecidedCall, PendingCall,
21};
22
23use crate::tools::policy::{Object, Rejection};
24
25pub const FILE: &str = "approvals.json";
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct RunGrant {
35 pub object: Object,
37 #[serde(default)]
39 pub run: Option<String>,
40 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
43 pub once: bool,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct TaskDecisions {
50 #[serde(default)]
52 pub approved: Vec<RunGrant>,
53 #[serde(default)]
55 pub rejected: Vec<Rejection>,
56 #[serde(default)]
58 pub nudges: Vec<String>,
59 #[serde(default)]
61 pub decided: Vec<Decided>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(from = "WhoRecord")]
67pub struct Who {
68 pub name: String,
70 pub via: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub client: Option<String>,
75 pub basis: String,
79}
80
81#[derive(Deserialize)]
84struct WhoRecord {
85 name: String,
86 via: String,
87 #[serde(default)]
88 client: Option<String>,
89 #[serde(default = "asserted")]
90 basis: String,
91}
92
93impl From<WhoRecord> for Who {
94 fn from(r: WhoRecord) -> Self {
95 let (via, client) = match Via::read(&r.via) {
96 Some((v, named)) => (v.as_str().to_owned(), named.or(r.client)),
97 None => (r.via, r.client),
98 };
99 Self {
100 name: r.name,
101 via,
102 client,
103 basis: r.basis,
104 }
105 }
106}
107
108fn asserted() -> String {
109 "asserted".to_owned()
110}
111
112impl Who {
113 #[must_use]
116 pub fn local(via: Via) -> Self {
117 Self::local_in(Path::new("."), via)
118 }
119
120 #[must_use]
122 pub fn local_in(dir: &Path, via: Via) -> Self {
123 Self {
124 name: who_in(dir),
125 via: via.as_str().to_owned(),
126 client: None,
127 basis: "asserted".to_owned(),
128 }
129 }
130
131 #[must_use]
134 pub fn connected(client: &str) -> Self {
135 Self {
136 name: who(),
137 via: Via::Acp.as_str().to_owned(),
138 client: Some(client.to_owned()),
139 basis: "connected".to_owned(),
140 }
141 }
142
143 #[must_use]
145 pub fn describe(&self) -> String {
146 format!("{} ({})", self.name, self.via)
147 }
148
149 #[must_use]
151 pub fn operator(&self) -> Option<agentplane::core::Operator> {
152 agentplane::core::Operator::from_parts(&self.name, &self.basis).ok()
153 }
154
155 #[must_use]
157 pub fn view(&self) -> speccoder_core::OperatorView {
158 speccoder_core::OperatorView {
159 actor: self.name.clone(),
160 basis: self.basis.clone(),
161 }
162 }
163}
164
165#[must_use]
169pub fn who() -> String {
170 who_in(Path::new("."))
171}
172
173#[must_use]
175pub fn who_in(dir: &Path) -> String {
176 crate::git::Git::at(dir)
177 .line(&["config", "user.name"])
178 .ok()
179 .filter(|n| !n.trim().is_empty())
180 .or_else(|| {
181 ["USER", "USERNAME", "LOGNAME"]
182 .iter()
183 .find_map(|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty()))
184 })
185 .unwrap_or_else(|| "unknown".to_owned())
186}
187
188#[must_use]
192pub fn offered_objects(
193 project_state: &Path,
194 feature: &str,
195) -> Vec<(speccoder_core::oracle::OfferedGrant, Object)> {
196 let Ok(decisions) = Decisions::load(project_state) else {
197 return Vec::new();
198 };
199 let prefix = format!("{feature}/");
200 let mut out: Vec<(speccoder_core::oracle::OfferedGrant, Object)> = Vec::new();
201 for (_, task) in decisions
202 .tasks
203 .iter()
204 .filter(|(k, _)| k.starts_with(&prefix))
205 {
206 for d in task.decided.iter().filter(|d| d.verb == "approve") {
207 let Some(o) = &d.object else { continue };
208 let id = o.grant_id();
209 if out.iter().any(|(g, _)| g.id == id) {
210 continue;
211 }
212 let object = match o {
213 Object::Command { argv_sha256, .. } => {
214 format!(
215 "{} · argv #{}",
216 o.describe(),
217 argv_sha256.get(..12).unwrap_or(argv_sha256)
218 )
219 }
220 _ => o.describe(),
221 };
222 out.push((
223 speccoder_core::oracle::OfferedGrant {
224 id,
225 class: o.class().as_str().to_owned(),
226 object,
227 },
228 o.clone(),
229 ));
230 }
231 }
232 out
233}
234
235#[must_use]
237pub fn offered_grants(
238 project_state: &Path,
239 feature: &str,
240) -> Vec<speccoder_core::oracle::OfferedGrant> {
241 offered_objects(project_state, feature)
242 .into_iter()
243 .map(|(g, _)| g)
244 .collect()
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Decided {
251 pub verb: String,
254 #[serde(default)]
256 pub object: Option<Object>,
257 #[serde(default)]
259 pub run: Option<String>,
260 #[serde(default)]
262 pub reason: Option<String>,
263 #[serde(default = "task_scope")]
266 pub scope: String,
267 #[serde(default)]
269 pub note: Option<String>,
270 pub by: Who,
272 pub at: u64,
274}
275
276fn task_scope() -> String {
277 "task".to_owned()
278}
279
280fn now() -> u64 {
281 std::time::SystemTime::now()
282 .duration_since(std::time::UNIX_EPOCH)
283 .map_or(0, |d| d.as_secs())
284}
285
286#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
288pub struct Decisions {
289 #[serde(default)]
291 pub tasks: BTreeMap<String, TaskDecisions>,
292}
293
294impl Decisions {
295 pub fn load(dir: &Path) -> Result<Self, String> {
299 match std::fs::read_to_string(dir.join(FILE)) {
300 Ok(text) => serde_json::from_str(&text).map_err(|e| {
301 format!(
302 "{}: {e} — written before decisions named objects; remove it to start afresh (its decisions are not carried over)",
303 dir.join(FILE).display()
304 )
305 }),
306 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
307 Err(e) => Err(format!("{}: {e}", dir.join(FILE).display())),
308 }
309 }
310
311 pub fn save(&self, dir: &Path) -> Result<(), String> {
313 std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
314 let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
315 let tmp = dir.join(format!("{FILE}.tmp"));
316 std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
317 std::fs::rename(&tmp, dir.join(FILE)).map_err(|e| format!("{}: {e}", dir.display()))
318 }
319
320 #[must_use]
322 pub fn for_task(&self, feature: &str, task: &str) -> TaskDecisions {
323 self.tasks
324 .get(&format!("{feature}/{task}"))
325 .cloned()
326 .unwrap_or_default()
327 }
328
329 #[must_use]
332 pub fn for_run(&self, feature: &str, task: &str, run: &str) -> Vec<Object> {
333 self.for_task(feature, task)
334 .approved
335 .into_iter()
336 .filter(|g| !g.once && g.run.as_deref() == Some(run))
337 .map(|g| g.object)
338 .collect()
339 }
340
341 pub fn take_once(&mut self, feature: &str, task: &str, run: &str) -> Vec<Object> {
345 let Some(d) = self.tasks.get_mut(&format!("{feature}/{task}")) else {
346 return Vec::new();
347 };
348 let (once, kept): (Vec<RunGrant>, Vec<RunGrant>) = std::mem::take(&mut d.approved)
349 .into_iter()
350 .partition(|g| g.once && g.run.as_deref() == Some(run));
351 d.approved = kept;
352 once.into_iter().map(|g| g.object).collect()
353 }
354
355 pub fn approve_once(&mut self, feature: &str, task: &str, object: Object, by: &Who) {
359 let d = self.tasks.entry(format!("{feature}/{task}")).or_default();
360 d.rejected.retain(|r| r.object != object);
361 let grant = RunGrant {
362 object: object.clone(),
363 run: None,
364 once: true,
365 };
366 if !d.approved.contains(&grant) {
367 d.approved.push(grant);
368 }
369 d.decided.push(Decided {
370 verb: "approve".to_owned(),
371 object: Some(object),
372 run: None,
373 reason: None,
374 scope: "once".to_owned(),
375 note: None,
376 by: by.clone(),
377 at: now(),
378 });
379 }
380
381 pub fn claim(&mut self, feature: &str, task: &str, run: &str) -> bool {
384 let Some(d) = self.tasks.get_mut(&format!("{feature}/{task}")) else {
385 return false;
386 };
387 let mut claimed = false;
388 for g in d.approved.iter_mut().filter(|g| g.run.is_none()) {
389 g.run = Some(run.to_owned());
390 claimed = true;
391 }
392 claimed
393 }
394
395 pub fn approve(
398 &mut self,
399 feature: &str,
400 task: &str,
401 object: Object,
402 run: Option<&str>,
403 by: &Who,
404 ) {
405 let d = self.tasks.entry(format!("{feature}/{task}")).or_default();
406 d.rejected.retain(|r| r.object != object);
407 let grant = RunGrant {
408 object: object.clone(),
409 run: run.map(str::to_owned),
410 once: false,
411 };
412 if !d.approved.contains(&grant) {
413 d.approved.push(grant);
414 }
415 d.decided.push(Decided {
416 verb: "approve".to_owned(),
417 object: Some(object),
418 run: run.map(str::to_owned),
419 reason: None,
420 scope: task_scope(),
421 note: None,
422 by: by.clone(),
423 at: now(),
424 });
425 }
426
427 pub fn note(&mut self, feature: &str, task: &str, decided: Decided) {
430 self.tasks
431 .entry(format!("{feature}/{task}"))
432 .or_default()
433 .decided
434 .push(decided);
435 }
436
437 pub fn reject(&mut self, feature: &str, task: &str, object: Object, reason: &str, by: &Who) {
439 let d = self.tasks.entry(format!("{feature}/{task}")).or_default();
440 d.approved.retain(|g| g.object != object);
441 d.rejected.retain(|r| r.object != object);
442 d.rejected.push(Rejection {
443 object: object.clone(),
444 reason: reason.to_owned(),
445 });
446 d.decided.push(Decided {
447 verb: "reject".to_owned(),
448 object: Some(object),
449 run: None,
450 reason: Some(reason.to_owned()),
451 scope: task_scope(),
452 note: None,
453 by: by.clone(),
454 at: now(),
455 });
456 }
457
458 #[must_use]
461 pub fn decided(&self, feature: &str, task: &str, object: &Object) -> bool {
462 let d = self.for_task(feature, task);
463 d.approved
464 .iter()
465 .any(|g| g.object == *object && g.run.is_none())
466 || d.rejected.iter().any(|r| r.object == *object)
467 }
468}
469
470pub mod coverage;
473pub mod derived;
474pub mod present;
475pub mod tasks;
476
477static ANSWERED_ALWAYS: std::sync::Mutex<std::collections::BTreeSet<String>> =
481 std::sync::Mutex::new(std::collections::BTreeSet::new());
482
483pub fn answered_always(call: &str) {
486 if let Ok(mut set) = ANSWERED_ALWAYS.lock() {
487 set.insert(call.to_owned());
488 }
489}
490
491pub(crate) fn take_answered_always(call: &str) -> bool {
494 ANSWERED_ALWAYS
495 .lock()
496 .map(|mut set| set.remove(call))
497 .unwrap_or(false)
498}
499
500pub use present::{PresentContext, Presentation, present};
501
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(deny_unknown_fields)]
507pub struct Source {
508 pub argument: String,
510 pub from: String,
514 pub turn: u32,
516}
517
518#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
522pub struct Request {
523 pub task: String,
525 #[serde(default)]
527 pub task_title: String,
528 #[serde(default)]
530 pub turn: u32,
531 pub tool: String,
533 pub arguments: serde_json::Value,
535 #[serde(default)]
537 pub kind: String,
538 pub reason: String,
540 #[serde(default)]
543 pub object: Option<Object>,
544 #[serde(default)]
547 pub changed: Option<String>,
548 pub risk: String,
551 #[serde(default)]
554 pub claim: Option<String>,
555 #[serde(default)]
558 pub sources: Vec<Source>,
559 #[serde(default)]
561 pub more: u32,
562 #[serde(default)]
565 pub options: Vec<String>,
566 #[serde(default)]
568 pub run: Option<String>,
569 #[serde(default)]
571 pub asked: u32,
572 #[serde(default)]
574 pub budget: u32,
575 #[serde(default)]
577 pub batched: bool,
578}
579
580impl Request {
581 #[must_use]
583 pub fn covers(&self) -> String {
584 self.object
585 .as_ref()
586 .map_or_else(|| format!("call `{}`", self.tool), Object::describe)
587 }
588}
589
590#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
592#[serde(rename_all = "snake_case")]
593pub enum Scope {
594 Once,
596 Task,
598 Run,
600}
601
602impl Scope {
603 #[must_use]
605 pub const fn name(self) -> &'static str {
606 match self {
607 Self::Once => "once",
608 Self::Task => "task",
609 Self::Run => "run",
610 }
611 }
612}
613
614#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
616#[serde(rename_all = "snake_case")]
617pub enum Decision {
618 Approve,
620 Reject,
622 Stop,
624}
625
626#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
628pub struct Answer {
629 pub decision: Decision,
631 pub scope: Scope,
633 #[serde(default)]
635 pub note: Option<String>,
636 #[serde(default)]
638 pub reason: Option<String>,
639 #[serde(default)]
641 pub by: Option<Who>,
642 #[serde(default)]
645 pub always: bool,
646 #[serde(default)]
648 pub resume_asking: bool,
649}
650
651impl Answer {
652 #[must_use]
655 pub fn always(by: Option<Who>) -> Self {
656 Self {
657 always: true,
658 ..Self::approve(Scope::Once, None, by)
659 }
660 }
661
662 #[must_use]
664 pub fn approve(scope: Scope, note: Option<String>, by: Option<Who>) -> Self {
665 Self {
666 decision: Decision::Approve,
667 scope,
668 note: note.filter(|n| !n.trim().is_empty()),
669 reason: None,
670 by,
671 always: false,
672 resume_asking: false,
673 }
674 }
675
676 #[must_use]
678 pub fn reject(reason: impl Into<String>, by: Option<Who>) -> Self {
679 Self {
680 decision: Decision::Reject,
681 scope: Scope::Once,
682 note: None,
683 reason: Some(reason.into()),
684 by,
685 always: false,
686 resume_asking: false,
687 }
688 }
689
690 #[must_use]
692 pub fn stop(by: Option<Who>) -> Self {
693 Self {
694 decision: Decision::Stop,
695 scope: Scope::Once,
696 note: None,
697 reason: None,
698 by,
699 always: false,
700 resume_asking: false,
701 }
702 }
703}
704
705pub trait Approver: Send + Sync + std::fmt::Debug {
709 fn ask(&self, p: &Presentation) -> Answer;
711
712 fn answer(&self, question: &str) -> Option<String> {
715 let _ = question;
716 None
717 }
718
719 fn via(&self) -> Via {
721 Via::Terminal
722 }
723
724 fn client(&self) -> Option<String> {
726 None
727 }
728
729 fn confirm(&self, request: &ConfirmRequest) -> Option<ConfirmAnswer> {
733 let _ = request;
734 None
735 }
736
737 fn at_stop(&self, stop: &StopRequest) -> Option<StopAnswer> {
741 let _ = stop;
742 None
743 }
744}
745
746#[derive(Debug, Clone, PartialEq, Eq)]
748pub struct StopRequest {
749 pub task: String,
751 pub title: String,
753 pub claim: Option<String>,
755 pub dependents: Vec<String>,
757}
758
759#[derive(Debug, Clone, PartialEq, Eq)]
762pub enum StopAnswer {
763 Done,
765 Hint(String),
767 Skip(String),
769}
770
771#[derive(Debug, Clone, PartialEq, Eq)]
773pub struct ShownReproduction {
774 pub statement: String,
776 pub words: String,
778 pub file: String,
780 pub test: String,
782 pub argv: Vec<String>,
785 pub excerpt: String,
787 pub content: String,
789}
790
791#[derive(Debug, Clone, PartialEq, Eq)]
793pub struct ConfirmRequest {
794 pub bug: String,
796 pub title: String,
798 pub digest: String,
800 pub reproductions: Vec<ShownReproduction>,
802 pub warning: Option<String>,
805}
806
807#[derive(Debug, Clone, PartialEq, Eq)]
809pub enum ConfirmAnswer {
810 Confirm,
812 Reject(String),
814}
815
816#[must_use]
821pub fn kind_of(tool: &str, arguments: &serde_json::Value) -> &'static str {
822 match tool {
823 "write_file" => "write",
824 "edit_file" => "edit",
825 "run_command" if arguments["network"] == true => "network",
826 "run_command" => "run",
827 t if t.starts_with(crate::mcp::PREFIX) => "mcp",
828 crate::acp::govern::AGENT_CALL => "agent",
829 _ => "edit",
830 }
831}
832
833#[must_use]
841pub fn options(
842 risk: &str,
843 has_object: bool,
844 derived: bool,
845 kind: &str,
846 always_risk: &str,
847) -> Vec<String> {
848 let mut o = vec!["once"];
849 if has_object && !derived {
850 o.push("task");
851 if risk != "high" {
852 o.push("run");
853 }
854 if matches!(kind, "run" | "network") && always_risk != "high" {
855 o.push("always");
856 }
857 }
858 o.extend(["reject", "stop"]);
859 o.into_iter().map(str::to_owned).collect()
860}
861
862#[must_use]
865pub fn claim(text: &str) -> Option<String> {
866 let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
867 let line = present::escape(&line);
868 if line.is_empty() {
869 return None;
870 }
871 Some(if line.chars().count() > 200 {
872 format!("{}…", line.chars().take(199).collect::<String>())
873 } else {
874 line
875 })
876}
877
878#[must_use]
880pub fn risk(tool: &str, reason: &str) -> &'static str {
881 let r = reason.to_lowercase();
882 if (tool == "run_command" && (r.contains("arbitrary") || r.contains("network")))
883 || tool == crate::acp::govern::AGENT_CALL
884 {
885 "high"
886 } else if tool.starts_with(crate::mcp::PREFIX)
887 || r.contains("manifest")
888 || r.contains("frozen")
889 || r.contains("repository state")
890 || r.contains("recursive")
891 {
892 "medium"
893 } else {
894 "low"
895 }
896}
897
898fn locate(
902 command: &str,
903 path: &Path,
904 feature: Option<&str>,
905 state: &Path,
906) -> Result<(String, std::path::PathBuf, std::path::PathBuf), Box<CommandResult>> {
907 let inspected = crate::inspect(path, feature);
908 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
909 return Err(Box::new(CommandResult::carried(command, &inspected)));
910 };
911 let Some(f) = snapshot.feature.as_ref() else {
912 return Err(Box::new(CommandResult::new(
913 command,
914 CommandStatus::SelectionNeeded,
915 inspected.message(),
916 )));
917 };
918 let root = std::path::PathBuf::from(&snapshot.root);
919 Ok((
920 f.feature.name.clone(),
921 crate::worktree::project_state_dir(state, &root),
922 root,
923 ))
924}
925
926#[must_use]
928pub fn object_view(object: &Object) -> speccoder_core::ObjectView {
929 speccoder_core::ObjectView {
930 class: object.class().as_str().to_owned(),
931 short: object.short(),
932 describe: object.describe(),
933 lines: present::object_lines(Some(object), None),
934 }
935}
936
937fn saved_request(feature: &str, task: &str, pending: &serde_json::Value) -> Option<Request> {
940 let mut r: Request = serde_json::from_value(pending["request"].clone()).ok()?;
941 if r.task.is_empty() {
942 r.task = format!("{feature}/{task}");
943 }
944 Some(r)
945}
946
947#[must_use]
950pub fn call_digest(id: &str, object: Option<&Object>) -> String {
951 use sha2::{Digest as _, Sha256};
952 let object = serde_json::to_string(&object).unwrap_or_default();
953 Sha256::digest(format!("{id}\n{object}").as_bytes())
954 .iter()
955 .map(|b| format!("{b:02x}"))
956 .collect()
957}
958
959fn with_digest(mut p: PendingCall, object: Option<&Object>) -> PendingCall {
961 p.digest = call_digest(&p.id(), object);
962 p
963}
964
965fn pending(
967 root: &Path,
968 feature: &str,
969 open: &[String],
970 decisions: &Decisions,
971) -> Vec<(PendingCall, Object)> {
972 let mut seen = std::collections::BTreeSet::new();
973 let mut out = Vec::new();
974 for saved in crate::attempts::list(root, feature) {
975 if !open.contains(&saved.task) || !seen.insert(saved.task.clone()) {
976 continue; }
978 let Some(p) = saved.pending.filter(|_| saved.outcome == "needs_approval") else {
979 continue;
980 };
981 let Some(request) = saved_request(feature, &saved.task, &p) else {
982 continue;
983 };
984 let Some(object) = request.object.clone() else {
985 continue;
986 };
987 if decisions.decided(feature, &saved.task, &object) {
988 continue;
989 }
990 let presentation = view(root, &saved.task, &saved.reference, &request);
991 out.push((
992 with_digest(
993 PendingCall {
994 task: saved.task.clone(),
995 covers: object.describe(),
996 tool: request.tool.clone(),
997 arguments: request.arguments.clone(),
998 reason: request.reason.clone(),
999 reference: saved.reference.clone(),
1000 presentation: Some(presentation),
1001 source: "attempt".to_owned(),
1002 kind: "approval".to_owned(),
1003 object: Some(object_view(&object)),
1004 sources: present::source_lines(&request),
1005 run: request.run.clone(),
1006 batched: request.batched,
1007 ..PendingCall::default()
1008 },
1009 Some(&object),
1010 ),
1011 object,
1012 ));
1013 }
1014 out
1015}
1016
1017fn view(
1020 root: &Path,
1021 task: &str,
1022 reference: &str,
1023 request: &Request,
1024) -> speccoder_core::ApprovalView {
1025 let path = request.arguments["path"].as_str().unwrap_or_default();
1026 let file_text = (!path.is_empty())
1027 .then(|| {
1028 crate::git::Git::at(root)
1029 .run(&["show", &format!("{reference}:./{path}")])
1030 .ok()
1031 .map(|b| String::from_utf8_lossy(&b).into_owned())
1032 })
1033 .flatten();
1034 presented(root, request, task, file_text)
1035}
1036
1037fn presented(
1040 root: &Path,
1041 request: &Request,
1042 task: &str,
1043 file_text: Option<String>,
1044) -> speccoder_core::ApprovalView {
1045 let config = crate::verify::find_config(root)
1046 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
1047 .unwrap_or_default();
1048 let sandbox = crate::sandbox::parse(&config).ok();
1049 let p = present(
1050 request,
1051 &PresentContext {
1052 root: None,
1053 sandbox: sandbox
1054 .as_ref()
1055 .filter(|c| c.mode != crate::sandbox::Mode::Off)
1056 .map(|_| {
1057 if cfg!(target_os = "macos") {
1058 "Seatbelt".to_owned()
1059 } else {
1060 "Landlock+seccomp".to_owned()
1061 }
1062 }),
1063 network: match sandbox.map(|c| c.network) {
1064 Some(crate::sandbox::Network::Loopback) => "loopback",
1065 Some(crate::sandbox::Network::On) => "unrestricted",
1066 _ => "none",
1067 }
1068 .to_owned(),
1069 file_text,
1070 },
1071 );
1072 speccoder_core::ApprovalView {
1073 kind: request.kind.clone(),
1074 title: p.title,
1075 subject: p.subject,
1076 body_lines: p.body,
1077 sandbox: p.sandbox,
1078 claim: request.claim.clone(),
1079 risk: request.risk.clone(),
1080 scope: format!("for the rest of {task}: {}", p.scope),
1081 object: p.object,
1082 sources: p.sources,
1083 options: request.options.clone(),
1084 }
1085}
1086
1087fn decided(feature: &str, decisions: &Decisions) -> Vec<DecidedCall> {
1088 let prefix = format!("{feature}/");
1089 let mut out = Vec::new();
1090 for (key, d) in &decisions.tasks {
1091 let Some(task) = key.strip_prefix(&prefix) else {
1092 continue;
1093 };
1094 let last = |o: &Object| {
1095 d.decided
1096 .iter()
1097 .rev()
1098 .find(|x| x.object.as_ref() == Some(o))
1099 };
1100 out.extend(d.approved.iter().map(|g| DecidedCall {
1101 task: task.to_owned(),
1102 decision: "approved".to_owned(),
1103 covers: g.object.describe(),
1104 reason: None,
1105 by: last(&g.object).map(|x| x.by.describe()),
1106 note: last(&g.object).and_then(|x| x.note.clone()),
1107 operator: last(&g.object).map(|x| x.by.view()),
1108 via: last(&g.object).map(|x| x.by.via.clone()),
1109 client: last(&g.object).and_then(|x| x.by.client.clone()),
1110 object: Some(object_view(&g.object)),
1111 run: g.run.clone(),
1112 scope: Some(if g.once { "once" } else { "task" }.to_owned()),
1113 ..DecidedCall::default()
1114 }));
1115 out.extend(d.rejected.iter().map(|r| DecidedCall {
1116 task: task.to_owned(),
1117 decision: "rejected".to_owned(),
1118 covers: r.object.describe(),
1119 reason: Some(r.reason.clone()),
1120 by: last(&r.object).map(|x| x.by.describe()),
1121 scope: Some("task".to_owned()),
1122 note: None,
1123 operator: last(&r.object).map(|x| x.by.view()),
1124 via: last(&r.object).map(|x| x.by.via.clone()),
1125 client: last(&r.object).and_then(|x| x.by.client.clone()),
1126 object: Some(object_view(&r.object)),
1127 ..DecidedCall::default()
1128 }));
1129 out.extend(
1131 d.decided
1132 .iter()
1133 .filter(|x| x.verb == "approve" && x.scope == "run")
1134 .filter_map(|x| {
1135 let object = x.object.as_ref()?;
1136 Some(DecidedCall {
1137 task: task.to_owned(),
1138 decision: "approved".to_owned(),
1139 covers: object.describe(),
1140 reason: None,
1141 by: Some(x.by.describe()),
1142 scope: Some("run".to_owned()),
1143 note: x.note.clone(),
1144 operator: Some(x.by.view()),
1145 via: Some(x.by.via.clone()),
1146 client: x.by.client.clone(),
1147 object: Some(object_view(object)),
1148 run: x.run.clone(),
1149 ..DecidedCall::default()
1150 })
1151 }),
1152 );
1153 }
1154 out
1155}
1156
1157pub(crate) fn still_waits(
1160 due: Option<agentplane::core::Timestamp>,
1161 now: agentplane::core::Timestamp,
1162) -> bool {
1163 due.is_none_or(|due| due > now)
1164}
1165
1166type Waiting = (PendingCall, agentplane::core::Task);
1168
1169fn waiting_tasks(root: &Path, dir: &Path, feature: &str) -> Result<Vec<Waiting>, String> {
1179 let rt = crate::tokio_runtime()?;
1180 let Some(plane) = crate::substrate::Plane::open_for_reading(dir).map_err(|e| e.0)? else {
1181 return Ok(Vec::new());
1182 };
1183 waiting_on(&rt, &plane, root, feature)
1184}
1185
1186fn waiting_on(
1188 rt: &tokio::runtime::Runtime,
1189 plane: &crate::substrate::Plane,
1190 root: &Path,
1191 feature: &str,
1192) -> Result<Vec<Waiting>, String> {
1193 let open = rt
1194 .block_on(plane.open_tasks())
1195 .map_err(|e| format!("listing the waiting calls: {}", e.0))?;
1196 let now = agentplane::core::Timestamp::now_utc();
1197 let prefix = format!("{feature}/");
1198 Ok(open
1199 .into_iter()
1200 .filter(|t| t.kind == tasks::APPROVAL)
1201 .filter(|t| still_waits(t.due_at, now))
1202 .filter_map(|t| {
1203 let request = tasks::request_of(&t.justification.proposed_action)?;
1204 let task = request.task.strip_prefix(&prefix)?.to_owned();
1205 let view = presented(root, &request, &task, None);
1206 Some((
1207 with_digest(
1208 PendingCall {
1209 task,
1210 covers: request.covers(),
1211 object: request.object.as_ref().map(object_view),
1212 sources: present::source_lines(&request),
1213 run: request.run.clone(),
1214 batched: request.batched,
1215 tool: request.tool.clone(),
1216 arguments: request.arguments.clone(),
1217 reason: request.reason.clone(),
1218 reference: String::new(),
1219 presentation: Some(view),
1220 call: t.id.to_hex(),
1221 source: "task".to_owned(),
1222 kind: tasks::APPROVAL.to_owned(),
1223 question: None,
1224 waiting_since: Some(t.created_at.unix_timestamp().max(0).unsigned_abs()),
1225 deadline: t.due_at.map(|d| d.unix_timestamp().max(0).unsigned_abs()),
1226 digest: String::new(),
1227 },
1228 request.object.as_ref(),
1229 ),
1230 t,
1231 ))
1232 })
1233 .collect())
1234}
1235
1236struct Deciding {
1244 rt: tokio::runtime::Runtime,
1245 plane: crate::substrate::Plane,
1246}
1247
1248impl Deciding {
1249 fn open(dir: &Path) -> Result<Option<Self>, String> {
1254 if !dir.join(crate::substrate::JOURNAL).exists() {
1255 return Ok(None);
1256 }
1257 let rt = crate::tokio_runtime()?;
1258 let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1259 rt.block_on(plane.expire_due(agentplane::core::Timestamp::now_utc()))
1260 .map_err(|e| format!("denying the calls past their deadline: {}", e.0))?;
1261 Ok(Some(Self { rt, plane }))
1262 }
1263
1264 fn waiting(&self, root: &Path, feature: &str) -> Result<Vec<Waiting>, String> {
1266 waiting_on(&self.rt, &self.plane, root, feature)
1267 }
1268
1269 fn decide(
1273 &self,
1274 task: &agentplane::core::Task,
1275 answer: &Answer,
1276 by: &Who,
1277 ) -> Result<(), String> {
1278 if !still_waits(task.due_at, agentplane::core::Timestamp::now_utc()) {
1279 return Err(
1280 "its deadline passed before it was decided: it is denied as expired".to_owned(),
1281 );
1282 }
1283 let decision = tasks::decision_for(answer, by, None)
1284 .ok_or_else(|| format!("`{}` is not a name a decision can carry", by.name))?;
1285 self.rt
1286 .block_on(
1287 self.plane
1288 .decide_at(task.id, &decision, Some(task.justification.digest())),
1289 )
1290 .map(|_| ())
1291 .map_err(|e| e.0)
1292 }
1293}
1294
1295pub fn expire_due(
1304 dir: &Path,
1305 now: agentplane::core::Timestamp,
1306) -> Result<Option<std::path::PathBuf>, String> {
1307 let rt = crate::tokio_runtime()?;
1308 let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1309 rt.block_on(plane.expire_due(now)).map_err(|e| e.0)?;
1310 Ok(plane.replaced_journal.clone())
1311}
1312
1313#[derive(Debug, Default)]
1317pub struct Elsewhere {
1318 pub waiting: Option<(String, String)>,
1320 pub replaced_journal: Option<std::path::PathBuf>,
1322}
1323
1324pub fn waiting_elsewhere(
1333 root: &Path,
1334 dir: &Path,
1335 feature: &str,
1336 task: &str,
1337) -> Result<Elsewhere, String> {
1338 let Some(deciding) = Deciding::open(dir)? else {
1339 return Ok(Elsewhere::default());
1340 };
1341 let format = crate::format::at(root);
1343 let ticked: Vec<String> = format
1344 .inspect(root, Some(feature))
1345 .and_then(|s| s.feature)
1346 .and_then(|f| f.tasks)
1347 .map(|ts| {
1348 ts.tasks
1349 .into_iter()
1350 .filter(|t| t.done)
1351 .map(|t| t.id)
1352 .collect()
1353 })
1354 .unwrap_or_default();
1355 let mut waiting = None;
1356 for (c, key) in deciding.waiting(root, feature)? {
1357 if ticked.contains(&c.task) {
1358 let by = agentplane::core::Operator::from_parts(who_in(root), "asserted")
1359 .map_err(|_| "no name to record the withdrawal under".to_owned())?;
1360 deciding
1361 .rt
1362 .block_on(deciding.plane.withdraw(&key, &by, "its task is ticked"))
1363 .map_err(|e| e.0)?;
1364 } else if waiting.is_none() && c.task != task && c.source == "task" {
1365 waiting = Some((c.task, c.covers));
1366 }
1367 }
1368 Ok(Elsewhere {
1369 waiting,
1370 replaced_journal: deciding.plane.replaced_journal.clone(),
1371 })
1372}
1373
1374pub fn withdraw_waiting(
1383 root: &Path,
1384 dir: &Path,
1385 feature: &str,
1386 reason: &str,
1387) -> Result<usize, String> {
1388 let Some(deciding) = Deciding::open(dir)? else {
1389 return Ok(0);
1390 };
1391 let rows = deciding.waiting(root, feature)?;
1392 if rows.is_empty() {
1393 return Ok(0);
1394 }
1395 let by = agentplane::core::Operator::from_parts(who_in(root), "asserted")
1396 .ok()
1397 .ok_or_else(|| "no name to record the withdrawal under".to_owned())?;
1398 for (_, task) in &rows {
1399 deciding
1400 .rt
1401 .block_on(deciding.plane.withdraw(task, &by, reason))
1402 .map_err(|e| e.0)?;
1403 }
1404 Ok(rows.len())
1405}
1406
1407fn partly_decided(recorded: &[String], failed: &str, of: usize) -> String {
1410 if recorded.is_empty() {
1411 return failed.to_owned();
1412 }
1413 format!(
1414 "{}\n{failed}\n{} of {of} call(s) were decided before it and stay decided; `speccoder report --approvals` lists what still waits",
1415 recorded.join("\n"),
1416 recorded.len()
1417 )
1418}
1419
1420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1422pub enum Verb<'a> {
1423 List,
1425 Approve(&'a str),
1427 ApproveNoted(&'a str, &'a str),
1429 Reject(&'a str, &'a str),
1431 Decide(Choice<'a>),
1433}
1434
1435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1437pub struct Choice<'a> {
1438 pub task: Option<&'a str>,
1440 pub call: Option<&'a str>,
1442 pub all: bool,
1444 pub approve: bool,
1446 pub scope: Scope,
1448 pub note: Option<&'a str>,
1450 pub reason: Option<&'a str>,
1452 pub digest: Option<&'a str>,
1457}
1458
1459impl<'a> Choice<'a> {
1460 #[must_use]
1462 pub fn approve(task: &'a str, note: Option<&'a str>) -> Self {
1463 Self {
1464 task: Some(task),
1465 call: None,
1466 all: false,
1467 approve: true,
1468 scope: Scope::Task,
1469 note,
1470 reason: None,
1471 digest: None,
1472 }
1473 }
1474
1475 #[must_use]
1477 pub fn reject(task: &'a str, reason: &'a str) -> Self {
1478 Self {
1479 task: Some(task),
1480 call: None,
1481 all: false,
1482 approve: false,
1483 scope: Scope::Task,
1484 note: None,
1485 reason: Some(reason),
1486 digest: None,
1487 }
1488 }
1489
1490 #[must_use]
1493 pub fn shown(self, call: &'a str, digest: &'a str) -> Self {
1494 Self {
1495 call: (!call.starts_with("attempt:")).then_some(call),
1496 digest: Some(digest),
1497 ..self
1498 }
1499 }
1500}
1501
1502pub fn list_with(path: &Path, feature: Option<&str>, state: &Path) -> CommandResult {
1504 decide_with(path, feature, Verb::List, state, Via::Terminal)
1506}
1507
1508#[must_use]
1510pub fn list(path: &Path, feature: Option<&str>) -> CommandResult {
1511 decide(path, feature, Verb::List, Via::Terminal)
1512}
1513
1514pub fn decide_with(
1517 path: &Path,
1518 feature: Option<&str>,
1519 verb: Verb<'_>,
1520 state: &Path,
1521 via: Via,
1522) -> CommandResult {
1523 let command = match verb {
1524 Verb::List => "approvals",
1525 Verb::Approve(_) | Verb::ApproveNoted(..) => "approve",
1526 Verb::Reject(..) => "reject",
1527 Verb::Decide(c) if c.approve => "approve",
1528 Verb::Decide(_) => "reject",
1529 };
1530 let (feature, dir, root) = match locate(command, path, feature, state) {
1531 Ok(x) => x,
1532 Err(r) => return *r,
1533 };
1534 let (mut decisions, decisions_unreadable) = match Decisions::load(&dir) {
1537 Ok(d) => (d, None),
1538 Err(e) if verb == Verb::List => (Decisions::default(), Some(e)),
1539 Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1540 };
1541 let lane_pending = |decisions: &Decisions, open: &[String]| {
1544 if decisions_unreadable.is_some() {
1545 Vec::new()
1546 } else {
1547 pending(&root, &feature, open, decisions)
1548 }
1549 };
1550 let status = crate::status::report(&root, Some(&feature));
1552 let open: Vec<String> = match status.data() {
1553 Some(CommandData::Report(r)) => r
1554 .tasks
1555 .iter()
1556 .filter(|t| matches!(t.state.as_str(), "ready" | "blocked"))
1557 .map(|t| t.id.clone())
1558 .collect(),
1559 _ => Vec::new(),
1560 };
1561 let choice = match verb {
1564 Verb::List => None,
1565 Verb::Approve(t) => Some(Choice::approve(t, None)),
1566 Verb::ApproveNoted(t, n) => Some(Choice::approve(t, Some(n))),
1567 Verb::Reject(t, r) => Some(Choice::reject(t, r)),
1568 Verb::Decide(c) => Some(c),
1569 };
1570 let mut deciding = None;
1574 let listed = if choice.is_some() {
1575 Deciding::open(&dir).and_then(|d| {
1576 let rows = d
1577 .as_ref()
1578 .map_or_else(|| Ok(Vec::new()), |d| d.waiting(&root, &feature));
1579 deciding = d;
1580 rows
1581 })
1582 } else {
1583 waiting_tasks(&root, &dir, &feature)
1584 };
1585 let (tasks_waiting, unreadable) = match listed {
1586 Ok(rows) => (rows, None),
1587 Err(e) => (Vec::new(), Some(e)),
1588 };
1589 let unreadable = match (unreadable, &decisions_unreadable) {
1590 (Some(a), Some(b)) => Some(format!("{a}; {b}")),
1591 (a, b) => a.or_else(|| b.clone()),
1592 };
1593 let waiting = lane_pending(&decisions, &open);
1594
1595 if let (Some(_), Some(why)) = (choice, &unreadable) {
1596 return CommandResult::new(
1597 command,
1598 CommandStatus::Error,
1599 &format!("the calls waiting for a decision could not be read: {why}"),
1600 );
1601 }
1602 if let Some(c) = choice {
1603 let matching: Vec<&(PendingCall, agentplane::core::Task)> = tasks_waiting
1604 .iter()
1605 .filter(|(p, _)| c.task.is_none_or(|t| p.task == t))
1606 .filter(|(p, _)| {
1607 c.call
1608 .is_none_or(|id| !id.is_empty() && p.call.starts_with(id))
1609 })
1610 .collect();
1611 if let Some(shown) = c.digest
1612 && matching.iter().any(|(p, _)| p.digest != shown)
1613 {
1614 return CommandResult::new(
1615 command,
1616 CommandStatus::UsageError,
1617 "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1618 )
1619 .with_code("CALL-CHANGED");
1620 }
1621 if matching.len() > 1 && !c.all {
1622 let list: Vec<String> = matching
1623 .iter()
1624 .map(|(p, _)| {
1625 format!(
1626 "{} ({}, {})",
1627 &p.call[..12.min(p.call.len())],
1628 p.task,
1629 p.covers
1630 )
1631 })
1632 .collect();
1633 return CommandResult::new(
1634 command,
1635 CommandStatus::UsageError,
1636 &format!(
1637 "{} calls are waiting; name one with --call, or decide them all with --all: {}",
1638 matching.len(),
1639 list.join(", ")
1640 ),
1641 );
1642 }
1643 if let (false, Some(deciding)) = (matching.is_empty(), &deciding) {
1644 let by = Who::local_in(&root, via);
1645 let mut lines = Vec::new();
1646 for (call, row) in &matching {
1647 let answer = if c.approve {
1648 Answer::approve(c.scope, c.note.map(str::to_owned), Some(by.clone()))
1649 } else {
1650 Answer::reject(c.reason.unwrap_or("no reason given"), Some(by.clone()))
1651 };
1652 if let Err(e) = deciding.decide(row, &answer, &by) {
1653 let failed = format!("not decided: {} for {}: {e}", call.covers, call.task);
1654 return CommandResult::new(
1655 command,
1656 CommandStatus::Error,
1657 &partly_decided(&lines, &failed, matching.len()),
1658 );
1659 }
1660 lines.push(if c.approve {
1661 format!(
1662 "approved: {} for {} ({}) — by {} ({})",
1663 call.covers,
1664 call.task,
1665 c.scope.name(),
1666 by.name,
1667 by.basis
1668 )
1669 } else {
1670 format!(
1671 "rejected: {} for {} — by {} ({}); the model will be told: {}",
1672 call.covers,
1673 call.task,
1674 by.name,
1675 by.basis,
1676 c.reason.unwrap_or("no reason given")
1677 )
1678 });
1679 }
1680 let (after, unreadable) = match deciding.waiting(&root, &feature) {
1682 Ok(rows) => (rows, None),
1683 Err(e) => (Vec::new(), Some(e)),
1684 };
1685 let lanes = lane_pending(&decisions, &open);
1686 let report = ApprovalsReport {
1687 decided: decided(&feature, &decisions),
1688 pending: after
1689 .into_iter()
1690 .map(|(c, _)| c)
1691 .chain(lanes.into_iter().map(|(c, _)| c))
1692 .collect(),
1693 feature,
1694 unreadable,
1695 batch: Vec::new(),
1696 };
1697 return CommandResult::new(
1698 command,
1699 CommandStatus::Ok,
1700 &format!(
1701 "{}; `speccoder run` continues each attempt where it stopped",
1702 lines.join("\n")
1703 ),
1704 )
1705 .with_data(CommandData::Approvals(Box::new(report)));
1706 }
1707 if c.all || c.call.is_some() || c.task.is_none() {
1708 return CommandResult::new(
1709 command,
1710 CommandStatus::NotFound,
1711 "no call matching that awaits a decision (see `speccoder report --approvals`)",
1712 );
1713 }
1714 }
1715 let shown = match verb {
1719 Verb::Decide(c) => c.digest,
1720 _ => None,
1721 };
1722 let verb = match verb {
1723 Verb::Decide(c) => match (c.task, c.approve, c.note) {
1724 (Some(t), true, Some(n)) => Verb::ApproveNoted(t, n),
1725 (Some(t), true, None) => Verb::Approve(t),
1726 (Some(t), false, _) => Verb::Reject(t, c.reason.unwrap_or("no reason given")),
1727 (None, ..) => Verb::List,
1728 },
1729 v => v,
1730 };
1731
1732 let message = match verb {
1733 Verb::List | Verb::Decide(_) => {
1735 let n = waiting.len() + tasks_waiting.len();
1736 match (&unreadable, n) {
1737 (Some(why), 0) => format!("the calls waiting for a decision are unknown: {why}"),
1738 (Some(why), n) => {
1739 format!("{n} call(s) await a decision; others may wait unseen: {why}")
1740 }
1741 (None, 0) => "nothing awaits a decision".to_owned(),
1742 (None, n) => format!("{n} call(s) await a decision"),
1743 }
1744 }
1745 Verb::Approve(task) | Verb::ApproveNoted(task, _) | Verb::Reject(task, _) => {
1746 let Some((call, grant)) = waiting.iter().find(|(c, _)| c.task == task) else {
1747 return CommandResult::new(
1748 command,
1749 CommandStatus::NotFound,
1750 &format!(
1751 "{task} has no call awaiting a decision (see `speccoder report --approvals`)"
1752 ),
1753 );
1754 };
1755 if shown.is_some_and(|d| d != call.digest) {
1756 return CommandResult::new(
1757 command,
1758 CommandStatus::UsageError,
1759 "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1760 )
1761 .with_code("CALL-CHANGED");
1762 }
1763 let what = match verb {
1764 Verb::Reject(_, reason) => {
1765 decisions.reject(
1766 &feature,
1767 task,
1768 grant.clone(),
1769 reason,
1770 &Who::local_in(&root, via),
1771 );
1772 format!(
1773 "rejected: {} for {task} — the model will be told: {reason}",
1774 call.covers
1775 )
1776 }
1777 _ => {
1778 decisions.approve(
1779 &feature,
1780 task,
1781 grant.clone(),
1782 None,
1783 &Who::local_in(&root, via),
1784 );
1785 if let Verb::ApproveNoted(_, note) = verb
1786 && !note.trim().is_empty()
1787 {
1788 let t = decisions
1789 .tasks
1790 .entry(format!("{feature}/{task}"))
1791 .or_default();
1792 if let Some(last) = t.decided.last_mut() {
1793 last.note = Some(note.trim().to_owned());
1794 }
1795 t.nudges.push(format!(
1797 "A person approved {}: {}",
1798 call.covers,
1799 note.trim()
1800 ));
1801 }
1802 format!("approved: {} for {task}", call.covers)
1803 }
1804 };
1805 if let Err(e) = decisions.save(&dir) {
1806 return CommandResult::new(command, CommandStatus::Error, &e);
1807 }
1808 format!("{what}; run the task again to continue (speccoder run --task {task})")
1809 }
1810 };
1811 let lanes = lane_pending(&decisions, &open);
1812 let report = ApprovalsReport {
1813 decided: decided(&feature, &decisions),
1814 pending: tasks_waiting
1815 .into_iter()
1816 .map(|(c, _)| c)
1817 .chain(lanes.into_iter().map(|(c, _)| c))
1818 .collect(),
1819 feature,
1820 unreadable,
1821 batch: Vec::new(),
1822 };
1823 let report = ApprovalsReport {
1824 batch: batches(&report.pending),
1825 ..report
1826 };
1827 CommandResult::new(command, CommandStatus::Ok, &message)
1828 .with_data(CommandData::Approvals(Box::new(report)))
1829}
1830
1831#[must_use]
1837pub fn batch_digest(digests: &[String]) -> String {
1838 use sha2::{Digest as _, Sha256};
1839 let mut ids = digests.to_vec();
1840 ids.sort();
1841 Sha256::digest(ids.join("\n").as_bytes())
1842 .iter()
1843 .map(|b| format!("{b:02x}"))
1844 .collect()
1845}
1846
1847#[must_use]
1850pub fn batches(pending: &[PendingCall]) -> Vec<speccoder_core::BatchView> {
1851 let mut runs: Vec<String> = pending
1852 .iter()
1853 .filter(|p| p.batched)
1854 .filter_map(|p| p.run.clone())
1855 .collect();
1856 runs.sort();
1857 runs.dedup();
1858 runs.into_iter()
1859 .map(|run| {
1860 let of_run: Vec<&PendingCall> = pending
1861 .iter()
1862 .filter(|p| p.run.as_deref() == Some(run.as_str()) && p.kind == tasks::APPROVAL)
1863 .collect();
1864 let groups = ["write", "command", "network", "mcp"]
1865 .iter()
1866 .filter_map(|class| {
1867 let calls: Vec<String> = of_run
1868 .iter()
1869 .filter(|p| {
1870 p.object.as_ref().map_or("write", |o| o.class.as_str()) == *class
1871 })
1872 .map(|p| p.id())
1873 .collect();
1874 (!calls.is_empty()).then(|| speccoder_core::BatchGroup {
1875 class: (*class).to_owned(),
1876 calls,
1877 })
1878 })
1879 .collect::<Vec<_>>();
1880 let digests: Vec<String> = of_run.iter().map(|p| p.digest.clone()).collect();
1881 speccoder_core::BatchView {
1882 digest: batch_digest(&digests),
1883 run,
1884 groups,
1885 }
1886 })
1887 .collect()
1888}
1889
1890#[derive(Debug, Clone, Copy)]
1892pub struct BatchChoice<'a> {
1893 pub run: &'a str,
1895 pub approve: bool,
1897 pub calls: &'a [String],
1899 pub digest: &'a str,
1901 pub resume_asking: bool,
1903 pub reason: Option<&'a str>,
1905}
1906
1907#[must_use]
1911pub fn decide_batch(
1912 path: &Path,
1913 feature: Option<&str>,
1914 choice: BatchChoice<'_>,
1915 state: Option<&Path>,
1916 via: Via,
1917) -> CommandResult {
1918 let command = "approvals";
1919 let Some(state) = state else {
1920 return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
1921 .with_code("NO-STATE-DIR");
1922 };
1923 let (feature, dir, root) = match locate(command, path, feature, state) {
1924 Ok(x) => x,
1925 Err(r) => return *r,
1926 };
1927 let listed = decide_with(path, Some(&feature), Verb::List, state, via);
1928 let Some(CommandData::Approvals(report)) = listed.data().cloned() else {
1929 return listed;
1930 };
1931 if let Some(why) = &report.unreadable {
1932 return CommandResult::new(
1933 command,
1934 CommandStatus::Error,
1935 &format!("the calls waiting for a decision could not be read: {why}"),
1936 );
1937 }
1938 let Some(batch) = report.batch.iter().find(|b| b.run == choice.run) else {
1939 return CommandResult::new(
1940 command,
1941 CommandStatus::NotFound,
1942 &format!("no calls wait together in run {}", choice.run),
1943 );
1944 };
1945 let listed_ids: Vec<String> = batch.groups.iter().flat_map(|g| g.calls.clone()).collect();
1946 if batch.digest != choice.digest {
1947 return CommandResult::new(
1948 command,
1949 CommandStatus::UsageError,
1950 &format!(
1951 "the list changed; {} calls are waiting now",
1952 listed_ids.len()
1953 ),
1954 )
1955 .with_data(CommandData::Approvals(report.clone()));
1956 }
1957 if choice.calls.is_empty() || choice.calls.iter().any(|c| !listed_ids.contains(c)) {
1958 return CommandResult::new(
1959 command,
1960 CommandStatus::UsageError,
1961 "name calls of the batch (or none to decide them all)",
1962 );
1963 }
1964 let by = Who::local_in(&root, via);
1965 let mut decisions = match Decisions::load(&dir) {
1966 Ok(d) => d,
1967 Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1968 };
1969 let deciding = match Deciding::open(&dir) {
1971 Ok(d) => d,
1972 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
1973 };
1974 let rows = match deciding.as_ref().map(|d| d.waiting(&root, &feature)) {
1975 Some(Ok(r)) => r,
1976 None => Vec::new(),
1977 Some(Err(e)) => return CommandResult::new(command, CommandStatus::Error, &e),
1978 };
1979 let reason = choice.reason.unwrap_or("no reason given");
1980 let mut lines = Vec::new();
1981 for id in choice.calls {
1982 let mut answer = if choice.approve {
1983 Answer::approve(Scope::Once, None, Some(by.clone()))
1984 } else {
1985 Answer::reject(reason, Some(by.clone()))
1986 };
1987 answer.resume_asking = choice.resume_asking;
1988 if let Some(task) = id.strip_prefix("attempt:") {
1989 let Some(p) = report
1991 .pending
1992 .iter()
1993 .find(|p| p.call.is_empty() && p.task == task)
1994 else {
1995 continue;
1996 };
1997 let object = pending(&root, &feature, &[task.to_owned()], &decisions)
1999 .into_iter()
2000 .find(|(now, _)| now.digest == p.digest)
2001 .map(|(_, o)| o);
2002 let Some(object) = object else {
2003 return CommandResult::new(
2004 command,
2005 CommandStatus::UsageError,
2006 &format!("the list changed; {task}'s attempt stopped on another call"),
2007 )
2008 .with_code("CALL-CHANGED");
2009 };
2010 if choice.approve {
2011 decisions.approve_once(&feature, task, object, &by);
2013 } else {
2014 decisions.reject(&feature, task, object, reason, &by);
2015 }
2016 lines.push(format!("{}: {}", p.task, p.covers));
2017 continue;
2018 }
2019 let (Some((p, row)), Some(deciding)) =
2020 (rows.iter().find(|(p, _)| p.call == *id), &deciding)
2021 else {
2022 continue;
2023 };
2024 if let Err(e) = deciding.decide(row, &answer, &by) {
2025 let failed = format!("not decided: {}: {} — {e}", p.task, p.covers);
2026 let message = partly_decided(&lines, &failed, choice.calls.len());
2027 if let Err(e) = decisions.save(&dir) {
2028 return CommandResult::new(
2029 command,
2030 CommandStatus::Error,
2031 &format!("{message}\n{e}"),
2032 );
2033 }
2034 return CommandResult::new(command, CommandStatus::Error, &message);
2035 }
2036 lines.push(format!("{}: {}", p.task, p.covers));
2037 }
2038 if choice.resume_asking
2039 && let Some(first) = report.pending.iter().find(|p| listed_ids.contains(&p.id()))
2040 {
2041 decisions.note(
2042 &feature,
2043 &first.task,
2044 Decided {
2045 verb: "resume_asking".to_owned(),
2046 object: None,
2047 run: Some(choice.run.to_owned()),
2048 reason: None,
2049 scope: "run".to_owned(),
2050 note: None,
2051 by: by.clone(),
2052 at: now(),
2053 },
2054 );
2055 }
2056 if let Err(e) = decisions.save(&dir) {
2057 return CommandResult::new(command, CommandStatus::Error, &e);
2058 }
2059 let after = decide_with(path, Some(&feature), Verb::List, state, via);
2060 CommandResult::new(
2061 command,
2062 CommandStatus::Ok,
2063 &format!(
2064 "{} {} call(s) once — by {} ({}){}: {}; `speccoder run` continues",
2065 if choice.approve {
2066 "approved"
2067 } else {
2068 "rejected"
2069 },
2070 lines.len(),
2071 by.name,
2072 by.via,
2073 if choice.resume_asking {
2074 "; the run asks again"
2075 } else {
2076 ""
2077 },
2078 lines.join(", ")
2079 ),
2080 )
2081 .with_data(
2082 after
2083 .data()
2084 .cloned()
2085 .unwrap_or(CommandData::Approvals(report)),
2086 )
2087}
2088
2089#[must_use]
2097pub fn allow_always(
2098 path: &Path,
2099 feature: Option<&str>,
2100 task: &str,
2101 call: &str,
2102 state: Option<&Path>,
2103 via: Via,
2104) -> CommandResult {
2105 let command = "config";
2106 let refuse = |m: &str| CommandResult::new(command, CommandStatus::UsageError, m);
2107 let Some(state) = state else {
2108 return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
2109 .with_code("NO-STATE-DIR");
2110 };
2111 let (feature, dir, root) = match locate(command, path, feature, state) {
2112 Ok(x) => x,
2113 Err(r) => return *r,
2114 };
2115 if !dir.join(crate::substrate::JOURNAL).exists() {
2116 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2117 }
2118 let Ok(id) = agentplane::core::TaskId::parse(call) else {
2119 return refuse("not a call id");
2120 };
2121 let deciding = match Deciding::open(&dir) {
2123 Ok(Some(d)) => d,
2124 Ok(None) => {
2125 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2126 }
2127 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
2128 };
2129 let row = match deciding.rt.block_on(deciding.plane.task(id)) {
2130 Ok(Some(row)) => row,
2131 Ok(None) => {
2132 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2133 }
2134 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e.0),
2135 };
2136 let Some(request) = tasks::request_of(&row.justification.proposed_action)
2137 .filter(|r| r.task == format!("{feature}/{task}"))
2138 else {
2139 return CommandResult::new(
2140 command,
2141 CommandStatus::NotFound,
2142 &format!("{task} has no such call"),
2143 );
2144 };
2145 let offered = matches!(request.kind.as_str(), "run" | "network")
2146 && request.options.iter().any(|o| o == "always");
2147 let Some(Object::Command { argv, network, .. }) = request.object.clone().filter(|_| offered)
2148 else {
2149 return refuse("Always Allow is not offered for this call");
2150 };
2151 let by = Who::local_in(&root, via);
2152 match row.state {
2153 agentplane::core::TaskState::Open | agentplane::core::TaskState::Claimed => {
2154 if let Err(e) = deciding.decide(&row, &Answer::always(Some(by.clone())), &by) {
2155 return CommandResult::new(command, CommandStatus::Error, &e);
2156 }
2157 }
2158 _ if take_answered_always(call) => {}
2159 _ => return refuse("this call was already decided"),
2160 }
2161 let key = if network {
2162 "sandbox.network_allow"
2163 } else {
2164 "commands.allow"
2165 };
2166 let r = crate::settings::apply_confirmed(
2167 &root,
2168 &[speccoder_core::ConfigChange {
2169 key: key.to_owned(),
2170 value: None,
2171 append: Some(serde_json::json!(argv)),
2172 }],
2173 );
2174 if r.status() != CommandStatus::Ok {
2175 return r;
2176 }
2177 let table = if network {
2178 "[sandbox] network_allow"
2179 } else {
2180 "[commands] allow"
2181 };
2182 CommandResult::new(
2183 command,
2184 CommandStatus::Ok,
2185 &format!(
2186 "allowed once; added exactly `{}` to {table} — nothing after it runs because of this (by {} via {}); commit speccoder.toml to keep it",
2187 argv.join(" "),
2188 by.name,
2189 by.via
2190 ),
2191 )
2192 .with_next(r.next().to_vec())
2193 .with_data(r.data().cloned().unwrap_or(CommandData::Config(Box::new(crate::settings::read(&root)))))
2194}
2195
2196#[must_use]
2198pub fn decide(path: &Path, feature: Option<&str>, verb: Verb<'_>, via: Via) -> CommandResult {
2199 decide_in(
2200 path,
2201 feature,
2202 verb,
2203 crate::worktree::state_dir().as_deref(),
2204 via,
2205 )
2206}
2207
2208#[must_use]
2210pub fn decide_in(
2211 path: &Path,
2212 feature: Option<&str>,
2213 verb: Verb<'_>,
2214 state: Option<&Path>,
2215 via: Via,
2216) -> CommandResult {
2217 match state {
2218 Some(state) => decide_with(path, feature, verb, state, via),
2219 None => CommandResult::new(
2220 "approvals",
2221 CommandStatus::ConfigError,
2222 "no state directory (set SPECCODER_STATE_DIR or HOME)",
2223 )
2224 .with_code("NO-STATE-DIR"),
2225 }
2226}
2227
2228#[cfg(test)]
2229mod tests {
2230 use super::*;
2231
2232 fn file(path: &str) -> Object {
2233 Object::Path {
2234 canonical: path.to_owned(),
2235 asked: path.to_owned(),
2236 }
2237 }
2238
2239 #[test]
2240 fn a_failure_part_way_through_says_what_was_decided_before_it() {
2241 let recorded = vec!["approved: write `a` for T005".to_owned()];
2242 let m = partly_decided(&recorded, "not decided: write `b` for T006: gone", 2);
2243 assert!(m.starts_with("approved: write `a` for T005\n"), "{m}");
2244 assert!(m.contains("not decided: write `b` for T006: gone"), "{m}");
2245 assert!(
2246 m.contains("1 of 2 call(s) were decided before it and stay decided"),
2247 "{m}"
2248 );
2249 assert_eq!(partly_decided(&[], "not decided: x", 1), "not decided: x");
2250 }
2251
2252 #[test]
2253 fn a_call_past_its_deadline_is_not_listed_as_waiting() {
2254 let now = agentplane::core::Timestamp::now_utc();
2255 let minute = std::time::Duration::from_secs(60);
2256 assert!(still_waits(None, now));
2257 assert!(still_waits(Some(now + minute), now));
2258 assert!(!still_waits(Some(now), now));
2259 assert!(!still_waits(Some(now - minute), now));
2260 }
2261
2262 #[test]
2263 fn decisions_round_trip_and_replace_each_other() {
2264 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2265 let g = file("Cargo.toml");
2266 let mut d = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2267 let by = Who::local(Via::Terminal);
2268 d.reject("f", "T1", g.clone(), "no new dependencies", &by);
2269 d.approve("f", "T1", g.clone(), Some("run-1"), &by);
2270 assert_eq!(d.tasks["f/T1"].decided.len(), 2);
2271 assert_eq!(d.tasks["f/T1"].decided[1].by, by);
2272 d.save(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2273 let back = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2274 assert_eq!(back.for_run("f", "T1", "run-1"), std::slice::from_ref(&g));
2275 assert!(back.for_task("f", "T1").rejected.is_empty());
2276 assert!(back.for_run("f", "T1", "run-2").is_empty());
2278 assert!(!back.decided("f", "T1", &g) && !back.decided("f", "T2", &g));
2279 }
2280
2281 #[test]
2282 fn a_lane_decision_is_claimed_by_the_next_run_only() {
2283 let mut d = Decisions::default();
2284 let by = Who::local(Via::Terminal);
2285 d.approve("f", "T1", file("package.json"), None, &by);
2286 assert!(d.decided("f", "T1", &file("package.json")));
2287 assert!(d.for_run("f", "T1", "run-2").is_empty());
2288 assert!(d.claim("f", "T1", "run-2"));
2289 assert_eq!(d.for_run("f", "T1", "run-2"), [file("package.json")]);
2290 assert!(!d.claim("f", "T1", "run-3"));
2292 assert!(d.for_run("f", "T1", "run-3").is_empty());
2293 }
2294
2295 #[test]
2296 fn a_decisions_file_from_before_objects_is_refused_by_name() {
2297 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2298 std::fs::write(
2299 dir.path().join(FILE),
2300 r#"{"tasks":{"f/T1":{"approved":[{"kind":"write","path":"Cargo.toml"}]}}}"#,
2301 )
2302 .unwrap_or_else(|e| panic!("{e}"));
2303 let e = Decisions::load(dir.path()).err().unwrap_or_default();
2304 assert!(
2305 e.contains(FILE) && e.contains("before decisions named objects"),
2306 "{e}"
2307 );
2308 }
2309
2310 #[test]
2311 fn remembered_options_need_an_object_and_text_not_copied_from_content() {
2312 let o = |risk, has, derived, kind, always| options(risk, has, derived, kind, always);
2313 assert_eq!(
2314 o("low", true, false, "run", "low"),
2315 ["once", "task", "run", "always", "reject", "stop"]
2316 );
2317 assert_eq!(
2319 o("high", true, false, "network", "low"),
2320 ["once", "task", "always", "reject", "stop"]
2321 );
2322 assert_eq!(
2323 o("high", true, false, "run", "high"),
2324 ["once", "task", "reject", "stop"]
2325 );
2326 assert_eq!(
2327 o("low", true, false, "write", "low"),
2328 ["once", "task", "run", "reject", "stop"]
2329 );
2330 assert_eq!(
2331 o("low", true, true, "run", "low"),
2332 ["once", "reject", "stop"]
2333 );
2334 assert_eq!(
2335 o("low", false, false, "run", "low"),
2336 ["once", "reject", "stop"]
2337 );
2338 }
2339
2340 #[test]
2341 fn a_runs_batch_lists_its_lanes_stopped_calls_too() {
2342 let call = |task: &str, id: &str, batched: bool, class: &str, run: &str| {
2343 with_digest(
2344 PendingCall {
2345 task: task.to_owned(),
2346 call: id.to_owned(),
2347 kind: tasks::APPROVAL.to_owned(),
2348 run: Some(run.to_owned()),
2349 batched,
2350 object: Some(speccoder_core::ObjectView {
2351 class: class.to_owned(),
2352 ..speccoder_core::ObjectView::default()
2353 }),
2354 ..PendingCall::default()
2355 },
2356 Some(&file(task)),
2357 )
2358 };
2359 let pending = vec![
2360 call("T1", "aa", true, "write", "run-1"),
2361 call("T2", "", false, "command", "run-1"),
2363 call("T3", "bb", false, "write", "run-2"),
2365 ];
2366 let b = batches(&pending);
2367 assert_eq!(b.len(), 1);
2368 assert_eq!(b[0].run, "run-1");
2369 let groups: Vec<(&str, Vec<String>)> = b[0]
2370 .groups
2371 .iter()
2372 .map(|g| (g.class.as_str(), g.calls.clone()))
2373 .collect();
2374 assert_eq!(
2375 groups,
2376 [
2377 ("write", vec!["aa".to_owned()]),
2378 ("command", vec!["attempt:T2".to_owned()])
2379 ]
2380 );
2381 assert_eq!(
2382 b[0].digest,
2383 batch_digest(&[pending[1].digest.clone(), pending[0].digest.clone()])
2384 );
2385 let mut newer = pending.clone();
2388 newer[1] = with_digest(newer[1].clone(), Some(&file("other.json")));
2389 assert_eq!(newer[1].id(), pending[1].id());
2390 assert_ne!(batches(&newer)[0].digest, b[0].digest);
2391 }
2392
2393 #[test]
2396 fn a_calls_digest_binds_its_id_and_object() {
2397 let a = call_digest("attempt:T1", Some(&file("a.json")));
2398 assert_eq!(a, call_digest("attempt:T1", Some(&file("a.json"))));
2399 assert_ne!(a, call_digest("attempt:T1", Some(&file("b.json"))));
2400 assert_ne!(a, call_digest("attempt:T2", Some(&file("a.json"))));
2401 assert_ne!(a, call_digest("attempt:T1", None));
2402 }
2403
2404 #[test]
2408 fn a_once_grant_is_given_to_one_attempt_only() {
2409 let mut d = Decisions::default();
2410 let by = Who::local(Via::Terminal);
2411 d.approve_once("f", "T1", file("package.json"), &by);
2412 assert!(d.decided("f", "T1", &file("package.json")));
2413 assert!(d.claim("f", "T1", "run-2"));
2414 assert!(d.for_run("f", "T1", "run-2").is_empty(), "not for the run");
2415 assert_eq!(d.take_once("f", "T1", "run-2"), [file("package.json")]);
2416 assert!(
2417 d.take_once("f", "T1", "run-2").is_empty(),
2418 "one attempt only"
2419 );
2420 assert_eq!(d.for_task("f", "T1").decided[0].scope, "once");
2421 let text = serde_json::to_string(&d).unwrap();
2423 assert_eq!(serde_json::from_str::<Decisions>(&text).unwrap(), d);
2424 d.approve("f", "T1", file("x"), Some("run-2"), &by);
2425 let grant = serde_json::to_value(&d.for_task("f", "T1").approved[0]).unwrap();
2426 assert!(grant.get("once").is_none(), "{grant}");
2427 }
2428}