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