use crate::engine::functions::config::{BuiltinKind, builtin_function_kind, can_dispatch_in};
use crate::engine::functions::{BoxedFunctionHandler, FunctionConfig, TemplateCompiler};
use crate::engine::steps::{StepKind, walk_authored_steps};
use crate::engine::workflow::Workflow;
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowIssue {
pub code: IssueCode,
pub message: String,
pub path: Option<String>,
pub task_id: Option<String>,
}
impl WorkflowIssue {
fn at(code: IssueCode, path: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
path: Some(path.into()),
task_id: None,
}
}
fn with_step(mut self, id: Option<&str>) -> Self {
self.task_id = id.map(str::to_string);
self
}
}
impl fmt::Display for WorkflowIssue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.path {
Some(path) => write!(f, "{path}: {} [{}]", self.message, self.code.as_str()),
None => write!(f, "{} [{}]", self.message, self.code.as_str()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum IssueCode {
EmptyWorkflowId,
EmptyWorkflowName,
NoTasks,
MissingStepId,
DuplicateStepId,
EmptyGroup,
GroupTooDeep,
MissingFunction,
InvalidFunctionName,
InvalidTerminal,
LoopIncrementTooSmall,
LoopBoundEmpty,
LoopCounterInvalid,
UnknownFunction,
MissingHandler,
InputParse,
TemplateCompile,
ParseFailed,
ValidateFailed,
}
impl IssueCode {
pub fn as_str(&self) -> &'static str {
match self {
Self::EmptyWorkflowId => "EMPTY_WORKFLOW_ID",
Self::EmptyWorkflowName => "EMPTY_WORKFLOW_NAME",
Self::NoTasks => "NO_TASKS",
Self::MissingStepId => "MISSING_STEP_ID",
Self::DuplicateStepId => "DUPLICATE_STEP_ID",
Self::EmptyGroup => "EMPTY_GROUP",
Self::GroupTooDeep => "GROUP_TOO_DEEP",
Self::MissingFunction => "MISSING_FUNCTION",
Self::InvalidFunctionName => "INVALID_FUNCTION_NAME",
Self::InvalidTerminal => "INVALID_TERMINAL",
Self::LoopIncrementTooSmall => "LOOP_INCREMENT_TOO_SMALL",
Self::LoopBoundEmpty => "LOOP_BOUND_EMPTY",
Self::LoopCounterInvalid => "LOOP_COUNTER_INVALID",
Self::UnknownFunction => "UNKNOWN_FUNCTION",
Self::MissingHandler => "MISSING_HANDLER",
Self::InputParse => "INPUT_PARSE",
Self::TemplateCompile => "TEMPLATE_COMPILE",
Self::ParseFailed => "PARSE_FAILED",
Self::ValidateFailed => "VALIDATE_FAILED",
}
}
}
impl fmt::Display for IssueCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Workflow {
pub fn validate_authored(json: &Value) -> Vec<WorkflowIssue> {
let mut issues = check_shape(json);
if !issues.is_empty() {
return issues;
}
let workflow: Workflow = match serde_json::from_value(json.clone()) {
Ok(w) => w,
Err(err) => {
issues.push(WorkflowIssue {
code: IssueCode::ParseFailed,
message: err.to_string(),
path: None,
task_id: None,
});
return issues;
}
};
if let Err(err) = workflow.validate() {
issues.push(WorkflowIssue {
code: IssueCode::ValidateFailed,
message: err.to_string(),
path: None,
task_id: None,
});
}
issues
}
}
pub(crate) fn check_against_registry(
workflow: &Workflow,
registry: &std::collections::HashMap<String, BoxedFunctionHandler>,
template_compiler: &TemplateCompiler,
) -> Vec<WorkflowIssue> {
let mut issues = Vec::new();
for task in &workflow.tasks {
let name = task.function.function_name();
if !can_dispatch_in(registry, name) {
let (code, message) = match builtin_function_kind(name) {
Some(BuiltinKind::RequiresHandler) => (
IssueCode::MissingHandler,
format!(
"'{name}' ships as a config schema only — register a handler under \
that name, or this workflow will build cleanly and fail every message"
),
),
_ => (
IssueCode::UnknownFunction,
format!("no handler is registered for '{name}', and it is not a built-in"),
),
};
issues.push(WorkflowIssue {
code,
message,
path: Some("function.name".to_string()),
task_id: Some(task.id.clone()),
});
continue;
}
let FunctionConfig::Custom { name, input, .. } = &task.function else {
continue;
};
let Some(handler) = registry.get(name) else {
continue;
};
let mut parsed = match handler.parse_input_box(input) {
Ok(parsed) => parsed,
Err(err) => {
issues.push(WorkflowIssue {
code: IssueCode::InputParse,
message: format!("input does not match the handler's Input type: {err}"),
path: Some("function.input".to_string()),
task_id: Some(task.id.clone()),
});
continue;
}
};
if let Err(err) = handler.compile_input_box(&mut *parsed, template_compiler) {
issues.push(WorkflowIssue {
code: IssueCode::TemplateCompile,
message: format!("a template field does not compile: {err}"),
path: Some("function.input".to_string()),
task_id: Some(task.id.clone()),
});
}
}
issues
}
fn check_shape(json: &Value) -> Vec<WorkflowIssue> {
let mut issues = Vec::new();
if non_empty_str(json.get("id")).is_none() {
issues.push(WorkflowIssue::at(
IssueCode::EmptyWorkflowId,
"id",
"workflow id must be a non-empty string",
));
}
if non_empty_str(json.get("name")).is_none() {
issues.push(WorkflowIssue::at(
IssueCode::EmptyWorkflowName,
"name",
"workflow name must be a non-empty string",
));
}
match json.get("tasks").and_then(Value::as_array) {
Some(tasks) if !tasks.is_empty() => {}
_ => issues.push(WorkflowIssue::at(
IssueCode::NoTasks,
"tasks",
"workflow must have at least one task",
)),
}
check_steps(json.get("tasks").unwrap_or(&Value::Null), &mut issues);
if let Some(loop_config) = json.get("loop") {
check_loop(loop_config, &mut issues);
}
issues
}
fn check_steps(tasks: &Value, issues: &mut Vec<WorkflowIssue>) {
let mut seen: HashMap<&str, String> = HashMap::new();
for step in walk_authored_steps(tasks) {
let id = non_empty_str(step.node.get("id"));
match id {
None => issues.push(
WorkflowIssue::at(
IssueCode::MissingStepId,
format!("{}.id", step.path),
"every step needs a non-empty id",
)
.with_step(None),
),
Some(id) => {
if let Some(first) = seen.get(id) {
issues.push(
WorkflowIssue::at(
IssueCode::DuplicateStepId,
format!("{}.id", step.path),
format!(
"step id '{id}' is already used at {first} — task groups \
share the task id namespace"
),
)
.with_step(Some(id)),
);
} else {
seen.insert(id, step.path.clone());
}
}
}
if let Some(terminal) = step.node.get("terminal") {
if !terminal.is_boolean() {
issues.push(
WorkflowIssue::at(
IssueCode::InvalidTerminal,
format!("{}.terminal", step.path),
"terminal must be a boolean",
)
.with_step(id),
);
}
}
match step.kind {
StepKind::Leaf => check_function(&step.path, step.node, id, issues),
StepKind::Group => {
let has_members = step
.node
.get("tasks")
.and_then(Value::as_array)
.is_some_and(|members| !members.is_empty());
if !has_members {
issues.push(
WorkflowIssue::at(
IssueCode::EmptyGroup,
format!("{}.tasks", step.path),
"a task group's tasks must be a non-empty array — \
an empty group can only be a mistake",
)
.with_step(id),
);
}
}
StepKind::TooDeep => issues.push(
WorkflowIssue::at(
IssueCode::GroupTooDeep,
step.path.clone(),
format!(
"task groups nested deeper than {} levels",
crate::engine::steps::MAX_GROUP_DEPTH
),
)
.with_step(id),
),
}
}
}
fn check_function(path: &str, node: &Value, id: Option<&str>, issues: &mut Vec<WorkflowIssue>) {
let Some(function) = node.get("function") else {
issues.push(
WorkflowIssue::at(
IssueCode::MissingFunction,
format!("{path}.function"),
"a task needs a function — an element with neither `function` nor \
`tasks` is neither a task nor a group",
)
.with_step(id),
);
return;
};
if !function.is_object() || non_empty_str(function.get("name")).is_none() {
issues.push(
WorkflowIssue::at(
IssueCode::InvalidFunctionName,
format!("{path}.function.name"),
"function must be an object with a non-empty name",
)
.with_step(id),
);
}
}
fn check_loop(config: &Value, issues: &mut Vec<WorkflowIssue>) {
if let Some(increment) = config.get("increment").and_then(Value::as_i64) {
if increment < 1 {
issues.push(WorkflowIssue::at(
IssueCode::LoopIncrementTooSmall,
"loop.increment",
format!(
"loop increment must be >= 1, got {increment} \
(a non-advancing counter would never reach max)"
),
));
}
}
let init = config.get("init").and_then(Value::as_i64).unwrap_or(0);
if let Some(max) = config.get("max").and_then(Value::as_i64) {
if max <= init {
issues.push(WorkflowIssue::at(
IssueCode::LoopBoundEmpty,
"loop.max",
format!(
"loop max ({max}) must be greater than init ({init}) — \
the bound is half-open, so this could never run a sweep"
),
));
}
}
if let Some(counter) = config.get("counter") {
if let Some(counter) = counter.as_str() {
if counter.is_empty() || counter.split('.').any(str::is_empty) {
issues.push(WorkflowIssue::at(
IssueCode::LoopCounterInvalid,
"loop.counter",
format!(
"loop counter must be a non-empty temp_data field path, got {counter:?}"
),
));
}
}
}
}
fn non_empty_str(field: Option<&Value>) -> Option<&str> {
field.and_then(Value::as_str).filter(|s| !s.is_empty())
}