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 waiting = deciding
1342 .waiting(root, feature)?
1343 .into_iter()
1344 .map(|(c, _)| c)
1345 .find(|c| c.task != task && c.source == "task")
1346 .map(|c| (c.task, c.covers));
1347 Ok(Elsewhere {
1348 waiting,
1349 replaced_journal: deciding.plane.replaced_journal.clone(),
1350 })
1351}
1352
1353pub fn withdraw_waiting(
1362 root: &Path,
1363 dir: &Path,
1364 feature: &str,
1365 reason: &str,
1366) -> Result<usize, String> {
1367 let Some(deciding) = Deciding::open(dir)? else {
1368 return Ok(0);
1369 };
1370 let rows = deciding.waiting(root, feature)?;
1371 if rows.is_empty() {
1372 return Ok(0);
1373 }
1374 let by = agentplane::core::Operator::from_parts(who_in(root), "asserted")
1375 .ok()
1376 .ok_or_else(|| "no name to record the withdrawal under".to_owned())?;
1377 for (_, task) in &rows {
1378 deciding
1379 .rt
1380 .block_on(deciding.plane.withdraw(task, &by, reason))
1381 .map_err(|e| e.0)?;
1382 }
1383 Ok(rows.len())
1384}
1385
1386fn partly_decided(recorded: &[String], failed: &str, of: usize) -> String {
1389 if recorded.is_empty() {
1390 return failed.to_owned();
1391 }
1392 format!(
1393 "{}\n{failed}\n{} of {of} call(s) were decided before it and stay decided; `speccoder report --approvals` lists what still waits",
1394 recorded.join("\n"),
1395 recorded.len()
1396 )
1397}
1398
1399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1401pub enum Verb<'a> {
1402 List,
1404 Approve(&'a str),
1406 ApproveNoted(&'a str, &'a str),
1408 Reject(&'a str, &'a str),
1410 Decide(Choice<'a>),
1412}
1413
1414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1416pub struct Choice<'a> {
1417 pub task: Option<&'a str>,
1419 pub call: Option<&'a str>,
1421 pub all: bool,
1423 pub approve: bool,
1425 pub scope: Scope,
1427 pub note: Option<&'a str>,
1429 pub reason: Option<&'a str>,
1431 pub digest: Option<&'a str>,
1436}
1437
1438impl<'a> Choice<'a> {
1439 #[must_use]
1441 pub fn approve(task: &'a str, note: Option<&'a str>) -> Self {
1442 Self {
1443 task: Some(task),
1444 call: None,
1445 all: false,
1446 approve: true,
1447 scope: Scope::Task,
1448 note,
1449 reason: None,
1450 digest: None,
1451 }
1452 }
1453
1454 #[must_use]
1456 pub fn reject(task: &'a str, reason: &'a str) -> Self {
1457 Self {
1458 task: Some(task),
1459 call: None,
1460 all: false,
1461 approve: false,
1462 scope: Scope::Task,
1463 note: None,
1464 reason: Some(reason),
1465 digest: None,
1466 }
1467 }
1468
1469 #[must_use]
1472 pub fn shown(self, call: &'a str, digest: &'a str) -> Self {
1473 Self {
1474 call: (!call.starts_with("attempt:")).then_some(call),
1475 digest: Some(digest),
1476 ..self
1477 }
1478 }
1479}
1480
1481pub fn list_with(path: &Path, feature: Option<&str>, state: &Path) -> CommandResult {
1483 decide_with(path, feature, Verb::List, state, Via::Terminal)
1485}
1486
1487#[must_use]
1489pub fn list(path: &Path, feature: Option<&str>) -> CommandResult {
1490 decide(path, feature, Verb::List, Via::Terminal)
1491}
1492
1493pub fn decide_with(
1496 path: &Path,
1497 feature: Option<&str>,
1498 verb: Verb<'_>,
1499 state: &Path,
1500 via: Via,
1501) -> CommandResult {
1502 let command = match verb {
1503 Verb::List => "approvals",
1504 Verb::Approve(_) | Verb::ApproveNoted(..) => "approve",
1505 Verb::Reject(..) => "reject",
1506 Verb::Decide(c) if c.approve => "approve",
1507 Verb::Decide(_) => "reject",
1508 };
1509 let (feature, dir, root) = match locate(command, path, feature, state) {
1510 Ok(x) => x,
1511 Err(r) => return *r,
1512 };
1513 let (mut decisions, decisions_unreadable) = match Decisions::load(&dir) {
1516 Ok(d) => (d, None),
1517 Err(e) if verb == Verb::List => (Decisions::default(), Some(e)),
1518 Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1519 };
1520 let lane_pending = |decisions: &Decisions, open: &[String]| {
1523 if decisions_unreadable.is_some() {
1524 Vec::new()
1525 } else {
1526 pending(&root, &feature, open, decisions)
1527 }
1528 };
1529 let status = crate::status::report(&root, Some(&feature));
1531 let open: Vec<String> = match status.data() {
1532 Some(CommandData::Report(r)) => r
1533 .tasks
1534 .iter()
1535 .filter(|t| matches!(t.state.as_str(), "ready" | "blocked"))
1536 .map(|t| t.id.clone())
1537 .collect(),
1538 _ => Vec::new(),
1539 };
1540 let choice = match verb {
1543 Verb::List => None,
1544 Verb::Approve(t) => Some(Choice::approve(t, None)),
1545 Verb::ApproveNoted(t, n) => Some(Choice::approve(t, Some(n))),
1546 Verb::Reject(t, r) => Some(Choice::reject(t, r)),
1547 Verb::Decide(c) => Some(c),
1548 };
1549 let mut deciding = None;
1553 let listed = if choice.is_some() {
1554 Deciding::open(&dir).and_then(|d| {
1555 let rows = d
1556 .as_ref()
1557 .map_or_else(|| Ok(Vec::new()), |d| d.waiting(&root, &feature));
1558 deciding = d;
1559 rows
1560 })
1561 } else {
1562 waiting_tasks(&root, &dir, &feature)
1563 };
1564 let (tasks_waiting, unreadable) = match listed {
1565 Ok(rows) => (rows, None),
1566 Err(e) => (Vec::new(), Some(e)),
1567 };
1568 let unreadable = match (unreadable, &decisions_unreadable) {
1569 (Some(a), Some(b)) => Some(format!("{a}; {b}")),
1570 (a, b) => a.or_else(|| b.clone()),
1571 };
1572 let waiting = lane_pending(&decisions, &open);
1573
1574 if let (Some(_), Some(why)) = (choice, &unreadable) {
1575 return CommandResult::new(
1576 command,
1577 CommandStatus::Error,
1578 &format!("the calls waiting for a decision could not be read: {why}"),
1579 );
1580 }
1581 if let Some(c) = choice {
1582 let matching: Vec<&(PendingCall, agentplane::core::Task)> = tasks_waiting
1583 .iter()
1584 .filter(|(p, _)| c.task.is_none_or(|t| p.task == t))
1585 .filter(|(p, _)| {
1586 c.call
1587 .is_none_or(|id| !id.is_empty() && p.call.starts_with(id))
1588 })
1589 .collect();
1590 if let Some(shown) = c.digest
1591 && matching.iter().any(|(p, _)| p.digest != shown)
1592 {
1593 return CommandResult::new(
1594 command,
1595 CommandStatus::UsageError,
1596 "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1597 )
1598 .with_code("CALL-CHANGED");
1599 }
1600 if matching.len() > 1 && !c.all {
1601 let list: Vec<String> = matching
1602 .iter()
1603 .map(|(p, _)| {
1604 format!(
1605 "{} ({}, {})",
1606 &p.call[..12.min(p.call.len())],
1607 p.task,
1608 p.covers
1609 )
1610 })
1611 .collect();
1612 return CommandResult::new(
1613 command,
1614 CommandStatus::UsageError,
1615 &format!(
1616 "{} calls are waiting; name one with --call, or decide them all with --all: {}",
1617 matching.len(),
1618 list.join(", ")
1619 ),
1620 );
1621 }
1622 if let (false, Some(deciding)) = (matching.is_empty(), &deciding) {
1623 let by = Who::local_in(&root, via);
1624 let mut lines = Vec::new();
1625 for (call, row) in &matching {
1626 let answer = if c.approve {
1627 Answer::approve(c.scope, c.note.map(str::to_owned), Some(by.clone()))
1628 } else {
1629 Answer::reject(c.reason.unwrap_or("no reason given"), Some(by.clone()))
1630 };
1631 if let Err(e) = deciding.decide(row, &answer, &by) {
1632 let failed = format!("not decided: {} for {}: {e}", call.covers, call.task);
1633 return CommandResult::new(
1634 command,
1635 CommandStatus::Error,
1636 &partly_decided(&lines, &failed, matching.len()),
1637 );
1638 }
1639 lines.push(if c.approve {
1640 format!(
1641 "approved: {} for {} ({}) — by {} ({})",
1642 call.covers,
1643 call.task,
1644 c.scope.name(),
1645 by.name,
1646 by.basis
1647 )
1648 } else {
1649 format!(
1650 "rejected: {} for {} — by {} ({}); the model will be told: {}",
1651 call.covers,
1652 call.task,
1653 by.name,
1654 by.basis,
1655 c.reason.unwrap_or("no reason given")
1656 )
1657 });
1658 }
1659 let (after, unreadable) = match deciding.waiting(&root, &feature) {
1661 Ok(rows) => (rows, None),
1662 Err(e) => (Vec::new(), Some(e)),
1663 };
1664 let lanes = lane_pending(&decisions, &open);
1665 let report = ApprovalsReport {
1666 decided: decided(&feature, &decisions),
1667 pending: after
1668 .into_iter()
1669 .map(|(c, _)| c)
1670 .chain(lanes.into_iter().map(|(c, _)| c))
1671 .collect(),
1672 feature,
1673 unreadable,
1674 batch: Vec::new(),
1675 };
1676 return CommandResult::new(
1677 command,
1678 CommandStatus::Ok,
1679 &format!(
1680 "{}; `speccoder run` continues each attempt where it stopped",
1681 lines.join("\n")
1682 ),
1683 )
1684 .with_data(CommandData::Approvals(Box::new(report)));
1685 }
1686 if c.all || c.call.is_some() || c.task.is_none() {
1687 return CommandResult::new(
1688 command,
1689 CommandStatus::NotFound,
1690 "no call matching that awaits a decision (see `speccoder report --approvals`)",
1691 );
1692 }
1693 }
1694 let shown = match verb {
1698 Verb::Decide(c) => c.digest,
1699 _ => None,
1700 };
1701 let verb = match verb {
1702 Verb::Decide(c) => match (c.task, c.approve, c.note) {
1703 (Some(t), true, Some(n)) => Verb::ApproveNoted(t, n),
1704 (Some(t), true, None) => Verb::Approve(t),
1705 (Some(t), false, _) => Verb::Reject(t, c.reason.unwrap_or("no reason given")),
1706 (None, ..) => Verb::List,
1707 },
1708 v => v,
1709 };
1710
1711 let message = match verb {
1712 Verb::List | Verb::Decide(_) => {
1714 let n = waiting.len() + tasks_waiting.len();
1715 match (&unreadable, n) {
1716 (Some(why), 0) => format!("the calls waiting for a decision are unknown: {why}"),
1717 (Some(why), n) => {
1718 format!("{n} call(s) await a decision; others may wait unseen: {why}")
1719 }
1720 (None, 0) => "nothing awaits a decision".to_owned(),
1721 (None, n) => format!("{n} call(s) await a decision"),
1722 }
1723 }
1724 Verb::Approve(task) | Verb::ApproveNoted(task, _) | Verb::Reject(task, _) => {
1725 let Some((call, grant)) = waiting.iter().find(|(c, _)| c.task == task) else {
1726 return CommandResult::new(
1727 command,
1728 CommandStatus::NotFound,
1729 &format!(
1730 "{task} has no call awaiting a decision (see `speccoder report --approvals`)"
1731 ),
1732 );
1733 };
1734 if shown.is_some_and(|d| d != call.digest) {
1735 return CommandResult::new(
1736 command,
1737 CommandStatus::UsageError,
1738 "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1739 )
1740 .with_code("CALL-CHANGED");
1741 }
1742 let what = match verb {
1743 Verb::Reject(_, reason) => {
1744 decisions.reject(
1745 &feature,
1746 task,
1747 grant.clone(),
1748 reason,
1749 &Who::local_in(&root, via),
1750 );
1751 format!(
1752 "rejected: {} for {task} — the model will be told: {reason}",
1753 call.covers
1754 )
1755 }
1756 _ => {
1757 decisions.approve(
1758 &feature,
1759 task,
1760 grant.clone(),
1761 None,
1762 &Who::local_in(&root, via),
1763 );
1764 if let Verb::ApproveNoted(_, note) = verb
1765 && !note.trim().is_empty()
1766 {
1767 let t = decisions
1768 .tasks
1769 .entry(format!("{feature}/{task}"))
1770 .or_default();
1771 if let Some(last) = t.decided.last_mut() {
1772 last.note = Some(note.trim().to_owned());
1773 }
1774 t.nudges.push(format!(
1776 "A person approved {}: {}",
1777 call.covers,
1778 note.trim()
1779 ));
1780 }
1781 format!("approved: {} for {task}", call.covers)
1782 }
1783 };
1784 if let Err(e) = decisions.save(&dir) {
1785 return CommandResult::new(command, CommandStatus::Error, &e);
1786 }
1787 format!("{what}; run the task again to continue (speccoder run --task {task})")
1788 }
1789 };
1790 let lanes = lane_pending(&decisions, &open);
1791 let report = ApprovalsReport {
1792 decided: decided(&feature, &decisions),
1793 pending: tasks_waiting
1794 .into_iter()
1795 .map(|(c, _)| c)
1796 .chain(lanes.into_iter().map(|(c, _)| c))
1797 .collect(),
1798 feature,
1799 unreadable,
1800 batch: Vec::new(),
1801 };
1802 let report = ApprovalsReport {
1803 batch: batches(&report.pending),
1804 ..report
1805 };
1806 CommandResult::new(command, CommandStatus::Ok, &message)
1807 .with_data(CommandData::Approvals(Box::new(report)))
1808}
1809
1810#[must_use]
1816pub fn batch_digest(digests: &[String]) -> String {
1817 use sha2::{Digest as _, Sha256};
1818 let mut ids = digests.to_vec();
1819 ids.sort();
1820 Sha256::digest(ids.join("\n").as_bytes())
1821 .iter()
1822 .map(|b| format!("{b:02x}"))
1823 .collect()
1824}
1825
1826#[must_use]
1829pub fn batches(pending: &[PendingCall]) -> Vec<speccoder_core::BatchView> {
1830 let mut runs: Vec<String> = pending
1831 .iter()
1832 .filter(|p| p.batched)
1833 .filter_map(|p| p.run.clone())
1834 .collect();
1835 runs.sort();
1836 runs.dedup();
1837 runs.into_iter()
1838 .map(|run| {
1839 let of_run: Vec<&PendingCall> = pending
1840 .iter()
1841 .filter(|p| p.run.as_deref() == Some(run.as_str()) && p.kind == tasks::APPROVAL)
1842 .collect();
1843 let groups = ["write", "command", "network", "mcp"]
1844 .iter()
1845 .filter_map(|class| {
1846 let calls: Vec<String> = of_run
1847 .iter()
1848 .filter(|p| {
1849 p.object.as_ref().map_or("write", |o| o.class.as_str()) == *class
1850 })
1851 .map(|p| p.id())
1852 .collect();
1853 (!calls.is_empty()).then(|| speccoder_core::BatchGroup {
1854 class: (*class).to_owned(),
1855 calls,
1856 })
1857 })
1858 .collect::<Vec<_>>();
1859 let digests: Vec<String> = of_run.iter().map(|p| p.digest.clone()).collect();
1860 speccoder_core::BatchView {
1861 digest: batch_digest(&digests),
1862 run,
1863 groups,
1864 }
1865 })
1866 .collect()
1867}
1868
1869#[derive(Debug, Clone, Copy)]
1871pub struct BatchChoice<'a> {
1872 pub run: &'a str,
1874 pub approve: bool,
1876 pub calls: &'a [String],
1878 pub digest: &'a str,
1880 pub resume_asking: bool,
1882 pub reason: Option<&'a str>,
1884}
1885
1886#[must_use]
1890pub fn decide_batch(
1891 path: &Path,
1892 feature: Option<&str>,
1893 choice: BatchChoice<'_>,
1894 state: Option<&Path>,
1895 via: Via,
1896) -> CommandResult {
1897 let command = "approvals";
1898 let Some(state) = state else {
1899 return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
1900 .with_code("NO-STATE-DIR");
1901 };
1902 let (feature, dir, root) = match locate(command, path, feature, state) {
1903 Ok(x) => x,
1904 Err(r) => return *r,
1905 };
1906 let listed = decide_with(path, Some(&feature), Verb::List, state, via);
1907 let Some(CommandData::Approvals(report)) = listed.data().cloned() else {
1908 return listed;
1909 };
1910 if let Some(why) = &report.unreadable {
1911 return CommandResult::new(
1912 command,
1913 CommandStatus::Error,
1914 &format!("the calls waiting for a decision could not be read: {why}"),
1915 );
1916 }
1917 let Some(batch) = report.batch.iter().find(|b| b.run == choice.run) else {
1918 return CommandResult::new(
1919 command,
1920 CommandStatus::NotFound,
1921 &format!("no calls wait together in run {}", choice.run),
1922 );
1923 };
1924 let listed_ids: Vec<String> = batch.groups.iter().flat_map(|g| g.calls.clone()).collect();
1925 if batch.digest != choice.digest {
1926 return CommandResult::new(
1927 command,
1928 CommandStatus::UsageError,
1929 &format!(
1930 "the list changed; {} calls are waiting now",
1931 listed_ids.len()
1932 ),
1933 )
1934 .with_data(CommandData::Approvals(report.clone()));
1935 }
1936 if choice.calls.is_empty() || choice.calls.iter().any(|c| !listed_ids.contains(c)) {
1937 return CommandResult::new(
1938 command,
1939 CommandStatus::UsageError,
1940 "name calls of the batch (or none to decide them all)",
1941 );
1942 }
1943 let by = Who::local_in(&root, via);
1944 let mut decisions = match Decisions::load(&dir) {
1945 Ok(d) => d,
1946 Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1947 };
1948 let deciding = match Deciding::open(&dir) {
1950 Ok(d) => d,
1951 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
1952 };
1953 let rows = match deciding.as_ref().map(|d| d.waiting(&root, &feature)) {
1954 Some(Ok(r)) => r,
1955 None => Vec::new(),
1956 Some(Err(e)) => return CommandResult::new(command, CommandStatus::Error, &e),
1957 };
1958 let reason = choice.reason.unwrap_or("no reason given");
1959 let mut lines = Vec::new();
1960 for id in choice.calls {
1961 let mut answer = if choice.approve {
1962 Answer::approve(Scope::Once, None, Some(by.clone()))
1963 } else {
1964 Answer::reject(reason, Some(by.clone()))
1965 };
1966 answer.resume_asking = choice.resume_asking;
1967 if let Some(task) = id.strip_prefix("attempt:") {
1968 let Some(p) = report
1970 .pending
1971 .iter()
1972 .find(|p| p.call.is_empty() && p.task == task)
1973 else {
1974 continue;
1975 };
1976 let object = pending(&root, &feature, &[task.to_owned()], &decisions)
1978 .into_iter()
1979 .find(|(now, _)| now.digest == p.digest)
1980 .map(|(_, o)| o);
1981 let Some(object) = object else {
1982 return CommandResult::new(
1983 command,
1984 CommandStatus::UsageError,
1985 &format!("the list changed; {task}'s attempt stopped on another call"),
1986 )
1987 .with_code("CALL-CHANGED");
1988 };
1989 if choice.approve {
1990 decisions.approve_once(&feature, task, object, &by);
1992 } else {
1993 decisions.reject(&feature, task, object, reason, &by);
1994 }
1995 lines.push(format!("{}: {}", p.task, p.covers));
1996 continue;
1997 }
1998 let (Some((p, row)), Some(deciding)) =
1999 (rows.iter().find(|(p, _)| p.call == *id), &deciding)
2000 else {
2001 continue;
2002 };
2003 if let Err(e) = deciding.decide(row, &answer, &by) {
2004 let failed = format!("not decided: {}: {} — {e}", p.task, p.covers);
2005 let message = partly_decided(&lines, &failed, choice.calls.len());
2006 if let Err(e) = decisions.save(&dir) {
2007 return CommandResult::new(
2008 command,
2009 CommandStatus::Error,
2010 &format!("{message}\n{e}"),
2011 );
2012 }
2013 return CommandResult::new(command, CommandStatus::Error, &message);
2014 }
2015 lines.push(format!("{}: {}", p.task, p.covers));
2016 }
2017 if choice.resume_asking
2018 && let Some(first) = report.pending.iter().find(|p| listed_ids.contains(&p.id()))
2019 {
2020 decisions.note(
2021 &feature,
2022 &first.task,
2023 Decided {
2024 verb: "resume_asking".to_owned(),
2025 object: None,
2026 run: Some(choice.run.to_owned()),
2027 reason: None,
2028 scope: "run".to_owned(),
2029 note: None,
2030 by: by.clone(),
2031 at: now(),
2032 },
2033 );
2034 }
2035 if let Err(e) = decisions.save(&dir) {
2036 return CommandResult::new(command, CommandStatus::Error, &e);
2037 }
2038 let after = decide_with(path, Some(&feature), Verb::List, state, via);
2039 CommandResult::new(
2040 command,
2041 CommandStatus::Ok,
2042 &format!(
2043 "{} {} call(s) once — by {} ({}){}: {}; `speccoder run` continues",
2044 if choice.approve {
2045 "approved"
2046 } else {
2047 "rejected"
2048 },
2049 lines.len(),
2050 by.name,
2051 by.via,
2052 if choice.resume_asking {
2053 "; the run asks again"
2054 } else {
2055 ""
2056 },
2057 lines.join(", ")
2058 ),
2059 )
2060 .with_data(
2061 after
2062 .data()
2063 .cloned()
2064 .unwrap_or(CommandData::Approvals(report)),
2065 )
2066}
2067
2068#[must_use]
2076pub fn allow_always(
2077 path: &Path,
2078 feature: Option<&str>,
2079 task: &str,
2080 call: &str,
2081 state: Option<&Path>,
2082 via: Via,
2083) -> CommandResult {
2084 let command = "config";
2085 let refuse = |m: &str| CommandResult::new(command, CommandStatus::UsageError, m);
2086 let Some(state) = state else {
2087 return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
2088 .with_code("NO-STATE-DIR");
2089 };
2090 let (feature, dir, root) = match locate(command, path, feature, state) {
2091 Ok(x) => x,
2092 Err(r) => return *r,
2093 };
2094 if !dir.join(crate::substrate::JOURNAL).exists() {
2095 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2096 }
2097 let Ok(id) = agentplane::core::TaskId::parse(call) else {
2098 return refuse("not a call id");
2099 };
2100 let deciding = match Deciding::open(&dir) {
2102 Ok(Some(d)) => d,
2103 Ok(None) => {
2104 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2105 }
2106 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
2107 };
2108 let row = match deciding.rt.block_on(deciding.plane.task(id)) {
2109 Ok(Some(row)) => row,
2110 Ok(None) => {
2111 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2112 }
2113 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e.0),
2114 };
2115 let Some(request) = tasks::request_of(&row.justification.proposed_action)
2116 .filter(|r| r.task == format!("{feature}/{task}"))
2117 else {
2118 return CommandResult::new(
2119 command,
2120 CommandStatus::NotFound,
2121 &format!("{task} has no such call"),
2122 );
2123 };
2124 let offered = matches!(request.kind.as_str(), "run" | "network")
2125 && request.options.iter().any(|o| o == "always");
2126 let Some(Object::Command { argv, network, .. }) = request.object.clone().filter(|_| offered)
2127 else {
2128 return refuse("Always Allow is not offered for this call");
2129 };
2130 let by = Who::local_in(&root, via);
2131 match row.state {
2132 agentplane::core::TaskState::Open | agentplane::core::TaskState::Claimed => {
2133 if let Err(e) = deciding.decide(&row, &Answer::always(Some(by.clone())), &by) {
2134 return CommandResult::new(command, CommandStatus::Error, &e);
2135 }
2136 }
2137 _ if take_answered_always(call) => {}
2138 _ => return refuse("this call was already decided"),
2139 }
2140 let key = if network {
2141 "sandbox.network_allow"
2142 } else {
2143 "commands.allow"
2144 };
2145 let r = crate::settings::apply_confirmed(
2146 &root,
2147 &[speccoder_core::ConfigChange {
2148 key: key.to_owned(),
2149 value: None,
2150 append: Some(serde_json::json!(argv)),
2151 }],
2152 );
2153 if r.status() != CommandStatus::Ok {
2154 return r;
2155 }
2156 let table = if network {
2157 "[sandbox] network_allow"
2158 } else {
2159 "[commands] allow"
2160 };
2161 CommandResult::new(
2162 command,
2163 CommandStatus::Ok,
2164 &format!(
2165 "allowed once; added exactly `{}` to {table} — nothing after it runs because of this (by {} via {}); commit speccoder.toml to keep it",
2166 argv.join(" "),
2167 by.name,
2168 by.via
2169 ),
2170 )
2171 .with_next(r.next().to_vec())
2172 .with_data(r.data().cloned().unwrap_or(CommandData::Config(Box::new(crate::settings::read(&root)))))
2173}
2174
2175#[must_use]
2177pub fn decide(path: &Path, feature: Option<&str>, verb: Verb<'_>, via: Via) -> CommandResult {
2178 decide_in(
2179 path,
2180 feature,
2181 verb,
2182 crate::worktree::state_dir().as_deref(),
2183 via,
2184 )
2185}
2186
2187#[must_use]
2189pub fn decide_in(
2190 path: &Path,
2191 feature: Option<&str>,
2192 verb: Verb<'_>,
2193 state: Option<&Path>,
2194 via: Via,
2195) -> CommandResult {
2196 match state {
2197 Some(state) => decide_with(path, feature, verb, state, via),
2198 None => CommandResult::new(
2199 "approvals",
2200 CommandStatus::ConfigError,
2201 "no state directory (set SPECCODER_STATE_DIR or HOME)",
2202 )
2203 .with_code("NO-STATE-DIR"),
2204 }
2205}
2206
2207#[cfg(test)]
2208mod tests {
2209 use super::*;
2210
2211 fn file(path: &str) -> Object {
2212 Object::Path {
2213 canonical: path.to_owned(),
2214 asked: path.to_owned(),
2215 }
2216 }
2217
2218 #[test]
2219 fn a_failure_part_way_through_says_what_was_decided_before_it() {
2220 let recorded = vec!["approved: write `a` for T005".to_owned()];
2221 let m = partly_decided(&recorded, "not decided: write `b` for T006: gone", 2);
2222 assert!(m.starts_with("approved: write `a` for T005\n"), "{m}");
2223 assert!(m.contains("not decided: write `b` for T006: gone"), "{m}");
2224 assert!(
2225 m.contains("1 of 2 call(s) were decided before it and stay decided"),
2226 "{m}"
2227 );
2228 assert_eq!(partly_decided(&[], "not decided: x", 1), "not decided: x");
2229 }
2230
2231 #[test]
2232 fn a_call_past_its_deadline_is_not_listed_as_waiting() {
2233 let now = agentplane::core::Timestamp::now_utc();
2234 let minute = std::time::Duration::from_secs(60);
2235 assert!(still_waits(None, now));
2236 assert!(still_waits(Some(now + minute), now));
2237 assert!(!still_waits(Some(now), now));
2238 assert!(!still_waits(Some(now - minute), now));
2239 }
2240
2241 #[test]
2242 fn decisions_round_trip_and_replace_each_other() {
2243 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2244 let g = file("Cargo.toml");
2245 let mut d = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2246 let by = Who::local(Via::Terminal);
2247 d.reject("f", "T1", g.clone(), "no new dependencies", &by);
2248 d.approve("f", "T1", g.clone(), Some("run-1"), &by);
2249 assert_eq!(d.tasks["f/T1"].decided.len(), 2);
2250 assert_eq!(d.tasks["f/T1"].decided[1].by, by);
2251 d.save(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2252 let back = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2253 assert_eq!(back.for_run("f", "T1", "run-1"), std::slice::from_ref(&g));
2254 assert!(back.for_task("f", "T1").rejected.is_empty());
2255 assert!(back.for_run("f", "T1", "run-2").is_empty());
2257 assert!(!back.decided("f", "T1", &g) && !back.decided("f", "T2", &g));
2258 }
2259
2260 #[test]
2261 fn a_lane_decision_is_claimed_by_the_next_run_only() {
2262 let mut d = Decisions::default();
2263 let by = Who::local(Via::Terminal);
2264 d.approve("f", "T1", file("package.json"), None, &by);
2265 assert!(d.decided("f", "T1", &file("package.json")));
2266 assert!(d.for_run("f", "T1", "run-2").is_empty());
2267 assert!(d.claim("f", "T1", "run-2"));
2268 assert_eq!(d.for_run("f", "T1", "run-2"), [file("package.json")]);
2269 assert!(!d.claim("f", "T1", "run-3"));
2271 assert!(d.for_run("f", "T1", "run-3").is_empty());
2272 }
2273
2274 #[test]
2275 fn a_decisions_file_from_before_objects_is_refused_by_name() {
2276 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2277 std::fs::write(
2278 dir.path().join(FILE),
2279 r#"{"tasks":{"f/T1":{"approved":[{"kind":"write","path":"Cargo.toml"}]}}}"#,
2280 )
2281 .unwrap_or_else(|e| panic!("{e}"));
2282 let e = Decisions::load(dir.path()).err().unwrap_or_default();
2283 assert!(
2284 e.contains(FILE) && e.contains("before decisions named objects"),
2285 "{e}"
2286 );
2287 }
2288
2289 #[test]
2290 fn remembered_options_need_an_object_and_text_not_copied_from_content() {
2291 let o = |risk, has, derived, kind, always| options(risk, has, derived, kind, always);
2292 assert_eq!(
2293 o("low", true, false, "run", "low"),
2294 ["once", "task", "run", "always", "reject", "stop"]
2295 );
2296 assert_eq!(
2298 o("high", true, false, "network", "low"),
2299 ["once", "task", "always", "reject", "stop"]
2300 );
2301 assert_eq!(
2302 o("high", true, false, "run", "high"),
2303 ["once", "task", "reject", "stop"]
2304 );
2305 assert_eq!(
2306 o("low", true, false, "write", "low"),
2307 ["once", "task", "run", "reject", "stop"]
2308 );
2309 assert_eq!(
2310 o("low", true, true, "run", "low"),
2311 ["once", "reject", "stop"]
2312 );
2313 assert_eq!(
2314 o("low", false, false, "run", "low"),
2315 ["once", "reject", "stop"]
2316 );
2317 }
2318
2319 #[test]
2320 fn a_runs_batch_lists_its_lanes_stopped_calls_too() {
2321 let call = |task: &str, id: &str, batched: bool, class: &str, run: &str| {
2322 with_digest(
2323 PendingCall {
2324 task: task.to_owned(),
2325 call: id.to_owned(),
2326 kind: tasks::APPROVAL.to_owned(),
2327 run: Some(run.to_owned()),
2328 batched,
2329 object: Some(speccoder_core::ObjectView {
2330 class: class.to_owned(),
2331 ..speccoder_core::ObjectView::default()
2332 }),
2333 ..PendingCall::default()
2334 },
2335 Some(&file(task)),
2336 )
2337 };
2338 let pending = vec![
2339 call("T1", "aa", true, "write", "run-1"),
2340 call("T2", "", false, "command", "run-1"),
2342 call("T3", "bb", false, "write", "run-2"),
2344 ];
2345 let b = batches(&pending);
2346 assert_eq!(b.len(), 1);
2347 assert_eq!(b[0].run, "run-1");
2348 let groups: Vec<(&str, Vec<String>)> = b[0]
2349 .groups
2350 .iter()
2351 .map(|g| (g.class.as_str(), g.calls.clone()))
2352 .collect();
2353 assert_eq!(
2354 groups,
2355 [
2356 ("write", vec!["aa".to_owned()]),
2357 ("command", vec!["attempt:T2".to_owned()])
2358 ]
2359 );
2360 assert_eq!(
2361 b[0].digest,
2362 batch_digest(&[pending[1].digest.clone(), pending[0].digest.clone()])
2363 );
2364 let mut newer = pending.clone();
2367 newer[1] = with_digest(newer[1].clone(), Some(&file("other.json")));
2368 assert_eq!(newer[1].id(), pending[1].id());
2369 assert_ne!(batches(&newer)[0].digest, b[0].digest);
2370 }
2371
2372 #[test]
2375 fn a_calls_digest_binds_its_id_and_object() {
2376 let a = call_digest("attempt:T1", Some(&file("a.json")));
2377 assert_eq!(a, call_digest("attempt:T1", Some(&file("a.json"))));
2378 assert_ne!(a, call_digest("attempt:T1", Some(&file("b.json"))));
2379 assert_ne!(a, call_digest("attempt:T2", Some(&file("a.json"))));
2380 assert_ne!(a, call_digest("attempt:T1", None));
2381 }
2382
2383 #[test]
2387 fn a_once_grant_is_given_to_one_attempt_only() {
2388 let mut d = Decisions::default();
2389 let by = Who::local(Via::Terminal);
2390 d.approve_once("f", "T1", file("package.json"), &by);
2391 assert!(d.decided("f", "T1", &file("package.json")));
2392 assert!(d.claim("f", "T1", "run-2"));
2393 assert!(d.for_run("f", "T1", "run-2").is_empty(), "not for the run");
2394 assert_eq!(d.take_once("f", "T1", "run-2"), [file("package.json")]);
2395 assert!(
2396 d.take_once("f", "T1", "run-2").is_empty(),
2397 "one attempt only"
2398 );
2399 assert_eq!(d.for_task("f", "T1").decided[0].scope, "once");
2400 let text = serde_json::to_string(&d).unwrap();
2402 assert_eq!(serde_json::from_str::<Decisions>(&text).unwrap(), d);
2403 d.approve("f", "T1", file("x"), Some("run-2"), &by);
2404 let grant = serde_json::to_value(&d.for_task("f", "T1").approved[0]).unwrap();
2405 assert!(grant.get("once").is_none(), "{grant}");
2406 }
2407}