use std::collections::BTreeMap;
use serde_json::Value;
use crate::connector::ConnectorType;
use crate::engine::FunctionRegistry;
use super::diagnostic::Diagnostic;
use super::set::Definition;
use super::set::{Boundary, DefinitionSet, Entity};
use crate::storage::repositories::channels::CreateChannelRequest;
use crate::storage::repositories::connectors::CreateConnectorRequest;
use crate::storage::repositories::workflows::CreateWorkflowRequest;
fn schema_diagnostics(
check: &'static str,
entity: &str,
def: &Definition,
err: &crate::errors::OrionError,
) -> Vec<Diagnostic> {
let fields = err.field_errors();
if fields.is_empty() {
return vec![
Diagnostic::error(check, entity.to_string(), err.to_string()).with_location(
&def.origin,
None,
None,
),
];
}
fields
.iter()
.map(|f| {
let d = Diagnostic::from_field_error(check, entity, f);
let path = d.path.clone();
let line = path.as_deref().and_then(|p| def.locate(p));
d.with_location(&def.origin, path.as_deref(), line)
})
.collect()
}
pub fn check(
set: &DefinitionSet,
boundary: &Boundary,
require_explicit_ids: bool,
functions: &FunctionRegistry,
) -> Vec<Diagnostic> {
let mut findings = Vec::new();
let connectors = check_connectors(set, &mut findings);
let workflows = check_workflows(set, require_explicit_ids, functions, &mut findings);
let channels = check_channels(set, &workflows.ids, require_explicit_ids, &mut findings);
check_closure(
set,
&workflows,
&connectors,
&channels,
boundary,
functions,
&mut findings,
);
check_env_refs(set, &mut findings);
for plugin in &set.plugins {
let functions = plugin.manifest.functions.len();
let component = match &plugin.digest {
Some(digest) => format!("component {digest}"),
None => "no component beside the manifest — its functions validate here but cannot \
run offline"
.to_string(),
};
findings.push(Diagnostic::note(
"plugin.manifest",
format!("plugin '{}'", plugin.manifest.name),
format!(
"{} declares {functions} function(s); {component}",
plugin.origin
),
));
}
for model in &set.models {
let entity = format!("model '{}'", model.manifest.name);
findings.push(Diagnostic::note(
"model.manifest",
&entity,
format!(
"{} declares {} input(s), {} output(s), format '{}'{}",
model.origin,
model.manifest.inputs.len(),
model.manifest.outputs.len(),
model.manifest.format,
match &model.manifest.reference {
Some(reference) => {
format!(
"; deployable as connector '{}', key '{}'",
reference.connector, reference.key
)
}
None => String::new(),
}
),
));
match (&model.graph, &model.digest, model.artifact_bytes) {
(Some(Ok(graph)), Some(digest), Some(bytes)) => {
findings.push(Diagnostic::note(
"model.stats",
&entity,
format!(
"artifact {digest} ({bytes} bytes): {} parameters, {} nodes, IR {}, \
opset {}; graph inputs {}, outputs {}",
graph.parameters,
graph.nodes,
graph.ir_version,
graph.opset,
quoted(&graph.input_names),
quoted(&graph.output_names),
),
));
if let Err(reason) = crate::model::check_boundary(&model.manifest, graph) {
findings.push(
Diagnostic::error("model.graph", &entity, reason).with_remedy(
"name the graph's tensors in the manifest's inputs and outputs",
),
);
}
}
(Some(Err(reason)), _, _) => findings.push(Diagnostic::error(
"model.graph",
&entity,
format!(
"the artifact beside {} does not read as a model: {reason}",
model.origin
),
)),
_ => findings.push(
Diagnostic::note(
"model.artifact_missing",
&entity,
match &model.manifest.artifact {
Some(rel) => format!(
"no artifact beside the manifest ({} names '{rel}') — references to \
the model validate here, but it cannot run offline or compile into \
an artifact",
model.origin
),
None => format!(
"{} names no artifact — references to the model validate here, but \
it cannot run offline or compile into an artifact",
model.origin
),
},
)
.with_remedy("put the file beside the manifest and name it with `artifact`"),
),
}
}
findings
}
fn quoted(names: &[String]) -> String {
names
.iter()
.map(|n| format!("'{n}'"))
.collect::<Vec<_>>()
.join(", ")
}
fn model_references(tasks: &Value) -> (Vec<(String, String)>, Vec<String>) {
let mut literal = Vec::new();
let mut dynamic = Vec::new();
for (path, task) in crate::engine::walk_steps(tasks).tasks {
let Some(function) = task.get("function") else {
continue;
};
if function.get("name").and_then(Value::as_str)
!= Some(crate::model::loader::INFER_FUNCTION)
{
continue;
}
let field = format!(
"{path}.function.input.{}",
crate::model::loader::INFER_MODEL_FIELD
);
match function
.get("input")
.and_then(|i| i.get(crate::model::loader::INFER_MODEL_FIELD))
{
Some(Value::String(id)) => literal.push((field, id.clone())),
Some(_) => dynamic.push(field),
None => {}
}
}
(literal, dynamic)
}
struct Workflows {
ids: Vec<String>,
tasks: Vec<(String, Value)>,
}
fn check_connectors(
set: &DefinitionSet,
findings: &mut Vec<Diagnostic>,
) -> BTreeMap<String, crate::engine::ConnectorFacts> {
let mut by_name = BTreeMap::new();
let mut seen: Vec<String> = Vec::new();
for def in set.iter(Entity::Connector) {
let req: CreateConnectorRequest = match serde_json::from_value(def.doc.clone()) {
Ok(req) => req,
Err(e) => {
findings.push(Diagnostic::error(
"parse.connector",
&def.origin,
format!("not a connector import item: {e}"),
));
continue;
}
};
if let Err(e) = crate::validation::validate_create_connector(&req) {
findings.extend(schema_diagnostics(
"schema.connector",
&format!("connector '{}'", req.name),
def,
&e,
));
}
if seen.contains(&req.name) {
findings.push(Diagnostic::error(
"duplicate.connector_name",
format!("connector '{}'", req.name),
"two connectors in the set share this name",
));
}
seen.push(req.name.clone());
by_name.insert(
req.name.clone(),
crate::engine::ConnectorFacts {
connector_type: req.connector_type,
is_mongo: req
.config
.get("connection_string")
.and_then(|c| c.as_str())
.is_some_and(crate::connector::is_mongo_url),
},
);
}
by_name
}
fn check_workflows(
set: &DefinitionSet,
require_explicit_ids: bool,
functions: &FunctionRegistry,
findings: &mut Vec<Diagnostic>,
) -> Workflows {
let loop_cap = crate::config::EngineConfig::default().max_loop_iterations;
let mut ids = Vec::new();
let mut tasks = Vec::new();
for def in set.iter(Entity::Workflow) {
let req: CreateWorkflowRequest = match serde_json::from_value(def.doc.clone()) {
Ok(req) => req,
Err(e) => {
findings.push(Diagnostic::error(
"parse.workflow",
&def.origin,
format!("not a workflow import item: {e}"),
));
continue;
}
};
let mut unverifiable: Vec<String> = Vec::new();
let mut undeclared: Vec<(String, String, String)> = Vec::new();
for task in crate::engine::leaf_tasks(&req.tasks) {
let Some(name) = task
.get("function")
.and_then(|f| f.get("name"))
.and_then(Value::as_str)
else {
continue;
};
if functions.contains(name) || !name.contains('.') {
continue;
}
match set.plugin_of(name) {
Some(plugin) => undeclared.push((
name.to_string(),
plugin.manifest.name.clone(),
plugin.origin.clone(),
)),
None if !unverifiable.iter().any(|f| f == name) => {
unverifiable.push(name.to_string());
}
None => {}
}
}
if let Err(e) = crate::validation::validate_create_workflow(&req, loop_cap, functions) {
let entity = format!("workflow '{}'", req.name);
for d in schema_diagnostics("schema.workflow", &entity, def, &e) {
let unknown =
|name: &str| d.message.starts_with(&format!("Unknown function '{name}'"));
if let Some(name) = unverifiable.iter().find(|n| unknown(n)) {
findings.push(
Diagnostic::note(
"plugin.unverifiable",
&entity,
format!(
"names plugin function '{name}', and the set carries no manifest \
for its plugin, so its input cannot be checked here; the admin \
API validates it against the active plugin"
),
)
.with_remedy(
"add the plugin's plugin.toml to the set, or pass --plugin-dir",
),
);
} else if let Some((name, plugin, origin)) =
undeclared.iter().find(|(n, _, _)| unknown(n))
{
findings.push(Diagnostic::error(
"closure.plugin",
&entity,
format!(
"names '{name}', which the manifest for plugin '{plugin}' ({origin}) \
does not declare"
),
));
} else {
findings.push(d);
}
}
}
for (path, message) in crate::validation::secret_reference_errors(&req.tasks, functions) {
findings.push(Diagnostic::error(
"env.unresolved",
format!("workflow '{}' {path}", req.name),
message,
));
}
for (path, message) in crate::validation::unresolvable_logic_warnings(&req.tasks, functions)
{
findings.push(Diagnostic::warning(
"logic.unresolvable",
format!("workflow '{}' {path}", req.name),
message,
));
}
for advisory in crate::validation::engine_advisories(&req.tasks, functions) {
findings.push(Diagnostic::warning(
advisory.check,
format!("workflow '{}' {}", req.name, advisory.path),
advisory.message,
));
}
match &req.workflow_id {
Some(id) => {
if ids.contains(id) {
findings.push(Diagnostic::error(
"duplicate.workflow_id",
format!("workflow '{}'", req.name),
format!("two workflows in the set share workflow_id '{id}'"),
));
}
ids.push(id.clone());
tasks.push((id.clone(), req.tasks.clone()));
}
None if require_explicit_ids => findings.push(
Diagnostic::error(
"missing.workflow_id",
format!("workflow '{}'", req.name),
"a package workflow must carry an explicit workflow_id — a generated \
id cannot be referenced by channels in the same package",
)
.with_remedy("add a workflow_id to the workflow definition"),
),
None => tasks.push((def.origin.clone(), req.tasks.clone())),
}
}
Workflows { ids, tasks }
}
fn check_channels(
set: &DefinitionSet,
workflow_ids: &[String],
require_explicit_ids: bool,
findings: &mut Vec<Diagnostic>,
) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
let mut channel_ids: Vec<String> = Vec::new();
let mut routes: Vec<(String, Vec<String>, i64, String)> = Vec::new();
for def in set.iter(Entity::Channel) {
let req: CreateChannelRequest = match serde_json::from_value(def.doc.clone()) {
Ok(req) => req,
Err(e) => {
findings.push(Diagnostic::error(
"parse.channel",
&def.origin,
format!("not a channel import item: {e}"),
));
continue;
}
};
if let Err(e) = crate::validation::validate_create_channel(&req) {
findings.extend(schema_diagnostics(
"schema.channel",
&format!("channel '{}'", req.name),
def,
&e,
));
}
match &req.channel_id {
Some(id) => {
if channel_ids.contains(id) {
findings.push(Diagnostic::error(
"duplicate.channel_id",
format!("channel '{}'", req.name),
format!("two channels in the set share channel_id '{id}'"),
));
}
channel_ids.push(id.clone());
}
None if require_explicit_ids => findings.push(Diagnostic::error(
"missing.channel_id",
format!("channel '{}'", req.name),
"a package channel must carry an explicit channel_id",
)),
None => {}
}
if names.contains(&req.name) {
findings.push(Diagnostic::error(
"duplicate.channel_name",
format!("channel '{}'", req.name),
"two channels in the set share this name — channel names are unique (K7)",
));
}
names.push(req.name.clone());
for (route, route_methods) in crate::channel::routing::declared_route_parts(
req.protocol.as_str(),
req.route_pattern.as_deref(),
req.methods.as_deref().unwrap_or_default(),
req.config
.get("oauth2_login")
.and_then(|o| o.get("callback_path"))
.and_then(|p| p.as_str()),
) {
let clash = routes
.iter()
.find(|(other_route, other_methods, priority, _)| {
*other_route == route
&& *priority == req.priority
&& crate::channel::routing::methods_overlap(other_methods, &route_methods)
});
match clash {
Some((_, _, _, first)) => findings.push(Diagnostic::error(
"duplicate.route_pattern",
format!("channel '{}'", req.name),
format!(
"{} {route} at priority {} is already served by channel '{first}'",
if route_methods.is_empty() {
"every method on".to_string()
} else {
route_methods.join("/")
},
req.priority,
),
)),
None => routes.push((route, route_methods, req.priority, req.name.clone())),
}
}
match &req.workflow_id {
Some(wf) if !wf.is_empty() => {
if !workflow_ids.iter().any(|id| id == wf) {
findings.push(Diagnostic::error(
"closure.workflow",
format!("channel '{}'", req.name),
format!("workflow '{wf}' is not in the set"),
));
}
}
_ => findings.push(Diagnostic::error(
"missing.workflow_id_ref",
format!("channel '{}'", req.name),
"no workflow_id — the channel can never activate",
)),
}
}
names
}
fn check_closure(
set: &DefinitionSet,
workflows: &Workflows,
connectors: &BTreeMap<String, crate::engine::ConnectorFacts>,
channels: &[String],
boundary: &Boundary,
functions: &FunctionRegistry,
findings: &mut Vec<Diagnostic>,
) {
for (workflow, tasks) in &workflows.tasks {
let entity = format!("workflow '{workflow}'");
let (literal, dynamic) = model_references(tasks);
for (path, model) in literal {
if set.model_of(&model).is_some() || boundary.allows_model(&model) {
continue;
}
let refusal = crate::errors::FieldError::new(
path,
"MODEL_UNKNOWN",
format!(
"model '{model}' is neither in the set nor declared on the boundary: a \
model_infer task naming a model by literal id needs its manifest here \
(a model.json, or --model-dir), or the workflow is quarantined on a node \
that does not serve it"
),
);
findings.push(Diagnostic::from_field_error(
"closure.model",
&entity,
&refusal,
));
}
for path in dynamic {
findings.push(Diagnostic::note(
"model.unverifiable",
&entity,
format!(
"{path} is computed, so the model it names is decided per message and \
cannot be checked here; a message naming a model the node does not \
serve fails that task as `unavailable`"
),
));
}
for problem in crate::engine::check_connector_refs(tasks, functions, |name| {
connectors.get(name).copied()
}) {
match problem {
crate::engine::RefProblem::Missing { connector } => {
if !boundary.allows_connector(connector) {
findings.push(Diagnostic::error(
"closure.connector",
&entity,
format!(
"connector '{connector}' is neither in the set nor declared \
on the boundary"
),
));
}
}
crate::engine::RefProblem::WrongType {
function,
connector,
actual,
wanted,
} => {
let wanted: Vec<&str> = wanted.iter().map(ConnectorType::as_str).collect();
findings.push(Diagnostic::error(
"type.connector",
&entity,
format!(
"'{function}' needs a {} connector, but '{connector}' is type \
'{actual}'",
wanted.join(" or ")
),
));
}
crate::engine::RefProblem::MissingMongoDatabase {
function,
connector,
} => findings.push(Diagnostic::error(
"type.mongo_database",
&entity,
format!(
"'{function}' points at MongoDB connector '{connector}' but sets no \
'database' — MongoDB connection strings carry no default database"
),
)),
}
}
let (targets, dynamic) = crate::engine::channel_call_targets(tasks);
for target in targets {
if !channels.iter().any(|n| n == target) && !boundary.allows_channel(target) {
findings.push(Diagnostic::error(
"closure.channel_call",
format!("workflow '{workflow}'"),
format!(
"channel_call target '{target}' is neither in the set nor declared \
on the boundary"
),
));
}
}
if dynamic {
findings.push(Diagnostic::warning(
"closure.channel_call_dynamic",
format!("workflow '{workflow}'"),
"resolves channel_call targets dynamically — closure checking cannot cover \
those calls",
));
}
}
}
fn check_env_refs(set: &DefinitionSet, findings: &mut Vec<Diagnostic>) {
let mut refs: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut secrets: BTreeMap<String, Vec<String>> = BTreeMap::new();
for def in &set.definitions {
collect_env(&def.doc, def, &mut refs, &mut secrets);
}
for (needs, mut where_used) in refs {
where_used.sort();
where_used.dedup();
findings.push(Diagnostic::note(
"env.reference",
where_used.join(", "),
format!("requires {needs}"),
));
}
for (name, mut where_used) in secrets {
where_used.sort();
where_used.dedup();
findings.push(Diagnostic::note(
"secrets.reference",
where_used.join(", "),
format!("requires a [secrets] entry named '{name}'"),
));
}
}
fn collect_env(
value: &Value,
def: &super::set::Definition,
out: &mut BTreeMap<String, Vec<String>>,
secrets: &mut BTreeMap<String, Vec<String>>,
) {
match value {
Value::String(s) => {
if crate::connector::secrets::is_resolvable_reference(s)
&& let Some((scheme, reference)) = crate::connector::secrets::parse_reference(s)
{
let needs = match scheme {
"env" => format!("environment variable '{reference}'"),
_ => format!("secret '{s}'"),
};
out.entry(needs).or_default().push(format!(
"{} '{}'",
def.entity.as_str(),
def.origin
));
}
}
Value::Array(items) => items.iter().for_each(|v| collect_env(v, def, out, secrets)),
Value::Object(map) => {
if let Some(name) = crate::engine::functions::secret_ref::secret_name(value) {
secrets.entry(name.to_string()).or_default().push(format!(
"{} '{}'",
def.entity.as_str(),
def.origin
));
return;
}
map.values().for_each(|v| collect_env(v, def, out, secrets));
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::definitions::{Boundary, Entity, ModelDefinition};
use crate::model::fixture;
use serde_json::json;
fn infer(id: &str, model: Value) -> Value {
json!({"id": id, "name": id, "function": {"name": "model_infer",
"input": {"model": model, "input": {"var": ""}}}})
}
fn workflow(id: &str, tasks: Value) -> (Entity, String, Value) {
(
Entity::Workflow,
format!("{id}.json"),
json!({"workflow_id": id, "name": id, "tasks": tasks}),
)
}
fn checks<'a>(findings: &'a [Diagnostic], check: &str) -> Vec<&'a Diagnostic> {
findings.iter().filter(|d| d.check == check).collect()
}
#[test]
fn model_references_resolve_against_manifests_or_the_boundary() {
let mut set = DefinitionSet::from_entries([workflow(
"score",
json!([
infer("known", json!("ada.c4-tiny")),
{"id": "group", "tasks": [infer("required", json!("ada.required"))]},
infer("missing", json!("ada.missing")),
infer("computed", json!({"var": "data.model"})),
]),
)]);
set.models.push(ModelDefinition::from_manifest(
"models/c4.json".to_string(),
fixture::manifest(),
));
let boundary = Boundary {
models: vec!["ada.required".to_string()],
..Boundary::default()
};
let findings = check(&set, &boundary, false, FunctionRegistry::builtin());
let closure = checks(&findings, "closure.model");
assert_eq!(closure.len(), 1, "{findings:#?}");
assert!(closure[0].is_error());
assert_eq!(
closure[0].path.as_deref(),
Some("tasks[2].function.input.model")
);
assert!(closure[0].message.contains("'ada.missing'"));
let unverifiable = checks(&findings, "model.unverifiable");
assert_eq!(unverifiable.len(), 1);
assert!(!unverifiable[0].is_error() && !unverifiable[0].is_warning());
assert!(
unverifiable[0]
.message
.contains("tasks[3].function.input.model is computed")
);
assert_eq!(checks(&findings, "model.manifest").len(), 1);
assert_eq!(checks(&findings, "model.artifact_missing").len(), 1);
assert!(checks(&findings, "model.stats").is_empty());
assert_eq!(
findings.iter().filter(|d| d.is_error()).count(),
1,
"{findings:#?}"
);
}
#[test]
fn a_manifest_with_its_artifact_reports_the_graphs_stats_and_boundary() {
let dir = std::env::temp_dir().join(format!("orion-check-models-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("dir");
std::fs::write(dir.join("c4-tiny.onnx"), fixture::ONNX).expect("write");
std::fs::write(dir.join("model.json"), fixture::MANIFEST).expect("write");
std::fs::write(
dir.join("wrong.json"),
fixture::MANIFEST
.replace("ada.c4-tiny", "ada.wrong")
.replace("\"name\": \"policy\"", "\"name\": \"logits\""),
)
.expect("write");
std::fs::write(dir.join("garbage.onnx"), b"\x00\x01not a graph").expect("write");
std::fs::write(
dir.join("garbage.json"),
fixture::MANIFEST
.replace("ada.c4-tiny", "ada.garbage")
.replace("c4-tiny.onnx", "garbage.onnx"),
)
.expect("write");
let (set, _) = DefinitionSet::from_directory(&dir).expect("loads");
assert_eq!(set.models.len(), 3);
let findings = check(
&set,
&Boundary::default(),
false,
FunctionRegistry::builtin(),
);
let _ = std::fs::remove_dir_all(&dir);
let stats = checks(&findings, "model.stats");
assert_eq!(stats.len(), 2, "{findings:#?}");
let c4 = stats
.iter()
.find(|d| d.entity == "model 'ada.c4-tiny'")
.expect("the fixture's stats");
assert!(c4.message.contains("1479 parameters"), "{}", c4.message);
assert!(c4.message.contains("6171 bytes"), "{}", c4.message);
assert!(c4.message.contains("opset 17"), "{}", c4.message);
let graph = checks(&findings, "model.graph");
assert_eq!(graph.len(), 2, "{findings:#?}");
let wrong = graph
.iter()
.find(|d| d.entity == "model 'ada.wrong'")
.expect("the boundary mismatch");
assert!(wrong.is_error());
assert!(
wrong.message.contains("output 'logits'"),
"{}",
wrong.message
);
let garbage = graph
.iter()
.find(|d| d.entity == "model 'ada.garbage'")
.expect("the unreadable file");
assert!(garbage.message.contains("does not read as a model"));
assert!(checks(&findings, "model.artifact_missing").is_empty());
}
#[test]
fn a_schema_refusal_reports_one_diagnostic_per_field_with_its_path() {
let doc = serde_json::json!({
"name": "",
"tasks": [{"id": "t1"}],
});
let set = DefinitionSet::from_entries([(Entity::Workflow, "wf.json".to_string(), doc)]);
let schema: Vec<_> = check(
&set,
&Boundary::default(),
false,
crate::engine::FunctionRegistry::builtin(),
)
.into_iter()
.filter(|d| d.check == "schema.workflow")
.collect();
assert!(
!schema.is_empty(),
"an invalid workflow must be refused by the set check"
);
assert!(
schema.iter().all(|d| d.path.is_some()),
"every schema diagnostic must name the field it is about: {schema:#?}"
);
}
#[test]
fn a_finding_from_a_loaded_directory_carries_its_file_and_line() {
let dir = std::env::temp_dir().join(format!(
"orion-check-spans-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("orders.json");
std::fs::write(
&path,
"{\n \"name\": \"\",\n \"tasks\": [\n { \"id\": \"t1\" }\n ]\n}\n",
)
.expect("write");
let (set, _report) = DefinitionSet::from_directory(&dir).expect("load");
let located: Vec<_> = check(
&set,
&Boundary::default(),
false,
crate::engine::FunctionRegistry::builtin(),
)
.into_iter()
.filter(|d| d.check == "schema.workflow")
.collect();
assert!(!located.is_empty(), "the workflow must be refused");
for d in &located {
assert_eq!(
d.file.as_deref(),
Some(path.display().to_string().as_str()),
"a finding must name the file it came from"
);
}
let any_line = located.iter().any(|d| d.line.is_some());
let _ = std::fs::remove_dir_all(&dir);
assert!(
any_line,
"at least one finding must resolve to a line:col — that is what \
carrying the spans is for: {located:#?}"
);
}
}