feat(workflow): add linear runtime for the four base step modes

This commit is contained in:
matevip 2026-05-08 15:04:07 +08:00
parent 3065d095fd
commit 4e457c2e8b
15 changed files with 937 additions and 0 deletions

View File

@ -0,0 +1,24 @@
package vip.mate.workflow.runtime;
/**
* SPI for "render prompt → run agent → return text response". Kept thin so
* unit tests can stub agent execution without booting the full StateGraph
* runtime. Production binding lives in {@link DefaultAgentInvoker} and
* delegates to {@code AgentService.chat(...)}.
*/
public interface AgentInvoker {
/**
* Invoke the resolved agent with {@code prompt} and return the agent's
* final response text. {@code conversationId} is the ephemeral conversation
* id created per workflow step the runner generates this so each step
* has its own conversational scope.
*/
String invoke(long agentId, String prompt, String conversationId);
/**
* Resolve a workspace-scoped agent name to its id. Returns {@code null}
* when the agent does not exist or is disabled.
*/
Long resolveAgentId(long workspaceId, String agentName);
}

View File

@ -0,0 +1,126 @@
package vip.mate.workflow.runtime;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import vip.mate.workflow.compiler.PebbleSubsetEvaluator;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import java.util.UUID;
/**
* Shared "render prompt → invoke agent → parse output" pipeline reused by
* the sequential / fan_out / conditional adapters. Centralising this here
* keeps each adapter file focused on its mode-specific dispatch logic
* (skip-on-condition, merge semantics) instead of repeating prompt rendering
* and content-type parsing.
*/
@Component
public class AgentStepExecutor {
private static final String TEXT = "text";
private static final String JSON = "json";
private final AgentInvoker agentInvoker;
private final PebbleSubsetEvaluator pebble;
private final PayloadStore payloadStore;
private final ObjectMapper objectMapper;
public AgentStepExecutor(AgentInvoker agentInvoker,
PebbleSubsetEvaluator pebble,
PayloadStore payloadStore,
ObjectMapper objectMapper) {
this.agentInvoker = agentInvoker;
this.pebble = pebble;
this.payloadStore = payloadStore;
this.objectMapper = objectMapper;
}
/**
* Resolve the agent, render the prompt with the current run context,
* invoke the agent, parse the response according to {@code outputContentType},
* and write the payload through the store. Returns a succeeded result on
* the happy path and a failed result when any step in the chain throws.
*/
public StepResult run(WorkflowStep step, WorkflowRunContext context) {
Long agentId = resolveAgentId(step, context.workspaceId());
if (agentId == null) {
return StepResult.failed("agent not resolvable for step '" + step.name()
+ "': agentName=" + step.agentName() + " agentId=" + step.agentId());
}
String prompt;
try {
prompt = renderPrompt(step, context);
} catch (Exception e) {
return StepResult.failed("prompt render failed for step '" + step.name()
+ "': " + e.getMessage());
}
String response;
String conversationId = "wf-run-" + context.runId() + "-step-" + step.name()
+ "-" + UUID.randomUUID();
try {
response = agentInvoker.invoke(agentId, prompt, conversationId);
if (response == null) response = "";
} catch (Exception e) {
return StepResult.failed("agent invocation failed for step '" + step.name()
+ "': " + e.getMessage());
}
String contentType = step.effectiveOutputContentType();
try {
Object parsedValue = parseResponse(response, contentType);
String payloadUri = (TEXT.equals(contentType))
? payloadStore.storeString(context.workspaceId(), response, "text/plain")
: payloadStore.storeString(context.workspaceId(), response, "application/json");
String summary = summarise(response);
return StepResult.succeeded(payloadUri, contentType, parsedValue, summary);
} catch (Exception e) {
return StepResult.failed("output parse failed for step '" + step.name()
+ "' (contentType=" + contentType + "): " + e.getMessage());
}
}
private Long resolveAgentId(WorkflowStep step, long workspaceId) {
if (step.agentId() != null) return step.agentId();
if (step.agentName() != null && !step.agentName().isBlank()) {
return agentInvoker.resolveAgentId(workspaceId, step.agentName());
}
return null;
}
private String renderPrompt(WorkflowStep step, WorkflowRunContext context) {
if (step.promptTemplate() == null || step.promptTemplate().isBlank()) {
return "";
}
var compiled = pebble.parseTemplate(step.promptTemplate());
return pebble.evaluateAsString(compiled, context.templateContext());
}
private Object parseResponse(String response, String contentType) throws Exception {
if (JSON.equals(contentType)) {
// Permissive: agents often wrap JSON in ```json fences.
String cleaned = stripCodeFence(response);
return objectMapper.readValue(cleaned, Object.class);
}
return response;
}
private static String stripCodeFence(String s) {
String trimmed = s.trim();
if (trimmed.startsWith("```")) {
int firstNewline = trimmed.indexOf('\n');
int lastFence = trimmed.lastIndexOf("```");
if (firstNewline > 0 && lastFence > firstNewline) {
return trimmed.substring(firstNewline + 1, lastFence).trim();
}
}
return trimmed;
}
private static String summarise(String response) {
if (response == null || response.isBlank()) return "";
String oneLine = response.replaceAll("\\s+", " ").trim();
return oneLine.length() <= 256 ? oneLine : oneLine.substring(0, 253) + "...";
}
}

View File

@ -0,0 +1,49 @@
package vip.mate.workflow.runtime;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
/**
* Production binding for {@link AgentInvoker}. Looks agents up by name within
* the workspace via {@link AgentMapper} and delegates execution to
* {@link AgentService#chat(Long, String, String)}. The conversation id is
* passed through as-is the runner is responsible for generating an ephemeral
* id per step so multi-step runs do not collide on conversation history.
*/
@Component
public class DefaultAgentInvoker implements AgentInvoker {
private final AgentService agentService;
private final AgentMapper agentMapper;
public DefaultAgentInvoker(AgentService agentService, AgentMapper agentMapper) {
this.agentService = agentService;
this.agentMapper = agentMapper;
}
@Override
public String invoke(long agentId, String prompt, String conversationId) {
return agentService.chat(agentId, prompt, conversationId);
}
@Override
public Long resolveAgentId(long workspaceId, String agentName) {
if (agentName == null || agentName.isBlank()) return null;
AgentEntity entity = agentMapper.selectOne(new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId)
.eq(AgentEntity::getName, agentName.trim())
.eq(AgentEntity::getEnabled, true));
if (entity == null) {
// Fall back to a workspace-agnostic lookup so global agents still
// resolve. This mirrors how the workflow ACL phase counts an agent
// as "resolvable" if it exists anywhere the user can see it.
entity = agentMapper.selectOne(new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getName, agentName.trim())
.eq(AgentEntity::getEnabled, true));
}
return entity == null ? null : entity.getId();
}
}

View File

@ -0,0 +1,120 @@
package vip.mate.workflow.runtime;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import vip.mate.workflow.model.WorkflowPayloadEntity;
import vip.mate.workflow.repository.WorkflowPayloadMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Objects;
import java.util.UUID;
/**
* Write-through facade over {@code mate_workflow_payload}. v0 stores every
* payload inline (storage_kind = "inline"); the table schema reserves room for
* a fs / s3 / oss spill-over flavour but no caller wires that yet. Callers
* receive a stable URI of the form {@code mwf://{workspaceId}/{uuid}} and
* resolve it back via {@link #readString(String)} or {@link #readBytes(String)}.
*/
@Service
public class PayloadStore {
private static final String SCHEME = "mwf://";
private static final String STORAGE_KIND_INLINE = "inline";
private final WorkflowPayloadMapper payloadMapper;
private final ObjectMapper objectMapper;
public PayloadStore(WorkflowPayloadMapper payloadMapper, ObjectMapper objectMapper) {
this.payloadMapper = payloadMapper;
this.objectMapper = objectMapper;
}
/** Store a UTF-8 string payload and return its stable URI. */
public String storeString(long workspaceId, String body, String contentType) {
byte[] bytes = (body == null ? "" : body).getBytes(StandardCharsets.UTF_8);
return storeBytes(workspaceId, bytes, contentType == null ? "text/plain" : contentType);
}
/** JSON-encode {@code value} and store it. {@code contentType} is fixed to {@code application/json}. */
public String storeJson(long workspaceId, Object value) {
try {
byte[] bytes = objectMapper.writeValueAsBytes(value);
return storeBytes(workspaceId, bytes, "application/json");
} catch (JsonProcessingException e) {
throw new PayloadStoreException("failed to serialize payload as JSON: " + e.getMessage(), e);
}
}
/** Store raw bytes and return the URI. */
public String storeBytes(long workspaceId, byte[] bytes, String contentType) {
Objects.requireNonNull(bytes, "bytes");
String uri = SCHEME + workspaceId + "/" + UUID.randomUUID();
WorkflowPayloadEntity row = new WorkflowPayloadEntity();
row.setPayloadUri(uri);
row.setWorkspaceId(workspaceId);
row.setContentBytes(bytes);
row.setStorageKind(STORAGE_KIND_INLINE);
row.setContentType(contentType);
row.setSha256(sha256Hex(bytes));
row.setSizeBytes((long) bytes.length);
row.setCreatedAt(LocalDateTime.now());
payloadMapper.insert(row);
return uri;
}
/** Resolve a payload URI to its raw bytes; throws when the URI is unknown. */
public byte[] readBytes(String payloadUri) {
WorkflowPayloadEntity row = lookup(payloadUri);
return row.getContentBytes() == null ? new byte[0] : row.getContentBytes();
}
/** Resolve a payload URI to its UTF-8 decoded string body. */
public String readString(String payloadUri) {
return new String(readBytes(payloadUri), StandardCharsets.UTF_8);
}
/** Resolve a payload URI to its JSON body parsed back into the requested shape. */
public <T> T readJson(String payloadUri, Class<T> type) {
try {
return objectMapper.readValue(readBytes(payloadUri), type);
} catch (Exception e) {
throw new PayloadStoreException(
"failed to deserialize payload " + payloadUri + " as " + type.getSimpleName()
+ ": " + e.getMessage(),
e);
}
}
private WorkflowPayloadEntity lookup(String payloadUri) {
WorkflowPayloadEntity row = payloadMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<WorkflowPayloadEntity>()
.eq(WorkflowPayloadEntity::getPayloadUri, payloadUri));
if (row == null) {
throw new PayloadStoreException("payload not found: " + payloadUri);
}
return row;
}
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(bytes));
} catch (NoSuchAlgorithmException e) {
// SHA-256 is part of the JCA standard set should never happen.
throw new IllegalStateException("SHA-256 not available", e);
}
}
/** Wrapper exception for payload-store failures. */
public static class PayloadStoreException extends RuntimeException {
public PayloadStoreException(String message) { super(message); }
public PayloadStoreException(String message, Throwable cause) { super(message, cause); }
}
}

View File

@ -0,0 +1,27 @@
package vip.mate.workflow.runtime;
import vip.mate.workflow.compiler.ir.WorkflowStep;
/**
* Strategy interface for executing a single workflow step. One implementation
* per {@code StepMode.typeName()}; the runner looks up the adapter by name and
* calls {@link #execute}. Adapters MUST NOT mutate {@link WorkflowRunContext}
* directly the runner publishes the {@link StepResult} into the context so
* fan_out groups can merge in deterministic order.
*/
public interface StepAdapter {
/**
* The mode name this adapter handles must match
* {@code StepMode.typeName()} (sequential / fan_out / collect / conditional /
* await_approval / dispatch_channel / write_memory).
*/
String typeName();
/**
* Execute one step. Implementations should never throw to signal a normal
* step failure return {@link StepResult#failed(String)} instead. Throwing
* is reserved for programmer / framework errors that should abort the run.
*/
StepResult execute(WorkflowStep step, WorkflowRunContext context);
}

View File

@ -0,0 +1,39 @@
package vip.mate.workflow.runtime;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Registry mapping mode {@code typeName} to its {@link StepAdapter} bean.
* Spring autowires every adapter on the classpath; the runner asks the
* registry which adapter to use and the registry rejects unknown modes
* up-front so a wiring bug surfaces at the run boundary instead of inside
* the executor loop.
*/
@Component
public class StepAdapterRegistry {
private final Map<String, StepAdapter> adapters;
public StepAdapterRegistry(List<StepAdapter> adapters) {
Map<String, StepAdapter> mapped = adapters.stream()
.collect(Collectors.toUnmodifiableMap(StepAdapter::typeName, Function.identity()));
this.adapters = mapped;
}
public StepAdapter get(String typeName) {
StepAdapter adapter = adapters.get(typeName);
if (adapter == null) {
throw new IllegalStateException("no step adapter registered for mode: " + typeName);
}
return adapter;
}
public boolean has(String typeName) {
return adapters.containsKey(typeName);
}
}

View File

@ -0,0 +1,43 @@
package vip.mate.workflow.runtime;
/**
* Outcome reported by a {@link StepAdapter#execute}. Records:
* <ul>
* <li>{@link State} succeeded / skipped / failed; the runner translates
* these to {@code mate_workflow_run_step.state}.</li>
* <li>{@code outputPayloadUri} payload URI for the step's output, or
* {@code null} when the step produced nothing (e.g. skipped, collect).</li>
* <li>{@code outputContentType} resolved content type, defaults to
* {@code text}; lets the runner persist {@code output_content_type}
* without rebuilding the step contract.</li>
* <li>{@code outputValue} the in-memory value to publish into the
* run context's {@code outputs} map. {@link String} for text content,
* {@link java.util.Map} / {@link java.util.List} for json content.
* {@code null} when the step has no {@code outputVar}.</li>
* <li>{@code outputSummary} / {@code errorMessage} short labels for the
* step row; both optional.</li>
* </ul>
*/
public record StepResult(
State state,
String outputPayloadUri,
String outputContentType,
Object outputValue,
String outputSummary,
String errorMessage
) {
public enum State { SUCCEEDED, SKIPPED, FAILED }
public static StepResult succeeded(String payloadUri, String contentType, Object value, String summary) {
return new StepResult(State.SUCCEEDED, payloadUri, contentType, value, summary, null);
}
public static StepResult skipped(String reason) {
return new StepResult(State.SKIPPED, null, null, null, reason, null);
}
public static StepResult failed(String errorMessage) {
return new StepResult(State.FAILED, null, null, null, null, errorMessage);
}
}

View File

@ -0,0 +1,64 @@
package vip.mate.workflow.runtime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Mutable run-scoped state shared across step adapters. Holds the per-run
* identity ({@code runId}, {@code workspaceId}), the resolved input bag, and
* the rolling outputs map keyed by {@code outputVar}. Adapters mutate this
* after each successful step so subsequent expressions / templates see the
* latest value via {@link #templateContext()}.
*
* <p>Not thread-safe by itself the runner ensures a single writer at a time.
* For the fan_out group, adapters write to a temporary local map and the
* runner merges results back into the shared context once the group completes.
*/
public class WorkflowRunContext {
private final long runId;
private final long workspaceId;
private final long workflowId;
private final long revisionId;
private final Map<String, Object> inputs;
private final Map<String, Object> outputs = new LinkedHashMap<>();
public WorkflowRunContext(long runId, long workspaceId, long workflowId, long revisionId,
Map<String, Object> inputs) {
this.runId = runId;
this.workspaceId = workspaceId;
this.workflowId = workflowId;
this.revisionId = revisionId;
this.inputs = inputs == null ? Map.of() : Map.copyOf(inputs);
}
public long runId() { return runId; }
public long workspaceId() { return workspaceId; }
public long workflowId() { return workflowId; }
public long revisionId() { return revisionId; }
public Map<String, Object> inputs() { return inputs; }
/** Mutable outputs map. Use {@link #putOutput} for writes. */
public synchronized Map<String, Object> outputs() {
return new LinkedHashMap<>(outputs);
}
public synchronized void putOutput(String name, Object value) {
if (name == null || name.isBlank()) return;
outputs.put(name, value);
}
/**
* Snapshot map shaped as {@code {"inputs": {...}, "outputs": {...}}}
* the contract every workflow expression / template assumes. The map is a
* defensive copy so concurrent fan_out branches can render templates
* against a stable view while another branch's success completes.
*/
public synchronized Map<String, Object> templateContext() {
Map<String, Object> ctx = new LinkedHashMap<>();
ctx.put("inputs", inputs);
ctx.put("outputs", new LinkedHashMap<>(outputs));
return ctx;
}
}

View File

@ -0,0 +1,22 @@
package vip.mate.workflow.runtime;
import java.util.Map;
/**
* Inputs the runner needs to start a single workflow run. Identity fields
* ({@code workflowId}, {@code revisionId}, {@code workspaceId}) tie the run
* row back to the published revision the runner walks. {@code triggeredBy}
* is a free-form label written into {@code mate_workflow_run.triggered_by}
* the runner doesn't interpret it.
*/
public record WorkflowRunRequest(
long workflowId,
long revisionId,
long workspaceId,
String triggeredBy,
Map<String, Object> inputs
) {
public WorkflowRunRequest {
inputs = inputs == null ? Map.of() : Map.copyOf(inputs);
}
}

View File

@ -0,0 +1,16 @@
package vip.mate.workflow.runtime;
/**
* Public outcome of a workflow run. {@code state} mirrors the row state
* machine ({@code succeeded} / {@code failed}); {@code finalOutputUri} is
* the payload URI of the last non-skipped step's output, or {@code null}
* when no step produced output. {@code errorMessage} is populated when the
* run aborted; {@code null} on success.
*/
public record WorkflowRunResult(
long runId,
String state,
String finalOutputUri,
String errorMessage
) {
}

View File

@ -0,0 +1,261 @@
package vip.mate.workflow.runtime;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.workflow.compiler.ir.StepMode;
import vip.mate.workflow.compiler.ir.WorkflowGraph;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import vip.mate.workflow.model.WorkflowRunEntity;
import vip.mate.workflow.model.WorkflowRunStepEntity;
import vip.mate.workflow.repository.WorkflowRunMapper;
import vip.mate.workflow.repository.WorkflowRunStepMapper;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
/**
* Linear executor for v0 workflows. Walks the graph step-by-step, batching
* adjacent {@code fan_out} steps + terminating {@code collect} into a single
* parallel group. The first failed (non-skipped) step aborts the run and
* marks the row {@code failed}. The last non-skipped step's output payload
* is recorded as {@code final_output_ref} on success.
*
* <p>StateGraph is intentionally not used here the four base modes
* (sequential / fan_out / collect / conditional) are linear plus one bounded
* parallel section, which a small executor handles more directly. When
* {@code await_approval} lands the runtime will switch to a graph-backed
* scheduler so pause / resume can survive a JVM restart.
*/
@Slf4j
@Service
public class WorkflowRunner {
private static final String STATE_RUNNING = "running";
private static final String STATE_SUCCEEDED = "succeeded";
private static final String STATE_FAILED = "failed";
private static final String STATE_SKIPPED = "skipped";
private static final ExecutorService FAN_OUT_EXECUTOR =
Executors.newVirtualThreadPerTaskExecutor();
private final WorkflowRunMapper runMapper;
private final WorkflowRunStepMapper stepMapper;
private final StepAdapterRegistry adapters;
private final PayloadStore payloadStore;
public WorkflowRunner(WorkflowRunMapper runMapper,
WorkflowRunStepMapper stepMapper,
StepAdapterRegistry adapters,
PayloadStore payloadStore) {
this.runMapper = runMapper;
this.stepMapper = stepMapper;
this.adapters = adapters;
this.payloadStore = payloadStore;
}
public WorkflowRunResult run(WorkflowGraph graph, WorkflowRunRequest request) {
WorkflowRunEntity runRow = openRun(request);
long runId = runRow.getId();
String inputsRef = payloadStore.storeJson(request.workspaceId(), request.inputs());
runRow.setInitialInputRef(inputsRef);
runMapper.updateById(runRow);
WorkflowRunContext ctx = new WorkflowRunContext(
runId,
request.workspaceId(),
request.workflowId(),
request.revisionId(),
request.inputs());
String lastSucceededOutputRef = null;
try {
int i = 0;
while (i < graph.steps().size()) {
WorkflowStep step = graph.steps().get(i);
int groupEnd = scanFanOutGroup(graph.steps(), i);
if (groupEnd > i) {
GroupOutcome out = executeFanOutGroup(graph.steps(), i, groupEnd, ctx);
if (out.failed) {
return finishFailed(runRow, out.errorMessage);
}
if (out.lastOutputRef != null) lastSucceededOutputRef = out.lastOutputRef;
i = groupEnd + 1;
} else {
StepResult result = executeStep(step, i, /*iterationIndex*/ null, ctx);
if (result.state() == StepResult.State.FAILED) {
return finishFailed(runRow, result.errorMessage());
}
if (result.outputPayloadUri() != null) {
lastSucceededOutputRef = result.outputPayloadUri();
}
i++;
}
}
return finishSucceeded(runRow, lastSucceededOutputRef);
} catch (RuntimeException e) {
log.error("Workflow run {} aborted by unexpected exception", runId, e);
return finishFailed(runRow, "runtime error: " + e.getMessage());
}
}
/**
* Result of executing a contiguous {@code fan_out ... collect} block:
* either every branch succeeded (or skipped) and the merged outputs are
* already in the run context, or one branch failed and the runner aborts.
*/
private record GroupOutcome(boolean failed, String errorMessage, String lastOutputRef) {}
/**
* If {@code steps[start]} is the head of a fan_out group ( 2 consecutive
* fan_out followed by exactly one collect the schema validator already
* enforced this), return the index of the terminating collect. Otherwise
* return {@code start} so the caller treats it as a single-step.
*/
private static int scanFanOutGroup(List<WorkflowStep> steps, int start) {
if (!(steps.get(start).mode() instanceof StepMode.FanOut)) return start;
int j = start;
while (j < steps.size() && steps.get(j).mode() instanceof StepMode.FanOut) j++;
if (j < steps.size() && steps.get(j).mode() instanceof StepMode.Collect) {
return j;
}
return start;
}
private GroupOutcome executeFanOutGroup(List<WorkflowStep> steps, int from, int collectIdx,
WorkflowRunContext ctx) {
// Steps from..collectIdx-1 are fan_out branches; collectIdx is the join.
record Branch(int stepIndex, WorkflowStep step, Future<StepResult> future) {}
List<Branch> branches = new ArrayList<>();
for (int i = from; i < collectIdx; i++) {
int idx = i;
WorkflowStep step = steps.get(i);
Future<StepResult> future = FAN_OUT_EXECUTOR.submit(
() -> executeStep(step, idx, idx - from, ctx));
branches.add(new Branch(idx, step, future));
}
String lastOutputRef = null;
for (Branch branch : branches) {
try {
StepResult result = branch.future.get(resolveTimeoutSecs(branch.step), TimeUnit.SECONDS);
if (result.state() == StepResult.State.FAILED) {
return new GroupOutcome(true,
"fan_out branch '" + branch.step.name() + "' failed: " + result.errorMessage(),
null);
}
if (result.outputPayloadUri() != null) lastOutputRef = result.outputPayloadUri();
} catch (Exception e) {
return new GroupOutcome(true,
"fan_out branch '" + branch.step.name() + "' threw: " + e.getMessage(),
null);
}
}
// Run the collect adapter so the join is captured as its own row.
StepResult collectResult = executeStep(steps.get(collectIdx), collectIdx, null, ctx);
if (collectResult.state() == StepResult.State.FAILED) {
return new GroupOutcome(true, collectResult.errorMessage(), null);
}
return new GroupOutcome(false, null, lastOutputRef);
}
private static long resolveTimeoutSecs(WorkflowStep step) {
if (step.timeoutSecs() == null || step.timeoutSecs() <= 0) return 600L;
return step.timeoutSecs();
}
private StepResult executeStep(WorkflowStep step, int stepIndex, Integer iterationIndex,
WorkflowRunContext ctx) {
StepAdapter adapter = adapters.get(step.mode().typeName());
WorkflowRunStepEntity stepRow = openStep(ctx.runId(), stepIndex, iterationIndex, step);
long startNanos = System.nanoTime();
StepResult result;
try {
result = adapter.execute(step, ctx);
} catch (RuntimeException e) {
log.error("Adapter {} threw on run={} stepIndex={} step='{}'",
step.mode().typeName(), ctx.runId(), stepIndex, step.name(), e);
result = StepResult.failed("adapter threw: " + e.getMessage());
}
long elapsedMs = Duration.ofNanos(System.nanoTime() - startNanos).toMillis();
// ctx.putOutput is synchronised internally so concurrent fan_out
// branches can commit their results back to the shared run context
// without external locking.
if (result.state() == StepResult.State.SUCCEEDED && step.outputVar() != null
&& !step.outputVar().isBlank() && result.outputValue() != null) {
ctx.putOutput(step.outputVar(), result.outputValue());
}
closeStep(stepRow, result, elapsedMs);
return result;
}
private WorkflowRunEntity openRun(WorkflowRunRequest request) {
WorkflowRunEntity row = new WorkflowRunEntity();
row.setWorkflowId(request.workflowId());
row.setRevisionId(request.revisionId());
row.setWorkspaceId(request.workspaceId());
row.setState(STATE_RUNNING);
row.setTriggeredBy(request.triggeredBy());
row.setStartedAt(LocalDateTime.now());
runMapper.insert(row);
return row;
}
private WorkflowRunResult finishSucceeded(WorkflowRunEntity runRow, String finalOutputRef) {
runRow.setState(STATE_SUCCEEDED);
runRow.setFinalOutputRef(finalOutputRef);
runRow.setCompletedAt(LocalDateTime.now());
runMapper.updateById(runRow);
return new WorkflowRunResult(runRow.getId(), STATE_SUCCEEDED, finalOutputRef, null);
}
private WorkflowRunResult finishFailed(WorkflowRunEntity runRow, String errorMessage) {
runRow.setState(STATE_FAILED);
runRow.setErrorMessage(errorMessage);
runRow.setCompletedAt(LocalDateTime.now());
runMapper.updateById(runRow);
return new WorkflowRunResult(runRow.getId(), STATE_FAILED, null, errorMessage);
}
private WorkflowRunStepEntity openStep(long runId, int stepIndex, Integer iterationIndex,
WorkflowStep step) {
WorkflowRunStepEntity row = new WorkflowRunStepEntity();
row.setRunId(runId);
row.setStepIndex(stepIndex);
row.setIterationIndex(iterationIndex);
row.setStepName(step.name());
row.setAgentId(step.agentId());
row.setState(STATE_RUNNING);
row.setOutputContentType(step.effectiveOutputContentType());
row.setStartedAt(LocalDateTime.now());
stepMapper.insert(row);
return row;
}
private void closeStep(WorkflowRunStepEntity row, StepResult result, long durationMs) {
switch (result.state()) {
case SUCCEEDED -> row.setState(STATE_SUCCEEDED);
case SKIPPED -> row.setState(STATE_SKIPPED);
case FAILED -> row.setState(STATE_FAILED);
}
row.setOutputRef(result.outputPayloadUri());
if (result.outputContentType() != null) {
row.setOutputContentType(result.outputContentType());
}
row.setOutputSummary(result.outputSummary());
row.setErrorMessage(result.errorMessage());
row.setDurationMs(durationMs);
row.setCompletedAt(LocalDateTime.now());
stepMapper.updateById(row);
}
}

View File

@ -0,0 +1,26 @@
package vip.mate.workflow.runtime.mode;
import org.springframework.stereotype.Component;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import vip.mate.workflow.runtime.StepAdapter;
import vip.mate.workflow.runtime.StepResult;
import vip.mate.workflow.runtime.WorkflowRunContext;
/**
* {@code collect} barrier that closes the most recent fan_out group. The
* runner awaits the parallel branches before invoking this adapter, then
* publishes their merged outputs into the run context. The adapter itself
* does no agent work; it simply records a step row so the run history shows
* where the group joined and produces no payload of its own.
*/
@Component
public class CollectStepAdapter implements StepAdapter {
@Override
public String typeName() { return "collect"; }
@Override
public StepResult execute(WorkflowStep step, WorkflowRunContext context) {
return StepResult.succeeded(null, null, null, "fan_out group joined");
}
}

View File

@ -0,0 +1,55 @@
package vip.mate.workflow.runtime.mode;
import org.springframework.stereotype.Component;
import vip.mate.workflow.compiler.PebbleSubsetEvaluator;
import vip.mate.workflow.compiler.ir.StepMode;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import vip.mate.workflow.runtime.AgentStepExecutor;
import vip.mate.workflow.runtime.StepAdapter;
import vip.mate.workflow.runtime.StepResult;
import vip.mate.workflow.runtime.WorkflowRunContext;
/**
* {@code conditional} runs the embedded agent step only when the configured
* Pebble expression evaluates true against the current run context. A false
* verdict yields {@link StepResult.State#SKIPPED}; an evaluation error fails
* the step. Skipped steps still emit a run-step row so the history captures
* the routing decision.
*/
@Component
public class ConditionalStepAdapter implements StepAdapter {
private final PebbleSubsetEvaluator pebble;
private final AgentStepExecutor executor;
public ConditionalStepAdapter(PebbleSubsetEvaluator pebble, AgentStepExecutor executor) {
this.pebble = pebble;
this.executor = executor;
}
@Override
public String typeName() { return "conditional"; }
@Override
public StepResult execute(WorkflowStep step, WorkflowRunContext context) {
if (!(step.mode() instanceof StepMode.Conditional cond)) {
return StepResult.failed("conditional adapter received non-conditional mode: "
+ step.mode().typeName());
}
boolean truth;
try {
var compiled = pebble.parseExpression(cond.expression());
truth = pebble.evaluateAsBoolean(compiled, context.templateContext());
} catch (Exception e) {
return StepResult.failed("conditional expression evaluation failed for step '"
+ step.name() + "': " + e.getMessage());
}
if (!truth) {
return StepResult.skipped("guard expression evaluated false");
}
return executor.run(step, context);
}
}

View File

@ -0,0 +1,34 @@
package vip.mate.workflow.runtime.mode;
import org.springframework.stereotype.Component;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import vip.mate.workflow.runtime.AgentStepExecutor;
import vip.mate.workflow.runtime.StepAdapter;
import vip.mate.workflow.runtime.StepResult;
import vip.mate.workflow.runtime.WorkflowRunContext;
/**
* {@code fan_out} body of a parallel group. Each fan_out step runs against
* the run context snapshot that existed when the group started; the runner
* dispatches the whole group in parallel and merges {@code outputs} only when
* the terminating {@code collect} runs. From the adapter's perspective the
* step body is identical to a sequential agent call the parallelism is
* orchestrated upstream.
*/
@Component
public class FanOutStepAdapter implements StepAdapter {
private final AgentStepExecutor executor;
public FanOutStepAdapter(AgentStepExecutor executor) {
this.executor = executor;
}
@Override
public String typeName() { return "fan_out"; }
@Override
public StepResult execute(WorkflowStep step, WorkflowRunContext context) {
return executor.run(step, context);
}
}

View File

@ -0,0 +1,31 @@
package vip.mate.workflow.runtime.mode;
import org.springframework.stereotype.Component;
import vip.mate.workflow.compiler.ir.WorkflowStep;
import vip.mate.workflow.runtime.AgentStepExecutor;
import vip.mate.workflow.runtime.StepAdapter;
import vip.mate.workflow.runtime.StepResult;
import vip.mate.workflow.runtime.WorkflowRunContext;
/**
* {@code sequential} runs after the previous step finishes and threads its
* output forward via {@code outputs[outputVar]}. The default mode for any
* agent-call step that does not need parallel or guarded execution.
*/
@Component
public class SequentialStepAdapter implements StepAdapter {
private final AgentStepExecutor executor;
public SequentialStepAdapter(AgentStepExecutor executor) {
this.executor = executor;
}
@Override
public String typeName() { return "sequential"; }
@Override
public StepResult execute(WorkflowStep step, WorkflowRunContext context) {
return executor.run(step, context);
}
}