1mod 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
24pub const CLAIM_FIRST: &str = "Claim this request before reviewing it.";
26pub const NOT_CLAIMED: &str = "The request is not claimed.";
28
29pub 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
52pub 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
64pub 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#[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#[derive(Debug, Clone, PartialEq)]
100pub enum Job {
101 Claim { id: RequestId },
103 Run {
105 id: RequestId,
106 tool: ToolName,
107 claim: bool,
108 },
109 Submit {
111 id: RequestId,
112 result: Submission,
113 claim: bool,
114 },
115 Release { ids: Vec<RequestId>, quit: bool },
117}
118
119#[derive(Debug)]
120pub enum Outcome {
121 Claimed {
122 result: Result<HumanClaim, String>,
123 },
124 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 failed: Vec<(RequestId, String)>,
144 },
145}
146
147impl Outcome {
148 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#[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 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#[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 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 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
335pub 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 .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
374pub 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
392pub 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
410pub 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}