mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(workflow): add write_memory adapter with four merge strategies
This commit is contained in:
parent
7c3c68bcfe
commit
67279bb9c9
@ -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 + ")");
|
||||
}
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user