package vip.mate.skill.service; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import vip.mate.skill.model.SkillFileEntity; import vip.mate.skill.repository.SkillFileMapper; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; /** * Persistence layer for skill bundle files. *

* Treated as the canonical store: every install writes the full set of * scripts/references rows here, and {@code SkillFileSyncer} mirrors them * to the local workspace cache on every node so script execution works * across a multi-instance deployment that shares one database. * * @author MateClaw Team */ @Slf4j @Service @RequiredArgsConstructor public class SkillFileService { private final SkillFileMapper mapper; /** All file rows owned by a skill. */ public List listBySkillId(Long skillId) { if (skillId == null) return List.of(); QueryWrapper q = new QueryWrapper<>(); q.eq("skill_id", skillId); return mapper.selectList(q); } /** Compute SHA-256 hex of a UTF-8 string (used for idempotent diffs). */ public static String sha256Hex(String content) { if (content == null) content = ""; return sha256Hex(content.getBytes(StandardCharsets.UTF_8)); } public static String sha256Hex(byte[] bytes) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] digest = md.digest(bytes); StringBuilder sb = new StringBuilder(digest.length * 2); for (byte b : digest) sb.append(String.format("%02x", b)); return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 unavailable on this JVM", e); } } /** * Replace the skill's full file set with {@code newFiles}, using * write-then-prune semantics to mirror the on-disk applyBundleFiles. * *

Empty-bundle guard: if {@code newFiles} contains zero entries * for a bucket (scripts/ or references/) and there are existing rows * for that bucket, the rows are preserved unless {@code force=true}. * This blocks the same data-loss scenario that tripped up the FS path. * * @param skillId owning skill id * @param newFiles new full file set, keyed by path under workspace root * (e.g. {@code "scripts/run.py"}) * @param force bypass empty-bundle guard */ @Transactional public ApplyResult applyBundleFiles(Long skillId, Map newFiles, boolean force) { if (skillId == null) { return new ApplyResult(0, 0, false, false); } Map incoming = newFiles == null ? Map.of() : newFiles; boolean newHasScripts = bucketHasEntries(incoming, "scripts/"); boolean newHasRefs = bucketHasEntries(incoming, "references/"); boolean newHasTemplates = bucketHasEntries(incoming, "templates/"); List existing = listBySkillId(skillId); boolean existingHasScripts = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("scripts/")); boolean existingHasRefs = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("references/")); boolean existingHasTemplates = existing.stream().anyMatch(e -> e.getFilePath() != null && e.getFilePath().startsWith("templates/")); boolean preserveScripts = !newHasScripts && existingHasScripts && !force; boolean preserveRefs = !newHasRefs && existingHasRefs && !force; boolean preserveTemplates = !newHasTemplates && existingHasTemplates && !force; Map existingByPath = new HashMap<>(); for (SkillFileEntity e : existing) existingByPath.put(e.getFilePath(), e); Set keepPaths = new HashSet<>(); if (preserveScripts) { for (SkillFileEntity e : existing) { if (e.getFilePath() != null && e.getFilePath().startsWith("scripts/")) { keepPaths.add(e.getFilePath()); } } } if (preserveRefs) { for (SkillFileEntity e : existing) { if (e.getFilePath() != null && e.getFilePath().startsWith("references/")) { keepPaths.add(e.getFilePath()); } } } if (preserveTemplates) { for (SkillFileEntity e : existing) { if (e.getFilePath() != null && e.getFilePath().startsWith("templates/")) { keepPaths.add(e.getFilePath()); } } } keepPaths.addAll(incoming.keySet()); int written = 0; LocalDateTime now = LocalDateTime.now(); for (var entry : incoming.entrySet()) { String path = entry.getKey(); String content = entry.getValue() == null ? "" : entry.getValue(); String hash = sha256Hex(content); int size = content.getBytes(StandardCharsets.UTF_8).length; SkillFileEntity prior = existingByPath.get(path); if (prior == null) { SkillFileEntity row = new SkillFileEntity(); row.setSkillId(skillId); row.setFilePath(path); row.setContent(content); row.setContentEncoding("utf8"); row.setContentSize(size); row.setSha256(hash); row.setCreateTime(now); row.setUpdateTime(now); mapper.insert(row); written++; } else if (!hash.equals(prior.getSha256())) { prior.setContent(content); prior.setContentEncoding("utf8"); prior.setContentSize(size); prior.setSha256(hash); prior.setUpdateTime(now); mapper.updateById(prior); written++; } } int pruned = 0; for (SkillFileEntity e : existing) { if (!keepPaths.contains(e.getFilePath())) { mapper.deleteById(e.getId()); pruned++; } } if (preserveScripts) { log.warn("Refused to prune scripts/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId); } if (preserveRefs) { log.warn("Refused to prune references/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId); } if (preserveTemplates) { log.warn("Refused to prune templates/ for skill_id={} — new bundle is empty. Pass force=true to override.", skillId); } return new ApplyResult(written, pruned, preserveScripts, preserveRefs); } /** Drop every file row for a skill (used on hard-delete). */ @Transactional public int deleteAllForSkill(Long skillId) { if (skillId == null) return 0; return mapper.deleteBySkillId(skillId); } /** One file row by exact path, or {@code null}. */ public SkillFileEntity getFile(Long skillId, String filePath) { if (skillId == null || filePath == null || filePath.isBlank()) return null; QueryWrapper q = new QueryWrapper<>(); q.eq("skill_id", skillId).eq("file_path", filePath); return mapper.selectOne(q); } /** * Create or update a single file row. Content hash and size are * recomputed; a same-hash write is a no-op so idempotent saves don't * churn {@code update_time}. * * @return the persisted row (existing row instance on no-op) */ @Transactional public SkillFileEntity upsertFile(Long skillId, String filePath, String content) { return upsertBytes(skillId, filePath, (content == null ? "" : content).getBytes(StandardCharsets.UTF_8)); } /** Preserve arbitrary bytes; only strictly valid UTF-8 without control bytes is editable text. */ @Transactional public SkillFileEntity upsertBytes(Long skillId, String filePath, byte[] bytes) { String text = null; try { text = StandardCharsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(bytes)).toString(); if (text.codePoints().anyMatch(c -> c < 32 && c != '\n' && c != '\r' && c != '\t')) text = null; } catch (java.nio.charset.CharacterCodingException binary) { // Keep the original bytes below. } String encoding = text == null ? "base64" : "utf8"; String content = text == null ? java.util.Base64.getEncoder().encodeToString(bytes) : text; String hash = sha256Hex(bytes); SkillFileEntity row = getFile(skillId, filePath); boolean insert = row == null; if (!insert && hash.equals(row.getSha256())) return row; LocalDateTime now = LocalDateTime.now(); if (insert) { row = new SkillFileEntity(); row.setSkillId(skillId); row.setFilePath(filePath); row.setCreateTime(now); } row.setContent(content); row.setContentEncoding(encoding); row.setContentSize(bytes.length); row.setSha256(hash); row.setUpdateTime(now); if (insert) mapper.insert(row); else mapper.updateById(row); return row; } /** Delete a single file row. Returns {@code true} when a row was removed. */ @Transactional public boolean deleteFile(Long skillId, String filePath) { SkillFileEntity existing = getFile(skillId, filePath); if (existing == null) return false; mapper.deleteById(existing.getId()); return true; } private boolean bucketHasEntries(Map files, String prefix) { for (String key : files.keySet()) { if (key != null && key.startsWith(prefix)) return true; } return false; } /** Outcome of {@link #applyBundleFiles}. */ public record ApplyResult(int rowsWritten, int rowsPruned, boolean scriptsPreservedDueToEmptyBundle, boolean referencesPreservedDueToEmptyBundle) {} }