diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index bbee1d22..feae1b20 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -17,6 +17,7 @@ import vip.mate.skill.lessons.SkillLessonsService; import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.runtime.SkillDependencyChecker; +import vip.mate.skill.runtime.SkillPackageResolver; import vip.mate.skill.runtime.SkillCatalogSort; import vip.mate.skill.runtime.SkillCatalogSorter; import vip.mate.skill.service.SkillService; @@ -57,6 +58,7 @@ public class SkillController { private final SkillService skillService; private final SkillRuntimeService skillRuntimeService; + private final SkillPackageResolver skillPackageResolver; private final SkillWorkspaceManager workspaceManager; private final BundledSkillSyncer bundledSkillSyncer; private final SkillFileSyncer skillFileSyncer; @@ -445,6 +447,20 @@ public class SkillController { } SkillEntity skill = skillService.getSkill(id); verifyResourceWorkspace(skill, workspaceId); + // Read-time store reconciliation: the workspace SKILL.md may have + // been edited out-of-band (agent shell tools in a chat session) + // with no refresh in between. One file read + hash compare in the + // steady state; when the file side actually changed, the content + // is ingested and a single-skill rescan re-projects manifest + // columns and drops stale runtime caches — so the detail view + // always shows what the runtime executes. + try { + if (skillPackageResolver.reconcileEntityContent(skill)) { + skillRuntimeService.rescanSingle(skill); + } + } catch (Exception ignored) { + // A reconcile failure must not break the detail view. + } return R.ok(skill); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillContentReconciler.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillContentReconciler.java new file mode 100644 index 00000000..7a37a4a6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillContentReconciler.java @@ -0,0 +1,258 @@ +package vip.mate.skill.runtime; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; + +/** + * Reconciles a skill's SKILL.md between its two stores: the canonical + * {@code mate_skill.skill_content} column and the convention-workspace + * file cache ({@code {workspace-root}/{name}/SKILL.md}). + * + *
The database column is the single source of truth; the workspace file + * is a materialized cache kept for script execution and direct-file + * tooling. Because agents (via shell tools) and operators may still edit + * the file in place, reconciliation is a three-way sync anchored on a + * sidecar marker ({@value #SYNC_MARKER}) that records the SHA-256 of the + * content at the last successful sync: + * + *
All writes are idempotent and failure-tolerant: an IO or DB error
+ * logs a warning and leaves the marker untouched, so the next resolve
+ * pass retries the same reconciliation.
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SkillContentReconciler {
+
+ /** Sidecar file holding the SHA-256 of SKILL.md at the last sync. */
+ public static final String SYNC_MARKER = ".skillmd.sha256";
+
+ /** Backup name for a file-side edit that loses a two-sided conflict. */
+ static final String CONFLICT_BACKUP = "SKILL.md.bak";
+
+ private final SkillMapper skillMapper;
+
+ /** What the reconciliation pass did. */
+ public enum Action {
+ /** Both stores already held the same content. */
+ IN_SYNC,
+ /** DB was blank; the workspace file was ingested into the DB. */
+ BACKFILLED_TO_DB,
+ /** The workspace file changed; its content was written to the DB. */
+ INGESTED_TO_DB,
+ /** The DB changed; its content was written to the workspace file. */
+ MATERIALIZED_TO_FS,
+ /** Both changed; DB won and the file edit was backed up. */
+ CONFLICT_DB_WON,
+ /** A store write failed; stores may still diverge. Retried next pass. */
+ FAILED
+ }
+
+ /** Reconciled content (the value both stores now agree on) + what happened. */
+ public record Outcome(String content, Action action) {}
+
+ /**
+ * Reconcile {@code entity}'s skill_content with the SKILL.md inside
+ * {@code workspaceDir}. On an ingest/backfill the passed entity's
+ * in-memory {@code skillContent} is updated too, so downstream resolve
+ * stages and diff-based write-backs see the merged value.
+ */
+ public Outcome reconcile(SkillEntity entity, Path workspaceDir) {
+ String dbContent = entity.getSkillContent() == null ? "" : entity.getSkillContent();
+ Path skillMd = workspaceDir.resolve("SKILL.md");
+ String fsContent = readFileQuietly(skillMd);
+
+ String dbHash = sha256(dbContent);
+ String fsHash = sha256(fsContent);
+ Path marker = workspaceDir.resolve(SYNC_MARKER);
+ String syncedHash = readMarker(marker);
+
+ if (dbHash.equals(fsHash)) {
+ if (!dbHash.equals(syncedHash)) {
+ writeMarkerQuietly(marker, dbHash);
+ }
+ return new Outcome(dbContent, Action.IN_SYNC);
+ }
+
+ if (fsContent.isBlank()) {
+ // File missing or empty while the DB has content: always
+ // materialize. Blank file content is never treated as an edit —
+ // that guard blocks the same wipe-on-empty scenario the bundle
+ // apply path protects against.
+ return materialize(entity, skillMd, marker, dbContent, dbHash, Action.MATERIALIZED_TO_FS);
+ }
+
+ if (dbContent.isBlank()) {
+ return ingest(entity, marker, fsContent, fsHash, Action.BACKFILLED_TO_DB);
+ }
+
+ // Both sides non-blank and different — use the marker to decide
+ // which side moved since the last sync.
+ if (syncedHash == null || syncedHash.equals(dbHash)) {
+ // DB unchanged since last sync (or legacy pre-marker state,
+ // where the directory was the effective runtime source):
+ // the file edit is the newer fact — ingest it.
+ return ingest(entity, marker, fsContent, fsHash, Action.INGESTED_TO_DB);
+ }
+ if (syncedHash.equals(fsHash)) {
+ // File unchanged since last sync; the DB moved — materialize.
+ return materialize(entity, skillMd, marker, dbContent, dbHash, Action.MATERIALIZED_TO_FS);
+ }
+
+ // Both sides changed since the last sync. The DB is canonical, so
+ // it wins; keep the losing file edit next to the file for manual
+ // recovery instead of silently discarding it.
+ backupQuietly(skillMd, workspaceDir.resolve(CONFLICT_BACKUP));
+ log.warn("SKILL.md conflict for skill '{}': both DB and workspace file changed since last sync; "
+ + "DB content wins, file edit saved as {}", entity.getName(), CONFLICT_BACKUP);
+ return materialize(entity, skillMd, marker, dbContent, dbHash, Action.CONFLICT_DB_WON);
+ }
+
+ /**
+ * Mirror a file-authoritative skill's content into the DB column so
+ * DB-reading consumers (admin console, API) see what the runtime
+ * actually executes. Used for skills with an explicitly configured
+ * {@code skillDir}, where the user-managed directory — not the DB —
+ * is the source of truth and no marker/backfill dance applies.
+ */
+ public void mirrorToDb(SkillEntity entity, String fsContent) {
+ if (fsContent == null || fsContent.isBlank()) return;
+ String dbContent = entity.getSkillContent() == null ? "" : entity.getSkillContent();
+ if (fsContent.equals(dbContent)) return;
+ if (writeDb(entity, fsContent)) {
+ log.info("Mirrored directory SKILL.md into skill_content for skill '{}' (explicit skillDir)",
+ entity.getName());
+ }
+ }
+
+ private Outcome ingest(SkillEntity entity, Path marker, String fsContent, String fsHash, Action action) {
+ if (!writeDb(entity, fsContent)) {
+ return new Outcome(fsContent, Action.FAILED);
+ }
+ writeMarkerQuietly(marker, fsHash);
+ log.info("Ingested workspace SKILL.md into skill_content for skill '{}' ({})",
+ entity.getName(), action);
+ return new Outcome(fsContent, action);
+ }
+
+ private Outcome materialize(SkillEntity entity, Path skillMd, Path marker,
+ String dbContent, String dbHash, Action action) {
+ try {
+ Files.createDirectories(skillMd.getParent());
+ Files.writeString(skillMd, dbContent, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ log.warn("Failed to materialize SKILL.md for skill '{}' → {}: {}",
+ entity.getName(), skillMd, e.getMessage());
+ return new Outcome(dbContent, Action.FAILED);
+ }
+ writeMarkerQuietly(marker, dbHash);
+ log.info("Materialized skill_content to workspace SKILL.md for skill '{}' ({})",
+ entity.getName(), action);
+ return new Outcome(dbContent, action);
+ }
+
+ /**
+ * Column-whitelisted DB write. {@code SkillEntity} declares several
+ * {@code FieldStrategy.ALWAYS} columns, so a partial
+ * {@code updateById} would null them out — the update wrapper touches
+ * only {@code skill_content} and {@code update_time}.
+ */
+ private boolean writeDb(SkillEntity entity, String content) {
+ if (entity.getId() == null) return false;
+ try {
+ skillMapper.update(null, new LambdaUpdateWrapper Mutates the passed entity's {@code skillContent} when the file
+ * side wins.
+ *
+ * @return {@code true} when the DB side changed — callers should then
+ * trigger a single-skill rescan so caches and manifest
+ * projections catch up.
+ */
+ public boolean reconcileEntityContent(SkillEntity entity) {
+ if (entity == null || entity.getId() == null || entity.getName() == null) return false;
+
+ String configuredDir = extractSkillDirString(entity);
+ if (configuredDir != null) {
+ Path explicit = Paths.get(configuredDir);
+ if (Files.exists(explicit) && Files.isDirectory(explicit)) {
+ Path skillMd = explicit.resolve("SKILL.md");
+ if (!Files.exists(skillMd)) return false;
+ try {
+ String before = entity.getSkillContent();
+ contentReconciler.mirrorToDb(entity, Files.readString(skillMd));
+ return !Objects.equals(before, entity.getSkillContent());
+ } catch (Exception e) {
+ log.warn("Read-time mirror failed for skill '{}': {}", entity.getName(), e.getMessage());
+ return false;
+ }
+ }
+ }
+
+ Path convention = workspaceManager.resolveConventionPath(entity.getName());
+ if (!Files.exists(convention) || !Files.isDirectory(convention)) return false;
+ SkillContentReconciler.Outcome outcome = contentReconciler.reconcile(entity, convention);
+ return outcome.action() == SkillContentReconciler.Action.INGESTED_TO_DB
+ || outcome.action() == SkillContentReconciler.Action.BACKFILLED_TO_DB;
+ }
+
/**
* Write back the latest scan status / findings JSON / timestamp when
* they differ from what's already on the row. Keeps the DB in sync
@@ -308,20 +353,35 @@ public class SkillPackageResolver {
// ==================== 阶段 1:内容解析 ====================
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
- Path skillMd = skillDir.resolve("SKILL.md");
-
- String content = "";
- String description = entity.getDescription();
-
- if (Files.exists(skillMd)) {
- try {
- content = Files.readString(skillMd);
- SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
- if (!parsed.getDescription().isBlank()) {
- description = parsed.getDescription();
+ String content;
+ if ("convention".equals(source)) {
+ // Convention workspace: the DB column is canonical and the
+ // directory is a materialized cache. Reconcile both stores so
+ // runtime content and DB-reading consumers (admin console, API)
+ // can never diverge — file edits made by agents/shell are
+ // ingested into the DB, DB edits are materialized to the file.
+ content = contentReconciler.reconcile(entity, skillDir).content();
+ } else {
+ // Explicit skillDir: the user-managed directory is the source
+ // of truth. Never write into it; mirror its content into the
+ // DB column so the console shows what actually executes.
+ Path skillMd = skillDir.resolve("SKILL.md");
+ content = "";
+ if (Files.exists(skillMd)) {
+ try {
+ content = Files.readString(skillMd);
+ contentReconciler.mirrorToDb(entity, content);
+ } catch (Exception e) {
+ log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage());
}
- } catch (Exception e) {
- log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage());
+ }
+ }
+
+ String description = entity.getDescription();
+ if (!content.isBlank()) {
+ SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
+ if (!parsed.getDescription().isBlank()) {
+ description = parsed.getDescription();
}
}
diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java
index 32db9f24..8bbb28d5 100644
--- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerLifecycleTest.java
@@ -56,7 +56,7 @@ class SkillControllerLifecycleTest {
@BeforeEach
void setUp() {
controller = new SkillController(
- skillService, null, null, null, null, null, null, null, null, null,
+ skillService, null, null, null, null, null, null, null, null, null, null,
agentBindingService, null, null,
skillLifecycleService, skillCuratorJob, skillCuratorReportStore);
}
diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java
index b8dd7d92..5a107fa5 100644
--- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java
@@ -41,6 +41,7 @@ class SkillControllerListEnabledTest {
controller = new SkillController(
skillService,
/* skillRuntimeService */ null,
+ /* skillPackageResolver */ null,
/* workspaceManager */ null,
/* bundledSkillSyncer */ null,
/* skillFileSyncer */ null,
diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java
index d9463b0c..141df1af 100644
--- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java
@@ -26,7 +26,7 @@ class SkillControllerVirtualGuardTest {
private final SkillController controller = new SkillController(
null, null, null, null, null, null, null, null, null, null, null, null, null,
- null, null, null);
+ null, null, null, null);
@Test
@DisplayName("update on a virtual MCP skill id is rejected before hitting the service")
@@ -64,7 +64,7 @@ class SkillControllerVirtualGuardTest {
void toggleForwardsVirtualMcpToBridge() {
McpSkillBridge bridge = mock(McpSkillBridge.class);
SkillController c = new SkillController(
- null, null, null, null, null, null, null, null, null, null, null,
+ null, null, null, null, null, null, null, null, null, null, null, null,
bridge, null, null, null, null);
long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
SkillEntity toggled = new SkillEntity();
@@ -98,7 +98,7 @@ class SkillControllerVirtualGuardTest {
SkillController real = new SkillController(
mock(vip.mate.skill.service.SkillService.class),
null, null, null, null, null, null, null, null, null, null, null, null,
- null, null, null);
+ null, null, null, null);
long snowflakeId = 1_900_000_001_000_000_902L;
// updateSkill on a mocked SkillService returns null without throwing,
// which is fine — we just need to confirm the guard didn't fire.
diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillContentReconcilerTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillContentReconcilerTest.java
new file mode 100644
index 00000000..18893638
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillContentReconcilerTest.java
@@ -0,0 +1,235 @@
+package vip.mate.skill.runtime;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+import vip.mate.skill.model.SkillEntity;
+import vip.mate.skill.repository.SkillMapper;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Three-way SKILL.md reconciliation between the canonical DB column and
+ * the convention-workspace file cache.
+ */
+class SkillContentReconcilerTest {
+
+ @TempDir
+ Path workspaceDir;
+
+ private SkillMapper skillMapper;
+ private SkillContentReconciler reconciler;
+
+ @BeforeAll
+ static void initTableInfo() {
+ // Lambda wrappers resolve column names from MyBatis-Plus's static
+ // TableInfo cache; in a Spring context this happens during mapper
+ // scan, in a plain unit test we trigger it manually.
+ TableInfoHelper.initTableInfo(
+ new MapperBuilderAssistant(new Configuration(), ""),
+ SkillEntity.class);
+ }
+
+ @BeforeEach
+ void setUp() {
+ skillMapper = mock(SkillMapper.class);
+ reconciler = new SkillContentReconciler(skillMapper);
+ }
+
+ private SkillEntity entity(String dbContent) {
+ SkillEntity e = new SkillEntity();
+ e.setId(1L);
+ e.setName("demo-skill");
+ e.setSkillContent(dbContent);
+ return e;
+ }
+
+ private void writeFile(String content) throws IOException {
+ Files.writeString(workspaceDir.resolve("SKILL.md"), content);
+ }
+
+ private void writeMarker(String ofContent) throws IOException {
+ Files.writeString(workspaceDir.resolve(SkillContentReconciler.SYNC_MARKER),
+ SkillContentReconciler.sha256(ofContent));
+ }
+
+ private String fileContent() throws IOException {
+ return Files.readString(workspaceDir.resolve("SKILL.md"));
+ }
+
+ private String markerContent() throws IOException {
+ return Files.readString(workspaceDir.resolve(SkillContentReconciler.SYNC_MARKER)).strip();
+ }
+
+ // ==================== in sync ====================
+
+ @Test
+ void inSyncHealsMissingMarker() throws IOException {
+ writeFile("same");
+ var outcome = reconciler.reconcile(entity("same"), workspaceDir);
+
+ assertThat(outcome.action()).isEqualTo(SkillContentReconciler.Action.IN_SYNC);
+ assertThat(outcome.content()).isEqualTo("same");
+ assertThat(markerContent()).isEqualTo(SkillContentReconciler.sha256("same"));
+ verify(skillMapper, never()).update(isNull(), any(Wrapper.class));
+ }
+
+ // ==================== blank-side guards ====================
+
+ @Test
+ void missingFileMaterializesFromDb() throws IOException {
+ var outcome = reconciler.reconcile(entity("db content"), workspaceDir);
+
+ assertThat(outcome.action()).isEqualTo(SkillContentReconciler.Action.MATERIALIZED_TO_FS);
+ assertThat(fileContent()).isEqualTo("db content");
+ assertThat(markerContent()).isEqualTo(SkillContentReconciler.sha256("db content"));
+ verify(skillMapper, never()).update(isNull(), any(Wrapper.class));
+ }
+
+ @Test
+ void blankFileNeverIngestedOverDbContent() throws IOException {
+ // A truncated/emptied file must not wipe the canonical content,
+ // even when the marker says the DB side is unchanged.
+ writeFile("");
+ writeMarker("db content");
+
+ var outcome = reconciler.reconcile(entity("db content"), workspaceDir);
+
+ assertThat(outcome.action()).isEqualTo(SkillContentReconciler.Action.MATERIALIZED_TO_FS);
+ assertThat(fileContent()).isEqualTo("db content");
+ verify(skillMapper, never()).update(isNull(), any(Wrapper.class));
+ }
+
+ @Test
+ void blankDbBackfillsFromFile() throws IOException {
+ writeFile("file content");
+ SkillEntity e = entity(null);
+
+ var outcome = reconciler.reconcile(e, workspaceDir);
+
+ assertThat(outcome.action()).isEqualTo(SkillContentReconciler.Action.BACKFILLED_TO_DB);
+ assertThat(outcome.content()).isEqualTo("file content");
+ assertThat(e.getSkillContent()).isEqualTo("file content");
+ assertThat(markerContent()).isEqualTo(SkillContentReconciler.sha256("file content"));
+ verify(skillMapper).update(isNull(), any(Wrapper.class));
+ }
+
+ // ==================== single-side changes ====================
+
+ @Test
+ void fileEditSinceLastSyncIsIngestedToDb() throws IOException {
+ // Marker == DB hash → the DB did not move; the file edit wins.
+ writeFile("edited via shell");
+ writeMarker("db content");
+ SkillEntity e = entity("db content");
+
+ var outcome = reconciler.reconcile(e, workspaceDir);
+
+ assertThat(outcome.action()).isEqualTo(SkillContentReconciler.Action.INGESTED_TO_DB);
+ assertThat(outcome.content()).isEqualTo("edited via shell");
+ assertThat(e.getSkillContent()).isEqualTo("edited via shell");
+ assertThat(markerContent()).isEqualTo(SkillContentReconciler.sha256("edited via shell"));
+ verify(skillMapper).update(isNull(), any(Wrapper.class));
+ }
+
+ @Test
+ void dbEditSinceLastSyncIsMaterializedToFile() throws IOException {
+ // Marker == file hash → the file did not move; the DB edit wins.
+ writeFile("old content");
+ writeMarker("old content");
+
+ var outcome = reconciler.reconcile(entity("new db content"), workspaceDir);
+
+ assertThat(outcome.action()).isEqualTo(SkillContentReconciler.Action.MATERIALIZED_TO_FS);
+ assertThat(fileContent()).isEqualTo("new db content");
+ assertThat(markerContent()).isEqualTo(SkillContentReconciler.sha256("new db content"));
+ verify(skillMapper, never()).update(isNull(), any(Wrapper.class));
+ }
+
+ // ==================== legacy + conflict ====================
+
+ @Test
+ void legacyDivergenceWithoutMarkerLetsFileWinOnce() throws IOException {
+ // Pre-marker installs resolved runtime content from the directory,
+ // so on first reconcile the file reflects what was in effect.
+ writeFile("file version");
+ SkillEntity e = entity("db version");
+
+ var outcome = reconciler.reconcile(e, workspaceDir);
+
+ assertThat(outcome.action()).isEqualTo(SkillContentReconciler.Action.INGESTED_TO_DB);
+ assertThat(e.getSkillContent()).isEqualTo("file version");
+ }
+
+ @Test
+ void twoSidedConflictDbWinsAndFileIsBackedUp() throws IOException {
+ writeFile("file edit");
+ writeMarker("common ancestor");
+
+ var outcome = reconciler.reconcile(entity("db edit"), workspaceDir);
+
+ assertThat(outcome.action()).isEqualTo(SkillContentReconciler.Action.CONFLICT_DB_WON);
+ assertThat(fileContent()).isEqualTo("db edit");
+ assertThat(Files.readString(workspaceDir.resolve(SkillContentReconciler.CONFLICT_BACKUP)))
+ .isEqualTo("file edit");
+ assertThat(markerContent()).isEqualTo(SkillContentReconciler.sha256("db edit"));
+ verify(skillMapper, never()).update(isNull(), any(Wrapper.class));
+ }
+
+ // ==================== ingest write shape ====================
+
+ @Test
+ void ingestWritesViaColumnWhitelistedWrapper() throws IOException {
+ writeFile("edited via shell");
+ writeMarker("db content");
+
+ reconciler.reconcile(entity("db content"), workspaceDir);
+
+ // The write must go through an update wrapper (column whitelist),
+ // not updateById — SkillEntity carries FieldStrategy.ALWAYS columns
+ // that a partial updateById would null out.
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor