use crate::engine::compiler::TEMPLATE_KEY_ESCAPE;
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,
DuplicateTemplateKey,
EscapedTemplateKey,
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::DuplicateTemplateKey => "DUPLICATE_TEMPLATE_KEY",
Self::EscapedTemplateKey => "ESCAPED_TEMPLATE_KEY",
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: Self = 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);
issues.extend(check_template_keys(workflow));
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();
for_each_expression(workflow, &mut |value, field, task_id, sink| {
check_expression(value, field, task_id, sink, secrets, &mut issues);
});
issues
}
fn names_in_order<V>(map: &HashMap<String, V>) -> Vec<&String> {
let mut names: Vec<&String> = map.keys().collect();
names.sort_unstable();
names
}
fn for_each_expression(
workflow: &Workflow,
check: &mut impl FnMut(&Value, &str, Option<&str>, Sink),
) {
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);
let field = format!("function.input.mappings[{i}].path");
check(mapping.path.as_json(), &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);
let field = format!("function.input.rules[{i}].message");
check(rule.message.as_json(), &field, id, Sink::Message);
}
}
FunctionConfig::Filter { input, .. } => {
check(&input.condition, "function.input.condition", id, Sink::Bool);
}
FunctionConfig::Log { input, .. } => {
check(&input.message, "function.input.message", id, Sink::Message);
for name in names_in_order(&input.fields) {
let field = format!("function.input.fields.{name}");
check(&input.fields[name], &field, id, Sink::Message);
}
}
FunctionConfig::HttpCall { input, .. } => {
check(
input.connector.as_json(),
"function.input.connector",
id,
Sink::Handler,
);
check(
input.timeout_ms.as_json(),
"function.input.timeout_ms",
id,
Sink::Handler,
);
for name in names_in_order(&input.headers) {
let field = format!("function.input.headers.{name}");
check(input.headers[name].as_json(), &field, id, Sink::Handler);
}
for (name, template) in [
("path", &input.path),
("body", &input.body),
("body_format", &input.body_format),
("response_path", &input.response_path),
("response_format", &input.response_format),
] {
if let Some(t) = template {
let field = format!("function.input.{name}");
check(t.as_json(), &field, id, Sink::Handler);
}
}
}
FunctionConfig::Enrich { input, .. } => {
for (name, template) in [
("connector", &input.connector),
("merge_path", &input.merge_path),
("timeout_ms", &input.timeout_ms),
] {
let field = format!("function.input.{name}");
check(template.as_json(), &field, id, Sink::Handler);
}
if let Some(t) = &input.path {
check(t.as_json(), "function.input.path", id, Sink::Handler);
}
}
FunctionConfig::PublishKafka { input, .. } => {
for (name, template) in [("connector", &input.connector), ("topic", &input.topic)] {
let field = format!("function.input.{name}");
check(template.as_json(), &field, id, Sink::Handler);
}
for (name, template) in [("key", &input.key), ("value", &input.value)] {
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 { input, .. } | FunctionConfig::ParseXml { input, .. } => {
check(
input.source.as_json(),
"function.input.source",
id,
Sink::Message,
);
check(
input.target.as_json(),
"function.input.target",
id,
Sink::Message,
);
}
FunctionConfig::PublishJson { input, .. }
| FunctionConfig::PublishXml { input, .. } => {
check(
input.source.as_json(),
"function.input.source",
id,
Sink::Message,
);
check(
input.target.as_json(),
"function.input.target",
id,
Sink::Message,
);
check(
input.root_element.as_json(),
"function.input.root_element",
id,
Sink::Message,
);
}
}
}
}
pub(crate) fn check_template_keys(workflow: &Workflow) -> Vec<WorkflowIssue> {
let mut issues = Vec::new();
for_each_expression(workflow, &mut |value, field, task_id, sink| {
if sink != Sink::Input {
walk_template_keys(value, field, task_id, &mut issues);
}
});
issues
}
pub(crate) fn refusing_template_key_issues(workflow: &Workflow) -> Vec<WorkflowIssue> {
let mut issues = check_template_keys(workflow);
issues.retain(|i| i.code == IssueCode::DuplicateTemplateKey);
issues
}
fn walk_template_keys(
value: &Value,
path: &str,
task_id: Option<&str>,
issues: &mut Vec<WorkflowIssue>,
) {
match value {
Value::Array(items) => {
for (i, item) in items.iter().enumerate() {
walk_template_keys(item, &format!("{path}[{i}]"), task_id, issues);
}
}
Value::Object(map) => {
report_escaped_and_duplicate_keys(map, path, task_id, issues);
for (key, child) in map {
walk_template_keys(child, &format!("{path}.{key}"), task_id, issues);
}
}
_ => {}
}
}
fn report_escaped_and_duplicate_keys(
map: &serde_json::Map<String, Value>,
path: &str,
task_id: Option<&str>,
issues: &mut Vec<WorkflowIssue>,
) {
let mut emitted: HashMap<String, &str> = HashMap::new();
for key in map.keys() {
if let Some(stripped) = key.strip_prefix(TEMPLATE_KEY_ESCAPE) {
issues.push(
WorkflowIssue::at(
IssueCode::EscapedTemplateKey,
format!("{path}.{key}"),
format!(
"'{key}' is emitted as '{stripped}' — one \
'{TEMPLATE_KEY_ESCAPE}' is stripped from every template key. Double it \
to '{TEMPLATE_KEY_ESCAPE}{key}' to emit '{key}' itself"
),
)
.with_step(task_id),
);
}
let out = key
.strip_prefix(TEMPLATE_KEY_ESCAPE)
.unwrap_or(key)
.to_string();
if let Some(other) = emitted.insert(out.clone(), key) {
issues.push(
WorkflowIssue::at(
IssueCode::DuplicateTemplateKey,
format!("{path}.{key}"),
format!(
"'{other}' and '{key}' both emit the key '{out}', so this object would \
carry it twice — later reads see only the first while serialization \
emits both"
),
)
.with_step(task_id),
);
}
}
}
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())
}