Skip to main content

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}