feat(workflow): add write_memory adapter with four merge strategies

This commit is contained in:
matevip 2026-05-08 15:04:33 +08:00
parent 7c3c68bcfe
commit 67279bb9c9
4 changed files with 295 additions and 0 deletions

View File

@ -0,0 +1,57 @@
package vip.mate.workflow.runtime;
import org.springframework.stereotype.Component;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.workspace.document.model.WorkspaceFileEntity;
/**
* Production binding for {@link MemoryWriter}. Resolves {@code employeeId}
* (a string in the wire format) to the agent id keying
* {@code mate_workspace_file}, applies the chosen merge strategy via
* {@link MergeStrategies}, then persists the result through
* {@link WorkspaceFileService#saveFile}.
*
* <p>v0 treats {@code employeeId} as the numeric agent id rendered as a
* string. Looking the agent up by name was considered but pushes name
* uniqueness into the runtime the schema validator already accepts only
* a string so the wire format does not change. When we add a "human
* employee" surface this binding will grow a separate code path.
*/
@Component
public class DefaultMemoryWriter implements MemoryWriter {
private final WorkspaceFileService fileService;
public DefaultMemoryWriter(WorkspaceFileService fileService) {
this.fileService = fileService;
}
@Override
public Result write(long workspaceId, String employeeId, String file,
String mergeStrategy, String content) {
Long agentId;
try {
agentId = Long.parseLong(employeeId);
} catch (NumberFormatException e) {
return Result.fail("employeeId '" + employeeId
+ "' is not a valid agent id (numeric string expected)");
}
WorkspaceFileEntity existing = fileService.getFile(agentId, file);
String existingBody = existing == null ? "" : (existing.getContent() == null ? "" : existing.getContent());
String merged;
try {
merged = MergeStrategies.apply(existingBody, content, mergeStrategy);
} catch (IllegalArgumentException e) {
return Result.fail(e.getMessage());
}
try {
fileService.saveFile(agentId, file, merged);
} catch (Exception e) {
return Result.fail("failed to persist memory file '" + file + "': " + e.getMessage());
}
return Result.ok(mergeStrategy + " merged " + content.length() + " chars into "
+ file + " (agent " + agentId + ")");
}
}

View File

@ -0,0 +1,24 @@
package vip.mate.workflow.runtime;
/**
* SPI for the {@code write_memory} step. Hides the workspace-file storage
* implementation behind a small surface so tests can stub the file side
* effect without booting WorkspaceFileService. Production binding lives in
* {@link DefaultMemoryWriter}.
*/
public interface MemoryWriter {
/**
* Apply {@code mergeStrategy} to {@code content} against the existing
* file body for {@code (workspaceId, employeeId, file)} and persist the
* result. Returns a {@link Result} carrying a short summary so the step
* row's {@code output_summary} captures what changed.
*/
Result write(long workspaceId, String employeeId, String file,
String mergeStrategy, String content);
record Result(boolean success, String summary, String errorMessage) {
public static Result ok(String summary) { return new Result(true, summary, null); }
public static Result fail(String error) { return new Result(false, null, error); }
}
}

View File

@ -0,0 +1,139 @@
package vip.mate.workflow.runtime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Pure helpers implementing the four v0 merge strategies the {@code write_memory}
* step supports. Stateless so the same logic backs the production
* {@link MemoryWriter} binding and any test fake.
*
* <ul>
* <li>{@code append} incoming content is concatenated to the existing
* body with a separating blank line. The simplest no-magic merge.</li>
* <li>{@code replace_section} the incoming body's first non-blank line is
* expected to be a Markdown {@code ## } heading; if a section with that
* heading already exists in the file, it is replaced (heading inclusive
* through the line before the next {@code ## } heading or EOF);
* otherwise the incoming body is appended with a blank-line separator.</li>
* <li>{@code upsert_kv} every line of the incoming body that matches
* {@code key: value} is treated as a key/value pair. Existing matching
* keys are updated in place; new keys are appended. Non-kv lines in the
* incoming body are dropped (they would otherwise re-introduce
* freeform text on every run).</li>
* <li>{@code overwrite} replace the file with the incoming body
* verbatim. The escape hatch when no other strategy fits.</li>
* </ul>
*/
public final class MergeStrategies {
/** Heading line for {@code replace_section}. */
private static final Pattern SECTION_HEADING = Pattern.compile(
"^##\\s+.+$", Pattern.MULTILINE);
/** {@code key: value} line for {@code upsert_kv} parsing. */
private static final Pattern KV_LINE = Pattern.compile(
"^([A-Za-z0-9_.\\-]+)\\s*:\\s*(.*)$");
private MergeStrategies() {}
public static String apply(String existing, String incoming, String strategy) {
String existingSafe = existing == null ? "" : existing;
String incomingSafe = incoming == null ? "" : incoming;
return switch (strategy) {
case "append" -> append(existingSafe, incomingSafe);
case "replace_section" -> replaceSection(existingSafe, incomingSafe);
case "upsert_kv" -> upsertKv(existingSafe, incomingSafe);
case "overwrite" -> incomingSafe;
default -> throw new IllegalArgumentException(
"unknown merge strategy '" + strategy
+ "' — must be append / replace_section / upsert_kv / overwrite");
};
}
private static String append(String existing, String incoming) {
if (existing.isEmpty()) return incoming;
if (incoming.isEmpty()) return existing;
String trimmed = existing.endsWith("\n") ? existing : existing + "\n";
return trimmed + "\n" + incoming;
}
private static String replaceSection(String existing, String incoming) {
String heading = firstHeading(incoming);
if (heading == null) {
// No heading on the incoming side fall back to append so the
// step never silently drops content.
return append(existing, incoming);
}
int existingStart = indexOfHeading(existing, heading);
if (existingStart < 0) {
return append(existing, incoming);
}
int existingEnd = indexOfNextHeading(existing, existingStart + heading.length());
if (existingEnd < 0) existingEnd = existing.length();
StringBuilder out = new StringBuilder();
out.append(existing, 0, existingStart);
out.append(incoming);
if (!incoming.endsWith("\n")) out.append('\n');
if (existingEnd < existing.length()) {
out.append(existing, existingEnd, existing.length());
}
return out.toString();
}
private static String firstHeading(String body) {
Matcher m = SECTION_HEADING.matcher(body);
return m.find() ? m.group().stripTrailing() : null;
}
private static int indexOfHeading(String body, String heading) {
// Match the heading at start of line (after any line break or at
// position 0) so we don't false-match an inline "## " inside a code
// block by accident.
Pattern p = Pattern.compile("(?m)^" + Pattern.quote(heading) + "\\s*$");
Matcher m = p.matcher(body);
return m.find() ? m.start() : -1;
}
private static int indexOfNextHeading(String body, int from) {
Matcher m = SECTION_HEADING.matcher(body);
if (m.find(from)) return m.start();
return -1;
}
private static String upsertKv(String existing, String incoming) {
Map<String, String> updates = new LinkedHashMap<>();
for (String line : incoming.split("\\R", -1)) {
Matcher m = KV_LINE.matcher(line.trim());
if (m.matches()) {
updates.put(m.group(1), m.group(2));
}
}
if (updates.isEmpty()) return existing;
StringBuilder out = new StringBuilder();
for (String line : existing.split("\\R", -1)) {
Matcher m = KV_LINE.matcher(line.trim());
if (m.matches() && updates.containsKey(m.group(1))) {
out.append(m.group(1)).append(": ").append(updates.remove(m.group(1)));
} else {
out.append(line);
}
out.append('\n');
}
// Trim trailing empty line we always added so a clean file stays clean.
if (out.length() > 0 && out.charAt(out.length() - 1) == '\n') {
out.setLength(out.length() - 1);
}
// Append any incoming keys that did not exist in the original file.
for (var e : updates.entrySet()) {
if (out.length() > 0 && out.charAt(out.length() - 1) != '\n') {
out.append('\n');
}
out.append(e.getKey()).append(": ").append(e.getValue());
}
return out.toString();
}
}

View File

@ -0,0 +1,75 @@
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.MemoryWriter;
import vip.mate.workflow.runtime.PayloadStore;
import vip.mate.workflow.runtime.StepAdapter;
import vip.mate.workflow.runtime.StepResult;
import vip.mate.workflow.runtime.WorkflowRunContext;
/**
* {@code write_memory} render the content template, then delegate to
* {@link MemoryWriter} to apply the configured merge strategy against the
* target memory file. The rendered content is also written through to
* {@code mate_workflow_payload} so the step row's {@code output_ref} points
* at the exact text that was merged in (independent of the file's final
* post-merge state, which downstream tooling may want to diff).
*/
@Component
public class WriteMemoryStepAdapter implements StepAdapter {
private final PebbleSubsetEvaluator pebble;
private final PayloadStore payloadStore;
private final MemoryWriter memoryWriter;
public WriteMemoryStepAdapter(PebbleSubsetEvaluator pebble,
PayloadStore payloadStore,
MemoryWriter memoryWriter) {
this.pebble = pebble;
this.payloadStore = payloadStore;
this.memoryWriter = memoryWriter;
}
@Override
public String typeName() { return "write_memory"; }
@Override
public StepResult execute(WorkflowStep step, WorkflowRunContext context) {
if (!(step.mode() instanceof StepMode.WriteMemory cfg)) {
return StepResult.failed("write_memory adapter received non-write_memory mode: "
+ step.mode().typeName());
}
String rendered;
try {
var compiled = pebble.parseTemplate(cfg.content());
rendered = pebble.evaluateAsString(compiled, context.templateContext());
} catch (Exception e) {
return StepResult.failed("write_memory content render failed for step '"
+ step.name() + "': " + e.getMessage());
}
// Resolve template-form employeeId now that the run context exists
// the publish-time ACL phase deliberately skipped checking templates.
String employeeId;
try {
var compiled = pebble.parseTemplate(cfg.employeeId());
employeeId = pebble.evaluateAsString(compiled, context.templateContext());
} catch (Exception e) {
return StepResult.failed("write_memory employeeId template failed for step '"
+ step.name() + "': " + e.getMessage());
}
MemoryWriter.Result result = memoryWriter.write(
context.workspaceId(), employeeId, cfg.file(), cfg.mergeStrategy(), rendered);
if (!result.success()) {
return StepResult.failed(result.errorMessage());
}
String payloadUri = payloadStore.storeString(context.workspaceId(), rendered, "text/markdown");
return StepResult.succeeded(payloadUri, "text", rendered, result.summary());
}
}