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