mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(skill): sync builtin skill scripts to DB and self-heal missing workspace scripts
Bundled skill scripts/ and references/ are now persisted to mate_skill_file during startup sync; a workspace missing its scripts directory is force-restored from the classpath bundle even when the SKILL.md version is unchanged; and builtin skills with neither DB rows nor on-disk files backfill from the classpath. Fixes installs performed from builds whose jar shipped without bundle scripts.
This commit is contained in:
parent
bf6bed5511
commit
02772d58b8
@ -7,6 +7,9 @@ import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.core.io.support.ResourcePatternResolver;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.service.SkillFileService;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.workspace.bundle.ClasspathBundleSource;
|
||||
import vip.mate.skill.workspace.bundle.MaterializeOptions;
|
||||
import vip.mate.skill.workspace.bundle.SkillBundleMaterializer;
|
||||
@ -18,12 +21,14 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Discovers {@code classpath:skills/*/SKILL.md} at startup and copies
|
||||
* Discovers {@code classpath:skills/*\/SKILL.md} at startup and copies
|
||||
* each into the user's skill workspace, upgrading on version bumps.
|
||||
*
|
||||
* <p>Split out of {@link SkillWorkspaceManager} so the workspace service
|
||||
@ -52,6 +57,8 @@ public class BundledSkillSyncer {
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final SkillBundleMaterializer bundleMaterializer;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final SkillService skillService;
|
||||
private final SkillFileService skillFileService;
|
||||
|
||||
/**
|
||||
* Run a full sync pass. Idempotent — safe to call from both startup
|
||||
@ -94,35 +101,69 @@ public class BundledSkillSyncer {
|
||||
Path targetDir = workspaceManager.resolveConventionPath(skillName);
|
||||
boolean firstInstall = !Files.exists(targetDir);
|
||||
|
||||
SkillBundleSource source = new ClasspathBundleSource(resolver,
|
||||
bundledPath + "/" + skillName);
|
||||
Map<String, String> bundleFiles = loadBundleFilesFromSource(source);
|
||||
|
||||
SkillEntity skill = skillService.findByName(skillName);
|
||||
if (skill != null && skill.getId() != null && !bundleFiles.isEmpty()) {
|
||||
skillFileService.applyBundleFiles(skill.getId(), bundleFiles, false);
|
||||
}
|
||||
|
||||
if (!firstInstall) {
|
||||
String bundledVersion = readVersion(manifest);
|
||||
String workspaceVersion = readVersion(targetDir.resolve("SKILL.md"));
|
||||
if (bundledVersion == null || !isNewerVersion(bundledVersion, workspaceVersion)) {
|
||||
boolean missingScriptsOnDisk = hasScriptsInBundle(bundleFiles) && !Files.exists(targetDir.resolve("scripts"));
|
||||
|
||||
if (!missingScriptsOnDisk && (bundledVersion == null || !isNewerVersion(bundledVersion, workspaceVersion))) {
|
||||
log.debug("Bundled skill '{}' workspace is current (bundled={}, workspace={}), skipping",
|
||||
skillName, bundledVersion, workspaceVersion);
|
||||
return false;
|
||||
}
|
||||
log.info("Bundled skill '{}' version {} > workspace version {}, upgrading",
|
||||
skillName, bundledVersion, workspaceVersion);
|
||||
workspaceManager.archiveWorkspace(skillName);
|
||||
if (!missingScriptsOnDisk) {
|
||||
log.info("Bundled skill '{}' version {} > workspace version {}, upgrading",
|
||||
skillName, bundledVersion, workspaceVersion);
|
||||
workspaceManager.archiveWorkspace(skillName);
|
||||
} else {
|
||||
log.info("Bundled skill '{}' is missing scripts directory on disk, force copying bundle", skillName);
|
||||
}
|
||||
}
|
||||
|
||||
copyBundle(resolver, bundledPath, skillName, targetDir);
|
||||
copyBundle(source, targetDir);
|
||||
eventPublisher.publishEvent(
|
||||
new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.CREATED, targetDir));
|
||||
log.info("{} bundled skill '{}' → {}", firstInstall ? "Synced" : "Upgraded", skillName, targetDir);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void copyBundle(ResourcePatternResolver resolver, String bundledPath,
|
||||
String skillName, Path targetDir) {
|
||||
SkillBundleSource source = new ClasspathBundleSource(resolver,
|
||||
bundledPath + "/" + skillName);
|
||||
private Map<String, String> loadBundleFilesFromSource(SkillBundleSource source) {
|
||||
Map<String, String> files = new LinkedHashMap<>();
|
||||
try {
|
||||
for (SkillBundleSource.BundleAsset asset : source.assets()) {
|
||||
String path = asset.relativePath();
|
||||
if (path.startsWith("scripts/") || path.startsWith("references/")) {
|
||||
try (InputStream is = asset.open().get()) {
|
||||
String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
files.put(path, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load bundle assets from {}: {}", source.origin(), e.getMessage());
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private boolean hasScriptsInBundle(Map<String, String> bundleFiles) {
|
||||
return bundleFiles.keySet().stream().anyMatch(p -> p.startsWith("scripts/"));
|
||||
}
|
||||
|
||||
private void copyBundle(SkillBundleSource source, Path targetDir) {
|
||||
try {
|
||||
bundleMaterializer.materialize(source, targetDir, MaterializeOptions.verbatim());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to copy bundled skill '{}' from {}: {}",
|
||||
skillName, source.origin(), e.getMessage());
|
||||
log.warn("Failed to copy bundled skill from {}: {}",
|
||||
source.origin(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -99,6 +99,9 @@ public class SkillFileSyncer {
|
||||
int backfilled = 0;
|
||||
if (dbFiles.isEmpty()) {
|
||||
backfilled = backfillFromDiskIfNeeded(skill, workspaceDir);
|
||||
if (backfilled == 0 && Boolean.TRUE.equals(skill.getBuiltin())) {
|
||||
backfilled = backfillFromClasspathIfNeeded(skill);
|
||||
}
|
||||
if (backfilled > 0) {
|
||||
didBackfill = true;
|
||||
dbFiles = skillFileService.listBySkillId(skill.getId());
|
||||
@ -120,6 +123,38 @@ public class SkillFileSyncer {
|
||||
return new PerSkillReport(materialized, alreadyCurrent, backfilled, didBackfill);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfills builtin skill bundle files from classpath when both DB and disk are empty.
|
||||
*/
|
||||
private int backfillFromClasspathIfNeeded(SkillEntity skill) {
|
||||
org.springframework.core.io.support.ResourcePatternResolver resolver =
|
||||
new org.springframework.core.io.support.PathMatchingResourcePatternResolver();
|
||||
String bundledPath = "skills/" + skill.getName();
|
||||
vip.mate.skill.workspace.bundle.SkillBundleSource source =
|
||||
new vip.mate.skill.workspace.bundle.ClasspathBundleSource(resolver, bundledPath);
|
||||
|
||||
java.util.Map<String, String> ingested = new java.util.LinkedHashMap<>();
|
||||
try {
|
||||
for (vip.mate.skill.workspace.bundle.SkillBundleSource.BundleAsset asset : source.assets()) {
|
||||
String relative = asset.relativePath();
|
||||
if (relative.startsWith("scripts/") || relative.startsWith("references/")) {
|
||||
try (java.io.InputStream is = asset.open().get()) {
|
||||
ingested.put(relative, new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to backfill builtin skill '{}' from classpath: {}", skill.getName(), e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (ingested.isEmpty()) return 0;
|
||||
skillFileService.applyBundleFiles(skill.getId(), ingested, false);
|
||||
log.info("Backfilled {} bundle file(s) from classpath into mate_skill_file for builtin skill '{}' (id={})",
|
||||
ingested.size(), skill.getName(), skill.getId());
|
||||
return ingested.size();
|
||||
}
|
||||
|
||||
private enum MaterializeOutcome { WROTE, CURRENT, SKIPPED }
|
||||
|
||||
private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) {
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
package vip.mate.skill.workspace;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.service.SkillFileService;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.skill.workspace.bundle.SkillBundleMaterializer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Tests for {@link BundledSkillSyncer} ensuring bundled skill scripts are synced to DB and workspace disk.
|
||||
*/
|
||||
class BundledSkillSyncerTest {
|
||||
|
||||
@TempDir
|
||||
Path tmp;
|
||||
|
||||
private SkillWorkspaceProperties properties;
|
||||
private SkillWorkspaceManager workspaceManager;
|
||||
private SkillBundleMaterializer bundleMaterializer;
|
||||
private SkillService skillService;
|
||||
private SkillFileService skillFileService;
|
||||
private BundledSkillSyncer syncer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new SkillWorkspaceProperties();
|
||||
properties.setRoot(tmp.toString());
|
||||
properties.setBundledSkillsPath("skills");
|
||||
|
||||
workspaceManager = new SkillWorkspaceManager(properties, mock(ApplicationEventPublisher.class));
|
||||
bundleMaterializer = new SkillBundleMaterializer();
|
||||
skillService = mock(SkillService.class);
|
||||
skillFileService = mock(SkillFileService.class);
|
||||
|
||||
syncer = new BundledSkillSyncer(
|
||||
properties,
|
||||
workspaceManager,
|
||||
bundleMaterializer,
|
||||
mock(ApplicationEventPublisher.class),
|
||||
skillService,
|
||||
skillFileService
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Sync applies classpath bundle files to DB for existing skill entities")
|
||||
void syncAppliesBundleFilesToDb() {
|
||||
SkillEntity pptxSkill = new SkillEntity();
|
||||
pptxSkill.setId(100L);
|
||||
pptxSkill.setName("pptx");
|
||||
|
||||
when(skillService.findByName("pptx")).thenReturn(pptxSkill);
|
||||
|
||||
List<String> synced = syncer.sync();
|
||||
|
||||
assertTrue(synced.contains("pptx"));
|
||||
verify(skillFileService, atLeastOnce()).applyBundleFiles(eq(100L), anyMap(), eq(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Sync forces copy to disk when scripts directory is missing despite unchanged version")
|
||||
void syncForcesDiskCopyWhenScriptsDirMissing() throws IOException {
|
||||
Path pptxDir = tmp.resolve("pptx");
|
||||
Files.createDirectories(pptxDir);
|
||||
// Create SKILL.md with current version but omit scripts directory
|
||||
Files.writeString(pptxDir.resolve("SKILL.md"), "---\nname: pptx\nversion: 1.0.0\n---\n");
|
||||
|
||||
SkillEntity pptxSkill = new SkillEntity();
|
||||
pptxSkill.setId(100L);
|
||||
pptxSkill.setName("pptx");
|
||||
when(skillService.findByName("pptx")).thenReturn(pptxSkill);
|
||||
|
||||
List<String> synced = syncer.sync();
|
||||
|
||||
assertTrue(synced.contains("pptx"), "Should force re-sync when scripts directory is missing");
|
||||
assertTrue(Files.exists(pptxDir.resolve("scripts")), "scripts directory should now be copied to disk");
|
||||
}
|
||||
}
|
||||
@ -129,6 +129,24 @@ class SkillFileSyncerTest {
|
||||
verify(mapper, times(2)).insert(any(SkillFileEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DB and FS empty, builtin skill: backfills from classpath into DB")
|
||||
void backfillsFromClasspathWhenBuiltinAndDbFsEmpty() {
|
||||
SkillEntity skill = newSkill(10L, "pdf");
|
||||
skill.setBuiltin(true);
|
||||
when(skillService.listSkills()).thenReturn(List.of(skill));
|
||||
|
||||
List<SkillFileEntity> after = List.of(
|
||||
newRow(1L, 10L, "scripts/pdftotext.py", "pdf script")
|
||||
);
|
||||
when(mapper.selectList(any())).thenReturn(List.of(), List.of(), after);
|
||||
|
||||
var report = syncer.syncAll();
|
||||
|
||||
assertEquals(1, report.skillsBackfilled());
|
||||
verify(mapper, atLeastOnce()).insert(any(SkillFileEntity.class));
|
||||
}
|
||||
|
||||
private static SkillEntity newSkill(Long id, String name) {
|
||||
SkillEntity s = new SkillEntity();
|
||||
s.setId(id);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user