use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard};
use argonauts_core::events::RunEventKind;
use argonauts_core::events::session::{CliWorkflowRun, EndReason, InputPartKind};
use argonauts_core::events::workflow::{
AgentLeftOut, AgentSession, RunNote, WorkflowEvent, WorkflowReport,
};
use argonauts_core::log::{GAVE_NO_ANSWER, Note, NoteLevel};
use argonauts_core::names::{ModelRef, RoleName, SessionKey};
use argonauts_core::session_runner::Access;
use argonauts_core::settings::crew::CrewRole;
use argonauts_runner::{AgentCall, AgentEnd, Check, Checked, JournalEntry, Kept, Script};
use futures::future::BoxFuture;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
mod beside;
mod no_answer;
pub(super) mod reading;
use super::{goal, journal, tasks};
use crate::engine::host::{Fallbacks, Host, InPlaceOf, Ran, SessionPlan};
use crate::engine::job_fallback;
use crate::error::{EngineError, EngineResult, Stall};
use crate::prompts::instructions::{self, CallAuthor};
use crate::roles;
use crate::task::support::part;
use crate::workflow::StepCall;
pub(super) struct Bridge<'a> {
pub host: &'a Host,
pub call: &'a StepCall,
stop: CancellationToken,
error: Mutex<Option<EngineError>>,
pub tasks: tasks::Book,
sessions: Mutex<BTreeMap<u32, SessionKey>>,
rows: Mutex<BTreeMap<u32, u32>>,
pub size_asked: Option<String>,
pub max_rounds: BTreeMap<Option<String>, u32>,
pub workflow_words: Option<String>,
pub own_workflow_in: BTreeSet<String>,
ran: Mutex<BTreeSet<(String, CrewRole)>>,
phases: Mutex<BTreeMap<(Option<String>, CrewRole), PhaseAgents>>,
second_tried: Mutex<BTreeSet<String>>,
wrote: AtomicBool,
readers: Mutex<BTreeMap<Round, BTreeMap<u32, ModelRef>>>,
}
type Round = (Option<String>, Option<String>, u32, CrewRole);
fn round_of(call: &AgentCall) -> Round {
(
call.task.clone(),
call.phase.clone(),
call.try_number,
call.role,
)
}
#[derive(Debug, Default)]
struct PhaseAgents {
working: BTreeSet<String>,
answered: BTreeSet<String>,
left_out: Vec<LeftOut>,
}
#[derive(Debug, Clone)]
struct LeftOut {
label: String,
said: String,
cli: String,
tried: Vec<String>,
}
fn job_words(call: &AgentCall) -> (&'static str, &'static str) {
match call.role {
CrewRole::Reviewer => ("reviewer", "review"),
_ => ("proposer", "proposal"),
}
}
fn count_in_words(count: usize) -> String {
const WORDS: [&str; 13] = [
"none", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
"eleven", "twelve",
];
WORDS
.get(count)
.map_or_else(|| count.to_string(), |word| (*word).to_owned())
}
impl<'a> Bridge<'a> {
pub fn never_ran(&self, required: &[argonauts_core::required::RequiredStep]) -> Vec<String> {
let ran = lock(&self.ran);
required
.iter()
.filter(|step| {
!ran.iter().any(|(phase, role)| {
*role == step.role && argonauts_core::required::same_name(phase, &step.name)
})
})
.map(|step| step.name.clone())
.collect()
}
pub fn new(host: &'a Host, call: &'a StepCall, stop: CancellationToken) -> Self {
Self {
host,
call,
stop,
error: Mutex::default(),
tasks: tasks::Book::open(journal::place(host, call, "tasks.json")),
sessions: Mutex::new(journal::sessions(host, call)),
rows: Mutex::default(),
size_asked: None,
max_rounds: BTreeMap::new(),
workflow_words: None,
own_workflow_in: BTreeSet::new(),
ran: Mutex::default(),
phases: Mutex::default(),
second_tried: Mutex::default(),
wrote: AtomicBool::new(false),
readers: Mutex::default(),
}
}
pub fn author<'c>(&'c self, call: &'c AgentCall) -> CallAuthor<'c> {
match &self.workflow_words {
Some(name) => CallAuthor::Workflow {
name,
step: call.phase.as_deref(),
},
None => CallAuthor::Orchestrator,
}
}
pub fn error(&self) -> Option<EngineError> {
lock(&self.error).take()
}
pub fn take_error(&self) -> EngineError {
self.error().unwrap_or(EngineError::Interrupted)
}
pub fn ran(&self, call: &AgentCall, session: &SessionKey) -> EngineResult<()> {
lock(&self.rows).insert(call.row, call.index);
let mut sessions = lock(&self.sessions);
sessions.insert(call.index, session.clone());
journal::keep_sessions(self.host, self.call, &sessions)
}
fn row_sessions(&self) -> Vec<AgentSession> {
let sessions = lock(&self.sessions);
lock(&self.rows)
.iter()
.filter_map(|(row, call)| {
Some(AgentSession {
agent: *row,
session: sessions.get(call)?.clone(),
})
})
.collect()
}
fn failed(&self, err: EngineError) -> AgentEnd {
let ends_the_step = matches!(
err,
EngineError::Interrupted
| EngineError::Stalled(_)
| EngineError::Journal(_)
| EngineError::Io(_)
| EngineError::Git(_)
);
let why = err.to_string();
if ends_the_step {
let mut first = lock(&self.error);
if first.is_none() {
*first = Some(err);
}
self.stop.cancel();
}
AgentEnd::failed(why)
}
fn leave_out(&self, call: &AgentCall, stall: &Stall) -> Option<AgentEnd> {
let fewest = self.fewest_answers(call)?;
let others = {
let mut phases = lock(&self.phases);
let agents = phases.entry((call.phase.clone(), call.role)).or_default();
let others = agents.working.len() + agents.answered.len();
if others < fewest {
return None;
}
agents.left_out.push(LeftOut {
label: call.label.clone(),
said: stall.job.said.clone(),
cli: cli_of(stall),
tried: stall.tried.clone(),
});
others
};
let goes_on = if self.is_council() {
format!("the council goes on with {others}")
} else {
format!("the workflow goes on with {others}")
};
let line = format!(
"{} is left out: {} and nothing else can take it · {goes_on}",
call.label, stall.job.said
);
let (acts_as, gives) = job_words(call);
let whole = if self.is_council() {
"a council"
} else {
"the workflow"
};
let again = "the next run asks it again";
let means = vec![
format!(
"{whole} can do without one {gives}, so it goes on with {}",
count_in_words(others)
),
if stall.job.signed_out {
format!("sign in to {} and {again}", cli_of(stall))
} else {
again.to_owned()
},
];
let host = self.host;
let left = AgentLeftOut {
step: self.call.at.step.clone(),
label: call.label.clone(),
meant_for: stall
.job
.asked
.clone()
.unwrap_or_else(|| ModelRef::new("", None)),
why: stall.job.short.clone(),
said: stall.job.said.clone(),
acts_as: acts_as.to_owned(),
means,
};
let recorded = host
.record(
None,
None,
RunEventKind::Workflow(WorkflowEvent::LeftOut(left)),
)
.and_then(|_| {
let note = RunNote {
text: line.clone(),
workflow: Some(self.call.at.step.workflow.clone()),
left: false,
};
host.record(
None,
None,
RunEventKind::Workflow(WorkflowEvent::Note(note)),
)
})
.and_then(|_| {
let note = Note {
level: NoteLevel::Warn,
text: line.clone(),
};
host.record(None, None, RunEventKind::Note(note))
});
if let Err(err) = recorded {
return Some(self.failed(err));
}
Some(AgentEnd::failed(line))
}
fn fewest_answers(&self, call: &AgentCall) -> Option<usize> {
match call.role {
CrewRole::Reviewer => Some(1),
CrewRole::Worker if self.is_council() && self.proposes(call) => Some(2),
_ => None,
}
}
fn proposes(&self, call: &AgentCall) -> bool {
call.role == CrewRole::Worker
&& !call.writes
&& call.task.is_none()
&& !self.wrote.load(Ordering::Relaxed)
}
fn is_council(&self) -> bool {
self.host.settings().workflow == "council"
}
fn stop_with_left_out(&self, call: &AgentCall, mut stall: Box<Stall>) -> Box<Stall> {
let phases = lock(&self.phases);
let Some(agents) = phases.get(&(call.phase.clone(), call.role)) else {
return stall;
};
let mut stop = stall.run_stop();
let this = LeftOut {
label: call.label.clone(),
said: stall.job.said.clone(),
cli: cli_of(&stall),
tried: stall.tried.clone(),
};
let mut out: Vec<&LeftOut> = agents.left_out.iter().chain([&this]).collect();
out.sort_by(|a, b| a.label.cmp(&b.label));
stop.agents = out
.iter()
.map(|left| format!("{} · {}", left.label, left.said))
.collect();
stop.tried = Vec::new();
let in_step = stall.in_step.clone();
stop.waits_for = Vec::new();
for left in &out {
for tried in &left.tried {
if !stop.tried.contains(tried) {
stop.tried.push(tried.clone());
}
}
if !stop.waits_for.contains(&left.cli) {
stop.waits_for.push(left.cli.clone());
}
}
stop.tried = crate::error::with_step(&stop.tried, &in_step);
let mut kept: Vec<String> = agents.answered.union(&agents.working).cloned().collect();
kept.sort();
if let Some(fewest) = self.fewest_answers(call) {
let only = argonauts_core::text::and_list(&kept);
let makes = if kept.len() == 1 {
"can make one"
} else {
"can make them"
};
stop.why = match fewest {
2 if self.is_council() => {
format!("a council needs two proposals, and only {only} {makes}")
}
_ => format!(
"{} needs {fewest} answers, and only {only} {makes}",
call.phase.as_deref().unwrap_or("the step")
),
};
} else {
stop.why = format!("nothing else can take {}", call.label);
stop.agents = out
.iter()
.map(|left| format!("{} · {}", left.label, left.said))
.collect();
}
stop.kept = kept;
stall.stop = Some(Box::new(stop));
stall
}
async fn run_agent(&self, call: &AgentCall) -> EngineResult<AgentEnd> {
let worker = self.pick(call)?;
if call.writes {
return tasks::work(self, call, &worker).await;
}
self.reads(call, &worker);
let mut whole = None;
let (workdir, change) = match tasks::change_of(self, call).await? {
Some((tree, change)) => (tree, Some(change)),
None if call.task.is_none() && !self.builders(call).is_empty() => {
let workdir = match tasks::whole_tree(self, call.index).await? {
tasks::Whole::Tree(tree) => {
let path = tree.path.clone();
whole = Some(tree);
path
}
tasks::Whole::Nothing => self.host.repo().base_worktree.clone(),
tasks::Whole::Conflict(why) => return Ok(AgentEnd::failed(why)),
};
(workdir, tasks::combined_change(self).await?)
}
None => (self.host.repo().base_worktree.clone(), None),
};
let reviews = change.is_some();
let mut parts = vec![instructions::of_call(
self.host,
&call.prompt,
self.author(call),
)];
let checks = match call.task.as_deref() {
Some(task) => tasks::checks_of(self, Some(task)),
None if matches!(call.role.fine_role(), RoleName::Judge | RoleName::Reviewer) => {
tasks::all_checks(self)
}
None => None,
}
.map(|checks| part(InputPartKind::CheckOutput, "the checks", checks, None));
let brief = parts
.iter()
.chain(&checks)
.map(|part| part.text.len())
.sum();
let reader = self.reader(call, worker, change.as_ref(), brief).await;
if let Some(change) = reader.change {
parts.push(part(InputPartKind::Evidence, "the change", change, None));
}
parts.extend(checks);
let first_orchestrator = call.role.fine_role() == RoleName::Orchestrator
&& call.task.is_none()
&& !self.host.read(|state| {
state
.sessions
.by_key
.values()
.any(|session| session.started.role == RoleName::Orchestrator)
});
let fork_of = first_orchestrator
.then(|| crate::orchestrated::fork_for(self.host, &reader.worker))
.flatten();
let plan = SessionPlan {
at: Some(self.call.at.clone()),
role: call.role.fine_role(),
worker: reader.worker,
task: None,
access: reader.access,
step: None,
workdir,
parts,
json_schema: call.schema.clone(),
resume: None,
slot: 0,
label: Some(call.label.clone()),
reviews,
fresh: false,
independent_of: self.builders(call),
in_place_of: reader.in_place_of,
fork_of,
};
let known = lock(&self.sessions).get(&call.index).cloned();
let keep = |key: &SessionKey| self.ran(call, key);
let ran = self
.host
.run_session_in_turn(plan, known.as_ref(), Fallbacks::Allowed, &keep)
.await;
if let Some(whole) = whole {
whole.done(self).await;
}
let ran = ran?;
self.ran(call, &ran.key)?;
let end = answer(self.host, call, &ran);
if let Ok(verdict) = &end.result {
tasks::judged(self, call, &ran, verdict)?;
}
Ok(end)
}
fn builders(&self, call: &AgentCall) -> Vec<ModelRef> {
if !matches!(call.role, CrewRole::Reviewer | CrewRole::Judge) {
return Vec::new();
}
if call.task.is_some() {
return tasks::author(self, call).into_iter().collect();
}
tasks::authors(self)
}
fn pick(&self, call: &AgentCall) -> EngineResult<ModelRef> {
if let Some(model) = &call.model {
return model
.parse()
.map_err(|err| EngineError::Refused(format!("{}: {err}", call.label)));
}
let models = self.ranked(call);
let nth = call.model_index as usize % models.len().max(1);
let picked = models
.get(nth)
.map(|model| self.unrepeated(call, model, &models));
picked.ok_or_else(|| {
EngineError::Refused(format!(
"no model is set for the {} to take {}",
call.role, call.label
))
})
}
fn unrepeated(&self, call: &AgentCall, picked: &ModelRef, models: &[ModelRef]) -> ModelRef {
if !matches!(call.role, CrewRole::Reviewer | CrewRole::Judge) {
return picked.clone();
}
let taken = lock(&self.readers)
.get(&round_of(call))
.is_some_and(|readers| {
readers
.iter()
.any(|(index, model)| *index != call.index && model == picked)
});
if !taken {
return picked.clone();
}
self.first_free(call, models)
.unwrap_or_else(|| picked.clone())
}
fn ranked(&self, call: &AgentCall) -> Vec<ModelRef> {
let host = self.host;
let mut models = roles::candidates(host.settings(), call.role.fine_role());
let available = host.services().workers.available();
if models.iter().any(|model| available.contains(model)) {
models.retain(|model| available.contains(model));
}
if let Some(author) = tasks::author(self, call) {
if call.not_the_author && models.iter().any(|model| *model != author) {
models.retain(|model| *model != author);
}
if matches!(call.role, CrewRole::Reviewer | CrewRole::Judge) || call.not_the_author {
let theirs = roles::provider(host, &author);
models.sort_by_key(|model| roles::provider(host, model) == theirs);
}
} else if call.task.is_none() {
let theirs: BTreeSet<String> = self
.builders(call)
.iter()
.map(|builder| roles::provider(host, builder))
.collect();
models.sort_by_key(|model| theirs.contains(&roles::provider(host, model)));
}
models
}
async fn reader(
&self,
call: &AgentCall,
worker: ModelRef,
change: Option<&reading::Change>,
brief: usize,
) -> Reader {
if !crate::review::is_endpoint(self.host, &worker) {
return Reader {
worker,
access: Access::ReadOnly,
change: change.map(reading::Change::for_a_reader_with_tools),
in_place_of: None,
};
}
let Some(change) = change else {
return Reader {
worker,
access: Access::NoTools,
change: None,
in_place_of: None,
};
};
let whole = change.for_a_reader_without_tools(None);
let (most, why) = match reading::room(self.host, &worker, brief, &whole).await {
reading::Room::Holds => {
return Reader {
worker,
access: Access::NoTools,
change: Some(whole),
in_place_of: None,
};
}
reading::Room::Short { most, why } => (most, why),
};
match self.with_tools(call) {
Some(taker) => {
self.reads(call, &taker);
let line = format!(
"{} goes to {}, in place of {}, {why}",
call.label,
roles::display_name(&taker),
roles::display_name(&worker)
);
if let Err(err) =
job_fallback::say_in_the_view(self.host, &self.call.at.step.workflow, line)
{
self.failed(err);
}
Reader {
worker: taker,
access: Access::ReadOnly,
change: Some(change.for_a_reader_with_tools()),
in_place_of: Some(InPlaceOf { asked: worker, why }),
}
}
None => Reader {
worker,
access: Access::NoTools,
change: Some(change.for_a_reader_without_tools(Some(most))),
in_place_of: None,
},
}
}
fn with_tools(&self, call: &AgentCall) -> Option<ModelRef> {
let host = self.host;
let models: Vec<ModelRef> = self
.ranked(call)
.into_iter()
.filter(|model| !crate::review::is_endpoint(host, model))
.collect();
let theirs = self.builders_providers(call);
self.first_free(call, &models).or_else(|| {
models
.iter()
.find(|model| !theirs.contains(&roles::provider(host, model)))
.or_else(|| models.first())
.cloned()
})
}
fn first_free(&self, call: &AgentCall, models: &[ModelRef]) -> Option<ModelRef> {
let taken: BTreeSet<ModelRef> = lock(&self.readers)
.get(&round_of(call))
.map(|readers| {
readers
.iter()
.filter(|(index, _)| **index != call.index)
.map(|(_, model)| model.clone())
.collect()
})
.unwrap_or_default();
let theirs = self.builders_providers(call);
let free = |model: &&ModelRef| !taken.contains(*model);
models
.iter()
.find(|model| free(model) && !theirs.contains(&roles::provider(self.host, model)))
.or_else(|| models.iter().find(free))
.cloned()
}
fn builders_providers(&self, call: &AgentCall) -> BTreeSet<String> {
self.builders(call)
.iter()
.map(|builder| roles::provider(self.host, builder))
.collect()
}
fn reads(&self, call: &AgentCall, worker: &ModelRef) {
if matches!(call.role, CrewRole::Reviewer | CrewRole::Judge) {
lock(&self.readers)
.entry(round_of(call))
.or_default()
.insert(call.index, worker.clone());
}
}
}
struct Reader {
worker: ModelRef,
access: Access,
change: Option<String>,
in_place_of: Option<InPlaceOf>,
}
impl argonauts_runner::Host for Bridge<'_> {
fn agent<'b>(&'b self, call: &'b AgentCall) -> BoxFuture<'b, AgentEnd> {
Box::pin(async move {
if call.writes {
self.wrote.store(true, Ordering::Relaxed);
}
if let Some(phase) = &call.phase {
lock(&self.ran).insert((phase.clone(), call.role));
}
let place = (call.phase.clone(), call.role);
lock(&self.phases)
.entry(place.clone())
.or_default()
.working
.insert(call.label.clone());
let ran = self.run_agent(call).await;
{
let mut phases = lock(&self.phases);
let agents = phases.entry(place).or_default();
agents.working.remove(&call.label);
if matches!(&ran, Ok(end) if end.result.is_ok()) {
agents.answered.insert(call.label.clone());
}
}
let end = match ran {
Ok(end) => end,
Err(EngineError::Stalled(mut stall)) => {
if let Ok(own) = self.pick(call) {
job_fallback::named_by_its_own(self.host, &mut stall, &own);
}
match self.leave_out(call, &stall) {
Some(left) => return left,
None => match self.beside(call, stall).await {
Ok(end) => end,
Err(stall) => self
.failed(EngineError::Stalled(self.stop_with_left_out(call, stall))),
},
}
}
Err(err) => self.failed(err),
};
if let Err(why) = &end.result {
let reads_on = [no_answer::ENDED_WITHOUT_AN_ANSWER, tasks::SKIPPED];
let text = if reads_on.iter().any(|start| why.starts_with(start)) {
format!("{} {why}", call.label)
} else {
format!("{}{GAVE_NO_ANSWER}{why}", call.label)
};
let note = Note {
level: NoteLevel::Warn,
text,
};
let _ = self.host.record(None, None, RunEventKind::Note(note));
}
end
})
}
fn check<'b>(&'b self, check: &'b Check) -> BoxFuture<'b, Checked> {
Box::pin(async move {
let mut whole = None;
let workdir = match tasks::tree_of(self, check.task.as_deref()).await {
Ok(Some(tree)) => tree,
_ if check.task.is_none() => match tasks::whole_tree(self, check.index).await {
Ok(tasks::Whole::Tree(tree)) => {
let path = tree.path.clone();
whole = Some(tree);
path
}
Ok(tasks::Whole::Nothing) => self.host.repo().base_worktree.clone(),
Ok(tasks::Whole::Conflict(why)) => {
return Checked {
holds: false,
failed: format!("the check couldn't run: {why}"),
stop: true,
};
}
Err(err) => {
let why = self.failed(err).result.err().unwrap_or_default();
return Checked {
holds: false,
failed: format!("the check couldn't run: {why}"),
stop: true,
};
}
},
_ => self.host.repo().base_worktree.clone(),
};
let task = tasks::task_of(self, check.task.as_deref());
let max_rounds = self.max_rounds.get(&check.step).copied();
let checked = goal::check(
self.host,
self.call,
check,
task.as_ref(),
max_rounds,
&workdir,
);
let checked = checked.await;
if let Some(whole) = whole {
whole.done(self).await;
}
match checked {
Ok(checked) => checked,
Err(err) => {
let why = self.failed(err).result.err().unwrap_or_default();
Checked {
holds: false,
failed: format!("the check couldn't run: {why}"),
stop: true,
}
}
}
})
}
fn workflow(&self, name: &str) -> Result<Script, String> {
super::workflow_named(self.host, name)
}
fn replayed(&self, call: &AgentCall) {
if call.writes {
self.wrote.store(true, Ordering::Relaxed);
}
lock(&self.rows).insert(call.row, call.index);
lock(&self.phases)
.entry((call.phase.clone(), call.role))
.or_default()
.answered
.insert(call.label.clone());
}
fn report(&self, workflow: &CliWorkflowRun) {
let mut workflow = workflow.clone();
for agent in &mut workflow.agents {
agent.prompt = None;
agent.result = None;
}
let report = WorkflowReport {
step: self.call.at.step.clone(),
workflow,
sessions: self.row_sessions(),
size_asked: self.size_asked.clone(),
};
let event = RunEventKind::Workflow(WorkflowEvent::Reported(Box::new(report)));
if let Err(err) = self.host.record(None, None, event) {
self.failed(err);
}
}
fn log(&self, message: &str) {
let note = RunNote {
text: message.to_owned(),
workflow: Some(self.call.workflow().clone()),
left: false,
};
let event = RunEventKind::Workflow(WorkflowEvent::Note(note));
if let Err(err) = self.host.record(None, None, event) {
self.failed(err);
}
}
fn keep(&self, kept: &Kept) {
if let Err(err) = self.tasks.keep(kept) {
self.failed(err);
}
}
fn journal(&self, entry: &JournalEntry) {
if let Err(err) = journal::append(self.host, self.call, entry) {
self.failed(err);
}
}
fn tokens_spent(&self) -> u64 {
self.host.read(|state| {
state
.sessions
.by_key
.values()
.filter_map(|session| session.end.as_ref())
.map(|end| end.spend.input_tokens + end.spend.output_tokens)
.sum()
})
}
fn now_ms(&self) -> u64 {
let since = self.host.now() - time::OffsetDateTime::UNIX_EPOCH;
u64::try_from(since.whole_milliseconds()).unwrap_or_default()
}
}
pub(super) fn answer(host: &Host, call: &AgentCall, ran: &Ran) -> AgentEnd {
let end = &ran.end;
let said = end
.result_text
.as_deref()
.or(end.last_message.as_deref())
.map(str::trim)
.filter(|text| !text.is_empty());
let result = if end.reason != EndReason::Completed {
let cli = argonauts_core::display_names::DisplayNames::default().cli(&ran.worker.agent);
Err(match end.reason.plain_line(&cli) {
Some(why) => why,
None => format!("ended early: {}", crate::status::folder::words(&end.reason)),
})
} else if let Some(schema) = &call.schema {
end.structured
.clone()
.or_else(|| said.and_then(argonauts_core::answer_format::json_in))
.ok_or_else(|| no_answer::why(host, &ran.key))
.and_then(|value| fits(schema, value))
} else {
Ok(Value::String(said.unwrap_or_default().to_owned()))
};
AgentEnd {
result,
model: Some(roles::display_name(&ran.worker)),
tokens: end.spend.input_tokens + end.spend.output_tokens,
tool_calls: u32::try_from(end.tool_calls).unwrap_or(u32::MAX),
duration_ms: end.duration_ms,
}
}
fn fits(schema: &Value, value: Value) -> Result<Value, String> {
let required = schema
.get("required")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let missing = required
.iter()
.filter_map(Value::as_str)
.find(|key| value.get(key).is_none());
match missing {
Some(key) => Err(format!("its answer has no {key}")),
None => Ok(value),
}
}
fn cli_of(stall: &Stall) -> String {
let names = argonauts_core::display_names::DisplayNames::default();
stall
.job
.asked
.as_ref()
.map(|asked| names.cli(asked.agent.as_str()))
.unwrap_or_default()
}
pub(super) fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}