use crate::engine::functions::config::{BuiltinKind, builtin_function_kind, can_dispatch_in};
use crate::engine::functions::{BoxedFunctionHandler, FunctionConfig, TemplateCompiler};
use crate::engine::secrets::{SECRET_OPERATOR, Secrets};
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,
UnknownSecret,
SecretInMessageWrite,
InvalidSecretStore,
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::UnknownSecret => "UNKNOWN_SECRET",
Self::SecretInMessageWrite => "SECRET_IN_MESSAGE_WRITE",
Self::InvalidSecretStore => "INVALID_SECRET_STORE",
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,
secrets: &Secrets,
) -> Vec<WorkflowIssue> {
let mut issues = check_secrets(workflow, secrets);
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
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Sink {
Bool,
Handler,
Input,
Message,
}
impl Sink {
fn records(self) -> bool {
matches!(self, Self::Message)
}
fn points_at_reference(self) -> bool {
matches!(self, Self::Input)
}
}
pub(crate) fn check_secrets(workflow: &Workflow, secrets: &Secrets) -> Vec<WorkflowIssue> {
let mut issues = Vec::new();
let mut check = |value: &Value, field: &str, task_id: Option<&str>, sink: Sink| {
check_expression(value, field, task_id, sink, secrets, &mut issues);
};
check(&workflow.condition, "condition", None, Sink::Bool);
for task in &workflow.tasks {
for group in &task.group_starts {
let id = Some(group.id.as_str());
check(&group.condition, "condition", id, Sink::Bool);
}
let id = Some(task.id.as_str());
check(&task.condition, "condition", id, Sink::Bool);
match &task.function {
FunctionConfig::Map { input, .. } => {
for (i, mapping) in input.mappings.iter().enumerate() {
let field = format!("function.input.mappings[{i}].logic");
check(&mapping.logic, &field, id, Sink::Message);
}
}
FunctionConfig::Validation { input, .. } => {
for (i, rule) in input.rules.iter().enumerate() {
let field = format!("function.input.rules[{i}].logic");
check(&rule.logic, &field, id, Sink::Bool);
}
}
FunctionConfig::Filter { input, .. } => {
check(&input.condition, "function.input.condition", id, Sink::Bool);
}
FunctionConfig::Log { input, .. } => {
check(&input.message, "function.input.message", id, Sink::Message);
let mut names: Vec<&String> = input.fields.keys().collect();
names.sort_unstable();
for name in names {
let field = format!("function.input.fields.{name}");
check(&input.fields[name], &field, id, Sink::Message);
}
}
FunctionConfig::HttpCall { input, .. } => {
for (name, template) in [
("path_logic", &input.path_logic),
("body_logic", &input.body_logic),
] {
if let Some(t) = template {
let field = format!("function.input.{name}");
check(t.as_json(), &field, id, Sink::Handler);
}
}
}
FunctionConfig::Enrich { input, .. } => {
if let Some(t) = &input.path_logic {
check(t.as_json(), "function.input.path_logic", id, Sink::Handler);
}
}
FunctionConfig::PublishKafka { input, .. } => {
for (name, template) in [
("key_logic", &input.key_logic),
("value_logic", &input.value_logic),
] {
if let Some(t) = template {
let field = format!("function.input.{name}");
check(t.as_json(), &field, id, Sink::Handler);
}
}
}
FunctionConfig::Custom { input, .. } => {
check(input, "function.input", id, Sink::Input);
}
FunctionConfig::ParseJson { .. }
| FunctionConfig::ParseXml { .. }
| FunctionConfig::PublishJson { .. }
| FunctionConfig::PublishXml { .. } => {}
}
}
issues
}
fn check_expression(
value: &Value,
field: &str,
task_id: Option<&str>,
sink: Sink,
secrets: &Secrets,
issues: &mut Vec<WorkflowIssue>,
) {
let mut refs = Vec::new();
collect_secret_refs(value, field, &mut refs);
if refs.is_empty() {
return;
}
if sink.records() {
issues.push(
WorkflowIssue::at(
IssueCode::SecretInMessageWrite,
field,
"reads a secret, and the engine records this expression's result — \
compute derived values in a custom handler instead",
)
.with_step(task_id),
);
return;
}
for (path, key) in &refs {
let Some(key) = key else { continue };
if secrets.get(key).is_some() {
continue;
}
let at = if sink.points_at_reference() {
path.as_str()
} else {
field
};
issues.push(
WorkflowIssue::at(
IssueCode::UnknownSecret,
at,
format!("secret '{key}' is not declared on the engine"),
)
.with_step(task_id),
);
}
}
fn collect_secret_refs<'v>(value: &'v Value, path: &str, out: &mut Vec<(String, Option<&'v str>)>) {
match value {
Value::Object(map) => {
if map.len() == 1 {
if let Some(arg) = map.get(SECRET_OPERATOR) {
let literal = match arg {
Value::String(s) => Some(s.as_str()),
Value::Array(items) if items.len() == 1 => items[0].as_str(),
_ => None,
};
out.push((path.to_string(), literal));
collect_secret_refs(arg, &format!("{path}.{SECRET_OPERATOR}"), out);
return;
}
}
for (key, child) in map {
collect_secret_refs(child, &format!("{path}.{key}"), out);
}
}
Value::Array(items) => {
for (i, child) in items.iter().enumerate() {
collect_secret_refs(child, &format!("{path}[{i}]"), out);
}
}
_ => {}
}
}
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())
}