Skip to main content

artifactize_core/
review.rs

1//! Human review: the facts and lifecycle jobs every interface shares. The terminal screens
2//! live in `artifactize-tui`.
3
4mod detail;
5mod form;
6mod runs;
7pub mod search;
8
9pub use detail::{Stage, meta, outcome_text, shared, stage, technical};
10pub use form::{Field, Form, Input, Key, boundary, template};
11pub use runs::Runs;
12
13use crate::{
14    config::{HumanToolKind, ToolName},
15    human,
16    store::{self, HumanClaim, Request},
17    tools::human::{CommandLine, Content, ToolResult},
18    types::RequestId,
19};
20use serde_json::Value;
21use std::path::PathBuf;
22use tokio_util::sync::CancellationToken;
23
24/// The refusal for reviewing a request nobody claimed.
25pub const CLAIM_FIRST: &str = "Claim this request before reviewing it.";
26/// The refusal for releasing a claim that is not this reviewer's.
27pub const NOT_CLAIMED: &str = "The request is not claimed.";
28
29/// What `reviewer` may do with the request now: its id and whether nobody holds the claim
30/// yet, or why it is read-only (loading, settled, claimed by someone else). Every interface
31/// asks this, so they refuse alike.
32pub fn actionable(
33    view: Option<&crate::store::RequestView>,
34    reviewer: &crate::types::ReviewerId,
35) -> Result<(RequestId, bool), String> {
36    let view = view.ok_or("The request is still loading.")?;
37    if view.request.status() != crate::types::RequestStatus::WaitingHuman {
38        return Err(format!(
39            "The request is {}; there is nothing to review.",
40            view.request.status()
41        ));
42    }
43    match &view.claim {
44        Some(claim) if &claim.reviewer != reviewer => Err(format!(
45            "Claimed by {}; read-only for {}.",
46            claim.reviewer, reviewer
47        )),
48        claim => Ok((view.request.id.clone(), claim.is_none())),
49    }
50}
51
52/// The refusal for choosing a verdict, running a tool or submitting, as the reviewer sees it:
53/// only the claimant reviews, and a claim is always the explicit one.
54pub fn review_refusal(
55    view: Option<&crate::store::RequestView>,
56    reviewer: &crate::types::ReviewerId,
57) -> Option<String> {
58    match actionable(view, reviewer) {
59        Ok((_, false)) => None,
60        Ok((_, true)) | Err(_) => Some(CLAIM_FIRST.into()),
61    }
62}
63
64/// The refusal for releasing: only a claim of this reviewer is released.
65pub fn release_refusal(
66    view: Option<&crate::store::RequestView>,
67    reviewer: &crate::types::ReviewerId,
68) -> Option<String> {
69    match actionable(view, reviewer) {
70        Ok((_, false)) => None,
71        Ok((_, true)) => Some(NOT_CLAIMED.into()),
72        Err(error) => Some(error),
73    }
74}
75
76/// Lifecycle work with owned inputs, so the screen keeps drawing while it runs.
77#[derive(Debug, Clone, PartialEq)]
78pub enum Job {
79    /// Explicit claim for an embedded single-request review.
80    Claim { id: RequestId },
81    /// Claim first when `claim`, then run the tool.
82    Run {
83        id: RequestId,
84        tool: ToolName,
85        claim: bool,
86    },
87    /// Claim first when `claim`, then submit and publish like `request submit`.
88    Submit {
89        id: RequestId,
90        verdict: crate::runtime::Verdict,
91        result: Value,
92        claim: bool,
93    },
94    /// Release the reviewer's remaining claims; quit afterwards when `quit`.
95    Release { ids: Vec<RequestId>, quit: bool },
96}
97
98#[derive(Debug)]
99pub enum Outcome {
100    Claimed {
101        result: Result<HumanClaim, String>,
102    },
103    /// Publishing failed after settlement; never offer to submit the verdict twice.
104    SavedLocally {
105        request: Box<Request>,
106        error: String,
107    },
108    Ran {
109        tool: ToolName,
110        claimed: Option<HumanClaim>,
111        result: Result<ToolResult, String>,
112    },
113    Submitted {
114        id: RequestId,
115        claimed: Option<HumanClaim>,
116        result: Result<Box<Request>, String>,
117    },
118    Released {
119        ids: Vec<RequestId>,
120        quit: bool,
121        /// Request IDs whose release failed, with the error.
122        failed: Vec<(RequestId, String)>,
123    },
124}
125
126impl Outcome {
127    /// What the review tells the reviewer about the outcome, and whether it is an error.
128    pub fn notice(&self) -> (String, bool) {
129        match self {
130            Self::SavedLocally { error, .. } => (error.clone(), true),
131            Self::Claimed { result: Ok(_) } => (
132                "Claimed. Fill GREEN or RED fields, then Submit.".into(),
133                false,
134            ),
135            Self::Claimed { result: Err(error) } => (error.clone(), true),
136            Self::Ran { tool, result, .. } => {
137                let launched = Content::Launch { launched: true };
138                match result {
139                    Ok(result) if result.is_error() => (
140                        format!("{tool} failed; a tool error is not a verdict."),
141                        true,
142                    ),
143                    Ok(result) if result.content().contains(&launched) => {
144                        (format!("{tool} launched."), false)
145                    }
146                    Ok(_) => (format!("{tool} finished."), false),
147                    Err(_) => (format!("{tool} did not run."), true),
148                }
149            }
150            Self::Submitted {
151                result: Ok(request),
152                ..
153            } => (
154                format!(
155                    "Submitted {} for {} ({}).",
156                    request.status(),
157                    request.eval_id,
158                    request.id
159                ),
160                false,
161            ),
162            Self::Submitted {
163                result: Err(error), ..
164            } => (error.clone(), true),
165            Self::Released { failed, .. } if failed.is_empty() => ("Claim released.".into(), false),
166            Self::Released { failed, .. } => {
167                let lines = failed.iter().map(|(id, error)| format!("{id}: {error}"));
168                (lines.collect::<Vec<_>>().join("\n"), true)
169            }
170        }
171    }
172}
173
174/// A declared Human tool as the saved definition records it.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct Tool {
177    pub name: ToolName,
178    pub kind: HumanToolKind,
179    pub description: String,
180    pub declared: Runs,
181}
182
183impl Tool {
184    /// What running the tool does, as the Tools pane shows it: a command's resolved line once
185    /// `resolved` is known, else its declaration; a builtin's action on its logical target.
186    pub fn action(&self, resolved: Option<&Result<CommandLine, String>>) -> String {
187        match (&self.declared, resolved) {
188            (Runs::Command(_), Some(Ok(command))) => {
189                let program = crate::platform::path_text(std::path::Path::new(&command.program));
190                let words = std::iter::once(program).chain(command.args.iter().cloned());
191                let words: Vec<_> = words.collect();
192                format!("$ {}", shell(words.iter().map(String::as_str)))
193            }
194            (declared, _) => declared.line(),
195        }
196    }
197}
198
199/// The last tool result or failure, shown in the output pane.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Output {
202    pub title: String,
203    pub text: String,
204    pub error: bool,
205}
206
207impl Job {
208    /// Carry out the job through the `human` lifecycle API, as `request` does; every UI runs
209    /// its Human actions through here.
210    pub async fn run(
211        self,
212        state: PathBuf,
213        reviewer: crate::types::ReviewerId,
214        cancel: CancellationToken,
215    ) -> Outcome {
216        let mut claimed = None;
217        match self {
218            Job::Claim { id } => {
219                let result = async {
220                    let (receipts, _) = human::open(&state, &id).await?;
221                    human::claim(&receipts, &id, &reviewer).await
222                }
223                .await;
224                Outcome::Claimed { result }
225            }
226            Job::Run {
227                id,
228                tool,
229                claim: first,
230            } => {
231                let result = async {
232                    let (receipts, _) = human::open(&state, &id).await?;
233                    if first {
234                        claimed = Some(human::claim(&receipts, &id, &reviewer).await?);
235                    }
236                    human::run_human_tool(&receipts, &id, &reviewer, &tool, cancel).await
237                }
238                .await;
239                Outcome::Ran {
240                    tool,
241                    claimed,
242                    result,
243                }
244            }
245            Job::Submit {
246                id,
247                verdict: _,
248                result,
249                claim: first,
250            } => {
251                let result = async {
252                    if first {
253                        let (receipts, _) = human::open(&state, &id).await?;
254                        claimed = Some(human::claim(&receipts, &id, &reviewer).await?);
255                    }
256                    human::submit_and_publish(&state, &id, &reviewer, &result, cancel)
257                        .await
258                        .map(Box::new)
259                }
260                .await;
261                if let Err(error) = &result
262                    && let Ok(view) = store::read_request(&state, &id).await
263                    && matches!(
264                        view.request.status(),
265                        crate::types::RequestStatus::Green | crate::types::RequestStatus::Red
266                    )
267                    && view.execution.as_ref().is_some_and(|execution| {
268                        execution.reviewer.as_deref() == Some(reviewer.as_str())
269                    })
270                {
271                    return Outcome::SavedLocally {
272                        request: Box::new(view.request),
273                        error: error.clone(),
274                    };
275                }
276                Outcome::Submitted {
277                    id,
278                    claimed,
279                    result,
280                }
281            }
282            Job::Release { ids, quit } => {
283                let mut failed = Vec::new();
284                for id in &ids {
285                    let release = async {
286                        // Settled requests and claims released elsewhere leave nothing to release.
287                        let view = store::read_request(&state, id).await?;
288                        if view.claim.is_none_or(|claim| claim.reviewer != reviewer) {
289                            return Ok(());
290                        }
291                        let (receipts, _) = human::open(&state, id).await?;
292                        human::unclaim(&receipts, id, &reviewer).await.map(drop)
293                    };
294                    if let Err(error) = release.await {
295                        failed.push((id.clone(), error));
296                    }
297                }
298                Outcome::Released { ids, quit, failed }
299            }
300        }
301    }
302}
303
304/// Declared Human tools in the eval scope, named like the registry.
305pub fn tools(request: &Request) -> Vec<Tool> {
306    use crate::store::definitions::HumanTool;
307    let Some(artifacts) = request
308        .human_definition
309        .as_ref()
310        .and_then(|definition| definition.artifacts.value())
311    else {
312        return Vec::new();
313    };
314    let mut tools: Vec<_> = artifacts
315        .iter()
316        .flat_map(|(id, artifact)| {
317            artifact
318                .views
319                .value()
320                .and_then(|views| views.human_tools.value())
321                .into_iter()
322                .flatten()
323                // A saved name that is not a valid tool name has no tool to run; the
324                // registry, built from the same declarations, refuses it as well.
325                .filter_map(move |(operation, tool)| {
326                    let (kind, description) = match tool {
327                        HumanTool::Command(tool) => (tool.kind, &tool.description),
328                        HumanTool::Builtin(tool) => (tool.kind, &tool.description),
329                    };
330                    Some(Tool {
331                        name: format!("{operation}_{id}").parse().ok()?,
332                        kind,
333                        description: description.replace("{artifactName}", id.as_str()),
334                        declared: Runs::parse(artifacts, id, tool),
335                    })
336                })
337        })
338        .collect();
339    tools.sort_by(|a, b| a.name.cmp(&b.name));
340    tools
341}
342
343/// The owner schema of `verdict` recorded with the request: `None` when it declares none.
344/// Without a recorded Human definition the schemas are unknown, not empty: an empty form
345/// would submit a verdict the schema then rejects.
346pub fn schema(
347    request: &Request,
348    verdict: crate::runtime::Verdict,
349) -> Result<Option<Value>, String> {
350    let definition = request.human_definition.as_ref().ok_or(
351        "This request records no Human definition; open the original request its actions go to.",
352    )?;
353    let declaration = definition.eval.declaration.value();
354    let schema = declaration.and_then(|declaration| match verdict {
355        crate::runtime::Verdict::Green => declaration.pass_schema.value(),
356        crate::runtime::Verdict::Red => declaration.fail_schema.value(),
357    });
358    Ok(schema.map(|schema| Value::Object(schema.clone())))
359}
360
361/// Words joined for an on-screen preview, double-quoted when a word has whitespace or a
362/// quote of its own. This is a reading aid, not a shell command: it claims no single
363/// shell's escaping rules, which differ between Unix shells and Windows's.
364pub fn shell<'a>(words: impl IntoIterator<Item = &'a str>) -> String {
365    let quote = |word: &str| {
366        let plain = !word.is_empty()
367            && word
368                .chars()
369                .all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%{}".contains(c));
370        if plain {
371            word.to_owned()
372        } else {
373            format!("\"{}\"", word.replace('"', "\"\""))
374        }
375    };
376    words.into_iter().map(quote).collect::<Vec<_>>().join(" ")
377}
378
379/// The output pane's view of a tool run: its text, or why it failed.
380pub fn output(tool: &str, result: Result<ToolResult, String>) -> Output {
381    match result {
382        Ok(result) => Output {
383            title: if result.is_error() {
384                format!("{tool} · tool error")
385            } else {
386                tool.to_owned()
387            },
388            text: result
389                .content()
390                .iter()
391                .map(|content| match content {
392                    Content::Text { text } => text.trim_end().to_owned(),
393                    Content::Launch { launched: true } => "launched".into(),
394                    Content::Launch { launched: false } => "not launched".into(),
395                })
396                .collect::<Vec<_>>()
397                .join("\n"),
398            error: result.is_error(),
399        },
400        Err(error) => Output {
401            title: format!("{tool} · failed"),
402            text: error,
403            error: true,
404        },
405    }
406}