From 3065d095fdcb3cd9a45556e523dac4ee75ce6e8e Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 8 May 2026 15:03:53 +0800 Subject: [PATCH] feat(workflow): add publish-time compiler for the linear step DSL --- mateclaw-server/pom.xml | 11 + .../mate/workflow/compiler/CompileError.java | 19 ++ .../compiler/ExpressionException.java | 11 + .../compiler/OutputContentTypeChecker.java | 99 +++++++ .../compiler/PebbleSubsetEvaluator.java | 141 ++++++++++ .../workflow/compiler/PublishContext.java | 9 + .../workflow/compiler/WorkflowAclPort.java | 24 ++ .../compiler/WorkflowAclValidator.java | 106 ++++++++ .../WorkflowCompileFailedException.java | 36 +++ .../workflow/compiler/WorkflowCompiler.java | 112 ++++++++ .../compiler/WorkflowParseException.java | 12 + .../workflow/compiler/WorkflowParser.java | 243 ++++++++++++++++++ .../compiler/WorkflowSchemaValidator.java | 226 ++++++++++++++++ .../mate/workflow/compiler/ir/ErrorMode.java | 24 ++ .../mate/workflow/compiler/ir/StepMode.java | 64 +++++ .../workflow/compiler/ir/WorkflowGraph.java | 19 ++ .../workflow/compiler/ir/WorkflowInput.java | 5 + .../workflow/compiler/ir/WorkflowStep.java | 26 ++ 18 files changed, 1187 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/CompileError.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/ExpressionException.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/OutputContentTypeChecker.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/PebbleSubsetEvaluator.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/PublishContext.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclPort.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclValidator.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompileFailedException.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompiler.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParseException.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParser.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowSchemaValidator.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/ErrorMode.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/StepMode.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowGraph.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowInput.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowStep.java diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml index 6e15c9c6..54a79e66 100644 --- a/mateclaw-server/pom.xml +++ b/mateclaw-server/pom.xml @@ -458,6 +458,17 @@ pdfbox 3.0.3 + + + + io.pebbletemplates + pebble + 3.2.2 + diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/CompileError.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/CompileError.java new file mode 100644 index 00000000..d4ab52ff --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/CompileError.java @@ -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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ExpressionException.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ExpressionException.java new file mode 100644 index 00000000..ff94933a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ExpressionException.java @@ -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); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/OutputContentTypeChecker.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/OutputContentTypeChecker.java new file mode 100644 index 00000000..e3228845 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/OutputContentTypeChecker.java @@ -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: + * + * + *

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..} + * 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 check(WorkflowGraph graph) { + if (graph == null || graph.steps().isEmpty()) { + return List.of(); + } + Map outputVarToContentType = collectOutputVars(graph); + + List 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 collectOutputVars(WorkflowGraph graph) { + Map 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 outputContentTypes, + List 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")); + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PebbleSubsetEvaluator.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PebbleSubsetEvaluator.java new file mode 100644 index 00000000..d0aface2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PebbleSubsetEvaluator.java @@ -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: + *

+ * + *

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 context) { + StringWriter writer = new StringWriter(); + evaluate(compiled, context, writer); + return writer.toString(); + } + + public boolean evaluateAsBoolean(Compiled compiled, Map context) { + String rendered = evaluateAsString(compiled, context).trim(); + return "true".equalsIgnoreCase(rendered); + } + + private void evaluate(Compiled compiled, Map 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) { + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PublishContext.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PublishContext.java new file mode 100644 index 00000000..4c950065 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PublishContext.java @@ -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) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclPort.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclPort.java new file mode 100644 index 00000000..86bd2cb9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclPort.java @@ -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); +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclValidator.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclValidator.java new file mode 100644 index 00000000..40b2ff29 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclValidator.java @@ -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. + * + *

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 validate(WorkflowGraph graph, PublishContext ctx, WorkflowAclPort port) { + if (graph == null || graph.steps().isEmpty()) { + return List.of(); + } + List 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 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 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 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("{{"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompileFailedException.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompileFailedException.java new file mode 100644 index 00000000..670e1848 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompileFailedException.java @@ -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 errors; + + public WorkflowCompileFailedException(List errors) { + super(buildMessage(errors)); + this.errors = List.copyOf(errors); + } + + public List errors() { + return errors; + } + + private static String buildMessage(List 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(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompiler.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompiler.java new file mode 100644 index 00000000..d0ea6fad --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompiler.java @@ -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: + *

+ */ +@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 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 checkExpressionSyntax(WorkflowGraph graph) { + List 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 errors) { + public boolean ok() { return errors.isEmpty(); } + + public void requireOk() { + if (!ok()) { + throw new WorkflowCompileFailedException(errors); + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParseException.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParseException.java new file mode 100644 index 00000000..2b870155 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParseException.java @@ -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); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParser.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParser.java new file mode 100644 index 00000000..79244e13 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParser.java @@ -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. + * + *

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 inputs = parseInputs(root.get("inputs")); + List steps = parseSteps(root.get("steps")); + + return new WorkflowGraph(schemaVersion, inputs, steps); + } + + private List parseInputs(JsonNode node) { + if (node == null || node.isNull()) { + return List.of(); + } + if (!node.isArray()) { + throw new WorkflowParseException("inputs must be a JSON array"); + } + List 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 parseSteps(JsonNode node) { + if (node == null || node.isNull()) { + return List.of(); + } + if (!node.isArray()) { + throw new WorkflowParseException("steps must be a JSON array"); + } + List 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 parseStringList(JsonNode node) { + if (node == null || node.isNull()) { + return List.of(); + } + if (!node.isArray()) { + throw new WorkflowParseException("expected JSON array, got " + node.getNodeType()); + } + List 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 parseStringMap(JsonNode node) { + if (node == null || node.isNull()) { + return Map.of(); + } + if (!node.isObject()) { + throw new WorkflowParseException("expected JSON object, got " + node.getNodeType()); + } + Map out = new HashMap<>(); + Iterator> it = node.fields(); + while (it.hasNext()) { + Map.Entry e = it.next(); + JsonNode v = e.getValue(); + out.put(e.getKey(), v == null || v.isNull() ? null : v.asText()); + } + return out; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowSchemaValidator.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowSchemaValidator.java new file mode 100644 index 00000000..9f27de4a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowSchemaValidator.java @@ -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: + *

    + *
  • A fan_out group must have at least two consecutive fan_out steps and + * must be terminated by a collect.
  • + *
  • A collect must follow a fan_out group.
  • + *
  • An await_approval step cannot live inside a fan_out group (multiple + * concurrent approvals have no aggregation UX).
  • + *
+ * + *

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 validate(WorkflowGraph graph) { + List 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 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 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 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 errors) { + Set 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 errors) { + List 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/ErrorMode.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/ErrorMode.java new file mode 100644 index 00000000..67b4b8f1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/ErrorMode.java @@ -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"; } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/StepMode.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/StepMode.java new file mode 100644 index 00000000..2ad53cee --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/StepMode.java @@ -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 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 channels, + Map 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"; } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowGraph.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowGraph.java new file mode 100644 index 00000000..2a47f922 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowGraph.java @@ -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 inputs, + List steps +) { + public WorkflowGraph { + inputs = inputs == null ? List.of() : List.copyOf(inputs); + steps = steps == null ? List.of() : List.copyOf(steps); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowInput.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowInput.java new file mode 100644 index 00000000..06f2fdf7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowInput.java @@ -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) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowStep.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowStep.java new file mode 100644 index 00000000..363bdc9f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowStep.java @@ -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; + } +}