diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java index 1e28c896..10e4d326 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/BundledSkillSyncer.java @@ -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. * *

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 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 loadBundleFilesFromSource(SkillBundleSource source) { + Map 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 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()); } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java index 9acafb7a..78ad5387 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillFileSyncer.java @@ -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 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) { diff --git a/mateclaw-server/src/test/java/vip/mate/skill/workspace/BundledSkillSyncerTest.java b/mateclaw-server/src/test/java/vip/mate/skill/workspace/BundledSkillSyncerTest.java new file mode 100644 index 00000000..c4e46771 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/workspace/BundledSkillSyncerTest.java @@ -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 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 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"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java b/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java index 50e44f8a..c219a6da 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/workspace/SkillFileSyncerTest.java @@ -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 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);