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() != crate::types::RequestStatus::WaitingHuman {
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)]
78pub enum Job {
79 Claim { id: RequestId },
81 Run {
83 id: RequestId,
84 tool: ToolName,
85 claim: bool,
86 },
87 Submit {
89 id: RequestId,
90 verdict: crate::runtime::Verdict,
91 result: Value,
92 claim: bool,
93 },
94 Release { ids: Vec<RequestId>, quit: bool },
96}
97
98#[derive(Debug)]
99pub enum Outcome {
100 Claimed {
101 result: Result<HumanClaim, String>,
102 },
103 SavedLocally {
105 request: Box<Request>,
106 error: String,
107 },
108 Ran {
109 tool: ToolName,
110 claimed: Option<HumanClaim>,
111 result: Result<ToolResult, String>,
112 },
113 Submitted {
114 id: RequestId,
115 claimed: Option<HumanClaim>,
116 result: Result<Box<Request>, String>,
117 },
118 Released {
119 ids: Vec<RequestId>,
120 quit: bool,
121 failed: Vec<(RequestId, String)>,
123 },
124}
125
126impl Outcome {
127 pub fn notice(&self) -> (String, bool) {
129 match self {
130 Self::SavedLocally { error, .. } => (error.clone(), true),
131 Self::Claimed { result: Ok(_) } => (
132 "Claimed. Fill GREEN or RED fields, then Submit.".into(),
133 false,
134 ),
135 Self::Claimed { result: Err(error) } => (error.clone(), true),
136 Self::Ran { tool, result, .. } => {
137 let launched = Content::Launch { launched: true };
138 match result {
139 Ok(result) if result.is_error() => (
140 format!("{tool} failed; a tool error is not a verdict."),
141 true,
142 ),
143 Ok(result) if result.content().contains(&launched) => {
144 (format!("{tool} launched."), false)
145 }
146 Ok(_) => (format!("{tool} finished."), false),
147 Err(_) => (format!("{tool} did not run."), true),
148 }
149 }
150 Self::Submitted {
151 result: Ok(request),
152 ..
153 } => (
154 format!(
155 "Submitted {} for {} ({}).",
156 request.status(),
157 request.eval_id,
158 request.id
159 ),
160 false,
161 ),
162 Self::Submitted {
163 result: Err(error), ..
164 } => (error.clone(), true),
165 Self::Released { failed, .. } if failed.is_empty() => ("Claim released.".into(), false),
166 Self::Released { failed, .. } => {
167 let lines = failed.iter().map(|(id, error)| format!("{id}: {error}"));
168 (lines.collect::<Vec<_>>().join("\n"), true)
169 }
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct Tool {
177 pub name: ToolName,
178 pub kind: HumanToolKind,
179 pub description: String,
180 pub declared: Runs,
181}
182
183impl Tool {
184 pub fn action(&self, resolved: Option<&Result<CommandLine, String>>) -> String {
187 match (&self.declared, resolved) {
188 (Runs::Command(_), Some(Ok(command))) => {
189 let program = crate::platform::path_text(std::path::Path::new(&command.program));
190 let words = std::iter::once(program).chain(command.args.iter().cloned());
191 let words: Vec<_> = words.collect();
192 format!("$ {}", shell(words.iter().map(String::as_str)))
193 }
194 (declared, _) => declared.line(),
195 }
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Output {
202 pub title: String,
203 pub text: String,
204 pub error: bool,
205}
206
207impl Job {
208 pub async fn run(
211 self,
212 state: PathBuf,
213 reviewer: crate::types::ReviewerId,
214 cancel: CancellationToken,
215 ) -> Outcome {
216 let mut claimed = None;
217 match self {
218 Job::Claim { id } => {
219 let result = async {
220 let (receipts, _) = human::open(&state, &id).await?;
221 human::claim(&receipts, &id, &reviewer).await
222 }
223 .await;
224 Outcome::Claimed { result }
225 }
226 Job::Run {
227 id,
228 tool,
229 claim: first,
230 } => {
231 let result = async {
232 let (receipts, _) = human::open(&state, &id).await?;
233 if first {
234 claimed = Some(human::claim(&receipts, &id, &reviewer).await?);
235 }
236 human::run_human_tool(&receipts, &id, &reviewer, &tool, cancel).await
237 }
238 .await;
239 Outcome::Ran {
240 tool,
241 claimed,
242 result,
243 }
244 }
245 Job::Submit {
246 id,
247 verdict: _,
248 result,
249 claim: first,
250 } => {
251 let result = async {
252 if first {
253 let (receipts, _) = human::open(&state, &id).await?;
254 claimed = Some(human::claim(&receipts, &id, &reviewer).await?);
255 }
256 human::submit_and_publish(&state, &id, &reviewer, &result, cancel)
257 .await
258 .map(Box::new)
259 }
260 .await;
261 if let Err(error) = &result
262 && let Ok(view) = store::read_request(&state, &id).await
263 && matches!(
264 view.request.status(),
265 crate::types::RequestStatus::Green | crate::types::RequestStatus::Red
266 )
267 && view.execution.as_ref().is_some_and(|execution| {
268 execution.reviewer.as_deref() == Some(reviewer.as_str())
269 })
270 {
271 return Outcome::SavedLocally {
272 request: Box::new(view.request),
273 error: error.clone(),
274 };
275 }
276 Outcome::Submitted {
277 id,
278 claimed,
279 result,
280 }
281 }
282 Job::Release { ids, quit } => {
283 let mut failed = Vec::new();
284 for id in &ids {
285 let release = async {
286 let view = store::read_request(&state, id).await?;
288 if view.claim.is_none_or(|claim| claim.reviewer != reviewer) {
289 return Ok(());
290 }
291 let (receipts, _) = human::open(&state, id).await?;
292 human::unclaim(&receipts, id, &reviewer).await.map(drop)
293 };
294 if let Err(error) = release.await {
295 failed.push((id.clone(), error));
296 }
297 }
298 Outcome::Released { ids, quit, failed }
299 }
300 }
301 }
302}
303
304pub fn tools(request: &Request) -> Vec<Tool> {
306 use crate::store::definitions::HumanTool;
307 let Some(artifacts) = request
308 .human_definition
309 .as_ref()
310 .and_then(|definition| definition.artifacts.value())
311 else {
312 return Vec::new();
313 };
314 let mut tools: Vec<_> = artifacts
315 .iter()
316 .flat_map(|(id, artifact)| {
317 artifact
318 .views
319 .value()
320 .and_then(|views| views.human_tools.value())
321 .into_iter()
322 .flatten()
323 .filter_map(move |(operation, tool)| {
326 let (kind, description) = match tool {
327 HumanTool::Command(tool) => (tool.kind, &tool.description),
328 HumanTool::Builtin(tool) => (tool.kind, &tool.description),
329 };
330 Some(Tool {
331 name: format!("{operation}_{id}").parse().ok()?,
332 kind,
333 description: description.replace("{artifactName}", id.as_str()),
334 declared: Runs::parse(artifacts, id, tool),
335 })
336 })
337 })
338 .collect();
339 tools.sort_by(|a, b| a.name.cmp(&b.name));
340 tools
341}
342
343pub fn schema(
347 request: &Request,
348 verdict: crate::runtime::Verdict,
349) -> Result<Option<Value>, String> {
350 let definition = request.human_definition.as_ref().ok_or(
351 "This request records no Human definition; open the original request its actions go to.",
352 )?;
353 let declaration = definition.eval.declaration.value();
354 let schema = declaration.and_then(|declaration| match verdict {
355 crate::runtime::Verdict::Green => declaration.pass_schema.value(),
356 crate::runtime::Verdict::Red => declaration.fail_schema.value(),
357 });
358 Ok(schema.map(|schema| Value::Object(schema.clone())))
359}
360
361pub fn shell<'a>(words: impl IntoIterator<Item = &'a str>) -> String {
365 let quote = |word: &str| {
366 let plain = !word.is_empty()
367 && word
368 .chars()
369 .all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%{}".contains(c));
370 if plain {
371 word.to_owned()
372 } else {
373 format!("\"{}\"", word.replace('"', "\"\""))
374 }
375 };
376 words.into_iter().map(quote).collect::<Vec<_>>().join(" ")
377}
378
379pub fn output(tool: &str, result: Result<ToolResult, String>) -> Output {
381 match result {
382 Ok(result) => Output {
383 title: if result.is_error() {
384 format!("{tool} · tool error")
385 } else {
386 tool.to_owned()
387 },
388 text: result
389 .content()
390 .iter()
391 .map(|content| match content {
392 Content::Text { text } => text.trim_end().to_owned(),
393 Content::Launch { launched: true } => "launched".into(),
394 Content::Launch { launched: false } => "not launched".into(),
395 })
396 .collect::<Vec<_>>()
397 .join("\n"),
398 error: result.is_error(),
399 },
400 Err(error) => Output {
401 title: format!("{tool} · failed"),
402 text: error,
403 error: true,
404 },
405 }
406}