mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(workflow): add publish-time compiler for the linear step DSL
This commit is contained in:
parent
f3b1cba6dc
commit
3065d095fd
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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("{{");
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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"; }
|
||||
}
|
||||
}
|
||||
@ -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"; }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user