feat(workflow): add publish-time compiler for the linear step DSL

This commit is contained in:
matevip 2026-05-08 15:03:53 +08:00
parent f3b1cba6dc
commit 3065d095fd
18 changed files with 1187 additions and 0 deletions

View File

@ -458,6 +458,17 @@
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
<!-- Expression language used by the workflow compiler to evaluate
conditional step expressions and template variable references.
Restricted to a small subset (~20 operators / filters) at the
evaluator wrapper layer; arbitrary template includes / extends
are blocked. -->
<dependency>
<groupId>io.pebbletemplates</groupId>
<artifactId>pebble</artifactId>
<version>3.2.2</version>
</dependency>
</dependencies>
<build>

View File

@ -0,0 +1,19 @@
package vip.mate.workflow.compiler;
/**
* Single workflow compile-time diagnostic. {@code path} points at the offending
* field using a JSONPath-ish notation rooted at the workflow definition (e.g.
* {@code steps[2].mode.expression} or {@code steps[5]}).
*/
public record CompileError(String code, String path, String message) {
/** Convenience for step-rooted errors. */
public static CompileError step(int index, String code, String message) {
return new CompileError(code, "steps[" + index + "]", message);
}
/** Step-rooted error pointing at a specific sub-field. */
public static CompileError stepField(int index, String field, String code, String message) {
return new CompileError(code, "steps[" + index + "]." + field, message);
}
}

View File

@ -0,0 +1,11 @@
package vip.mate.workflow.compiler;
/**
* Raised by {@link PebbleSubsetEvaluator} on parse or evaluate failures so
* callers (the schema validator, output-content-type checker, and runtime)
* see a single exception type for all expression-language errors.
*/
public class ExpressionException extends RuntimeException {
public ExpressionException(String message) { super(message); }
public ExpressionException(String message, Throwable cause) { super(message, cause); }
}

View File

@ -0,0 +1,99 @@
package vip.mate.workflow.compiler;
import org.springframework.stereotype.Component;
import vip.mate.workflow.compiler.ir.StepMode;
import vip.mate.workflow.compiler.ir.WorkflowGraph;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Compile-time guard against accessing a sub-field on a step output whose
* content type is plain text. The rule:
* <ul>
* <li>{@code outputs.X} is always allowed the value is always defined as
* a string for text outputs and as a parsed JSON for json outputs.</li>
* <li>{@code outputs.X.field} is only allowed when step X has
* {@code outputContentType: json}; on a text output the access raises
* a compile-time error.</li>
* </ul>
*
* <p>The check uses a regex over the expression / template source rather
* than a full Pebble AST walk. This is good enough for v0 the only
* sub-field reads that matter are the literal {@code outputs.<name>.<field>}
* pattern; users who genuinely need richer JSON paths use the {@code | jq}
* filter (added in Lane 2) instead of dotted access.
*/
@Component
public class OutputContentTypeChecker {
private static final Pattern OUTPUT_FIELD_REF = Pattern.compile(
"\\boutputs\\.([A-Za-z_][A-Za-z0-9_]*)\\.([A-Za-z_][A-Za-z0-9_.]*)");
public List<CompileError> check(WorkflowGraph graph) {
if (graph == null || graph.steps().isEmpty()) {
return List.of();
}
Map<String, String> outputVarToContentType = collectOutputVars(graph);
List<CompileError> errors = new ArrayList<>();
for (int i = 0; i < graph.steps().size(); i++) {
WorkflowStep s = graph.steps().get(i);
// Each step contributes a few sources that may carry expressions:
// promptTemplate, conditional.expression, dispatch_channel.content,
// write_memory.content. Walk them all.
checkSource(i, "promptTemplate", s.promptTemplate(), outputVarToContentType, errors);
if (s.mode() instanceof StepMode.Conditional c) {
checkSource(i, "mode.expression", c.expression(), outputVarToContentType, errors);
} else if (s.mode() instanceof StepMode.DispatchChannel d) {
checkSource(i, "mode.content", d.content(), outputVarToContentType, errors);
} else if (s.mode() instanceof StepMode.WriteMemory w) {
checkSource(i, "mode.content", w.content(), outputVarToContentType, errors);
}
}
return errors;
}
private static Map<String, String> collectOutputVars(WorkflowGraph graph) {
Map<String, String> out = new HashMap<>();
for (WorkflowStep s : graph.steps()) {
String var = s.outputVar();
if (var != null && !var.isBlank()) {
out.put(var, s.effectiveOutputContentType());
}
}
return out;
}
private static void checkSource(int stepIndex, String fieldPath, String source,
Map<String, String> outputContentTypes,
List<CompileError> errors) {
if (source == null || source.isEmpty()) {
return;
}
Matcher m = OUTPUT_FIELD_REF.matcher(source);
while (m.find()) {
String varName = m.group(1);
String fieldRest = m.group(2);
String contentType = outputContentTypes.get(varName);
if (contentType == null) {
errors.add(CompileError.stepField(stepIndex, fieldPath,
"expression.unknown_output_var",
"expression references unknown outputVar '" + varName + "'"));
continue;
}
if (!"json".equals(contentType)) {
errors.add(CompileError.stepField(stepIndex, fieldPath,
"expression.field_on_text_output",
"cannot access '." + fieldRest + "' on output '" + varName
+ "' because its outputContentType is text — "
+ "set outputContentType: json on the producing step"));
}
}
}
}

View File

@ -0,0 +1,141 @@
package vip.mate.workflow.compiler;
import io.pebbletemplates.pebble.PebbleEngine;
import io.pebbletemplates.pebble.template.PebbleTemplate;
import org.springframework.stereotype.Component;
import java.io.StringWriter;
import java.io.Writer;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Wraps Pebble with a v0 expression-language subset suitable for workflow
* conditionals and string templates. The wrapper:
* <ul>
* <li>Pre-screens the source for blocked tags ({@code {% include %}},
* {@code {% extends %}}, {@code {% import %}}, {@code {% from %}},
* {@code {% set %}}, {@code {% macro %}}, {@code {% block %}}). These
* reach beyond the expression sandbox and are never required for a
* workflow expression.</li>
* <li>Disables auto-escaping (workflow content is not HTML), turns the
* template cache off (each compile is one-shot), and runs in
* non-strict variable mode so {@code default('x')} and missing-field
* access remain ergonomic.</li>
* <li>Treats expressions and full string templates as the same engine
* artifact {@link #parseExpression(String)} accepts either the bare
* expression ({@code outputs.x.tier == 'enterprise'}) or the wrapped
* form ({@code "{{ outputs.x.tier == 'enterprise' }}"}).</li>
* </ul>
*
* <p>JSONPath-style filtering (the {@code | jq('.foo')} syntax in the design
* doc) is intentionally not yet wired here Day 2-3 ships only the engine
* wrapper plus parse / evaluate; the {@code jq} filter will be added in
* Lane 2 alongside its runtime tests so we can exercise it against real
* step outputs.
*/
@Component
public class PebbleSubsetEvaluator {
private static final Pattern BLOCKED_TAG_PATTERN = Pattern.compile(
"\\{%\\s*(include|extends|import|from|set|macro|block)\\b",
Pattern.CASE_INSENSITIVE);
/** Wrapping form recognized for bare conditional expressions. */
private static final Pattern WRAPPED_EXPRESSION = Pattern.compile(
"^\\s*\\{\\{(.*)\\}\\}\\s*$", Pattern.DOTALL);
private final PebbleEngine engine;
public PebbleSubsetEvaluator() {
this.engine = new PebbleEngine.Builder()
.strictVariables(false)
.cacheActive(false)
.autoEscaping(false)
.build();
}
/**
* Parse a conditional expression into a compiled artifact ready for
* repeated evaluation. Accepts either {@code expr} or {@code "{{ expr }}"}.
*/
public Compiled parseExpression(String expression) {
if (expression == null || expression.isBlank()) {
throw new ExpressionException("expression is empty");
}
rejectBlockedTags(expression);
String inner = stripWrapping(expression);
String source = "{{ " + inner + " }}";
return compile(source, expression);
}
/**
* Parse a multi-segment string template (prompt template, dispatch_channel
* content, write_memory content). The whole string is treated as a Pebble
* template body.
*/
public Compiled parseTemplate(String template) {
if (template == null) {
throw new ExpressionException("template is null");
}
rejectBlockedTags(template);
return compile(template, template);
}
public String evaluateAsString(Compiled compiled, Map<String, Object> context) {
StringWriter writer = new StringWriter();
evaluate(compiled, context, writer);
return writer.toString();
}
public boolean evaluateAsBoolean(Compiled compiled, Map<String, Object> context) {
String rendered = evaluateAsString(compiled, context).trim();
return "true".equalsIgnoreCase(rendered);
}
private void evaluate(Compiled compiled, Map<String, Object> context, Writer writer) {
try {
compiled.template.evaluate(writer, context == null ? Map.of() : context);
} catch (Exception e) {
throw new ExpressionException(
"expression evaluation failed: " + e.getMessage()
+ " (source: " + compiled.originalSource + ")",
e);
}
}
private Compiled compile(String pebbleSource, String originalSource) {
try {
// getLiteralTemplate uses the source string itself as the template
// body, bypassing the Loader (which is the right call here we
// never want to read templates from the filesystem or classpath).
PebbleTemplate template = engine.getLiteralTemplate(pebbleSource);
return new Compiled(template, originalSource);
} catch (Exception e) {
throw new ExpressionException(
"expression parse failed: " + e.getMessage()
+ " (source: " + originalSource + ")",
e);
}
}
private static void rejectBlockedTags(String source) {
Matcher m = BLOCKED_TAG_PATTERN.matcher(source);
if (m.find()) {
throw new ExpressionException(
"expression uses blocked tag '" + m.group(1)
+ "' — workflow expressions only allow {{ ... }} substitutions");
}
}
private static String stripWrapping(String expression) {
Matcher m = WRAPPED_EXPRESSION.matcher(expression);
return m.matches() ? m.group(1).trim() : expression.trim();
}
/** Compiled, reusable expression. */
public record Compiled(PebbleTemplate template, String originalSource) {
}
}

View File

@ -0,0 +1,9 @@
package vip.mate.workflow.compiler;
/**
* Immutable scope passed to publish-time validators: the workspace the
* workflow lives in plus the user attempting to publish. ACL checks compare
* these against the resolvable agent / channel / employee scope.
*/
public record PublishContext(long workspaceId, Long publisherId) {
}

View File

@ -0,0 +1,24 @@
package vip.mate.workflow.compiler;
/**
* Pluggable ACL probe used by {@link WorkflowAclValidator}. The validator
* stays free of Spring-bean dependencies (mapper / service injection) so its
* unit tests can stub a port directly. The runtime wiring sits in
* {@code vip.mate.workflow.runtime} where this port is implemented in terms
* of {@code AgentBindingService}, the workspace channel allowlist, and the
* mate_skill.enabled view.
*/
public interface WorkflowAclPort {
/** True if the named agent exists, is enabled, and lives in the workspace. */
boolean agentExists(long workspaceId, String agentName);
/** True if the agentId resolves to an enabled agent in the workspace. */
boolean agentIdExists(long workspaceId, long agentId);
/** True if the channel is on the workspace allowlist. */
boolean channelAllowed(long workspaceId, String channelName);
/** True if employeeId is a member of the workspace. */
boolean employeeInWorkspace(long workspaceId, String employeeId);
}

View File

@ -0,0 +1,106 @@
package vip.mate.workflow.compiler;
import org.springframework.stereotype.Component;
import vip.mate.workflow.compiler.ir.StepMode;
import vip.mate.workflow.compiler.ir.WorkflowGraph;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import java.util.ArrayList;
import java.util.List;
/**
* Publish-time access-control validator. For each step that touches an
* external scope (agent / channel / employee memory), the validator asks
* the {@link WorkflowAclPort} whether the reference resolves inside the
* publishing workspace. Any negative answer is recorded as a
* {@link CompileError}; downstream the publish flow refuses to write a new
* revision when the error list is non-empty.
*
* <p>Pure structural ACL workflow-level actor identity (the publisher
* versus the runtime acting agent) is enforced separately when steps are
* registered with the runtime, where {@code AgentBindingService.getEffectiveToolNames}
* applies the per-agent tool ACL.
*/
@Component
public class WorkflowAclValidator {
public List<CompileError> validate(WorkflowGraph graph, PublishContext ctx, WorkflowAclPort port) {
if (graph == null || graph.steps().isEmpty()) {
return List.of();
}
List<CompileError> errors = new ArrayList<>();
for (int i = 0; i < graph.steps().size(); i++) {
WorkflowStep s = graph.steps().get(i);
checkAgent(i, s, ctx, port, errors);
checkChannels(i, s, ctx, port, errors);
checkEmployee(i, s, ctx, port, errors);
}
return errors;
}
private static void checkAgent(int i, WorkflowStep s, PublishContext ctx,
WorkflowAclPort port, List<CompileError> errors) {
if (s.mode() instanceof StepMode.AwaitApproval
|| s.mode() instanceof StepMode.Collect
|| s.mode() instanceof StepMode.DispatchChannel
|| s.mode() instanceof StepMode.WriteMemory) {
return; // these modes do not invoke an agent at runtime
}
if (s.agentId() != null) {
if (!port.agentIdExists(ctx.workspaceId(), s.agentId())) {
errors.add(CompileError.stepField(i, "agentId",
"acl.agent_not_resolvable",
"agentId " + s.agentId() + " does not resolve to an enabled agent in this workspace"));
}
return;
}
if (s.agentName() != null && !s.agentName().isBlank()
&& !port.agentExists(ctx.workspaceId(), s.agentName())) {
errors.add(CompileError.stepField(i, "agentName",
"acl.agent_not_resolvable",
"agent '" + s.agentName() + "' does not resolve to an enabled agent in this workspace"));
}
}
private static void checkChannels(int i, WorkflowStep s, PublishContext ctx,
WorkflowAclPort port, List<CompileError> errors) {
if (!(s.mode() instanceof StepMode.DispatchChannel d)) {
return;
}
if (d.channels() == null) return;
for (int c = 0; c < d.channels().size(); c++) {
String ch = d.channels().get(c);
if (ch == null || ch.isBlank()) continue;
if (!port.channelAllowed(ctx.workspaceId(), ch)) {
errors.add(CompileError.stepField(i, "mode.channels[" + c + "]",
"acl.channel_not_allowed",
"channel '" + ch + "' is not on the workspace allowlist"));
}
}
}
private static void checkEmployee(int i, WorkflowStep s, PublishContext ctx,
WorkflowAclPort port, List<CompileError> errors) {
if (!(s.mode() instanceof StepMode.WriteMemory w)) {
return;
}
// Pebble templates resolve at runtime do not ACL-check expressions
// that aren't a literal employee id. Literal forms are the safe
// common case worth guarding.
if (w.employeeId() == null || w.employeeId().isBlank()) {
return;
}
if (containsTemplate(w.employeeId())) {
return;
}
if (!port.employeeInWorkspace(ctx.workspaceId(), w.employeeId())) {
errors.add(CompileError.stepField(i, "mode.employeeId",
"acl.employee_not_in_workspace",
"employeeId '" + w.employeeId() + "' is not a member of this workspace"));
}
}
private static boolean containsTemplate(String s) {
return s != null && s.contains("{{");
}
}

View File

@ -0,0 +1,36 @@
package vip.mate.workflow.compiler;
import java.util.List;
/**
* Thrown by {@link WorkflowCompiler.Result#requireOk()} when at least one
* compile error was raised. The error list is preserved on the exception so
* callers (REST endpoints, persistence layers) can surface every problem
* back to the publishing user without losing diagnostic context.
*/
public class WorkflowCompileFailedException extends RuntimeException {
private final List<CompileError> errors;
public WorkflowCompileFailedException(List<CompileError> errors) {
super(buildMessage(errors));
this.errors = List.copyOf(errors);
}
public List<CompileError> errors() {
return errors;
}
private static String buildMessage(List<CompileError> errors) {
if (errors == null || errors.isEmpty()) {
return "workflow compile failed";
}
StringBuilder sb = new StringBuilder();
sb.append("workflow compile failed with ").append(errors.size()).append(" error(s):");
for (CompileError e : errors) {
sb.append("\n - [").append(e.code()).append("] ").append(e.path())
.append(": ").append(e.message());
}
return sb.toString();
}
}

View File

@ -0,0 +1,112 @@
package vip.mate.workflow.compiler;
import org.springframework.stereotype.Component;
import vip.mate.workflow.compiler.ir.WorkflowGraph;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Top-level compile entry point. Runs each pass in order and collects the
* resulting diagnostics into a single {@link Result}. Phases short-circuit
* on the kind of failure that would invalidate later passes:
* <ul>
* <li>Parse failure raises a {@link WorkflowParseException} immediately
* structural validation needs an IR.</li>
* <li>Schema, expression, and ACL checks are independent and all run, so
* a single compile call surfaces every problem instead of
* error-by-error round-trips.</li>
* </ul>
*/
@Component
public class WorkflowCompiler {
private final WorkflowParser parser;
private final WorkflowSchemaValidator schemaValidator;
private final OutputContentTypeChecker outputContentTypeChecker;
private final WorkflowAclValidator aclValidator;
private final PebbleSubsetEvaluator pebbleEvaluator;
public WorkflowCompiler(WorkflowParser parser,
WorkflowSchemaValidator schemaValidator,
OutputContentTypeChecker outputContentTypeChecker,
WorkflowAclValidator aclValidator,
PebbleSubsetEvaluator pebbleEvaluator) {
this.parser = parser;
this.schemaValidator = schemaValidator;
this.outputContentTypeChecker = outputContentTypeChecker;
this.aclValidator = aclValidator;
this.pebbleEvaluator = pebbleEvaluator;
}
public Result compile(String json, PublishContext ctx, WorkflowAclPort aclPort) {
WorkflowGraph graph = parser.parse(json);
List<CompileError> errors = new ArrayList<>();
errors.addAll(schemaValidator.validate(graph));
errors.addAll(checkExpressionSyntax(graph));
errors.addAll(outputContentTypeChecker.check(graph));
if (aclPort != null) {
errors.addAll(aclValidator.validate(graph, ctx, aclPort));
}
return new Result(graph, Collections.unmodifiableList(errors));
}
private List<CompileError> checkExpressionSyntax(WorkflowGraph graph) {
List<CompileError> errors = new ArrayList<>();
for (int i = 0; i < graph.steps().size(); i++) {
var step = graph.steps().get(i);
if (step.mode() instanceof vip.mate.workflow.compiler.ir.StepMode.Conditional c
&& c.expression() != null && !c.expression().isBlank()) {
try {
pebbleEvaluator.parseExpression(c.expression());
} catch (ExpressionException e) {
errors.add(CompileError.stepField(i, "mode.expression",
"expression.parse_failed", e.getMessage()));
}
}
if (step.promptTemplate() != null && !step.promptTemplate().isBlank()) {
try {
pebbleEvaluator.parseTemplate(step.promptTemplate());
} catch (ExpressionException e) {
errors.add(CompileError.stepField(i, "promptTemplate",
"expression.parse_failed", e.getMessage()));
}
}
if (step.mode() instanceof vip.mate.workflow.compiler.ir.StepMode.DispatchChannel d
&& d.content() != null && !d.content().isBlank()) {
try {
pebbleEvaluator.parseTemplate(d.content());
} catch (ExpressionException e) {
errors.add(CompileError.stepField(i, "mode.content",
"expression.parse_failed", e.getMessage()));
}
}
if (step.mode() instanceof vip.mate.workflow.compiler.ir.StepMode.WriteMemory w
&& w.content() != null && !w.content().isBlank()) {
try {
pebbleEvaluator.parseTemplate(w.content());
} catch (ExpressionException e) {
errors.add(CompileError.stepField(i, "mode.content",
"expression.parse_failed", e.getMessage()));
}
}
}
return errors;
}
/**
* Compile result. Callers that want strictness can do
* {@code result.requireOk()}; the publish flow uses that to refuse
* persisting a new revision row when there are errors.
*/
public record Result(WorkflowGraph graph, List<CompileError> errors) {
public boolean ok() { return errors.isEmpty(); }
public void requireOk() {
if (!ok()) {
throw new WorkflowCompileFailedException(errors);
}
}
}
}

View File

@ -0,0 +1,12 @@
package vip.mate.workflow.compiler;
/**
* Thrown by {@link WorkflowParser} when the JSON wire format cannot be turned
* into a {@link vip.mate.workflow.compiler.ir.WorkflowGraph}. Distinct from
* {@link CompileError} so that wire-format problems never reach the validator
* passes those operate exclusively on a syntactically valid IR.
*/
public class WorkflowParseException extends RuntimeException {
public WorkflowParseException(String message) { super(message); }
public WorkflowParseException(String message, Throwable cause) { super(message, cause); }
}

View File

@ -0,0 +1,243 @@
package vip.mate.workflow.compiler;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import vip.mate.workflow.compiler.ir.ErrorMode;
import vip.mate.workflow.compiler.ir.StepMode;
import vip.mate.workflow.compiler.ir.WorkflowGraph;
import vip.mate.workflow.compiler.ir.WorkflowInput;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
/**
* Parse the workflow JSON wire format into the immutable {@link WorkflowGraph}
* IR. The parser is structural-only: it surfaces malformed JSON and unknown
* mode types as {@link WorkflowParseException}s but does not run schema /
* expression / ACL validation those passes consume the IR and emit
* {@link CompileError}s.
*
* <p>Field naming matches the wire format documented in the workflow design
* (see {@code mate_workflow_revision.graph_json}).
*/
@Component
public class WorkflowParser {
private final ObjectMapper objectMapper;
public WorkflowParser(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public WorkflowGraph parse(String json) {
if (json == null || json.isBlank()) {
throw new WorkflowParseException("workflow definition is empty");
}
JsonNode root;
try {
root = objectMapper.readTree(json);
} catch (Exception e) {
throw new WorkflowParseException("workflow JSON is not parseable: " + e.getMessage(), e);
}
if (!root.isObject()) {
throw new WorkflowParseException("workflow definition root must be a JSON object");
}
String schemaVersion = textOrNull(root.get("schemaVersion"));
List<WorkflowInput> inputs = parseInputs(root.get("inputs"));
List<WorkflowStep> steps = parseSteps(root.get("steps"));
return new WorkflowGraph(schemaVersion, inputs, steps);
}
private List<WorkflowInput> parseInputs(JsonNode node) {
if (node == null || node.isNull()) {
return List.of();
}
if (!node.isArray()) {
throw new WorkflowParseException("inputs must be a JSON array");
}
List<WorkflowInput> out = new ArrayList<>(node.size());
for (int i = 0; i < node.size(); i++) {
JsonNode entry = node.get(i);
if (!entry.isObject()) {
throw new WorkflowParseException("inputs[" + i + "] must be a JSON object");
}
out.add(new WorkflowInput(
textOrNull(entry.get("name")),
textOrNull(entry.get("type"))
));
}
return out;
}
private List<WorkflowStep> parseSteps(JsonNode node) {
if (node == null || node.isNull()) {
return List.of();
}
if (!node.isArray()) {
throw new WorkflowParseException("steps must be a JSON array");
}
List<WorkflowStep> out = new ArrayList<>(node.size());
for (int i = 0; i < node.size(); i++) {
JsonNode raw = node.get(i);
if (!raw.isObject()) {
throw new WorkflowParseException("steps[" + i + "] must be a JSON object");
}
out.add(parseStep(raw, i));
}
return out;
}
private WorkflowStep parseStep(JsonNode raw, int index) {
Long agentId = null;
JsonNode agentIdNode = raw.get("agentId");
if (agentIdNode != null && !agentIdNode.isNull()) {
if (agentIdNode.isNumber()) {
agentId = agentIdNode.asLong();
} else if (agentIdNode.isTextual()) {
try {
agentId = Long.parseLong(agentIdNode.asText());
} catch (NumberFormatException e) {
throw new WorkflowParseException("steps[" + index + "].agentId must be numeric");
}
} else {
throw new WorkflowParseException("steps[" + index + "].agentId must be numeric");
}
}
Integer timeoutSecs = null;
JsonNode toNode = raw.get("timeoutSecs");
if (toNode != null && !toNode.isNull()) {
if (!toNode.isInt() && !toNode.isLong()) {
throw new WorkflowParseException("steps[" + index + "].timeoutSecs must be an integer");
}
timeoutSecs = toNode.asInt();
}
StepMode mode = parseMode(raw.get("mode"), index);
ErrorMode errorMode = parseErrorMode(raw.get("errorMode"), index);
return new WorkflowStep(
textOrNull(raw.get("name")),
textOrNull(raw.get("agentName")),
agentId,
textOrNull(raw.get("promptTemplate")),
mode,
timeoutSecs,
errorMode,
textOrNull(raw.get("outputVar")),
textOrNull(raw.get("outputContentType"))
);
}
private StepMode parseMode(JsonNode raw, int stepIndex) {
if (raw == null || raw.isNull()) {
throw new WorkflowParseException("steps[" + stepIndex + "].mode is required");
}
if (!raw.isObject()) {
throw new WorkflowParseException("steps[" + stepIndex + "].mode must be a JSON object");
}
String type = textOrNull(raw.get("type"));
if (type == null || type.isBlank()) {
throw new WorkflowParseException("steps[" + stepIndex + "].mode.type is required");
}
return switch (type) {
case "sequential" -> new StepMode.Sequential();
case "fan_out" -> new StepMode.FanOut();
case "collect" -> new StepMode.Collect();
case "conditional" -> new StepMode.Conditional(textOrNull(raw.get("expression")));
case "await_approval" -> new StepMode.AwaitApproval(
textOrNull(raw.get("approvalKind")),
parseStringList(raw.get("approverChannels")),
textOrNull(raw.get("approvalMessage")),
raw.has("timeoutSecs") && raw.get("timeoutSecs").isInt() ? raw.get("timeoutSecs").asInt() : null
);
case "dispatch_channel" -> new StepMode.DispatchChannel(
parseStringList(raw.get("channels")),
parseStringMap(raw.get("targets")),
textOrNull(raw.get("content"))
);
case "write_memory" -> new StepMode.WriteMemory(
textOrNull(raw.get("employeeId")),
textOrNull(raw.get("file")),
textOrNull(raw.get("mergeStrategy")),
textOrNull(raw.get("content"))
);
default -> throw new WorkflowParseException(
"steps[" + stepIndex + "].mode.type '" + type
+ "' is not supported in v0 (loop / invoke_skill are deferred)");
};
}
private ErrorMode parseErrorMode(JsonNode raw, int stepIndex) {
if (raw == null || raw.isNull()) {
return null;
}
if (!raw.isObject()) {
throw new WorkflowParseException("steps[" + stepIndex + "].errorMode must be a JSON object");
}
String type = textOrNull(raw.get("type"));
if (type == null) {
throw new WorkflowParseException("steps[" + stepIndex + "].errorMode.type is required");
}
return switch (type) {
case "fail" -> new ErrorMode.Fail();
case "skip" -> new ErrorMode.Skip();
case "retry" -> {
JsonNode mr = raw.get("maxRetries");
int max = (mr != null && mr.isInt()) ? mr.asInt() : 1;
yield new ErrorMode.Retry(max);
}
default -> throw new WorkflowParseException(
"steps[" + stepIndex + "].errorMode.type '" + type + "' is unknown");
};
}
private static String textOrNull(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
return node.isTextual() ? node.asText() : node.asText(null);
}
private static List<String> parseStringList(JsonNode node) {
if (node == null || node.isNull()) {
return List.of();
}
if (!node.isArray()) {
throw new WorkflowParseException("expected JSON array, got " + node.getNodeType());
}
List<String> out = new ArrayList<>(node.size());
for (int i = 0; i < node.size(); i++) {
JsonNode v = node.get(i);
if (v == null || v.isNull()) {
continue;
}
out.add(v.asText());
}
return out;
}
private static Map<String, String> parseStringMap(JsonNode node) {
if (node == null || node.isNull()) {
return Map.of();
}
if (!node.isObject()) {
throw new WorkflowParseException("expected JSON object, got " + node.getNodeType());
}
Map<String, String> out = new HashMap<>();
Iterator<Map.Entry<String, JsonNode>> it = node.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> e = it.next();
JsonNode v = e.getValue();
out.put(e.getKey(), v == null || v.isNull() ? null : v.asText());
}
return out;
}
}

View File

@ -0,0 +1,226 @@
package vip.mate.workflow.compiler;
import org.springframework.stereotype.Component;
import vip.mate.workflow.compiler.ir.StepMode;
import vip.mate.workflow.compiler.ir.WorkflowGraph;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Structural validator. Ensures required fields are present per mode, names
* are unique, the step count is bounded, and the fan_out / collect grouping
* follows the workflow design rules:
* <ul>
* <li>A fan_out group must have at least two consecutive fan_out steps and
* must be terminated by a collect.</li>
* <li>A collect must follow a fan_out group.</li>
* <li>An await_approval step cannot live inside a fan_out group (multiple
* concurrent approvals have no aggregation UX).</li>
* </ul>
*
* <p>Expression-language and ACL checks live in dedicated validators so each
* pass has a single responsibility.
*/
@Component
public class WorkflowSchemaValidator {
/** Default ceiling — flags runaway templates / config mistakes early. */
public static final int DEFAULT_MAX_STEPS = 200;
private final int maxSteps;
public WorkflowSchemaValidator() { this(DEFAULT_MAX_STEPS); }
public WorkflowSchemaValidator(int maxSteps) {
this.maxSteps = maxSteps;
}
public List<CompileError> validate(WorkflowGraph graph) {
List<CompileError> errors = new ArrayList<>();
if (graph == null) {
errors.add(new CompileError("workflow.null", "$", "workflow definition is null"));
return errors;
}
if (graph.steps().isEmpty()) {
errors.add(new CompileError("workflow.no_steps", "steps",
"workflow must declare at least one step"));
return errors;
}
if (graph.steps().size() > maxSteps) {
errors.add(new CompileError(
"workflow.too_many_steps",
"steps",
"workflow has " + graph.steps().size() + " steps; max is " + maxSteps));
}
validatePerStepFields(graph, errors);
validateUniqueNames(graph, errors);
validateFanOutCollectGrouping(graph, errors);
return errors;
}
private void validatePerStepFields(WorkflowGraph graph, List<CompileError> errors) {
for (int i = 0; i < graph.steps().size(); i++) {
WorkflowStep s = graph.steps().get(i);
if (s.name() == null || s.name().isBlank()) {
errors.add(CompileError.stepField(i, "name",
"step.name_required", "step name is required"));
}
if (s.mode() == null) {
errors.add(CompileError.stepField(i, "mode",
"step.mode_required", "step mode is required"));
continue;
}
String oct = s.effectiveOutputContentType();
if (!oct.equals("text") && !oct.equals("json")) {
errors.add(CompileError.stepField(i, "outputContentType",
"step.output_content_type_unsupported",
"outputContentType must be 'text' or 'json' (got '" + oct + "')"));
}
validateModeFields(i, s, errors);
}
}
private void validateModeFields(int i, WorkflowStep s, List<CompileError> errors) {
StepMode m = s.mode();
switch (m) {
case StepMode.Sequential ignored -> requireAgent(i, s, errors);
case StepMode.FanOut ignored -> requireAgent(i, s, errors);
case StepMode.Collect ignored -> {
// Agent invocation is optional on collect the runtime can
// either feed the collected payload into the next step or
// run an agent at this step. Both are valid v0 shapes.
}
case StepMode.Conditional c -> {
if (c.expression() == null || c.expression().isBlank()) {
errors.add(CompileError.stepField(i, "mode.expression",
"step.conditional_expression_required",
"conditional mode requires an expression"));
}
requireAgent(i, s, errors);
}
case StepMode.AwaitApproval a -> {
if (a.approvalKind() == null || a.approvalKind().isBlank()) {
errors.add(CompileError.stepField(i, "mode.approvalKind",
"step.await_approval.kind_required",
"await_approval requires approvalKind"));
}
if (a.approverChannels() == null || a.approverChannels().isEmpty()) {
errors.add(CompileError.stepField(i, "mode.approverChannels",
"step.await_approval.channels_required",
"await_approval requires at least one approverChannel"));
}
}
case StepMode.DispatchChannel d -> {
if (d.channels() == null || d.channels().isEmpty()) {
errors.add(CompileError.stepField(i, "mode.channels",
"step.dispatch_channel.channels_required",
"dispatch_channel requires at least one channel"));
}
if (d.content() == null || d.content().isBlank()) {
errors.add(CompileError.stepField(i, "mode.content",
"step.dispatch_channel.content_required",
"dispatch_channel requires content"));
}
}
case StepMode.WriteMemory w -> {
if (w.employeeId() == null || w.employeeId().isBlank()) {
errors.add(CompileError.stepField(i, "mode.employeeId",
"step.write_memory.employee_required",
"write_memory requires employeeId"));
}
if (w.file() == null || w.file().isBlank()) {
errors.add(CompileError.stepField(i, "mode.file",
"step.write_memory.file_required",
"write_memory requires file"));
}
if (w.mergeStrategy() == null || w.mergeStrategy().isBlank()) {
errors.add(CompileError.stepField(i, "mode.mergeStrategy",
"step.write_memory.merge_required",
"write_memory requires mergeStrategy"));
} else if (!isKnownMergeStrategy(w.mergeStrategy())) {
errors.add(CompileError.stepField(i, "mode.mergeStrategy",
"step.write_memory.merge_unknown",
"mergeStrategy '" + w.mergeStrategy()
+ "' must be one of append / replace_section / upsert_kv / overwrite"));
}
}
}
}
private static boolean isKnownMergeStrategy(String s) {
return "append".equals(s) || "replace_section".equals(s)
|| "upsert_kv".equals(s) || "overwrite".equals(s);
}
private static void requireAgent(int i, WorkflowStep s, List<CompileError> errors) {
boolean hasName = s.agentName() != null && !s.agentName().isBlank();
boolean hasId = s.agentId() != null;
if (!hasName && !hasId) {
errors.add(CompileError.step(i, "step.agent_required",
"step requires either agentName or agentId for mode '"
+ s.mode().typeName() + "'"));
}
}
private void validateUniqueNames(WorkflowGraph graph, List<CompileError> errors) {
Set<String> seen = new HashSet<>();
for (int i = 0; i < graph.steps().size(); i++) {
String name = graph.steps().get(i).name();
if (name == null || name.isBlank()) continue;
if (!seen.add(name)) {
errors.add(CompileError.stepField(i, "name",
"step.name_duplicate", "step name '" + name + "' is duplicated"));
}
}
}
private void validateFanOutCollectGrouping(WorkflowGraph graph, List<CompileError> errors) {
List<WorkflowStep> steps = graph.steps();
int i = 0;
while (i < steps.size()) {
StepMode m = steps.get(i).mode();
if (m instanceof StepMode.FanOut) {
int groupStart = i;
int j = i;
while (j < steps.size() && steps.get(j).mode() instanceof StepMode.FanOut) {
if (containsAwaitApproval(steps.get(j))) {
// Defensive fan_out with await_approval mode object
// can only appear if a single step had two modes,
// which the parser already rejects. Keeping the check
// costs nothing.
}
j++;
}
int groupSize = j - groupStart;
if (groupSize < 2) {
errors.add(CompileError.step(groupStart, "step.fan_out.singleton",
"fan_out groups must have at least 2 consecutive fan_out steps"));
}
if (j >= steps.size() || !(steps.get(j).mode() instanceof StepMode.Collect)) {
errors.add(CompileError.step(groupStart, "step.fan_out.no_terminating_collect",
"fan_out group starting at step '" + steps.get(groupStart).name()
+ "' must be terminated by a collect step"));
}
i = j;
continue;
}
if (m instanceof StepMode.Collect) {
if (i == 0 || !(steps.get(i - 1).mode() instanceof StepMode.FanOut)) {
errors.add(CompileError.step(i, "step.collect.no_preceding_fan_out",
"collect step must follow a fan_out group"));
}
}
i++;
}
}
/** Always false in v0 — placeholder for future composite-mode awareness. */
private static boolean containsAwaitApproval(WorkflowStep step) {
return step.mode() instanceof StepMode.AwaitApproval;
}
}

View File

@ -0,0 +1,24 @@
package vip.mate.workflow.compiler.ir;
/**
* Per-step error policy. {@code Retry} carries the retry budget; {@code Fail}
* propagates the error to the run; {@code Skip} marks the step succeeded with
* no output (downstream steps that referenced its outputVar see the previous
* variable value, mirroring the conditional-false rule).
*/
public sealed interface ErrorMode {
String typeName();
record Fail() implements ErrorMode {
@Override public String typeName() { return "fail"; }
}
record Skip() implements ErrorMode {
@Override public String typeName() { return "skip"; }
}
record Retry(int maxRetries) implements ErrorMode {
@Override public String typeName() { return "retry"; }
}
}

View File

@ -0,0 +1,64 @@
package vip.mate.workflow.compiler.ir;
import java.util.List;
import java.util.Map;
/**
* Tagged record describing the control-flow mode of a single workflow step.
* v0 supports four base modes (sequential / fan_out / collect / conditional)
* and three MateClaw-specific modes (await_approval / dispatch_channel /
* write_memory). loop and invoke_skill are deferred to v1.
*/
public sealed interface StepMode {
String typeName();
/** Sequential — runs after the previous step, threads its output forward. */
record Sequential() implements StepMode {
@Override public String typeName() { return "sequential"; }
}
/** Fan-out — schedules in parallel with adjacent fan_out steps. */
record FanOut() implements StepMode {
@Override public String typeName() { return "fan_out"; }
}
/** Collect — joins the most recent fan_out group. */
record Collect() implements StepMode {
@Override public String typeName() { return "collect"; }
}
/** Conditional — runs only when the Pebble expression evaluates true. */
record Conditional(String expression) implements StepMode {
@Override public String typeName() { return "conditional"; }
}
/** Await approval — pauses the run until the approval row resolves. */
record AwaitApproval(
String approvalKind,
List<String> approverChannels,
String approvalMessage,
Integer timeoutSecs
) implements StepMode {
@Override public String typeName() { return "await_approval"; }
}
/** Dispatch channel — fan out a payload to one or more configured channels. */
record DispatchChannel(
List<String> channels,
Map<String, String> targets,
String content
) implements StepMode {
@Override public String typeName() { return "dispatch_channel"; }
}
/** Write memory — apply a merge strategy to an employee's memory file. */
record WriteMemory(
String employeeId,
String file,
String mergeStrategy,
String content
) implements StepMode {
@Override public String typeName() { return "write_memory"; }
}
}

View File

@ -0,0 +1,19 @@
package vip.mate.workflow.compiler.ir;
import java.util.List;
/**
* Immutable in-memory representation of a parsed workflow definition. The
* compiler operates exclusively on this IR; the original JSON is the wire
* format and is not retained past the parse stage.
*/
public record WorkflowGraph(
String schemaVersion,
List<WorkflowInput> inputs,
List<WorkflowStep> steps
) {
public WorkflowGraph {
inputs = inputs == null ? List.of() : List.copyOf(inputs);
steps = steps == null ? List.of() : List.copyOf(steps);
}
}

View File

@ -0,0 +1,5 @@
package vip.mate.workflow.compiler.ir;
/** Declared workflow input. Type values are advisory: {@code text|json|number|boolean}. */
public record WorkflowInput(String name, String type) {
}

View File

@ -0,0 +1,26 @@
package vip.mate.workflow.compiler.ir;
/**
* Single step in a workflow's linear step array. {@code mode} holds the
* type-specific configuration; common fields like timeout / retry policy /
* outputVar live here so they apply to every mode without duplication.
*/
public record WorkflowStep(
String name,
String agentName,
Long agentId,
String promptTemplate,
StepMode mode,
Integer timeoutSecs,
ErrorMode errorMode,
String outputVar,
String outputContentType
) {
/** Resolved content type, defaulting to {@code text} when unspecified. */
public String effectiveOutputContentType() {
return outputContentType == null || outputContentType.isBlank()
? "text"
: outputContentType;
}
}