feat(skill): single-source SKILL.md — DB canonical, workspace file as tracked cache

The runtime resolved SKILL.md from the workspace directory while the
admin console read the skill_content column, so out-of-band file edits
(agent shell tools in a chat session) changed runtime behavior but never
showed up in the console, and a failed workspace export left agents
executing stale content the console claimed was current.

- SkillContentReconciler: three-way sync between the canonical DB column
  and the convention-workspace file, anchored on a sidecar hash marker.
  File-side edits ingest into the DB, DB-side edits materialize to the
  file, two-sided conflicts resolve DB-wins with a backup.
- Skill detail GET performs a read-time reconcile and triggers a
  single-skill rescan when the file side changed, so a console query is
  always current without waiting for the runtime cache TTL.
- SkillMarket detail drawer refetches the row and runtime status on open
  instead of rendering the page-load list snapshot.
This commit is contained in:
matevip 2026-07-22 14:20:27 +08:00
parent 8a55bdd367
commit 5e188cd77b
8 changed files with 616 additions and 17 deletions

View File

@ -17,6 +17,7 @@ import vip.mate.skill.lessons.SkillLessonsService;
import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.model.SkillEntity; import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.SkillDependencyChecker; import vip.mate.skill.runtime.SkillDependencyChecker;
import vip.mate.skill.runtime.SkillPackageResolver;
import vip.mate.skill.runtime.SkillCatalogSort; import vip.mate.skill.runtime.SkillCatalogSort;
import vip.mate.skill.runtime.SkillCatalogSorter; import vip.mate.skill.runtime.SkillCatalogSorter;
import vip.mate.skill.service.SkillService; import vip.mate.skill.service.SkillService;
@ -57,6 +58,7 @@ public class SkillController {
private final SkillService skillService; private final SkillService skillService;
private final SkillRuntimeService skillRuntimeService; private final SkillRuntimeService skillRuntimeService;
private final SkillPackageResolver skillPackageResolver;
private final SkillWorkspaceManager workspaceManager; private final SkillWorkspaceManager workspaceManager;
private final BundledSkillSyncer bundledSkillSyncer; private final BundledSkillSyncer bundledSkillSyncer;
private final SkillFileSyncer skillFileSyncer; private final SkillFileSyncer skillFileSyncer;
@ -445,6 +447,20 @@ public class SkillController {
} }
SkillEntity skill = skillService.getSkill(id); SkillEntity skill = skillService.getSkill(id);
verifyResourceWorkspace(skill, workspaceId); 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); return R.ok(skill);
} }

View File

@ -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}).
*
* <p>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:
*
* <ul>
* <li>DB == file in sync; heal a missing/stale marker.</li>
* <li>File missing/blank, DB has content materialize DB file.
* A blank file is never ingested over non-blank DB content.</li>
* <li>DB blank, file has content backfill file DB (covers installs
* that predate the canonical column).</li>
* <li>File changed since last sync, DB unchanged ingest file DB.
* This is what makes shell/agent edits to the file visible to
* DB-reading consumers (admin console, API).</li>
* <li>DB changed since last sync, file unchanged materialize DB file.
* This heals nodes whose workspace export was missed or failed.</li>
* <li>No marker and both sides non-blank but different (legacy state)
* the file wins once: prior releases resolved runtime content from
* the directory, so the file reflects what was actually in effect.</li>
* <li>Both sides changed since last sync DB wins; the losing file is
* kept as {@code SKILL.md.bak} before being overwritten.</li>
* </ul>
*
* <p>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<SkillEntity>()
.eq(SkillEntity::getId, entity.getId())
.set(SkillEntity::getSkillContent, content)
.set(SkillEntity::getUpdateTime, LocalDateTime.now()));
entity.setSkillContent(content);
return true;
} catch (Exception e) {
log.warn("Failed to write skill_content for skill '{}': {}", entity.getName(), e.getMessage());
return false;
}
}
private String readFileQuietly(Path file) {
if (!Files.exists(file)) return "";
try {
return Files.readString(file, StandardCharsets.UTF_8);
} catch (IOException e) {
log.warn("Failed to read {}: {}", file, e.getMessage());
return "";
}
}
private String readMarker(Path marker) {
if (!Files.exists(marker)) return null;
try {
String value = Files.readString(marker, StandardCharsets.UTF_8).strip();
return value.isEmpty() ? null : value;
} catch (IOException e) {
log.warn("Failed to read sync marker {}: {}", marker, e.getMessage());
return null;
}
}
private void writeMarkerQuietly(Path marker, String hash) {
try {
Files.createDirectories(marker.getParent());
Files.writeString(marker, hash, StandardCharsets.UTF_8);
} catch (IOException e) {
log.warn("Failed to write sync marker {}: {}", marker, e.getMessage());
}
}
private void backupQuietly(Path source, Path backup) {
try {
if (Files.exists(source)) {
Files.copy(source, backup, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
log.warn("Failed to back up {} → {}: {}", source, backup, e.getMessage());
}
}
static String sha256(String content) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(
md.digest((content == null ? "" : content).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable on this JVM", e);
}
}
}

View File

@ -59,6 +59,7 @@ public class SkillPackageResolver {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SkillWorkspaceManager workspaceManager; private final SkillWorkspaceManager workspaceManager;
private final SkillMapper skillMapper; private final SkillMapper skillMapper;
private final SkillContentReconciler contentReconciler;
/** /**
* RFC-090 §14.4 knowledge-skill wrapper tool factory. * RFC-090 §14.4 knowledge-skill wrapper tool factory.
* {@code @Lazy} because WikiSkillWrapperToolFactory pulls in Wiki * {@code @Lazy} because WikiSkillWrapperToolFactory pulls in Wiki
@ -113,6 +114,7 @@ public class SkillPackageResolver {
ObjectMapper objectMapper, ObjectMapper objectMapper,
SkillWorkspaceManager workspaceManager, SkillWorkspaceManager workspaceManager,
SkillMapper skillMapper, SkillMapper skillMapper,
SkillContentReconciler contentReconciler,
@Lazy WikiSkillWrapperToolFactory wikiWrapperFactory, @Lazy WikiSkillWrapperToolFactory wikiWrapperFactory,
@Lazy AcpSkillWrapperToolFactory acpWrapperFactory, @Lazy AcpSkillWrapperToolFactory acpWrapperFactory,
@Lazy ScriptSkillWrapperToolFactory scriptWrapperFactory, @Lazy ScriptSkillWrapperToolFactory scriptWrapperFactory,
@ -125,6 +127,7 @@ public class SkillPackageResolver {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.workspaceManager = workspaceManager; this.workspaceManager = workspaceManager;
this.skillMapper = skillMapper; this.skillMapper = skillMapper;
this.contentReconciler = contentReconciler;
this.wikiWrapperFactory = wikiWrapperFactory; this.wikiWrapperFactory = wikiWrapperFactory;
this.acpWrapperFactory = acpWrapperFactory; this.acpWrapperFactory = acpWrapperFactory;
this.scriptWrapperFactory = scriptWrapperFactory; this.scriptWrapperFactory = scriptWrapperFactory;
@ -175,6 +178,48 @@ public class SkillPackageResolver {
return resolved; return resolved;
} }
/**
* Content-store-only reconciliation for read paths (e.g. the admin
* detail view). Runs the same SKILL.md store sync a full resolve
* performs without the scan / dependency / manifest stages so a
* detail query always returns the content the runtime would execute,
* even when the workspace file was edited out-of-band (agent shell
* tools, manual edits) and no refresh has run yet.
*
* <p>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 * Write back the latest scan status / findings JSON / timestamp when
* they differ from what's already on the row. Keeps the DB in sync * they differ from what's already on the row. Keeps the DB in sync
@ -308,20 +353,35 @@ public class SkillPackageResolver {
// ==================== 阶段 1内容解析 ==================== // ==================== 阶段 1内容解析 ====================
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) { private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
Path skillMd = skillDir.resolve("SKILL.md"); String content;
if ("convention".equals(source)) {
String content = ""; // Convention workspace: the DB column is canonical and the
String description = entity.getDescription(); // directory is a materialized cache. Reconcile both stores so
// runtime content and DB-reading consumers (admin console, API)
if (Files.exists(skillMd)) { // can never diverge file edits made by agents/shell are
try { // ingested into the DB, DB edits are materialized to the file.
content = Files.readString(skillMd); content = contentReconciler.reconcile(entity, skillDir).content();
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content); } else {
if (!parsed.getDescription().isBlank()) { // Explicit skillDir: the user-managed directory is the source
description = parsed.getDescription(); // 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();
} }
} }

View File

@ -56,7 +56,7 @@ class SkillControllerLifecycleTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
controller = new SkillController( 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, agentBindingService, null, null,
skillLifecycleService, skillCuratorJob, skillCuratorReportStore); skillLifecycleService, skillCuratorJob, skillCuratorReportStore);
} }

View File

@ -41,6 +41,7 @@ class SkillControllerListEnabledTest {
controller = new SkillController( controller = new SkillController(
skillService, skillService,
/* skillRuntimeService */ null, /* skillRuntimeService */ null,
/* skillPackageResolver */ null,
/* workspaceManager */ null, /* workspaceManager */ null,
/* bundledSkillSyncer */ null, /* bundledSkillSyncer */ null,
/* skillFileSyncer */ null, /* skillFileSyncer */ null,

View File

@ -26,7 +26,7 @@ class SkillControllerVirtualGuardTest {
private final SkillController controller = new SkillController( 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, null, null, null, null, null, null,
null, null, null); null, null, null, null);
@Test @Test
@DisplayName("update on a virtual MCP skill id is rejected before hitting the service") @DisplayName("update on a virtual MCP skill id is rejected before hitting the service")
@ -64,7 +64,7 @@ class SkillControllerVirtualGuardTest {
void toggleForwardsVirtualMcpToBridge() { void toggleForwardsVirtualMcpToBridge() {
McpSkillBridge bridge = mock(McpSkillBridge.class); McpSkillBridge bridge = mock(McpSkillBridge.class);
SkillController c = new SkillController( 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); bridge, null, null, null, null);
long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
SkillEntity toggled = new SkillEntity(); SkillEntity toggled = new SkillEntity();
@ -98,7 +98,7 @@ class SkillControllerVirtualGuardTest {
SkillController real = new SkillController( SkillController real = new SkillController(
mock(vip.mate.skill.service.SkillService.class), 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, null, null, null, null, null,
null, null, null); null, null, null, null);
long snowflakeId = 1_900_000_001_000_000_902L; long snowflakeId = 1_900_000_001_000_000_902L;
// updateSkill on a mocked SkillService returns null without throwing, // updateSkill on a mocked SkillService returns null without throwing,
// which is fine we just need to confirm the guard didn't fire. // which is fine we just need to confirm the guard didn't fire.

View File

@ -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<LambdaUpdateWrapper<SkillEntity>> captor =
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
verify(skillMapper).update(isNull(), captor.capture());
assertThat(captor.getValue().getSqlSet()).contains("skill_content");
}
// ==================== explicit-dir mirror ====================
@Test
void mirrorToDbWritesOnlyWhenDifferent() {
SkillEntity e = entity("same");
reconciler.mirrorToDb(e, "same");
verify(skillMapper, never()).update(isNull(), any(Wrapper.class));
reconciler.mirrorToDb(e, "changed");
verify(skillMapper).update(isNull(), any(Wrapper.class));
assertThat(e.getSkillContent()).isEqualTo("changed");
}
@Test
void mirrorToDbIgnoresBlankFileContent() {
SkillEntity e = entity("db content");
reconciler.mirrorToDb(e, "");
reconciler.mirrorToDb(e, null);
verify(skillMapper, never()).update(isNull(), any(Wrapper.class));
assertThat(e.getSkillContent()).isEqualTo("db content");
}
}

View File

@ -894,6 +894,35 @@ function openDetailDrawer(
editingIdentity.value = false editingIdentity.value = false
editingBody.value = false editingBody.value = false
detailDrawerVisible.value = true detailDrawerVisible.value = true
// The list rows are a snapshot from page load; the skill may have been
// modified since (e.g. by an agent in a chat session). Refetch the row
// so the drawer never shows stale content; the edit forms are seeded
// only after the fresh row lands.
void refreshDetailSkill(skill, opts)
}
/** Refetch the drawer's skill from the backend and sync the list row.
* Edit-mode seeding waits for the fresh row so the user never edits a
* stale body. On fetch failure the snapshot stays and edit mode still
* opens (last-known content beats a dead drawer). */
async function refreshDetailSkill(skill: Skill, opts: { editIdentity?: boolean; editBody?: boolean } = {}) {
if (!isVirtualSkillId(skill.id)) {
try {
const res: any = await skillApi.get(skill.id)
const fresh: Skill | undefined = res?.data
// Apply only if the drawer still shows this skill.
if (fresh && detailSkill.value && String(detailSkill.value.id) === String(fresh.id)) {
detailSkill.value = { ...detailSkill.value, ...fresh }
patchSkillInPlace(fresh)
}
} catch {
// Keep the list snapshot; the drawer still renders.
}
// Runtime-derived tabs (tools / features / security) read from the
// status map refresh it too so a just-rescanned skill shows current.
void loadRuntimeStatus()
}
if (!detailSkill.value || String(detailSkill.value.id) !== String(skill.id)) return
if (opts.editIdentity) startEditIdentity() if (opts.editIdentity) startEditIdentity()
if (opts.editBody) startEditBody() if (opts.editBody) startEditBody()
} }