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::operation_of(&self.name, &self.artifact)
212 }
213
214 pub fn operation_of<'a>(name: &'a ToolName, artifact: &crate::types::ArtifactName) -> &'a str {
216 name.as_str()
217 .strip_suffix(&format!("_{}", artifact.tool_suffix()))
218 .unwrap_or(name.as_str())
219 }
220
221 pub fn action(&self, resolved: Option<&Result<CommandLine, String>>) -> String {
224 match (&self.declared, resolved) {
225 (Runs::Command(_), Some(Ok(command))) => {
226 let program = crate::platform::path_text(std::path::Path::new(&command.program));
227 let words = std::iter::once(program).chain(command.args.iter().cloned());
228 let words: Vec<_> = words.collect();
229 format!("$ {}", shell(words.iter().map(String::as_str)))
230 }
231 (declared, _) => declared.line(),
232 }
233 }
234}
235
236impl Job {
237 pub async fn run(
241 self,
242 state: PathBuf,
243 reviewer: crate::types::ReviewerId,
244 cancel: CancellationToken,
245 log: log::Sink,
246 ) -> Outcome {
247 let mut claimed = None;
248 match self {
249 Job::Claim { id } => {
250 let result = async {
251 let (receipts, _) = human::open(&state, &id).await?;
252 let result = human::claim(&receipts, &id, &reviewer).await;
253 let closed = receipts.close().await;
254 result.and_then(|claim| closed.map(|()| claim))
255 }
256 .await;
257 Outcome::Claimed { result }
258 }
259 Job::Run {
260 id,
261 tool,
262 claim: first,
263 } => {
264 let declared = store::read_request(&state, &id)
266 .await
267 .ok()
268 .and_then(|view| {
269 tools(&view.request)
270 .into_iter()
271 .find(|declared| declared.name == tool)
272 });
273 let run = match &declared {
274 Some(declared) => {
275 log.start(&id, declared.operation(), declared.artifact.as_str())
276 }
277 None => log.start(&id, tool.as_str(), ""),
278 };
279 let result = async {
280 let (receipts, _) = match human::open(&state, &id).await {
281 Ok(opened) => opened,
282 Err(error) => {
283 run.end(log::End::Failed(error.clone()));
284 return Err(error);
285 }
286 };
287 let result = async {
288 if first {
289 match human::claim(&receipts, &id, &reviewer).await {
290 Ok(claim) => claimed = Some(claim),
291 Err(error) => {
292 run.end(log::End::Failed(error.clone()));
293 return Err(error);
294 }
295 }
296 }
297 human::run_human_tool(&receipts, &id, &reviewer, &tool, cancel, run).await
298 }
299 .await;
300 let closed = receipts.close().await;
301 result.and_then(|result| closed.map(|()| result))
302 }
303 .await;
304 Outcome::Ran {
305 tool,
306 claimed,
307 result,
308 }
309 }
310 Job::Submit {
311 id,
312 result,
313 claim: first,
314 } => {
315 let result = async {
316 if first {
317 let (receipts, _) = human::open(&state, &id).await?;
318 let result = human::claim(&receipts, &id, &reviewer).await;
319 let closed = receipts.close().await;
320 claimed = Some(result.and_then(|claim| closed.map(|()| claim))?);
321 }
322 human::submit_and_publish(&state, &id, &reviewer, &result.to_json(), cancel)
323 .await
324 .map(Box::new)
325 }
326 .await;
327 if let Err(error) = &result
328 && let Ok(view) = store::read_request(&state, &id).await
329 && matches!(
330 view.request.status(),
331 crate::types::RequestStatus::Green | crate::types::RequestStatus::Red
332 )
333 && view.execution.as_ref().is_some_and(|execution| {
334 execution.reviewer.as_deref() == Some(reviewer.as_str())
335 })
336 {
337 return Outcome::SavedLocally {
338 request: Box::new(std::sync::Arc::unwrap_or_clone(view.request)),
339 error: error.clone(),
340 };
341 }
342 Outcome::Submitted {
343 id,
344 claimed,
345 result,
346 }
347 }
348 Job::Release { ids, quit } => {
349 let mut failed = Vec::new();
350 for id in &ids {
351 let release = async {
352 let view = store::read_request(&state, id).await?;
354 if view.claim.is_none_or(|claim| claim.reviewer != reviewer) {
355 return Ok(());
356 }
357 let (receipts, _) = human::open(&state, id).await?;
358 let result = human::unclaim(&receipts, id, &reviewer).await.map(drop);
359 let closed = receipts.close().await;
360 result.and(closed)
361 };
362 if let Err(error) = release.await {
363 failed.push((id.clone(), error));
364 }
365 }
366 Outcome::Released { ids, quit, failed }
367 }
368 }
369 }
370}
371
372pub fn tools(request: &Request) -> Vec<Tool> {
374 request
375 .human_definition
376 .as_ref()
377 .and_then(|definition| definition.artifacts.value())
378 .map(declared)
379 .unwrap_or_default()
380}
381
382pub type Resolved = Result<CommandLine, String>;
384
385pub struct ArtifactDeclarations {
389 config: crate::config::RepoConfig,
390 artifact: crate::types::ArtifactName,
391}
392
393impl ArtifactDeclarations {
394 pub fn read(
398 repo: &std::path::Path,
399 artifact: &crate::types::ArtifactName,
400 site: &crate::config::Site,
401 ) -> Result<Self, crate::config::ScopeReadError> {
402 Ok(Self {
403 config: crate::config::read_artifact_scope(repo, artifact, site)?,
404 artifact: artifact.clone(),
405 })
406 }
407
408 pub fn tools(&self) -> Result<Vec<(Tool, Option<Resolved>)>, String> {
411 let registry =
412 crate::tools::human::Registry::for_artifact(&self.config, self.artifact.as_str())?;
413 let artifact = &self.config.artifacts[&self.artifact];
414 let mut tools: Vec<_> = artifact
415 .views
416 .human_tools
417 .iter()
418 .map(|(operation, declaration)| {
419 let name: ToolName = format!("{operation}_{}", self.artifact.tool_suffix())
420 .parse()
421 .expect("validated tool and Artifact names");
422 let command = registry.is_command(&name).then(|| registry.command(&name));
423 let tool = Tool {
424 name,
425 artifact: self.artifact.clone(),
426 kind: declaration.kind(),
427 description: declaration
428 .description()
429 .replace("{artifactName}", self.artifact.as_str()),
430 declared: Runs::declared(&self.config.artifacts, &self.artifact, declaration),
431 };
432 (tool, command)
433 })
434 .collect();
435 tools.sort_by(|(a, _), (b, _)| a.name.cmp(&b.name));
436 Ok(tools)
437 }
438
439 pub async fn run_tool(
442 &self,
443 tool: &ToolName,
444 cancel: CancellationToken,
445 run: log::Run,
446 ) -> Result<ToolResult, String> {
447 let artifact = &self.artifact;
448 let registry =
449 match crate::tools::human::Registry::for_artifact(&self.config, artifact.as_str()) {
450 Ok(registry) => registry,
451 Err(error) => {
452 run.end(log::End::Failed(error.clone()));
453 return Err(error);
454 }
455 };
456 if !registry
458 .list()
459 .any(|entry| &entry.name == tool && &entry.artifact_id == artifact)
460 {
461 let error = format!("{artifact} declares no Human tool {tool}.");
462 run.end(log::End::Failed(error.clone()));
463 return Err(error);
464 }
465 Ok(registry.call_logged(tool, cancel, run).await)
466 }
467}
468
469fn declared(
471 artifacts: &std::collections::BTreeMap<
472 crate::types::ArtifactName,
473 crate::store::definitions::Artifact,
474 >,
475) -> Vec<Tool> {
476 use crate::store::definitions::HumanTool;
477 let mut tools: Vec<_> = artifacts
478 .iter()
479 .flat_map(|(id, artifact)| {
480 artifact
481 .views
482 .value()
483 .and_then(|views| views.human_tools.value())
484 .into_iter()
485 .flatten()
486 .filter_map(move |(operation, tool)| {
489 let (kind, description) = match tool {
490 HumanTool::Command(tool) => (tool.kind, &tool.description),
491 HumanTool::Builtin(tool) => (tool.kind, &tool.description),
492 };
493 Some(Tool {
494 name: format!("{operation}_{}", id.tool_suffix()).parse().ok()?,
495 artifact: id.clone(),
496 kind,
497 description: description.replace("{artifactName}", id.as_str()),
498 declared: Runs::parse(artifacts, id, tool),
499 })
500 })
501 })
502 .collect();
503 tools.sort_by(|a, b| a.name.cmp(&b.name));
504 tools
505}
506
507pub fn schema(
511 request: &Request,
512 verdict: crate::runtime::Verdict,
513) -> Result<Option<Value>, String> {
514 let definition = request.human_definition.as_ref().ok_or(
515 "This request records no Human definition; open the original request its actions go to.",
516 )?;
517 let declaration = definition.eval.declaration.value();
518 let schema = declaration.and_then(|declaration| match verdict {
519 crate::runtime::Verdict::Green => declaration.pass_schema.value(),
520 crate::runtime::Verdict::Red => declaration.fail_schema.value(),
521 });
522 Ok(schema.map(|schema| Value::Object(schema.clone())))
523}
524
525pub fn shell<'a>(words: impl IntoIterator<Item = &'a str>) -> String {
529 let quote = |word: &str| {
530 let plain = !word.is_empty()
531 && word
532 .chars()
533 .all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%{}".contains(c));
534 if plain {
535 word.to_owned()
536 } else {
537 format!("\"{}\"", word.replace('"', "\"\""))
538 }
539 };
540 words.into_iter().map(quote).collect::<Vec<_>>().join(" ")
541}
542
543#[cfg(test)]
544mod submission_tests {
545 use super::Submission;
546 use crate::runtime::Verdict;
547 use serde_json::json;
548
549 #[test]
550 fn submission_parses_one_verdict_and_keeps_its_owner_fields() {
551 for (value, verdict) in [
552 (json!({"verdict":"GREEN","approved":true}), Verdict::Green),
553 (json!({"verdict":"RED","reason":"needs work"}), Verdict::Red),
554 ] {
555 let submitted = Submission::try_from(value.clone()).unwrap();
556 assert_eq!(submitted.verdict(), verdict);
557 assert_eq!(submitted.to_json(), value);
558 }
559 assert!(Submission::try_from(json!({"approved":true})).is_err());
560 assert!(Submission::try_from(json!({"verdict":"ERROR"})).is_err());
561 }
562}