basis_tasks/approve.rs
1//! Approval policy: what a task's recorded spawn request says about
2//! consequential calls, and who answers `Prompt` while a process executes it.
3//!
4//! A task's `approve` mode is durable — recorded in `meta.json` at spawn and
5//! read back at every attach, exactly as `--provider` or `--model` are — so
6//! it lives beside them here rather than in the CLI that merely parses it off
7//! a flag.
8
9use basis::Approver;
10
11/// Every consequential call is put to this, exactly once per task: `Always`
12/// allows, `Never` refuses, `Prompt` asks whoever is executing the task's
13/// current turn.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Approve {
17 /// Allow consequential calls without asking.
18 Always,
19 /// Ask whatever [`PromptHost`] the executing process supplied. Refused at
20 /// spawn and at attach for a process with none, or with one that cannot
21 /// currently ask (see [`validate_approval`]).
22 #[default]
23 Prompt,
24 /// Refuse anything that changes state outside the process.
25 Never,
26}
27
28/// How a process answers `Approve::Prompt` while it executes a task's turns,
29/// and whether there is anyone there to ask at all.
30///
31/// The executor is whichever process holds a task's attach lock (ADR-0019),
32/// and only that process's own environment knows whether a person — or any
33/// other approving party — is behind it. A library has no terminal to ask at
34/// (ADR-0011, the same reason `basis-cli`'s `TerminalApprover` lives in the
35/// binary and not in `basis`), so `basis-tasks` does not decide this; a host
36/// that wants `Prompt` to work supplies one via
37/// [`Tasks::with_prompt_host`](crate::Tasks::with_prompt_host). A `Tasks`
38/// with none refuses `Prompt` the same way an unaskable process always did —
39/// safely, and by name.
40pub trait PromptHost: Send + Sync {
41 /// Whether this process can currently put a question to whoever answers
42 /// for it. Checked before a `Prompt`-mode task is allowed to spawn, and
43 /// again at every attach.
44 fn can_ask(&self) -> bool;
45
46 /// The approver used for one task's turns while this process drives it.
47 /// Called once per attach, only when [`can_ask`](Self::can_ask) said yes.
48 fn approver(&self) -> Box<dyn Approver>;
49}
50
51/// `Prompt` is answerable exactly when a process is driving the task *and*
52/// has somewhere to put the question — see [`PromptHost`]. `Always` and
53/// `Never` ask nobody, so they need no host at all.
54///
55/// Public because a host wants this checked as early as possible: refusing
56/// `Prompt` at spawn, before a task directory is even minted, is a cheaper
57/// and clearer failure than minting one that can never make progress.
58pub fn validate_approval(approve: Approve, interactive: bool) -> Result<(), crate::Error> {
59 match approve {
60 Approve::Always | Approve::Never => Ok(()),
61 Approve::Prompt if interactive => Ok(()),
62 Approve::Prompt => Err(crate::Error::new(
63 "`Approve::Prompt` needs a process driving the task with a prompt host that can \
64 currently ask; use `Always` or `Never` for work nobody is attached to",
65 )),
66 }
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn prompt_approval_needs_a_driver_that_can_ask() {
75 for approve in [Approve::Always, Approve::Never] {
76 assert!(
77 validate_approval(approve, false).is_ok(),
78 "{approve:?} asks nobody"
79 );
80 assert!(
81 validate_approval(approve, true).is_ok(),
82 "{approve:?} asks nobody"
83 );
84 }
85
86 assert!(
87 validate_approval(Approve::Prompt, true).is_ok(),
88 "a host that can ask is exactly what `Prompt` needs"
89 );
90
91 let refused = validate_approval(Approve::Prompt, false)
92 .expect_err("nobody able to ask means nobody to ask")
93 .to_string();
94 assert!(refused.contains("ask"), "{refused}");
95 }
96}