1mod detail;
5mod form;
6mod runs;
7pub mod search;
8
9pub 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
26pub const CLAIM_FIRST: &str = "Claim this request before reviewing it.";
28pub const NOT_CLAIMED: &str = "The request is not claimed.";
30
31pub 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
54pub 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
66pub 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#[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#[derive(Debug, Clone, PartialEq)]
102pub enum Job {
103 Claim { id: RequestId },
105 Run {
107 id: RequestId,
108 tool: ToolName,
109 claim: bool,
110 },
111 Submit {
113 id: RequestId,
114 result: Submission,
115 claim: bool,
116 },
117 Release { ids: Vec<RequestId>, quit: bool },
119}
120
121#[derive(Debug)]
122pub enum Outcome {
123 Claimed {
124 result: Result<HumanClaim, String>,
125 },
126 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 failed: Vec<(RequestId, String)>,
146 },
147}
148
149impl Outcome {
150 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#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct Tool {
200 pub name: ToolName,
201 pub artifact: crate::types::ArtifactName,
203 pub kind: HumanToolKind,
204 pub description: String,
205 pub declared: Runs,
206}
207
208impl Tool {
209 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 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 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 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 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
368pub 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 .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
408pub 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
426pub 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}