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
738#[derive(Debug, Clone, PartialEq, Eq)]
740pub struct ShownReproduction {
741 pub statement: String,
743 pub words: String,
745 pub file: String,
747 pub test: String,
749 pub argv: Vec<String>,
752 pub excerpt: String,
754 pub content: String,
756}
757
758#[derive(Debug, Clone, PartialEq, Eq)]
760pub struct ConfirmRequest {
761 pub bug: String,
763 pub title: String,
765 pub digest: String,
767 pub reproductions: Vec<ShownReproduction>,
769 pub warning: Option<String>,
772}
773
774#[derive(Debug, Clone, PartialEq, Eq)]
776pub enum ConfirmAnswer {
777 Confirm,
779 Reject(String),
781}
782
783#[must_use]
788pub fn kind_of(tool: &str, arguments: &serde_json::Value) -> &'static str {
789 match tool {
790 "write_file" => "write",
791 "edit_file" => "edit",
792 "run_command" if arguments["network"] == true => "network",
793 "run_command" => "run",
794 t if t.starts_with(crate::mcp::PREFIX) => "mcp",
795 _ => "edit",
796 }
797}
798
799#[must_use]
807pub fn options(
808 risk: &str,
809 has_object: bool,
810 derived: bool,
811 kind: &str,
812 always_risk: &str,
813) -> Vec<String> {
814 let mut o = vec!["once"];
815 if has_object && !derived {
816 o.push("task");
817 if risk != "high" {
818 o.push("run");
819 }
820 if matches!(kind, "run" | "network") && always_risk != "high" {
821 o.push("always");
822 }
823 }
824 o.extend(["reject", "stop"]);
825 o.into_iter().map(str::to_owned).collect()
826}
827
828#[must_use]
831pub fn claim(text: &str) -> Option<String> {
832 let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
833 let line = present::escape(&line);
834 if line.is_empty() {
835 return None;
836 }
837 Some(if line.chars().count() > 200 {
838 format!("{}…", line.chars().take(199).collect::<String>())
839 } else {
840 line
841 })
842}
843
844#[must_use]
846pub fn risk(tool: &str, reason: &str) -> &'static str {
847 let r = reason.to_lowercase();
848 if tool == "run_command" && (r.contains("arbitrary") || r.contains("network")) {
849 "high"
850 } else if tool.starts_with(crate::mcp::PREFIX)
851 || r.contains("manifest")
852 || r.contains("frozen")
853 || r.contains("repository state")
854 || r.contains("recursive")
855 {
856 "medium"
857 } else {
858 "low"
859 }
860}
861
862fn locate(
866 command: &str,
867 path: &Path,
868 feature: Option<&str>,
869 state: &Path,
870) -> Result<(String, std::path::PathBuf, std::path::PathBuf), Box<CommandResult>> {
871 let inspected = crate::inspect(path, feature);
872 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
873 return Err(Box::new(CommandResult::carried(command, &inspected)));
874 };
875 let Some(f) = snapshot.feature.as_ref() else {
876 return Err(Box::new(CommandResult::new(
877 command,
878 CommandStatus::SelectionNeeded,
879 inspected.message(),
880 )));
881 };
882 let root = std::path::PathBuf::from(&snapshot.root);
883 Ok((
884 f.feature.name.clone(),
885 crate::worktree::project_state_dir(state, &root),
886 root,
887 ))
888}
889
890#[must_use]
892pub fn object_view(object: &Object) -> speccoder_core::ObjectView {
893 speccoder_core::ObjectView {
894 class: object.class().as_str().to_owned(),
895 short: object.short(),
896 describe: object.describe(),
897 lines: present::object_lines(Some(object), None),
898 }
899}
900
901fn saved_request(feature: &str, task: &str, pending: &serde_json::Value) -> Option<Request> {
904 let mut r: Request = serde_json::from_value(pending["request"].clone()).ok()?;
905 if r.task.is_empty() {
906 r.task = format!("{feature}/{task}");
907 }
908 Some(r)
909}
910
911#[must_use]
914pub fn call_digest(id: &str, object: Option<&Object>) -> String {
915 use sha2::{Digest as _, Sha256};
916 let object = serde_json::to_string(&object).unwrap_or_default();
917 Sha256::digest(format!("{id}\n{object}").as_bytes())
918 .iter()
919 .map(|b| format!("{b:02x}"))
920 .collect()
921}
922
923fn with_digest(mut p: PendingCall, object: Option<&Object>) -> PendingCall {
925 p.digest = call_digest(&p.id(), object);
926 p
927}
928
929fn pending(
931 root: &Path,
932 feature: &str,
933 open: &[String],
934 decisions: &Decisions,
935) -> Vec<(PendingCall, Object)> {
936 let mut seen = std::collections::BTreeSet::new();
937 let mut out = Vec::new();
938 for saved in crate::attempts::list(root, feature) {
939 if !open.contains(&saved.task) || !seen.insert(saved.task.clone()) {
940 continue; }
942 let Some(p) = saved.pending.filter(|_| saved.outcome == "needs_approval") else {
943 continue;
944 };
945 let Some(request) = saved_request(feature, &saved.task, &p) else {
946 continue;
947 };
948 let Some(object) = request.object.clone() else {
949 continue;
950 };
951 if decisions.decided(feature, &saved.task, &object) {
952 continue;
953 }
954 let presentation = view(root, &saved.task, &saved.reference, &request);
955 out.push((
956 with_digest(
957 PendingCall {
958 task: saved.task.clone(),
959 covers: object.describe(),
960 tool: request.tool.clone(),
961 arguments: request.arguments.clone(),
962 reason: request.reason.clone(),
963 reference: saved.reference.clone(),
964 presentation: Some(presentation),
965 source: "attempt".to_owned(),
966 kind: "approval".to_owned(),
967 object: Some(object_view(&object)),
968 sources: present::source_lines(&request),
969 run: request.run.clone(),
970 batched: request.batched,
971 ..PendingCall::default()
972 },
973 Some(&object),
974 ),
975 object,
976 ));
977 }
978 out
979}
980
981fn view(
984 root: &Path,
985 task: &str,
986 reference: &str,
987 request: &Request,
988) -> speccoder_core::ApprovalView {
989 let path = request.arguments["path"].as_str().unwrap_or_default();
990 let file_text = (!path.is_empty())
991 .then(|| {
992 crate::git::Git::at(root)
993 .run(&["show", &format!("{reference}:./{path}")])
994 .ok()
995 .map(|b| String::from_utf8_lossy(&b).into_owned())
996 })
997 .flatten();
998 presented(root, request, task, file_text)
999}
1000
1001fn presented(
1004 root: &Path,
1005 request: &Request,
1006 task: &str,
1007 file_text: Option<String>,
1008) -> speccoder_core::ApprovalView {
1009 let config = crate::verify::find_config(root)
1010 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
1011 .unwrap_or_default();
1012 let sandbox = crate::sandbox::parse(&config).ok();
1013 let p = present(
1014 request,
1015 &PresentContext {
1016 root: None,
1017 sandbox: sandbox
1018 .as_ref()
1019 .filter(|c| c.mode != crate::sandbox::Mode::Off)
1020 .map(|_| {
1021 if cfg!(target_os = "macos") {
1022 "Seatbelt".to_owned()
1023 } else {
1024 "Landlock+seccomp".to_owned()
1025 }
1026 }),
1027 network: match sandbox.map(|c| c.network) {
1028 Some(crate::sandbox::Network::Loopback) => "loopback",
1029 Some(crate::sandbox::Network::On) => "unrestricted",
1030 _ => "none",
1031 }
1032 .to_owned(),
1033 file_text,
1034 },
1035 );
1036 speccoder_core::ApprovalView {
1037 kind: request.kind.clone(),
1038 title: p.title,
1039 subject: p.subject,
1040 body_lines: p.body,
1041 sandbox: p.sandbox,
1042 claim: request.claim.clone(),
1043 risk: request.risk.clone(),
1044 scope: format!("for the rest of {task}: {}", p.scope),
1045 object: p.object,
1046 sources: p.sources,
1047 options: request.options.clone(),
1048 }
1049}
1050
1051fn decided(feature: &str, decisions: &Decisions) -> Vec<DecidedCall> {
1052 let prefix = format!("{feature}/");
1053 let mut out = Vec::new();
1054 for (key, d) in &decisions.tasks {
1055 let Some(task) = key.strip_prefix(&prefix) else {
1056 continue;
1057 };
1058 let last = |o: &Object| {
1059 d.decided
1060 .iter()
1061 .rev()
1062 .find(|x| x.object.as_ref() == Some(o))
1063 };
1064 out.extend(d.approved.iter().map(|g| DecidedCall {
1065 task: task.to_owned(),
1066 decision: "approved".to_owned(),
1067 covers: g.object.describe(),
1068 reason: None,
1069 by: last(&g.object).map(|x| x.by.describe()),
1070 note: last(&g.object).and_then(|x| x.note.clone()),
1071 operator: last(&g.object).map(|x| x.by.view()),
1072 via: last(&g.object).map(|x| x.by.via.clone()),
1073 client: last(&g.object).and_then(|x| x.by.client.clone()),
1074 object: Some(object_view(&g.object)),
1075 run: g.run.clone(),
1076 scope: Some(if g.once { "once" } else { "task" }.to_owned()),
1077 ..DecidedCall::default()
1078 }));
1079 out.extend(d.rejected.iter().map(|r| DecidedCall {
1080 task: task.to_owned(),
1081 decision: "rejected".to_owned(),
1082 covers: r.object.describe(),
1083 reason: Some(r.reason.clone()),
1084 by: last(&r.object).map(|x| x.by.describe()),
1085 scope: Some("task".to_owned()),
1086 note: None,
1087 operator: last(&r.object).map(|x| x.by.view()),
1088 via: last(&r.object).map(|x| x.by.via.clone()),
1089 client: last(&r.object).and_then(|x| x.by.client.clone()),
1090 object: Some(object_view(&r.object)),
1091 ..DecidedCall::default()
1092 }));
1093 out.extend(
1095 d.decided
1096 .iter()
1097 .filter(|x| x.verb == "approve" && x.scope == "run")
1098 .filter_map(|x| {
1099 let object = x.object.as_ref()?;
1100 Some(DecidedCall {
1101 task: task.to_owned(),
1102 decision: "approved".to_owned(),
1103 covers: object.describe(),
1104 reason: None,
1105 by: Some(x.by.describe()),
1106 scope: Some("run".to_owned()),
1107 note: x.note.clone(),
1108 operator: Some(x.by.view()),
1109 via: Some(x.by.via.clone()),
1110 client: x.by.client.clone(),
1111 object: Some(object_view(object)),
1112 run: x.run.clone(),
1113 ..DecidedCall::default()
1114 })
1115 }),
1116 );
1117 }
1118 out
1119}
1120
1121pub(crate) fn still_waits(
1124 due: Option<agentplane::core::Timestamp>,
1125 now: agentplane::core::Timestamp,
1126) -> bool {
1127 due.is_none_or(|due| due > now)
1128}
1129
1130type Waiting = (PendingCall, agentplane::core::Task);
1132
1133fn waiting_tasks(root: &Path, dir: &Path, feature: &str) -> Result<Vec<Waiting>, String> {
1143 let rt = crate::tokio_runtime()?;
1144 let Some(plane) = crate::substrate::Plane::open_for_reading(dir).map_err(|e| e.0)? else {
1145 return Ok(Vec::new());
1146 };
1147 waiting_on(&rt, &plane, root, feature)
1148}
1149
1150fn waiting_on(
1152 rt: &tokio::runtime::Runtime,
1153 plane: &crate::substrate::Plane,
1154 root: &Path,
1155 feature: &str,
1156) -> Result<Vec<Waiting>, String> {
1157 let open = rt
1158 .block_on(plane.open_tasks())
1159 .map_err(|e| format!("listing the waiting calls: {}", e.0))?;
1160 let now = agentplane::core::Timestamp::now_utc();
1161 let prefix = format!("{feature}/");
1162 Ok(open
1163 .into_iter()
1164 .filter(|t| t.kind == tasks::APPROVAL)
1165 .filter(|t| still_waits(t.due_at, now))
1166 .filter_map(|t| {
1167 let request = tasks::request_of(&t.justification.proposed_action)?;
1168 let task = request.task.strip_prefix(&prefix)?.to_owned();
1169 let view = presented(root, &request, &task, None);
1170 Some((
1171 with_digest(
1172 PendingCall {
1173 task,
1174 covers: request.covers(),
1175 object: request.object.as_ref().map(object_view),
1176 sources: present::source_lines(&request),
1177 run: request.run.clone(),
1178 batched: request.batched,
1179 tool: request.tool.clone(),
1180 arguments: request.arguments.clone(),
1181 reason: request.reason.clone(),
1182 reference: String::new(),
1183 presentation: Some(view),
1184 call: t.id.to_hex(),
1185 source: "task".to_owned(),
1186 kind: tasks::APPROVAL.to_owned(),
1187 question: None,
1188 waiting_since: Some(t.created_at.unix_timestamp().max(0).unsigned_abs()),
1189 deadline: t.due_at.map(|d| d.unix_timestamp().max(0).unsigned_abs()),
1190 digest: String::new(),
1191 },
1192 request.object.as_ref(),
1193 ),
1194 t,
1195 ))
1196 })
1197 .collect())
1198}
1199
1200struct Deciding {
1208 rt: tokio::runtime::Runtime,
1209 plane: crate::substrate::Plane,
1210}
1211
1212impl Deciding {
1213 fn open(dir: &Path) -> Result<Option<Self>, String> {
1218 if !dir.join(crate::substrate::JOURNAL).exists() {
1219 return Ok(None);
1220 }
1221 let rt = crate::tokio_runtime()?;
1222 let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1223 rt.block_on(plane.expire_due(agentplane::core::Timestamp::now_utc()))
1224 .map_err(|e| format!("denying the calls past their deadline: {}", e.0))?;
1225 Ok(Some(Self { rt, plane }))
1226 }
1227
1228 fn waiting(&self, root: &Path, feature: &str) -> Result<Vec<Waiting>, String> {
1230 waiting_on(&self.rt, &self.plane, root, feature)
1231 }
1232
1233 fn decide(
1237 &self,
1238 task: &agentplane::core::Task,
1239 answer: &Answer,
1240 by: &Who,
1241 ) -> Result<(), String> {
1242 if !still_waits(task.due_at, agentplane::core::Timestamp::now_utc()) {
1243 return Err(
1244 "its deadline passed before it was decided: it is denied as expired".to_owned(),
1245 );
1246 }
1247 let decision = tasks::decision_for(answer, by, None)
1248 .ok_or_else(|| format!("`{}` is not a name a decision can carry", by.name))?;
1249 self.rt
1250 .block_on(
1251 self.plane
1252 .decide_at(task.id, &decision, Some(task.justification.digest())),
1253 )
1254 .map(|_| ())
1255 .map_err(|e| e.0)
1256 }
1257}
1258
1259pub fn expire_due(
1268 dir: &Path,
1269 now: agentplane::core::Timestamp,
1270) -> Result<Option<std::path::PathBuf>, String> {
1271 let rt = crate::tokio_runtime()?;
1272 let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1273 rt.block_on(plane.expire_due(now)).map_err(|e| e.0)?;
1274 Ok(plane.replaced_journal.clone())
1275}
1276
1277#[derive(Debug, Default)]
1281pub struct Elsewhere {
1282 pub waiting: Option<(String, String)>,
1284 pub replaced_journal: Option<std::path::PathBuf>,
1286}
1287
1288pub fn waiting_elsewhere(
1297 root: &Path,
1298 dir: &Path,
1299 feature: &str,
1300 task: &str,
1301) -> Result<Elsewhere, String> {
1302 let Some(deciding) = Deciding::open(dir)? else {
1303 return Ok(Elsewhere::default());
1304 };
1305 let waiting = deciding
1306 .waiting(root, feature)?
1307 .into_iter()
1308 .map(|(c, _)| c)
1309 .find(|c| c.task != task && c.source == "task")
1310 .map(|c| (c.task, c.covers));
1311 Ok(Elsewhere {
1312 waiting,
1313 replaced_journal: deciding.plane.replaced_journal.clone(),
1314 })
1315}
1316
1317pub fn withdraw_waiting(
1326 root: &Path,
1327 dir: &Path,
1328 feature: &str,
1329 reason: &str,
1330) -> Result<usize, String> {
1331 let Some(deciding) = Deciding::open(dir)? else {
1332 return Ok(0);
1333 };
1334 let rows = deciding.waiting(root, feature)?;
1335 if rows.is_empty() {
1336 return Ok(0);
1337 }
1338 let by = agentplane::core::Operator::from_parts(who_in(root), "asserted")
1339 .ok()
1340 .ok_or_else(|| "no name to record the withdrawal under".to_owned())?;
1341 for (_, task) in &rows {
1342 deciding
1343 .rt
1344 .block_on(deciding.plane.withdraw(task, &by, reason))
1345 .map_err(|e| e.0)?;
1346 }
1347 Ok(rows.len())
1348}
1349
1350fn partly_decided(recorded: &[String], failed: &str, of: usize) -> String {
1353 if recorded.is_empty() {
1354 return failed.to_owned();
1355 }
1356 format!(
1357 "{}\n{failed}\n{} of {of} call(s) were decided before it and stay decided; `speccoder report --approvals` lists what still waits",
1358 recorded.join("\n"),
1359 recorded.len()
1360 )
1361}
1362
1363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1365pub enum Verb<'a> {
1366 List,
1368 Approve(&'a str),
1370 ApproveNoted(&'a str, &'a str),
1372 Reject(&'a str, &'a str),
1374 Decide(Choice<'a>),
1376}
1377
1378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1380pub struct Choice<'a> {
1381 pub task: Option<&'a str>,
1383 pub call: Option<&'a str>,
1385 pub all: bool,
1387 pub approve: bool,
1389 pub scope: Scope,
1391 pub note: Option<&'a str>,
1393 pub reason: Option<&'a str>,
1395 pub digest: Option<&'a str>,
1400}
1401
1402impl<'a> Choice<'a> {
1403 #[must_use]
1405 pub fn approve(task: &'a str, note: Option<&'a str>) -> Self {
1406 Self {
1407 task: Some(task),
1408 call: None,
1409 all: false,
1410 approve: true,
1411 scope: Scope::Task,
1412 note,
1413 reason: None,
1414 digest: None,
1415 }
1416 }
1417
1418 #[must_use]
1420 pub fn reject(task: &'a str, reason: &'a str) -> Self {
1421 Self {
1422 task: Some(task),
1423 call: None,
1424 all: false,
1425 approve: false,
1426 scope: Scope::Task,
1427 note: None,
1428 reason: Some(reason),
1429 digest: None,
1430 }
1431 }
1432
1433 #[must_use]
1436 pub fn shown(self, call: &'a str, digest: &'a str) -> Self {
1437 Self {
1438 call: (!call.starts_with("attempt:")).then_some(call),
1439 digest: Some(digest),
1440 ..self
1441 }
1442 }
1443}
1444
1445pub fn list_with(path: &Path, feature: Option<&str>, state: &Path) -> CommandResult {
1447 decide_with(path, feature, Verb::List, state, Via::Terminal)
1449}
1450
1451#[must_use]
1453pub fn list(path: &Path, feature: Option<&str>) -> CommandResult {
1454 decide(path, feature, Verb::List, Via::Terminal)
1455}
1456
1457pub fn decide_with(
1460 path: &Path,
1461 feature: Option<&str>,
1462 verb: Verb<'_>,
1463 state: &Path,
1464 via: Via,
1465) -> CommandResult {
1466 let command = match verb {
1467 Verb::List => "approvals",
1468 Verb::Approve(_) | Verb::ApproveNoted(..) => "approve",
1469 Verb::Reject(..) => "reject",
1470 Verb::Decide(c) if c.approve => "approve",
1471 Verb::Decide(_) => "reject",
1472 };
1473 let (feature, dir, root) = match locate(command, path, feature, state) {
1474 Ok(x) => x,
1475 Err(r) => return *r,
1476 };
1477 let (mut decisions, decisions_unreadable) = match Decisions::load(&dir) {
1480 Ok(d) => (d, None),
1481 Err(e) if verb == Verb::List => (Decisions::default(), Some(e)),
1482 Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1483 };
1484 let lane_pending = |decisions: &Decisions, open: &[String]| {
1487 if decisions_unreadable.is_some() {
1488 Vec::new()
1489 } else {
1490 pending(&root, &feature, open, decisions)
1491 }
1492 };
1493 let status = crate::status::report(&root, Some(&feature));
1495 let open: Vec<String> = match status.data() {
1496 Some(CommandData::Report(r)) => r
1497 .tasks
1498 .iter()
1499 .filter(|t| matches!(t.state.as_str(), "ready" | "blocked"))
1500 .map(|t| t.id.clone())
1501 .collect(),
1502 _ => Vec::new(),
1503 };
1504 let choice = match verb {
1507 Verb::List => None,
1508 Verb::Approve(t) => Some(Choice::approve(t, None)),
1509 Verb::ApproveNoted(t, n) => Some(Choice::approve(t, Some(n))),
1510 Verb::Reject(t, r) => Some(Choice::reject(t, r)),
1511 Verb::Decide(c) => Some(c),
1512 };
1513 let mut deciding = None;
1517 let listed = if choice.is_some() {
1518 Deciding::open(&dir).and_then(|d| {
1519 let rows = d
1520 .as_ref()
1521 .map_or_else(|| Ok(Vec::new()), |d| d.waiting(&root, &feature));
1522 deciding = d;
1523 rows
1524 })
1525 } else {
1526 waiting_tasks(&root, &dir, &feature)
1527 };
1528 let (tasks_waiting, unreadable) = match listed {
1529 Ok(rows) => (rows, None),
1530 Err(e) => (Vec::new(), Some(e)),
1531 };
1532 let unreadable = match (unreadable, &decisions_unreadable) {
1533 (Some(a), Some(b)) => Some(format!("{a}; {b}")),
1534 (a, b) => a.or_else(|| b.clone()),
1535 };
1536 let waiting = lane_pending(&decisions, &open);
1537
1538 if let (Some(_), Some(why)) = (choice, &unreadable) {
1539 return CommandResult::new(
1540 command,
1541 CommandStatus::Error,
1542 &format!("the calls waiting for a decision could not be read: {why}"),
1543 );
1544 }
1545 if let Some(c) = choice {
1546 let matching: Vec<&(PendingCall, agentplane::core::Task)> = tasks_waiting
1547 .iter()
1548 .filter(|(p, _)| c.task.is_none_or(|t| p.task == t))
1549 .filter(|(p, _)| {
1550 c.call
1551 .is_none_or(|id| !id.is_empty() && p.call.starts_with(id))
1552 })
1553 .collect();
1554 if let Some(shown) = c.digest
1555 && matching.iter().any(|(p, _)| p.digest != shown)
1556 {
1557 return CommandResult::new(
1558 command,
1559 CommandStatus::UsageError,
1560 "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1561 )
1562 .with_code("CALL-CHANGED");
1563 }
1564 if matching.len() > 1 && !c.all {
1565 let list: Vec<String> = matching
1566 .iter()
1567 .map(|(p, _)| {
1568 format!(
1569 "{} ({}, {})",
1570 &p.call[..12.min(p.call.len())],
1571 p.task,
1572 p.covers
1573 )
1574 })
1575 .collect();
1576 return CommandResult::new(
1577 command,
1578 CommandStatus::UsageError,
1579 &format!(
1580 "{} calls are waiting; name one with --call, or decide them all with --all: {}",
1581 matching.len(),
1582 list.join(", ")
1583 ),
1584 );
1585 }
1586 if let (false, Some(deciding)) = (matching.is_empty(), &deciding) {
1587 let by = Who::local_in(&root, via);
1588 let mut lines = Vec::new();
1589 for (call, row) in &matching {
1590 let answer = if c.approve {
1591 Answer::approve(c.scope, c.note.map(str::to_owned), Some(by.clone()))
1592 } else {
1593 Answer::reject(c.reason.unwrap_or("no reason given"), Some(by.clone()))
1594 };
1595 if let Err(e) = deciding.decide(row, &answer, &by) {
1596 let failed = format!("not decided: {} for {}: {e}", call.covers, call.task);
1597 return CommandResult::new(
1598 command,
1599 CommandStatus::Error,
1600 &partly_decided(&lines, &failed, matching.len()),
1601 );
1602 }
1603 lines.push(if c.approve {
1604 format!(
1605 "approved: {} for {} ({}) — by {} ({})",
1606 call.covers,
1607 call.task,
1608 c.scope.name(),
1609 by.name,
1610 by.basis
1611 )
1612 } else {
1613 format!(
1614 "rejected: {} for {} — by {} ({}); the model will be told: {}",
1615 call.covers,
1616 call.task,
1617 by.name,
1618 by.basis,
1619 c.reason.unwrap_or("no reason given")
1620 )
1621 });
1622 }
1623 let (after, unreadable) = match deciding.waiting(&root, &feature) {
1625 Ok(rows) => (rows, None),
1626 Err(e) => (Vec::new(), Some(e)),
1627 };
1628 let lanes = lane_pending(&decisions, &open);
1629 let report = ApprovalsReport {
1630 decided: decided(&feature, &decisions),
1631 pending: after
1632 .into_iter()
1633 .map(|(c, _)| c)
1634 .chain(lanes.into_iter().map(|(c, _)| c))
1635 .collect(),
1636 feature,
1637 unreadable,
1638 batch: Vec::new(),
1639 };
1640 return CommandResult::new(
1641 command,
1642 CommandStatus::Ok,
1643 &format!(
1644 "{}; `speccoder run` continues each attempt where it stopped",
1645 lines.join("\n")
1646 ),
1647 )
1648 .with_data(CommandData::Approvals(Box::new(report)));
1649 }
1650 if c.all || c.call.is_some() || c.task.is_none() {
1651 return CommandResult::new(
1652 command,
1653 CommandStatus::NotFound,
1654 "no call matching that awaits a decision (see `speccoder report --approvals`)",
1655 );
1656 }
1657 }
1658 let shown = match verb {
1662 Verb::Decide(c) => c.digest,
1663 _ => None,
1664 };
1665 let verb = match verb {
1666 Verb::Decide(c) => match (c.task, c.approve, c.note) {
1667 (Some(t), true, Some(n)) => Verb::ApproveNoted(t, n),
1668 (Some(t), true, None) => Verb::Approve(t),
1669 (Some(t), false, _) => Verb::Reject(t, c.reason.unwrap_or("no reason given")),
1670 (None, ..) => Verb::List,
1671 },
1672 v => v,
1673 };
1674
1675 let message = match verb {
1676 Verb::List | Verb::Decide(_) => {
1678 let n = waiting.len() + tasks_waiting.len();
1679 match (&unreadable, n) {
1680 (Some(why), 0) => format!("the calls waiting for a decision are unknown: {why}"),
1681 (Some(why), n) => {
1682 format!("{n} call(s) await a decision; others may wait unseen: {why}")
1683 }
1684 (None, 0) => "nothing awaits a decision".to_owned(),
1685 (None, n) => format!("{n} call(s) await a decision"),
1686 }
1687 }
1688 Verb::Approve(task) | Verb::ApproveNoted(task, _) | Verb::Reject(task, _) => {
1689 let Some((call, grant)) = waiting.iter().find(|(c, _)| c.task == task) else {
1690 return CommandResult::new(
1691 command,
1692 CommandStatus::NotFound,
1693 &format!(
1694 "{task} has no call awaiting a decision (see `speccoder report --approvals`)"
1695 ),
1696 );
1697 };
1698 if shown.is_some_and(|d| d != call.digest) {
1699 return CommandResult::new(
1700 command,
1701 CommandStatus::UsageError,
1702 "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1703 )
1704 .with_code("CALL-CHANGED");
1705 }
1706 let what = match verb {
1707 Verb::Reject(_, reason) => {
1708 decisions.reject(
1709 &feature,
1710 task,
1711 grant.clone(),
1712 reason,
1713 &Who::local_in(&root, via),
1714 );
1715 format!(
1716 "rejected: {} for {task} — the model will be told: {reason}",
1717 call.covers
1718 )
1719 }
1720 _ => {
1721 decisions.approve(
1722 &feature,
1723 task,
1724 grant.clone(),
1725 None,
1726 &Who::local_in(&root, via),
1727 );
1728 if let Verb::ApproveNoted(_, note) = verb
1729 && !note.trim().is_empty()
1730 {
1731 let t = decisions
1732 .tasks
1733 .entry(format!("{feature}/{task}"))
1734 .or_default();
1735 if let Some(last) = t.decided.last_mut() {
1736 last.note = Some(note.trim().to_owned());
1737 }
1738 t.nudges.push(format!(
1740 "A person approved {}: {}",
1741 call.covers,
1742 note.trim()
1743 ));
1744 }
1745 format!("approved: {} for {task}", call.covers)
1746 }
1747 };
1748 if let Err(e) = decisions.save(&dir) {
1749 return CommandResult::new(command, CommandStatus::Error, &e);
1750 }
1751 format!("{what}; run the task again to continue (speccoder run --task {task})")
1752 }
1753 };
1754 let lanes = lane_pending(&decisions, &open);
1755 let report = ApprovalsReport {
1756 decided: decided(&feature, &decisions),
1757 pending: tasks_waiting
1758 .into_iter()
1759 .map(|(c, _)| c)
1760 .chain(lanes.into_iter().map(|(c, _)| c))
1761 .collect(),
1762 feature,
1763 unreadable,
1764 batch: Vec::new(),
1765 };
1766 let report = ApprovalsReport {
1767 batch: batches(&report.pending),
1768 ..report
1769 };
1770 CommandResult::new(command, CommandStatus::Ok, &message)
1771 .with_data(CommandData::Approvals(Box::new(report)))
1772}
1773
1774#[must_use]
1780pub fn batch_digest(digests: &[String]) -> String {
1781 use sha2::{Digest as _, Sha256};
1782 let mut ids = digests.to_vec();
1783 ids.sort();
1784 Sha256::digest(ids.join("\n").as_bytes())
1785 .iter()
1786 .map(|b| format!("{b:02x}"))
1787 .collect()
1788}
1789
1790#[must_use]
1793pub fn batches(pending: &[PendingCall]) -> Vec<speccoder_core::BatchView> {
1794 let mut runs: Vec<String> = pending
1795 .iter()
1796 .filter(|p| p.batched)
1797 .filter_map(|p| p.run.clone())
1798 .collect();
1799 runs.sort();
1800 runs.dedup();
1801 runs.into_iter()
1802 .map(|run| {
1803 let of_run: Vec<&PendingCall> = pending
1804 .iter()
1805 .filter(|p| p.run.as_deref() == Some(run.as_str()) && p.kind == tasks::APPROVAL)
1806 .collect();
1807 let groups = ["write", "command", "network", "mcp"]
1808 .iter()
1809 .filter_map(|class| {
1810 let calls: Vec<String> = of_run
1811 .iter()
1812 .filter(|p| {
1813 p.object.as_ref().map_or("write", |o| o.class.as_str()) == *class
1814 })
1815 .map(|p| p.id())
1816 .collect();
1817 (!calls.is_empty()).then(|| speccoder_core::BatchGroup {
1818 class: (*class).to_owned(),
1819 calls,
1820 })
1821 })
1822 .collect::<Vec<_>>();
1823 let digests: Vec<String> = of_run.iter().map(|p| p.digest.clone()).collect();
1824 speccoder_core::BatchView {
1825 digest: batch_digest(&digests),
1826 run,
1827 groups,
1828 }
1829 })
1830 .collect()
1831}
1832
1833#[derive(Debug, Clone, Copy)]
1835pub struct BatchChoice<'a> {
1836 pub run: &'a str,
1838 pub approve: bool,
1840 pub calls: &'a [String],
1842 pub digest: &'a str,
1844 pub resume_asking: bool,
1846 pub reason: Option<&'a str>,
1848}
1849
1850#[must_use]
1854pub fn decide_batch(
1855 path: &Path,
1856 feature: Option<&str>,
1857 choice: BatchChoice<'_>,
1858 state: Option<&Path>,
1859 via: Via,
1860) -> CommandResult {
1861 let command = "approvals";
1862 let Some(state) = state else {
1863 return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
1864 .with_code("NO-STATE-DIR");
1865 };
1866 let (feature, dir, root) = match locate(command, path, feature, state) {
1867 Ok(x) => x,
1868 Err(r) => return *r,
1869 };
1870 let listed = decide_with(path, Some(&feature), Verb::List, state, via);
1871 let Some(CommandData::Approvals(report)) = listed.data().cloned() else {
1872 return listed;
1873 };
1874 if let Some(why) = &report.unreadable {
1875 return CommandResult::new(
1876 command,
1877 CommandStatus::Error,
1878 &format!("the calls waiting for a decision could not be read: {why}"),
1879 );
1880 }
1881 let Some(batch) = report.batch.iter().find(|b| b.run == choice.run) else {
1882 return CommandResult::new(
1883 command,
1884 CommandStatus::NotFound,
1885 &format!("no calls wait together in run {}", choice.run),
1886 );
1887 };
1888 let listed_ids: Vec<String> = batch.groups.iter().flat_map(|g| g.calls.clone()).collect();
1889 if batch.digest != choice.digest {
1890 return CommandResult::new(
1891 command,
1892 CommandStatus::UsageError,
1893 &format!(
1894 "the list changed; {} calls are waiting now",
1895 listed_ids.len()
1896 ),
1897 )
1898 .with_data(CommandData::Approvals(report.clone()));
1899 }
1900 if choice.calls.is_empty() || choice.calls.iter().any(|c| !listed_ids.contains(c)) {
1901 return CommandResult::new(
1902 command,
1903 CommandStatus::UsageError,
1904 "name calls of the batch (or none to decide them all)",
1905 );
1906 }
1907 let by = Who::local_in(&root, via);
1908 let mut decisions = match Decisions::load(&dir) {
1909 Ok(d) => d,
1910 Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1911 };
1912 let deciding = match Deciding::open(&dir) {
1914 Ok(d) => d,
1915 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
1916 };
1917 let rows = match deciding.as_ref().map(|d| d.waiting(&root, &feature)) {
1918 Some(Ok(r)) => r,
1919 None => Vec::new(),
1920 Some(Err(e)) => return CommandResult::new(command, CommandStatus::Error, &e),
1921 };
1922 let reason = choice.reason.unwrap_or("no reason given");
1923 let mut lines = Vec::new();
1924 for id in choice.calls {
1925 let mut answer = if choice.approve {
1926 Answer::approve(Scope::Once, None, Some(by.clone()))
1927 } else {
1928 Answer::reject(reason, Some(by.clone()))
1929 };
1930 answer.resume_asking = choice.resume_asking;
1931 if let Some(task) = id.strip_prefix("attempt:") {
1932 let Some(p) = report
1934 .pending
1935 .iter()
1936 .find(|p| p.call.is_empty() && p.task == task)
1937 else {
1938 continue;
1939 };
1940 let object = pending(&root, &feature, &[task.to_owned()], &decisions)
1942 .into_iter()
1943 .find(|(now, _)| now.digest == p.digest)
1944 .map(|(_, o)| o);
1945 let Some(object) = object else {
1946 return CommandResult::new(
1947 command,
1948 CommandStatus::UsageError,
1949 &format!("the list changed; {task}'s attempt stopped on another call"),
1950 )
1951 .with_code("CALL-CHANGED");
1952 };
1953 if choice.approve {
1954 decisions.approve_once(&feature, task, object, &by);
1956 } else {
1957 decisions.reject(&feature, task, object, reason, &by);
1958 }
1959 lines.push(format!("{}: {}", p.task, p.covers));
1960 continue;
1961 }
1962 let (Some((p, row)), Some(deciding)) =
1963 (rows.iter().find(|(p, _)| p.call == *id), &deciding)
1964 else {
1965 continue;
1966 };
1967 if let Err(e) = deciding.decide(row, &answer, &by) {
1968 let failed = format!("not decided: {}: {} — {e}", p.task, p.covers);
1969 let message = partly_decided(&lines, &failed, choice.calls.len());
1970 if let Err(e) = decisions.save(&dir) {
1971 return CommandResult::new(
1972 command,
1973 CommandStatus::Error,
1974 &format!("{message}\n{e}"),
1975 );
1976 }
1977 return CommandResult::new(command, CommandStatus::Error, &message);
1978 }
1979 lines.push(format!("{}: {}", p.task, p.covers));
1980 }
1981 if choice.resume_asking
1982 && let Some(first) = report.pending.iter().find(|p| listed_ids.contains(&p.id()))
1983 {
1984 decisions.note(
1985 &feature,
1986 &first.task,
1987 Decided {
1988 verb: "resume_asking".to_owned(),
1989 object: None,
1990 run: Some(choice.run.to_owned()),
1991 reason: None,
1992 scope: "run".to_owned(),
1993 note: None,
1994 by: by.clone(),
1995 at: now(),
1996 },
1997 );
1998 }
1999 if let Err(e) = decisions.save(&dir) {
2000 return CommandResult::new(command, CommandStatus::Error, &e);
2001 }
2002 let after = decide_with(path, Some(&feature), Verb::List, state, via);
2003 CommandResult::new(
2004 command,
2005 CommandStatus::Ok,
2006 &format!(
2007 "{} {} call(s) once — by {} ({}){}: {}; `speccoder run` continues",
2008 if choice.approve {
2009 "approved"
2010 } else {
2011 "rejected"
2012 },
2013 lines.len(),
2014 by.name,
2015 by.via,
2016 if choice.resume_asking {
2017 "; the run asks again"
2018 } else {
2019 ""
2020 },
2021 lines.join(", ")
2022 ),
2023 )
2024 .with_data(
2025 after
2026 .data()
2027 .cloned()
2028 .unwrap_or(CommandData::Approvals(report)),
2029 )
2030}
2031
2032#[must_use]
2040pub fn allow_always(
2041 path: &Path,
2042 feature: Option<&str>,
2043 task: &str,
2044 call: &str,
2045 state: Option<&Path>,
2046 via: Via,
2047) -> CommandResult {
2048 let command = "config";
2049 let refuse = |m: &str| CommandResult::new(command, CommandStatus::UsageError, m);
2050 let Some(state) = state else {
2051 return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
2052 .with_code("NO-STATE-DIR");
2053 };
2054 let (feature, dir, root) = match locate(command, path, feature, state) {
2055 Ok(x) => x,
2056 Err(r) => return *r,
2057 };
2058 if !dir.join(crate::substrate::JOURNAL).exists() {
2059 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2060 }
2061 let Ok(id) = agentplane::core::TaskId::parse(call) else {
2062 return refuse("not a call id");
2063 };
2064 let deciding = match Deciding::open(&dir) {
2066 Ok(Some(d)) => d,
2067 Ok(None) => {
2068 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2069 }
2070 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
2071 };
2072 let row = match deciding.rt.block_on(deciding.plane.task(id)) {
2073 Ok(Some(row)) => row,
2074 Ok(None) => {
2075 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2076 }
2077 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e.0),
2078 };
2079 let Some(request) = tasks::request_of(&row.justification.proposed_action)
2080 .filter(|r| r.task == format!("{feature}/{task}"))
2081 else {
2082 return CommandResult::new(
2083 command,
2084 CommandStatus::NotFound,
2085 &format!("{task} has no such call"),
2086 );
2087 };
2088 let offered = matches!(request.kind.as_str(), "run" | "network")
2089 && request.options.iter().any(|o| o == "always");
2090 let Some(Object::Command { argv, network, .. }) = request.object.clone().filter(|_| offered)
2091 else {
2092 return refuse("Always Allow is not offered for this call");
2093 };
2094 let by = Who::local_in(&root, via);
2095 match row.state {
2096 agentplane::core::TaskState::Open | agentplane::core::TaskState::Claimed => {
2097 if let Err(e) = deciding.decide(&row, &Answer::always(Some(by.clone())), &by) {
2098 return CommandResult::new(command, CommandStatus::Error, &e);
2099 }
2100 }
2101 _ if take_answered_always(call) => {}
2102 _ => return refuse("this call was already decided"),
2103 }
2104 let key = if network {
2105 "sandbox.network_allow"
2106 } else {
2107 "commands.allow"
2108 };
2109 let r = crate::settings::apply_confirmed(
2110 &root,
2111 &[speccoder_core::ConfigChange {
2112 key: key.to_owned(),
2113 value: None,
2114 append: Some(serde_json::json!(argv)),
2115 }],
2116 );
2117 if r.status() != CommandStatus::Ok {
2118 return r;
2119 }
2120 let table = if network {
2121 "[sandbox] network_allow"
2122 } else {
2123 "[commands] allow"
2124 };
2125 CommandResult::new(
2126 command,
2127 CommandStatus::Ok,
2128 &format!(
2129 "allowed once; added exactly `{}` to {table} — nothing after it runs because of this (by {} via {}); commit speccoder.toml to keep it",
2130 argv.join(" "),
2131 by.name,
2132 by.via
2133 ),
2134 )
2135 .with_next(r.next().to_vec())
2136 .with_data(r.data().cloned().unwrap_or(CommandData::Config(Box::new(crate::settings::read(&root)))))
2137}
2138
2139#[must_use]
2141pub fn decide(path: &Path, feature: Option<&str>, verb: Verb<'_>, via: Via) -> CommandResult {
2142 decide_in(
2143 path,
2144 feature,
2145 verb,
2146 crate::worktree::state_dir().as_deref(),
2147 via,
2148 )
2149}
2150
2151#[must_use]
2153pub fn decide_in(
2154 path: &Path,
2155 feature: Option<&str>,
2156 verb: Verb<'_>,
2157 state: Option<&Path>,
2158 via: Via,
2159) -> CommandResult {
2160 match state {
2161 Some(state) => decide_with(path, feature, verb, state, via),
2162 None => CommandResult::new(
2163 "approvals",
2164 CommandStatus::ConfigError,
2165 "no state directory (set SPECCODER_STATE_DIR or HOME)",
2166 )
2167 .with_code("NO-STATE-DIR"),
2168 }
2169}
2170
2171#[cfg(test)]
2172mod tests {
2173 use super::*;
2174
2175 fn file(path: &str) -> Object {
2176 Object::Path {
2177 canonical: path.to_owned(),
2178 asked: path.to_owned(),
2179 }
2180 }
2181
2182 #[test]
2183 fn a_failure_part_way_through_says_what_was_decided_before_it() {
2184 let recorded = vec!["approved: write `a` for T005".to_owned()];
2185 let m = partly_decided(&recorded, "not decided: write `b` for T006: gone", 2);
2186 assert!(m.starts_with("approved: write `a` for T005\n"), "{m}");
2187 assert!(m.contains("not decided: write `b` for T006: gone"), "{m}");
2188 assert!(
2189 m.contains("1 of 2 call(s) were decided before it and stay decided"),
2190 "{m}"
2191 );
2192 assert_eq!(partly_decided(&[], "not decided: x", 1), "not decided: x");
2193 }
2194
2195 #[test]
2196 fn a_call_past_its_deadline_is_not_listed_as_waiting() {
2197 let now = agentplane::core::Timestamp::now_utc();
2198 let minute = std::time::Duration::from_secs(60);
2199 assert!(still_waits(None, now));
2200 assert!(still_waits(Some(now + minute), now));
2201 assert!(!still_waits(Some(now), now));
2202 assert!(!still_waits(Some(now - minute), now));
2203 }
2204
2205 #[test]
2206 fn decisions_round_trip_and_replace_each_other() {
2207 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2208 let g = file("Cargo.toml");
2209 let mut d = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2210 let by = Who::local(Via::Terminal);
2211 d.reject("f", "T1", g.clone(), "no new dependencies", &by);
2212 d.approve("f", "T1", g.clone(), Some("run-1"), &by);
2213 assert_eq!(d.tasks["f/T1"].decided.len(), 2);
2214 assert_eq!(d.tasks["f/T1"].decided[1].by, by);
2215 d.save(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2216 let back = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2217 assert_eq!(back.for_run("f", "T1", "run-1"), std::slice::from_ref(&g));
2218 assert!(back.for_task("f", "T1").rejected.is_empty());
2219 assert!(back.for_run("f", "T1", "run-2").is_empty());
2221 assert!(!back.decided("f", "T1", &g) && !back.decided("f", "T2", &g));
2222 }
2223
2224 #[test]
2225 fn a_lane_decision_is_claimed_by_the_next_run_only() {
2226 let mut d = Decisions::default();
2227 let by = Who::local(Via::Terminal);
2228 d.approve("f", "T1", file("package.json"), None, &by);
2229 assert!(d.decided("f", "T1", &file("package.json")));
2230 assert!(d.for_run("f", "T1", "run-2").is_empty());
2231 assert!(d.claim("f", "T1", "run-2"));
2232 assert_eq!(d.for_run("f", "T1", "run-2"), [file("package.json")]);
2233 assert!(!d.claim("f", "T1", "run-3"));
2235 assert!(d.for_run("f", "T1", "run-3").is_empty());
2236 }
2237
2238 #[test]
2239 fn a_decisions_file_from_before_objects_is_refused_by_name() {
2240 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2241 std::fs::write(
2242 dir.path().join(FILE),
2243 r#"{"tasks":{"f/T1":{"approved":[{"kind":"write","path":"Cargo.toml"}]}}}"#,
2244 )
2245 .unwrap_or_else(|e| panic!("{e}"));
2246 let e = Decisions::load(dir.path()).err().unwrap_or_default();
2247 assert!(
2248 e.contains(FILE) && e.contains("before decisions named objects"),
2249 "{e}"
2250 );
2251 }
2252
2253 #[test]
2254 fn remembered_options_need_an_object_and_text_not_copied_from_content() {
2255 let o = |risk, has, derived, kind, always| options(risk, has, derived, kind, always);
2256 assert_eq!(
2257 o("low", true, false, "run", "low"),
2258 ["once", "task", "run", "always", "reject", "stop"]
2259 );
2260 assert_eq!(
2262 o("high", true, false, "network", "low"),
2263 ["once", "task", "always", "reject", "stop"]
2264 );
2265 assert_eq!(
2266 o("high", true, false, "run", "high"),
2267 ["once", "task", "reject", "stop"]
2268 );
2269 assert_eq!(
2270 o("low", true, false, "write", "low"),
2271 ["once", "task", "run", "reject", "stop"]
2272 );
2273 assert_eq!(
2274 o("low", true, true, "run", "low"),
2275 ["once", "reject", "stop"]
2276 );
2277 assert_eq!(
2278 o("low", false, false, "run", "low"),
2279 ["once", "reject", "stop"]
2280 );
2281 }
2282
2283 #[test]
2284 fn a_runs_batch_lists_its_lanes_stopped_calls_too() {
2285 let call = |task: &str, id: &str, batched: bool, class: &str, run: &str| {
2286 with_digest(
2287 PendingCall {
2288 task: task.to_owned(),
2289 call: id.to_owned(),
2290 kind: tasks::APPROVAL.to_owned(),
2291 run: Some(run.to_owned()),
2292 batched,
2293 object: Some(speccoder_core::ObjectView {
2294 class: class.to_owned(),
2295 ..speccoder_core::ObjectView::default()
2296 }),
2297 ..PendingCall::default()
2298 },
2299 Some(&file(task)),
2300 )
2301 };
2302 let pending = vec![
2303 call("T1", "aa", true, "write", "run-1"),
2304 call("T2", "", false, "command", "run-1"),
2306 call("T3", "bb", false, "write", "run-2"),
2308 ];
2309 let b = batches(&pending);
2310 assert_eq!(b.len(), 1);
2311 assert_eq!(b[0].run, "run-1");
2312 let groups: Vec<(&str, Vec<String>)> = b[0]
2313 .groups
2314 .iter()
2315 .map(|g| (g.class.as_str(), g.calls.clone()))
2316 .collect();
2317 assert_eq!(
2318 groups,
2319 [
2320 ("write", vec!["aa".to_owned()]),
2321 ("command", vec!["attempt:T2".to_owned()])
2322 ]
2323 );
2324 assert_eq!(
2325 b[0].digest,
2326 batch_digest(&[pending[1].digest.clone(), pending[0].digest.clone()])
2327 );
2328 let mut newer = pending.clone();
2331 newer[1] = with_digest(newer[1].clone(), Some(&file("other.json")));
2332 assert_eq!(newer[1].id(), pending[1].id());
2333 assert_ne!(batches(&newer)[0].digest, b[0].digest);
2334 }
2335
2336 #[test]
2339 fn a_calls_digest_binds_its_id_and_object() {
2340 let a = call_digest("attempt:T1", Some(&file("a.json")));
2341 assert_eq!(a, call_digest("attempt:T1", Some(&file("a.json"))));
2342 assert_ne!(a, call_digest("attempt:T1", Some(&file("b.json"))));
2343 assert_ne!(a, call_digest("attempt:T2", Some(&file("a.json"))));
2344 assert_ne!(a, call_digest("attempt:T1", None));
2345 }
2346
2347 #[test]
2351 fn a_once_grant_is_given_to_one_attempt_only() {
2352 let mut d = Decisions::default();
2353 let by = Who::local(Via::Terminal);
2354 d.approve_once("f", "T1", file("package.json"), &by);
2355 assert!(d.decided("f", "T1", &file("package.json")));
2356 assert!(d.claim("f", "T1", "run-2"));
2357 assert!(d.for_run("f", "T1", "run-2").is_empty(), "not for the run");
2358 assert_eq!(d.take_once("f", "T1", "run-2"), [file("package.json")]);
2359 assert!(
2360 d.take_once("f", "T1", "run-2").is_empty(),
2361 "one attempt only"
2362 );
2363 assert_eq!(d.for_task("f", "T1").decided[0].scope, "once");
2364 let text = serde_json::to_string(&d).unwrap();
2366 assert_eq!(serde_json::from_str::<Decisions>(&text).unwrap(), d);
2367 d.approve("f", "T1", file("x"), Some("run-2"), &by);
2368 let grant = serde_json::to_value(&d.for_task("f", "T1").approved[0]).unwrap();
2369 assert!(grant.get("once").is_none(), "{grant}");
2370 }
2371}