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.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);
}

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 SkillWorkspaceManager workspaceManager;
private final SkillMapper skillMapper;
private final SkillContentReconciler contentReconciler;
/**
* RFC-090 §14.4 knowledge-skill wrapper tool factory.
* {@code @Lazy} because WikiSkillWrapperToolFactory pulls in Wiki
@ -113,6 +114,7 @@ public class SkillPackageResolver {
ObjectMapper objectMapper,
SkillWorkspaceManager workspaceManager,
SkillMapper skillMapper,
SkillContentReconciler contentReconciler,
@Lazy WikiSkillWrapperToolFactory wikiWrapperFactory,
@Lazy AcpSkillWrapperToolFactory acpWrapperFactory,
@Lazy ScriptSkillWrapperToolFactory scriptWrapperFactory,
@ -125,6 +127,7 @@ public class SkillPackageResolver {
this.objectMapper = objectMapper;
this.workspaceManager = workspaceManager;
this.skillMapper = skillMapper;
this.contentReconciler = contentReconciler;
this.wikiWrapperFactory = wikiWrapperFactory;
this.acpWrapperFactory = acpWrapperFactory;
this.scriptWrapperFactory = scriptWrapperFactory;
@ -175,6 +178,48 @@ public class SkillPackageResolver {
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
* they differ from what's already on the row. Keeps the DB in sync
@ -308,21 +353,36 @@ public class SkillPackageResolver {
// ==================== 阶段 1内容解析 ====================
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
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");
String content = "";
String description = entity.getDescription();
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());
}
}
}
String description = entity.getDescription();
if (!content.isBlank()) {
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
if (!parsed.getDescription().isBlank()) {
description = parsed.getDescription();
}
} catch (Exception e) {
log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage());
}
}
Map<String, Object> references = directoryScanner.buildDirectoryTree(skillDir.resolve("references"));

View File

@ -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);
}

View File

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

View File

@ -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.

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
editingBody.value = false
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.editBody) startEditBody()
}