From 67279bb9c904b5e573147ea7d08faa75fdc1c390 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 8 May 2026 15:04:33 +0800 Subject: [PATCH] feat(workflow): add write_memory adapter with four merge strategies --- .../workflow/runtime/DefaultMemoryWriter.java | 57 +++++++ .../mate/workflow/runtime/MemoryWriter.java | 24 +++ .../workflow/runtime/MergeStrategies.java | 139 ++++++++++++++++++ .../runtime/mode/WriteMemoryStepAdapter.java | 75 ++++++++++ 4 files changed, 295 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/runtime/DefaultMemoryWriter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/runtime/MemoryWriter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/runtime/MergeStrategies.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/WriteMemoryStepAdapter.java diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/DefaultMemoryWriter.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/DefaultMemoryWriter.java new file mode 100644 index 00000000..18c7def1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/DefaultMemoryWriter.java @@ -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}. + * + *

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 + ")"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MemoryWriter.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MemoryWriter.java new file mode 100644 index 00000000..b1266ee9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MemoryWriter.java @@ -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); } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MergeStrategies.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MergeStrategies.java new file mode 100644 index 00000000..cc8d136b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/MergeStrategies.java @@ -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. + * + *

+ */ +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 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(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/WriteMemoryStepAdapter.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/WriteMemoryStepAdapter.java new file mode 100644 index 00000000..30a6860c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/mode/WriteMemoryStepAdapter.java @@ -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()); + } +}