use crate::approval::ApprovalViolation;
use salvor_runtime::RuntimeError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum EngineError {
#[error("map node `{node}`: the `over` reference `{over}` did not resolve to a list")]
MapOverNotAList {
node: String,
over: String,
},
#[error("map node `{node}`: {detail}")]
UnsupportedMapBody {
node: String,
detail: String,
},
#[error("branch node `{node}`: no case condition matched the routed value")]
NoBranchCaseMatched {
node: String,
},
#[error(
"branch node `{node}`: the decision agent replied `{reply}`, which is not one of the cases [{}]",
.cases.join(", ")
)]
BranchDecisionUnmatched {
node: String,
reply: String,
cases: Vec<String>,
},
#[error("fold node `{node}`: {detail}")]
UnsupportedFoldBody {
node: String,
detail: String,
},
#[error(
"fold node `{node}`: the `best_by` join reference `{reference}` named no comparable value in any pass"
)]
FoldNoComparableCandidate {
node: String,
reference: String,
},
#[error(
"fold node `{node}`: reached the max_iterations bound of {bound} without `stop_when` holding, and this fold declares `on_bound: fail`"
)]
FoldBoundExceeded {
node: String,
bound: u32,
},
#[error("agent node `{node}`: no agent registered for hash `{agent_hash}`")]
UnknownAgent {
node: String,
agent_hash: String,
},
#[error("tool node `{node}`: no tool registered under the name `{tool}`")]
UnknownTool {
node: String,
tool: String,
},
#[error("the graph is not a well-formed acyclic document: {detail}")]
MalformedGraph {
detail: String,
},
#[error("tool node `{node}` failed: {message}")]
ToolFailed {
node: String,
message: String,
},
#[error(
"gate node `{node}`: the approval input does not satisfy the gate's approval_schema ({})",
.violations.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
)]
ApprovalSchemaViolation {
node: String,
violations: Vec<ApprovalViolation>,
},
#[error("could not serialize the graph document to hash it: {0}")]
GraphEncode(#[source] serde_json::Error),
#[error(transparent)]
Runtime(#[from] RuntimeError),
}
impl EngineError {
#[must_use]
pub fn is_permanent(&self) -> bool {
match self {
Self::MapOverNotAList { .. }
| Self::UnsupportedMapBody { .. }
| Self::NoBranchCaseMatched { .. }
| Self::BranchDecisionUnmatched { .. }
| Self::UnsupportedFoldBody { .. }
| Self::FoldNoComparableCandidate { .. }
| Self::FoldBoundExceeded { .. }
| Self::MalformedGraph { .. } => true,
Self::UnknownAgent { .. }
| Self::UnknownTool { .. }
| Self::ToolFailed { .. }
| Self::ApprovalSchemaViolation { .. }
| Self::GraphEncode(_)
| Self::Runtime(_) => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn variant_name(error: &EngineError) -> &'static str {
match error {
EngineError::MapOverNotAList { .. } => "MapOverNotAList",
EngineError::UnsupportedMapBody { .. } => "UnsupportedMapBody",
EngineError::NoBranchCaseMatched { .. } => "NoBranchCaseMatched",
EngineError::BranchDecisionUnmatched { .. } => "BranchDecisionUnmatched",
EngineError::UnsupportedFoldBody { .. } => "UnsupportedFoldBody",
EngineError::FoldNoComparableCandidate { .. } => "FoldNoComparableCandidate",
EngineError::FoldBoundExceeded { .. } => "FoldBoundExceeded",
EngineError::UnknownAgent { .. } => "UnknownAgent",
EngineError::UnknownTool { .. } => "UnknownTool",
EngineError::MalformedGraph { .. } => "MalformedGraph",
EngineError::ToolFailed { .. } => "ToolFailed",
EngineError::ApprovalSchemaViolation { .. } => "ApprovalSchemaViolation",
EngineError::GraphEncode(_) => "GraphEncode",
EngineError::Runtime(_) => "Runtime",
}
}
fn samples() -> Vec<(EngineError, bool)> {
vec![
(
EngineError::MapOverNotAList {
node: "fanout".to_owned(),
over: "roster".to_owned(),
},
true,
),
(
EngineError::UnsupportedMapBody {
node: "fanout".to_owned(),
detail: "a `subgraph` body is not executed yet".to_owned(),
},
true,
),
(
EngineError::NoBranchCaseMatched {
node: "route".to_owned(),
},
true,
),
(
EngineError::BranchDecisionUnmatched {
node: "route".to_owned(),
reply: "maybe".to_owned(),
cases: vec!["yes".to_owned(), "no".to_owned()],
},
true,
),
(
EngineError::UnsupportedFoldBody {
node: "refine".to_owned(),
detail: "a `gate` body node cannot be a per-pass worker".to_owned(),
},
true,
),
(
EngineError::FoldNoComparableCandidate {
node: "refine".to_owned(),
reference: "score".to_owned(),
},
true,
),
(
EngineError::FoldBoundExceeded {
node: "refine".to_owned(),
bound: 3,
},
true,
),
(
EngineError::MalformedGraph {
detail: "the edges form a cycle".to_owned(),
},
true,
),
(
EngineError::UnknownAgent {
node: "research".to_owned(),
agent_hash: "sha256:0".to_owned(),
},
false,
),
(
EngineError::UnknownTool {
node: "publish".to_owned(),
tool: "publish_post".to_owned(),
},
false,
),
(
EngineError::ToolFailed {
node: "publish".to_owned(),
message: "publish endpoint unreachable".to_owned(),
},
false,
),
(
EngineError::ApprovalSchemaViolation {
node: "approve".to_owned(),
violations: vec![ApprovalViolation {
path: "$.approved".to_owned(),
message: "is a required property".to_owned(),
}],
},
false,
),
(
EngineError::GraphEncode(
serde_json::from_str::<serde_json::Value>("{").expect_err("malformed JSON"),
),
false,
),
(
EngineError::Runtime(RuntimeError::ResumeInputRejected(
"the store is unavailable".to_owned(),
)),
false,
),
]
}
#[test]
fn every_engine_error_variant_is_classified_permanent_or_transient() {
for (error, permanent) in samples() {
assert_eq!(
error.is_permanent(),
permanent,
"{}: classified against its documented side ({error})",
variant_name(&error)
);
}
let covered: BTreeSet<&'static str> = samples()
.iter()
.map(|(error, _)| variant_name(error))
.collect();
let expected: BTreeSet<&'static str> = [
"MapOverNotAList",
"UnsupportedMapBody",
"NoBranchCaseMatched",
"BranchDecisionUnmatched",
"UnsupportedFoldBody",
"FoldNoComparableCandidate",
"FoldBoundExceeded",
"UnknownAgent",
"UnknownTool",
"MalformedGraph",
"ToolFailed",
"ApprovalSchemaViolation",
"GraphEncode",
"Runtime",
]
.into_iter()
.collect();
assert_eq!(
covered, expected,
"every EngineError variant carries a sample and a decided classification"
);
}
#[test]
fn the_permanent_side_is_exactly_the_document_and_log_refusals() {
let permanent: BTreeSet<&'static str> = samples()
.iter()
.filter(|(error, _)| error.is_permanent())
.map(|(error, _)| variant_name(error))
.collect();
let expected: BTreeSet<&'static str> = [
"BranchDecisionUnmatched",
"FoldBoundExceeded",
"FoldNoComparableCandidate",
"MalformedGraph",
"MapOverNotAList",
"NoBranchCaseMatched",
"UnsupportedFoldBody",
"UnsupportedMapBody",
]
.into_iter()
.collect();
assert_eq!(permanent, expected);
}
}