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
1121fn waiting_tasks(
1128 root: &Path,
1129 dir: &Path,
1130 feature: &str,
1131) -> Result<Vec<(PendingCall, agentplane::core::Task)>, String> {
1132 if !dir.join(crate::substrate::JOURNAL).exists() {
1133 return Ok(Vec::new());
1134 }
1135 let rt = crate::tokio_runtime()?;
1136 let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1137 let _ = rt.block_on(plane.expire_due(agentplane::core::Timestamp::now_utc()));
1139 let open = rt
1140 .block_on(plane.open_tasks())
1141 .map_err(|e| format!("listing the waiting calls: {}", e.0))?;
1142 let prefix = format!("{feature}/");
1143 Ok(open
1144 .into_iter()
1145 .filter(|t| t.kind == tasks::APPROVAL)
1146 .filter_map(|t| {
1147 let request = tasks::request_of(&t.justification.proposed_action)?;
1148 let task = request.task.strip_prefix(&prefix)?.to_owned();
1149 let view = presented(root, &request, &task, None);
1150 Some((
1151 with_digest(
1152 PendingCall {
1153 task,
1154 covers: request.covers(),
1155 object: request.object.as_ref().map(object_view),
1156 sources: present::source_lines(&request),
1157 run: request.run.clone(),
1158 batched: request.batched,
1159 tool: request.tool.clone(),
1160 arguments: request.arguments.clone(),
1161 reason: request.reason.clone(),
1162 reference: String::new(),
1163 presentation: Some(view),
1164 call: t.id.to_hex(),
1165 source: "task".to_owned(),
1166 kind: tasks::APPROVAL.to_owned(),
1167 question: None,
1168 waiting_since: Some(t.created_at.unix_timestamp().max(0).unsigned_abs()),
1169 deadline: t.due_at.map(|d| d.unix_timestamp().max(0).unsigned_abs()),
1170 digest: String::new(),
1171 },
1172 request.object.as_ref(),
1173 ),
1174 t,
1175 ))
1176 })
1177 .collect())
1178}
1179
1180pub fn expire_due(dir: &Path, now: agentplane::core::Timestamp) -> Result<(), String> {
1184 let rt = crate::tokio_runtime()?;
1185 let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1186 rt.block_on(plane.expire_due(now)).map_err(|e| e.0)
1187}
1188
1189#[must_use]
1193pub fn waiting_elsewhere(
1194 root: &Path,
1195 dir: &Path,
1196 feature: &str,
1197 task: &str,
1198) -> Option<(String, String)> {
1199 waiting_tasks(root, dir, feature)
1202 .ok()?
1203 .into_iter()
1204 .map(|(c, _)| c)
1205 .find(|c| c.task != task && c.source == "task")
1206 .map(|c| (c.task, c.covers))
1207}
1208
1209pub fn withdraw_waiting(
1212 root: &Path,
1213 dir: &Path,
1214 feature: &str,
1215 reason: &str,
1216) -> Result<usize, String> {
1217 let rows = waiting_tasks(root, dir, feature)?;
1218 if rows.is_empty() {
1219 return Ok(0);
1220 }
1221 let rt = crate::tokio_runtime()?;
1222 let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1223 let by = agentplane::core::Operator::from_parts(who_in(root), "asserted")
1224 .ok()
1225 .ok_or_else(|| "no name to record the withdrawal under".to_owned())?;
1226 for (_, task) in &rows {
1227 rt.block_on(plane.withdraw(task, &by, reason))
1228 .map_err(|e| e.0)?;
1229 }
1230 Ok(rows.len())
1231}
1232
1233fn decide_waiting(
1236 dir: &Path,
1237 task: &agentplane::core::Task,
1238 answer: &Answer,
1239 by: &Who,
1240) -> Result<(), String> {
1241 let rt = crate::tokio_runtime()?;
1242 let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1243 let decision = tasks::decision_for(answer, by, None)
1244 .ok_or_else(|| format!("`{}` is not a name a decision can carry", by.name))?;
1245 rt.block_on(plane.decide(task.id, &decision))
1246 .map(|_| ())
1247 .map_err(|e| e.0)
1248}
1249
1250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1252pub enum Verb<'a> {
1253 List,
1255 Approve(&'a str),
1257 ApproveNoted(&'a str, &'a str),
1259 Reject(&'a str, &'a str),
1261 Decide(Choice<'a>),
1263}
1264
1265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1267pub struct Choice<'a> {
1268 pub task: Option<&'a str>,
1270 pub call: Option<&'a str>,
1272 pub all: bool,
1274 pub approve: bool,
1276 pub scope: Scope,
1278 pub note: Option<&'a str>,
1280 pub reason: Option<&'a str>,
1282 pub digest: Option<&'a str>,
1287}
1288
1289impl<'a> Choice<'a> {
1290 #[must_use]
1292 pub fn approve(task: &'a str, note: Option<&'a str>) -> Self {
1293 Self {
1294 task: Some(task),
1295 call: None,
1296 all: false,
1297 approve: true,
1298 scope: Scope::Task,
1299 note,
1300 reason: None,
1301 digest: None,
1302 }
1303 }
1304
1305 #[must_use]
1307 pub fn reject(task: &'a str, reason: &'a str) -> Self {
1308 Self {
1309 task: Some(task),
1310 call: None,
1311 all: false,
1312 approve: false,
1313 scope: Scope::Task,
1314 note: None,
1315 reason: Some(reason),
1316 digest: None,
1317 }
1318 }
1319
1320 #[must_use]
1323 pub fn shown(self, call: &'a str, digest: &'a str) -> Self {
1324 Self {
1325 call: (!call.starts_with("attempt:")).then_some(call),
1326 digest: Some(digest),
1327 ..self
1328 }
1329 }
1330}
1331
1332pub fn list_with(path: &Path, feature: Option<&str>, state: &Path) -> CommandResult {
1334 decide_with(path, feature, Verb::List, state, Via::Terminal)
1336}
1337
1338#[must_use]
1340pub fn list(path: &Path, feature: Option<&str>) -> CommandResult {
1341 decide(path, feature, Verb::List, Via::Terminal)
1342}
1343
1344pub fn decide_with(
1347 path: &Path,
1348 feature: Option<&str>,
1349 verb: Verb<'_>,
1350 state: &Path,
1351 via: Via,
1352) -> CommandResult {
1353 let command = match verb {
1354 Verb::List => "approvals",
1355 Verb::Approve(_) | Verb::ApproveNoted(..) => "approve",
1356 Verb::Reject(..) => "reject",
1357 Verb::Decide(c) if c.approve => "approve",
1358 Verb::Decide(_) => "reject",
1359 };
1360 let (feature, dir, root) = match locate(command, path, feature, state) {
1361 Ok(x) => x,
1362 Err(r) => return *r,
1363 };
1364 let (mut decisions, decisions_unreadable) = match Decisions::load(&dir) {
1367 Ok(d) => (d, None),
1368 Err(e) if verb == Verb::List => (Decisions::default(), Some(e)),
1369 Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1370 };
1371 let lane_pending = |decisions: &Decisions, open: &[String]| {
1374 if decisions_unreadable.is_some() {
1375 Vec::new()
1376 } else {
1377 pending(&root, &feature, open, decisions)
1378 }
1379 };
1380 let status = crate::status::report(&root, Some(&feature));
1382 let open: Vec<String> = match status.data() {
1383 Some(CommandData::Report(r)) => r
1384 .tasks
1385 .iter()
1386 .filter(|t| matches!(t.state.as_str(), "ready" | "blocked"))
1387 .map(|t| t.id.clone())
1388 .collect(),
1389 _ => Vec::new(),
1390 };
1391 let (tasks_waiting, unreadable) = match waiting_tasks(&root, &dir, &feature) {
1392 Ok(rows) => (rows, None),
1393 Err(e) => (Vec::new(), Some(e)),
1394 };
1395 let unreadable = match (unreadable, &decisions_unreadable) {
1396 (Some(a), Some(b)) => Some(format!("{a}; {b}")),
1397 (a, b) => a.or_else(|| b.clone()),
1398 };
1399 let waiting = lane_pending(&decisions, &open);
1400
1401 let choice = match verb {
1404 Verb::List => None,
1405 Verb::Approve(t) => Some(Choice::approve(t, None)),
1406 Verb::ApproveNoted(t, n) => Some(Choice::approve(t, Some(n))),
1407 Verb::Reject(t, r) => Some(Choice::reject(t, r)),
1408 Verb::Decide(c) => Some(c),
1409 };
1410 if let (Some(_), Some(why)) = (choice, &unreadable) {
1411 return CommandResult::new(
1412 command,
1413 CommandStatus::Error,
1414 &format!("the calls waiting for a decision could not be read: {why}"),
1415 );
1416 }
1417 if let Some(c) = choice {
1418 let matching: Vec<&(PendingCall, agentplane::core::Task)> = tasks_waiting
1419 .iter()
1420 .filter(|(p, _)| c.task.is_none_or(|t| p.task == t))
1421 .filter(|(p, _)| {
1422 c.call
1423 .is_none_or(|id| !id.is_empty() && p.call.starts_with(id))
1424 })
1425 .collect();
1426 if let Some(shown) = c.digest
1427 && matching.iter().any(|(p, _)| p.digest != shown)
1428 {
1429 return CommandResult::new(
1430 command,
1431 CommandStatus::UsageError,
1432 "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1433 )
1434 .with_code("CALL-CHANGED");
1435 }
1436 if matching.len() > 1 && !c.all {
1437 let list: Vec<String> = matching
1438 .iter()
1439 .map(|(p, _)| {
1440 format!(
1441 "{} ({}, {})",
1442 &p.call[..12.min(p.call.len())],
1443 p.task,
1444 p.covers
1445 )
1446 })
1447 .collect();
1448 return CommandResult::new(
1449 command,
1450 CommandStatus::UsageError,
1451 &format!(
1452 "{} calls are waiting; name one with --call, or decide them all with --all: {}",
1453 matching.len(),
1454 list.join(", ")
1455 ),
1456 );
1457 }
1458 if !matching.is_empty() {
1459 let by = Who::local_in(&root, via);
1460 let mut lines = Vec::new();
1461 for (call, row) in &matching {
1462 let answer = if c.approve {
1463 Answer::approve(c.scope, c.note.map(str::to_owned), Some(by.clone()))
1464 } else {
1465 Answer::reject(c.reason.unwrap_or("no reason given"), Some(by.clone()))
1466 };
1467 if let Err(e) = decide_waiting(&dir, row, &answer, &by) {
1468 return CommandResult::new(command, CommandStatus::Error, &e);
1469 }
1470 lines.push(if c.approve {
1471 format!(
1472 "approved: {} for {} ({}) — by {} ({})",
1473 call.covers,
1474 call.task,
1475 c.scope.name(),
1476 by.name,
1477 by.basis
1478 )
1479 } else {
1480 format!(
1481 "rejected: {} for {} — by {} ({}); the model will be told: {}",
1482 call.covers,
1483 call.task,
1484 by.name,
1485 by.basis,
1486 c.reason.unwrap_or("no reason given")
1487 )
1488 });
1489 }
1490 let (after, unreadable) = match waiting_tasks(&root, &dir, &feature) {
1492 Ok(rows) => (rows, None),
1493 Err(e) => (Vec::new(), Some(e)),
1494 };
1495 let lanes = lane_pending(&decisions, &open);
1496 let report = ApprovalsReport {
1497 decided: decided(&feature, &decisions),
1498 pending: after
1499 .into_iter()
1500 .map(|(c, _)| c)
1501 .chain(lanes.into_iter().map(|(c, _)| c))
1502 .collect(),
1503 feature,
1504 unreadable,
1505 batch: Vec::new(),
1506 };
1507 return CommandResult::new(
1508 command,
1509 CommandStatus::Ok,
1510 &format!(
1511 "{}; `speccoder run` continues each attempt where it stopped",
1512 lines.join("\n")
1513 ),
1514 )
1515 .with_data(CommandData::Approvals(Box::new(report)));
1516 }
1517 if c.all || c.call.is_some() || c.task.is_none() {
1518 return CommandResult::new(
1519 command,
1520 CommandStatus::NotFound,
1521 "no call matching that awaits a decision (see `speccoder report --approvals`)",
1522 );
1523 }
1524 }
1525 let shown = match verb {
1529 Verb::Decide(c) => c.digest,
1530 _ => None,
1531 };
1532 let verb = match verb {
1533 Verb::Decide(c) => match (c.task, c.approve, c.note) {
1534 (Some(t), true, Some(n)) => Verb::ApproveNoted(t, n),
1535 (Some(t), true, None) => Verb::Approve(t),
1536 (Some(t), false, _) => Verb::Reject(t, c.reason.unwrap_or("no reason given")),
1537 (None, ..) => Verb::List,
1538 },
1539 v => v,
1540 };
1541
1542 let message = match verb {
1543 Verb::List | Verb::Decide(_) => {
1545 let n = waiting.len() + tasks_waiting.len();
1546 match (&unreadable, n) {
1547 (Some(why), 0) => format!("the calls waiting for a decision are unknown: {why}"),
1548 (Some(why), n) => {
1549 format!("{n} call(s) await a decision; others may wait unseen: {why}")
1550 }
1551 (None, 0) => "nothing awaits a decision".to_owned(),
1552 (None, n) => format!("{n} call(s) await a decision"),
1553 }
1554 }
1555 Verb::Approve(task) | Verb::ApproveNoted(task, _) | Verb::Reject(task, _) => {
1556 let Some((call, grant)) = waiting.iter().find(|(c, _)| c.task == task) else {
1557 return CommandResult::new(
1558 command,
1559 CommandStatus::NotFound,
1560 &format!(
1561 "{task} has no call awaiting a decision (see `speccoder report --approvals`)"
1562 ),
1563 );
1564 };
1565 if shown.is_some_and(|d| d != call.digest) {
1566 return CommandResult::new(
1567 command,
1568 CommandStatus::UsageError,
1569 "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1570 )
1571 .with_code("CALL-CHANGED");
1572 }
1573 let what = match verb {
1574 Verb::Reject(_, reason) => {
1575 decisions.reject(
1576 &feature,
1577 task,
1578 grant.clone(),
1579 reason,
1580 &Who::local_in(&root, via),
1581 );
1582 format!(
1583 "rejected: {} for {task} — the model will be told: {reason}",
1584 call.covers
1585 )
1586 }
1587 _ => {
1588 decisions.approve(
1589 &feature,
1590 task,
1591 grant.clone(),
1592 None,
1593 &Who::local_in(&root, via),
1594 );
1595 if let Verb::ApproveNoted(_, note) = verb
1596 && !note.trim().is_empty()
1597 {
1598 let t = decisions
1599 .tasks
1600 .entry(format!("{feature}/{task}"))
1601 .or_default();
1602 if let Some(last) = t.decided.last_mut() {
1603 last.note = Some(note.trim().to_owned());
1604 }
1605 t.nudges.push(format!(
1607 "A person approved {}: {}",
1608 call.covers,
1609 note.trim()
1610 ));
1611 }
1612 format!("approved: {} for {task}", call.covers)
1613 }
1614 };
1615 if let Err(e) = decisions.save(&dir) {
1616 return CommandResult::new(command, CommandStatus::Error, &e);
1617 }
1618 format!("{what}; run the task again to continue (speccoder run --task {task})")
1619 }
1620 };
1621 let lanes = lane_pending(&decisions, &open);
1622 let report = ApprovalsReport {
1623 decided: decided(&feature, &decisions),
1624 pending: tasks_waiting
1625 .into_iter()
1626 .map(|(c, _)| c)
1627 .chain(lanes.into_iter().map(|(c, _)| c))
1628 .collect(),
1629 feature,
1630 unreadable,
1631 batch: Vec::new(),
1632 };
1633 let report = ApprovalsReport {
1634 batch: batches(&report.pending),
1635 ..report
1636 };
1637 CommandResult::new(command, CommandStatus::Ok, &message)
1638 .with_data(CommandData::Approvals(Box::new(report)))
1639}
1640
1641#[must_use]
1647pub fn batch_digest(digests: &[String]) -> String {
1648 use sha2::{Digest as _, Sha256};
1649 let mut ids = digests.to_vec();
1650 ids.sort();
1651 Sha256::digest(ids.join("\n").as_bytes())
1652 .iter()
1653 .map(|b| format!("{b:02x}"))
1654 .collect()
1655}
1656
1657#[must_use]
1660pub fn batches(pending: &[PendingCall]) -> Vec<speccoder_core::BatchView> {
1661 let mut runs: Vec<String> = pending
1662 .iter()
1663 .filter(|p| p.batched)
1664 .filter_map(|p| p.run.clone())
1665 .collect();
1666 runs.sort();
1667 runs.dedup();
1668 runs.into_iter()
1669 .map(|run| {
1670 let of_run: Vec<&PendingCall> = pending
1671 .iter()
1672 .filter(|p| p.run.as_deref() == Some(run.as_str()) && p.kind == tasks::APPROVAL)
1673 .collect();
1674 let groups = ["write", "command", "network", "mcp"]
1675 .iter()
1676 .filter_map(|class| {
1677 let calls: Vec<String> = of_run
1678 .iter()
1679 .filter(|p| {
1680 p.object.as_ref().map_or("write", |o| o.class.as_str()) == *class
1681 })
1682 .map(|p| p.id())
1683 .collect();
1684 (!calls.is_empty()).then(|| speccoder_core::BatchGroup {
1685 class: (*class).to_owned(),
1686 calls,
1687 })
1688 })
1689 .collect::<Vec<_>>();
1690 let digests: Vec<String> = of_run.iter().map(|p| p.digest.clone()).collect();
1691 speccoder_core::BatchView {
1692 digest: batch_digest(&digests),
1693 run,
1694 groups,
1695 }
1696 })
1697 .collect()
1698}
1699
1700#[derive(Debug, Clone, Copy)]
1702pub struct BatchChoice<'a> {
1703 pub run: &'a str,
1705 pub approve: bool,
1707 pub calls: &'a [String],
1709 pub digest: &'a str,
1711 pub resume_asking: bool,
1713 pub reason: Option<&'a str>,
1715}
1716
1717#[must_use]
1721pub fn decide_batch(
1722 path: &Path,
1723 feature: Option<&str>,
1724 choice: BatchChoice<'_>,
1725 state: Option<&Path>,
1726 via: Via,
1727) -> CommandResult {
1728 let command = "approvals";
1729 let Some(state) = state else {
1730 return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
1731 .with_code("NO-STATE-DIR");
1732 };
1733 let (feature, dir, root) = match locate(command, path, feature, state) {
1734 Ok(x) => x,
1735 Err(r) => return *r,
1736 };
1737 let listed = decide_with(path, Some(&feature), Verb::List, state, via);
1738 let Some(CommandData::Approvals(report)) = listed.data().cloned() else {
1739 return listed;
1740 };
1741 if let Some(why) = &report.unreadable {
1742 return CommandResult::new(
1743 command,
1744 CommandStatus::Error,
1745 &format!("the calls waiting for a decision could not be read: {why}"),
1746 );
1747 }
1748 let Some(batch) = report.batch.iter().find(|b| b.run == choice.run) else {
1749 return CommandResult::new(
1750 command,
1751 CommandStatus::NotFound,
1752 &format!("no calls wait together in run {}", choice.run),
1753 );
1754 };
1755 let listed_ids: Vec<String> = batch.groups.iter().flat_map(|g| g.calls.clone()).collect();
1756 if batch.digest != choice.digest {
1757 return CommandResult::new(
1758 command,
1759 CommandStatus::UsageError,
1760 &format!(
1761 "the list changed; {} calls are waiting now",
1762 listed_ids.len()
1763 ),
1764 )
1765 .with_data(CommandData::Approvals(report.clone()));
1766 }
1767 if choice.calls.is_empty() || choice.calls.iter().any(|c| !listed_ids.contains(c)) {
1768 return CommandResult::new(
1769 command,
1770 CommandStatus::UsageError,
1771 "name calls of the batch (or none to decide them all)",
1772 );
1773 }
1774 let by = Who::local_in(&root, via);
1775 let mut decisions = match Decisions::load(&dir) {
1776 Ok(d) => d,
1777 Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1778 };
1779 let rows = match waiting_tasks(&root, &dir, &feature) {
1780 Ok(r) => r,
1781 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
1782 };
1783 let reason = choice.reason.unwrap_or("no reason given");
1784 let mut lines = Vec::new();
1785 for id in choice.calls {
1786 let mut answer = if choice.approve {
1787 Answer::approve(Scope::Once, None, Some(by.clone()))
1788 } else {
1789 Answer::reject(reason, Some(by.clone()))
1790 };
1791 answer.resume_asking = choice.resume_asking;
1792 if let Some(task) = id.strip_prefix("attempt:") {
1793 let Some(p) = report
1795 .pending
1796 .iter()
1797 .find(|p| p.call.is_empty() && p.task == task)
1798 else {
1799 continue;
1800 };
1801 let object = pending(&root, &feature, &[task.to_owned()], &decisions)
1803 .into_iter()
1804 .find(|(now, _)| now.digest == p.digest)
1805 .map(|(_, o)| o);
1806 let Some(object) = object else {
1807 return CommandResult::new(
1808 command,
1809 CommandStatus::UsageError,
1810 &format!("the list changed; {task}'s attempt stopped on another call"),
1811 )
1812 .with_code("CALL-CHANGED");
1813 };
1814 if choice.approve {
1815 decisions.approve_once(&feature, task, object, &by);
1817 } else {
1818 decisions.reject(&feature, task, object, reason, &by);
1819 }
1820 lines.push(format!("{}: {}", p.task, p.covers));
1821 continue;
1822 }
1823 let Some((p, row)) = rows.iter().find(|(p, _)| p.call == *id) else {
1824 continue;
1825 };
1826 if let Err(e) = decide_waiting(&dir, row, &answer, &by) {
1827 return CommandResult::new(command, CommandStatus::Error, &e);
1828 }
1829 lines.push(format!("{}: {}", p.task, p.covers));
1830 }
1831 if choice.resume_asking
1832 && let Some(first) = report.pending.iter().find(|p| listed_ids.contains(&p.id()))
1833 {
1834 decisions.note(
1835 &feature,
1836 &first.task,
1837 Decided {
1838 verb: "resume_asking".to_owned(),
1839 object: None,
1840 run: Some(choice.run.to_owned()),
1841 reason: None,
1842 scope: "run".to_owned(),
1843 note: None,
1844 by: by.clone(),
1845 at: now(),
1846 },
1847 );
1848 }
1849 if let Err(e) = decisions.save(&dir) {
1850 return CommandResult::new(command, CommandStatus::Error, &e);
1851 }
1852 let after = decide_with(path, Some(&feature), Verb::List, state, via);
1853 CommandResult::new(
1854 command,
1855 CommandStatus::Ok,
1856 &format!(
1857 "{} {} call(s) once — by {} ({}){}: {}; `speccoder run` continues",
1858 if choice.approve {
1859 "approved"
1860 } else {
1861 "rejected"
1862 },
1863 lines.len(),
1864 by.name,
1865 by.via,
1866 if choice.resume_asking {
1867 "; the run asks again"
1868 } else {
1869 ""
1870 },
1871 lines.join(", ")
1872 ),
1873 )
1874 .with_data(
1875 after
1876 .data()
1877 .cloned()
1878 .unwrap_or(CommandData::Approvals(report)),
1879 )
1880}
1881
1882#[must_use]
1890pub fn allow_always(
1891 path: &Path,
1892 feature: Option<&str>,
1893 task: &str,
1894 call: &str,
1895 state: Option<&Path>,
1896 via: Via,
1897) -> CommandResult {
1898 let command = "config";
1899 let refuse = |m: &str| CommandResult::new(command, CommandStatus::UsageError, m);
1900 let Some(state) = state else {
1901 return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
1902 .with_code("NO-STATE-DIR");
1903 };
1904 let (feature, dir, root) = match locate(command, path, feature, state) {
1905 Ok(x) => x,
1906 Err(r) => return *r,
1907 };
1908 if !dir.join(crate::substrate::JOURNAL).exists() {
1909 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
1910 }
1911 let Ok(id) = agentplane::core::TaskId::parse(call) else {
1912 return refuse("not a call id");
1913 };
1914 let row = match crate::tokio_runtime().and_then(|rt| {
1915 let plane = crate::substrate::Plane::open_for_decisions(&dir).map_err(|e| e.0)?;
1916 rt.block_on(plane.task(id)).map_err(|e| e.0)
1917 }) {
1918 Ok(Some(row)) => row,
1919 Ok(None) => {
1920 return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
1921 }
1922 Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
1923 };
1924 let Some(request) = tasks::request_of(&row.justification.proposed_action)
1925 .filter(|r| r.task == format!("{feature}/{task}"))
1926 else {
1927 return CommandResult::new(
1928 command,
1929 CommandStatus::NotFound,
1930 &format!("{task} has no such call"),
1931 );
1932 };
1933 let offered = matches!(request.kind.as_str(), "run" | "network")
1934 && request.options.iter().any(|o| o == "always");
1935 let Some(Object::Command { argv, network, .. }) = request.object.clone().filter(|_| offered)
1936 else {
1937 return refuse("Always Allow is not offered for this call");
1938 };
1939 let by = Who::local_in(&root, via);
1940 match row.state {
1941 agentplane::core::TaskState::Open | agentplane::core::TaskState::Claimed => {
1942 if let Err(e) = decide_waiting(&dir, &row, &Answer::always(Some(by.clone())), &by) {
1943 return CommandResult::new(command, CommandStatus::Error, &e);
1944 }
1945 }
1946 _ if take_answered_always(call) => {}
1947 _ => return refuse("this call was already decided"),
1948 }
1949 let key = if network {
1950 "sandbox.network_allow"
1951 } else {
1952 "commands.allow"
1953 };
1954 let r = crate::settings::apply_confirmed(
1955 &root,
1956 &[speccoder_core::ConfigChange {
1957 key: key.to_owned(),
1958 value: None,
1959 append: Some(serde_json::json!(argv)),
1960 }],
1961 );
1962 if r.status() != CommandStatus::Ok {
1963 return r;
1964 }
1965 let table = if network {
1966 "[sandbox] network_allow"
1967 } else {
1968 "[commands] allow"
1969 };
1970 CommandResult::new(
1971 command,
1972 CommandStatus::Ok,
1973 &format!(
1974 "allowed once; added exactly `{}` to {table} — nothing after it runs because of this (by {} via {}); commit speccoder.toml to keep it",
1975 argv.join(" "),
1976 by.name,
1977 by.via
1978 ),
1979 )
1980 .with_next(r.next().to_vec())
1981 .with_data(r.data().cloned().unwrap_or(CommandData::Config(Box::new(crate::settings::read(&root)))))
1982}
1983
1984#[must_use]
1986pub fn decide(path: &Path, feature: Option<&str>, verb: Verb<'_>, via: Via) -> CommandResult {
1987 decide_in(
1988 path,
1989 feature,
1990 verb,
1991 crate::worktree::state_dir().as_deref(),
1992 via,
1993 )
1994}
1995
1996#[must_use]
1998pub fn decide_in(
1999 path: &Path,
2000 feature: Option<&str>,
2001 verb: Verb<'_>,
2002 state: Option<&Path>,
2003 via: Via,
2004) -> CommandResult {
2005 match state {
2006 Some(state) => decide_with(path, feature, verb, state, via),
2007 None => CommandResult::new(
2008 "approvals",
2009 CommandStatus::ConfigError,
2010 "no state directory (set SPECCODER_STATE_DIR or HOME)",
2011 )
2012 .with_code("NO-STATE-DIR"),
2013 }
2014}
2015
2016#[cfg(test)]
2017mod tests {
2018 use super::*;
2019
2020 fn file(path: &str) -> Object {
2021 Object::Path {
2022 canonical: path.to_owned(),
2023 asked: path.to_owned(),
2024 }
2025 }
2026
2027 #[test]
2028 fn decisions_round_trip_and_replace_each_other() {
2029 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2030 let g = file("Cargo.toml");
2031 let mut d = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2032 let by = Who::local(Via::Terminal);
2033 d.reject("f", "T1", g.clone(), "no new dependencies", &by);
2034 d.approve("f", "T1", g.clone(), Some("run-1"), &by);
2035 assert_eq!(d.tasks["f/T1"].decided.len(), 2);
2036 assert_eq!(d.tasks["f/T1"].decided[1].by, by);
2037 d.save(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2038 let back = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2039 assert_eq!(back.for_run("f", "T1", "run-1"), std::slice::from_ref(&g));
2040 assert!(back.for_task("f", "T1").rejected.is_empty());
2041 assert!(back.for_run("f", "T1", "run-2").is_empty());
2043 assert!(!back.decided("f", "T1", &g) && !back.decided("f", "T2", &g));
2044 }
2045
2046 #[test]
2047 fn a_lane_decision_is_claimed_by_the_next_run_only() {
2048 let mut d = Decisions::default();
2049 let by = Who::local(Via::Terminal);
2050 d.approve("f", "T1", file("package.json"), None, &by);
2051 assert!(d.decided("f", "T1", &file("package.json")));
2052 assert!(d.for_run("f", "T1", "run-2").is_empty());
2053 assert!(d.claim("f", "T1", "run-2"));
2054 assert_eq!(d.for_run("f", "T1", "run-2"), [file("package.json")]);
2055 assert!(!d.claim("f", "T1", "run-3"));
2057 assert!(d.for_run("f", "T1", "run-3").is_empty());
2058 }
2059
2060 #[test]
2061 fn a_decisions_file_from_before_objects_is_refused_by_name() {
2062 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2063 std::fs::write(
2064 dir.path().join(FILE),
2065 r#"{"tasks":{"f/T1":{"approved":[{"kind":"write","path":"Cargo.toml"}]}}}"#,
2066 )
2067 .unwrap_or_else(|e| panic!("{e}"));
2068 let e = Decisions::load(dir.path()).err().unwrap_or_default();
2069 assert!(
2070 e.contains(FILE) && e.contains("before decisions named objects"),
2071 "{e}"
2072 );
2073 }
2074
2075 #[test]
2076 fn remembered_options_need_an_object_and_text_not_copied_from_content() {
2077 let o = |risk, has, derived, kind, always| options(risk, has, derived, kind, always);
2078 assert_eq!(
2079 o("low", true, false, "run", "low"),
2080 ["once", "task", "run", "always", "reject", "stop"]
2081 );
2082 assert_eq!(
2084 o("high", true, false, "network", "low"),
2085 ["once", "task", "always", "reject", "stop"]
2086 );
2087 assert_eq!(
2088 o("high", true, false, "run", "high"),
2089 ["once", "task", "reject", "stop"]
2090 );
2091 assert_eq!(
2092 o("low", true, false, "write", "low"),
2093 ["once", "task", "run", "reject", "stop"]
2094 );
2095 assert_eq!(
2096 o("low", true, true, "run", "low"),
2097 ["once", "reject", "stop"]
2098 );
2099 assert_eq!(
2100 o("low", false, false, "run", "low"),
2101 ["once", "reject", "stop"]
2102 );
2103 }
2104
2105 #[test]
2106 fn a_runs_batch_lists_its_lanes_stopped_calls_too() {
2107 let call = |task: &str, id: &str, batched: bool, class: &str, run: &str| {
2108 with_digest(
2109 PendingCall {
2110 task: task.to_owned(),
2111 call: id.to_owned(),
2112 kind: tasks::APPROVAL.to_owned(),
2113 run: Some(run.to_owned()),
2114 batched,
2115 object: Some(speccoder_core::ObjectView {
2116 class: class.to_owned(),
2117 ..speccoder_core::ObjectView::default()
2118 }),
2119 ..PendingCall::default()
2120 },
2121 Some(&file(task)),
2122 )
2123 };
2124 let pending = vec![
2125 call("T1", "aa", true, "write", "run-1"),
2126 call("T2", "", false, "command", "run-1"),
2128 call("T3", "bb", false, "write", "run-2"),
2130 ];
2131 let b = batches(&pending);
2132 assert_eq!(b.len(), 1);
2133 assert_eq!(b[0].run, "run-1");
2134 let groups: Vec<(&str, Vec<String>)> = b[0]
2135 .groups
2136 .iter()
2137 .map(|g| (g.class.as_str(), g.calls.clone()))
2138 .collect();
2139 assert_eq!(
2140 groups,
2141 [
2142 ("write", vec!["aa".to_owned()]),
2143 ("command", vec!["attempt:T2".to_owned()])
2144 ]
2145 );
2146 assert_eq!(
2147 b[0].digest,
2148 batch_digest(&[pending[1].digest.clone(), pending[0].digest.clone()])
2149 );
2150 let mut newer = pending.clone();
2153 newer[1] = with_digest(newer[1].clone(), Some(&file("other.json")));
2154 assert_eq!(newer[1].id(), pending[1].id());
2155 assert_ne!(batches(&newer)[0].digest, b[0].digest);
2156 }
2157
2158 #[test]
2161 fn a_calls_digest_binds_its_id_and_object() {
2162 let a = call_digest("attempt:T1", Some(&file("a.json")));
2163 assert_eq!(a, call_digest("attempt:T1", Some(&file("a.json"))));
2164 assert_ne!(a, call_digest("attempt:T1", Some(&file("b.json"))));
2165 assert_ne!(a, call_digest("attempt:T2", Some(&file("a.json"))));
2166 assert_ne!(a, call_digest("attempt:T1", None));
2167 }
2168
2169 #[test]
2173 fn a_once_grant_is_given_to_one_attempt_only() {
2174 let mut d = Decisions::default();
2175 let by = Who::local(Via::Terminal);
2176 d.approve_once("f", "T1", file("package.json"), &by);
2177 assert!(d.decided("f", "T1", &file("package.json")));
2178 assert!(d.claim("f", "T1", "run-2"));
2179 assert!(d.for_run("f", "T1", "run-2").is_empty(), "not for the run");
2180 assert_eq!(d.take_once("f", "T1", "run-2"), [file("package.json")]);
2181 assert!(
2182 d.take_once("f", "T1", "run-2").is_empty(),
2183 "one attempt only"
2184 );
2185 assert_eq!(d.for_task("f", "T1").decided[0].scope, "once");
2186 let text = serde_json::to_string(&d).unwrap();
2188 assert_eq!(serde_json::from_str::<Decisions>(&text).unwrap(), d);
2189 d.approve("f", "T1", file("x"), Some("run-2"), &by);
2190 let grant = serde_json::to_value(&d.for_task("f", "T1").approved[0]).unwrap();
2191 assert!(grant.get("once").is_none(), "{grant}");
2192 }
2193}