mod detail;
mod form;
mod runs;
pub mod search;
pub use crate::tools::human::log;
pub use detail::{Stage, meta, outcome_text, shared, stage, technical};
pub use form::{Field, Form, Input, Key, Row, check as check_schema};
pub use runs::Runs;
use crate::{
config::{HumanToolKind, ToolName},
human,
store::{self, HumanClaim, Request},
tools::human::{CommandLine, Content, ToolResult},
types::RequestId,
};
use serde_json::Value;
use std::path::PathBuf;
use tokio_util::sync::CancellationToken;
pub const CLAIM_FIRST: &str = "Claim this request before reviewing it.";
pub const NOT_CLAIMED: &str = "The request is not claimed.";
pub fn actionable(
view: Option<&crate::store::RequestView>,
reviewer: &crate::types::ReviewerId,
) -> Result<(RequestId, bool), String> {
let view = view.ok_or("The request is still loading.")?;
if !view.request.status().is_human_wait() {
return Err(format!(
"The request is {}; there is nothing to review.",
view.request.status()
));
}
match &view.claim {
Some(claim) if &claim.reviewer != reviewer => Err(format!(
"Claimed by {}; read-only for {}.",
claim.reviewer, reviewer
)),
claim => Ok((view.request.id.clone(), claim.is_none())),
}
}
pub fn review_refusal(
view: Option<&crate::store::RequestView>,
reviewer: &crate::types::ReviewerId,
) -> Option<String> {
match actionable(view, reviewer) {
Ok((_, false)) => None,
Ok((_, true)) | Err(_) => Some(CLAIM_FIRST.into()),
}
}
pub fn release_refusal(
view: Option<&crate::store::RequestView>,
reviewer: &crate::types::ReviewerId,
) -> Option<String> {
match actionable(view, reviewer) {
Ok((_, false)) => None,
Ok((_, true)) => Some(NOT_CLAIMED.into()),
Err(error) => Some(error),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Submission(crate::agent::verdict::ValidatedResult);
impl TryFrom<Value> for Submission {
type Error = String;
fn try_from(value: Value) -> Result<Self, Self::Error> {
serde_json::from_value(value)
.map(Self)
.map_err(|error| error.to_string())
}
}
impl Submission {
pub fn verdict(&self) -> crate::runtime::Verdict {
self.0.verdict
}
pub fn to_json(&self) -> Value {
self.0.to_json()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Job {
Claim { id: RequestId },
Run {
id: RequestId,
tool: ToolName,
claim: bool,
},
Submit {
id: RequestId,
result: Submission,
claim: bool,
},
Release { ids: Vec<RequestId>, quit: bool },
}
#[derive(Debug)]
pub enum Outcome {
Claimed {
result: Result<HumanClaim, String>,
},
SavedLocally {
request: Box<Request>,
error: String,
},
Ran {
tool: ToolName,
claimed: Option<HumanClaim>,
result: Result<ToolResult, String>,
},
Submitted {
id: RequestId,
claimed: Option<HumanClaim>,
result: Result<Box<Request>, String>,
},
Released {
ids: Vec<RequestId>,
quit: bool,
failed: Vec<(RequestId, String)>,
},
}
impl Outcome {
pub fn notice(&self) -> (String, bool) {
match self {
Self::SavedLocally { error, .. } => (error.clone(), true),
Self::Claimed { result: Ok(_) } => (
"Claimed. Fill GREEN or RED fields, then Submit.".into(),
false,
),
Self::Claimed { result: Err(error) } => (error.clone(), true),
Self::Ran { tool, result, .. } => {
let launched = Content::Launch { launched: true };
match result {
Ok(result) if result.is_error() => (
format!("{tool} failed; a tool error is not a verdict."),
true,
),
Ok(result) if result.content().contains(&launched) => {
(format!("{tool} launched."), false)
}
Ok(_) => (format!("{tool} finished."), false),
Err(_) => (format!("{tool} did not run."), true),
}
}
Self::Submitted {
result: Ok(request),
..
} => (
format!(
"Submitted {} for {} ({}).",
request.status(),
request.eval_id,
request.id
),
false,
),
Self::Submitted {
result: Err(error), ..
} => (error.clone(), true),
Self::Released { failed, .. } if failed.is_empty() => ("Claim released.".into(), false),
Self::Released { failed, .. } => {
let lines = failed.iter().map(|(id, error)| format!("{id}: {error}"));
(lines.collect::<Vec<_>>().join("\n"), true)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tool {
pub name: ToolName,
pub artifact: crate::types::ArtifactName,
pub kind: HumanToolKind,
pub description: String,
pub declared: Runs,
}
impl Tool {
pub fn operation(&self) -> &str {
self.name
.as_str()
.strip_suffix(&format!("_{}", self.artifact))
.unwrap_or(self.name.as_str())
}
pub fn action(&self, resolved: Option<&Result<CommandLine, String>>) -> String {
match (&self.declared, resolved) {
(Runs::Command(_), Some(Ok(command))) => {
let program = crate::platform::path_text(std::path::Path::new(&command.program));
let words = std::iter::once(program).chain(command.args.iter().cloned());
let words: Vec<_> = words.collect();
format!("$ {}", shell(words.iter().map(String::as_str)))
}
(declared, _) => declared.line(),
}
}
}
impl Job {
pub async fn run(
self,
state: PathBuf,
reviewer: crate::types::ReviewerId,
cancel: CancellationToken,
log: log::Sink,
) -> Outcome {
let mut claimed = None;
match self {
Job::Claim { id } => {
let result = async {
let (receipts, _) = human::open(&state, &id).await?;
let result = human::claim(&receipts, &id, &reviewer).await;
let closed = receipts.close().await;
result.and_then(|claim| closed.map(|()| claim))
}
.await;
Outcome::Claimed { result }
}
Job::Run {
id,
tool,
claim: first,
} => {
let declared = store::read_request(&state, &id)
.await
.ok()
.and_then(|view| {
tools(&view.request)
.into_iter()
.find(|declared| declared.name == tool)
});
let run = match &declared {
Some(declared) => {
log.start(&id, declared.operation(), declared.artifact.as_str())
}
None => log.start(&id, tool.as_str(), ""),
};
let result = async {
let (receipts, _) = match human::open(&state, &id).await {
Ok(opened) => opened,
Err(error) => {
run.end(log::End::Failed(error.clone()));
return Err(error);
}
};
let result = async {
if first {
match human::claim(&receipts, &id, &reviewer).await {
Ok(claim) => claimed = Some(claim),
Err(error) => {
run.end(log::End::Failed(error.clone()));
return Err(error);
}
}
}
human::run_human_tool(&receipts, &id, &reviewer, &tool, cancel, run).await
}
.await;
let closed = receipts.close().await;
result.and_then(|result| closed.map(|()| result))
}
.await;
Outcome::Ran {
tool,
claimed,
result,
}
}
Job::Submit {
id,
result,
claim: first,
} => {
let result = async {
if first {
let (receipts, _) = human::open(&state, &id).await?;
let result = human::claim(&receipts, &id, &reviewer).await;
let closed = receipts.close().await;
claimed = Some(result.and_then(|claim| closed.map(|()| claim))?);
}
human::submit_and_publish(&state, &id, &reviewer, &result.to_json(), cancel)
.await
.map(Box::new)
}
.await;
if let Err(error) = &result
&& let Ok(view) = store::read_request(&state, &id).await
&& matches!(
view.request.status(),
crate::types::RequestStatus::Green | crate::types::RequestStatus::Red
)
&& view.execution.as_ref().is_some_and(|execution| {
execution.reviewer.as_deref() == Some(reviewer.as_str())
})
{
return Outcome::SavedLocally {
request: Box::new(view.request),
error: error.clone(),
};
}
Outcome::Submitted {
id,
claimed,
result,
}
}
Job::Release { ids, quit } => {
let mut failed = Vec::new();
for id in &ids {
let release = async {
let view = store::read_request(&state, id).await?;
if view.claim.is_none_or(|claim| claim.reviewer != reviewer) {
return Ok(());
}
let (receipts, _) = human::open(&state, id).await?;
let result = human::unclaim(&receipts, id, &reviewer).await.map(drop);
let closed = receipts.close().await;
result.and(closed)
};
if let Err(error) = release.await {
failed.push((id.clone(), error));
}
}
Outcome::Released { ids, quit, failed }
}
}
}
}
pub fn tools(request: &Request) -> Vec<Tool> {
use crate::store::definitions::HumanTool;
let Some(artifacts) = request
.human_definition
.as_ref()
.and_then(|definition| definition.artifacts.value())
else {
return Vec::new();
};
let mut tools: Vec<_> = artifacts
.iter()
.flat_map(|(id, artifact)| {
artifact
.views
.value()
.and_then(|views| views.human_tools.value())
.into_iter()
.flatten()
.filter_map(move |(operation, tool)| {
let (kind, description) = match tool {
HumanTool::Command(tool) => (tool.kind, &tool.description),
HumanTool::Builtin(tool) => (tool.kind, &tool.description),
};
Some(Tool {
name: format!("{operation}_{id}").parse().ok()?,
artifact: id.clone(),
kind,
description: description.replace("{artifactName}", id.as_str()),
declared: Runs::parse(artifacts, id, tool),
})
})
})
.collect();
tools.sort_by(|a, b| a.name.cmp(&b.name));
tools
}
pub fn schema(
request: &Request,
verdict: crate::runtime::Verdict,
) -> Result<Option<Value>, String> {
let definition = request.human_definition.as_ref().ok_or(
"This request records no Human definition; open the original request its actions go to.",
)?;
let declaration = definition.eval.declaration.value();
let schema = declaration.and_then(|declaration| match verdict {
crate::runtime::Verdict::Green => declaration.pass_schema.value(),
crate::runtime::Verdict::Red => declaration.fail_schema.value(),
});
Ok(schema.map(|schema| Value::Object(schema.clone())))
}
pub fn shell<'a>(words: impl IntoIterator<Item = &'a str>) -> String {
let quote = |word: &str| {
let plain = !word.is_empty()
&& word
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%{}".contains(c));
if plain {
word.to_owned()
} else {
format!("\"{}\"", word.replace('"', "\"\""))
}
};
words.into_iter().map(quote).collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod submission_tests {
use super::Submission;
use crate::runtime::Verdict;
use serde_json::json;
#[test]
fn submission_parses_one_verdict_and_keeps_its_owner_fields() {
for (value, verdict) in [
(json!({"verdict":"GREEN","approved":true}), Verdict::Green),
(json!({"verdict":"RED","reason":"needs work"}), Verdict::Red),
] {
let submitted = Submission::try_from(value.clone()).unwrap();
assert_eq!(submitted.verdict(), verdict);
assert_eq!(submitted.to_json(), value);
}
assert!(Submission::try_from(json!({"approved":true})).is_err());
assert!(Submission::try_from(json!({"verdict":"ERROR"})).is_err());
}
}