diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 0ec166cc..d0af1f27 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -18,6 +18,8 @@ import vip.mate.exception.MateClawException; import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.acp.AcpSkillBridge; import vip.mate.skill.mcp.McpSkillBridge; +import vip.mate.skill.lifecycle.BlockedByBindingRow; +import vip.mate.skill.lifecycle.ConfirmRequiredException; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.SkillRuntimeService; @@ -25,9 +27,15 @@ import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.tool.model.AvailableToolDTO; import vip.mate.tool.service.AvailableToolService; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -181,6 +189,103 @@ public class AgentBindingService implements AgentBindingResolver { } } + // ==================== Lifecycle curator support ==================== + + /** + * Skill ids explicitly bound to at least one enabled agent (binding row + * {@code enabled = true} AND agent row {@code enabled = true}). The + * lifecycle curator excludes these from its candidate set so it never + * silently undoes a user's explicit skill picks. + */ + public Set skillIdsBoundToEnabledAgents() { + Set enabledAgentIds = enabledAgentIds(); + if (enabledAgentIds.isEmpty()) { + return Set.of(); + } + return skillBindingMapper.selectList(new LambdaQueryWrapper() + .eq(AgentSkillBinding::getEnabled, true)) + .stream() + .filter(b -> b.getSkillId() != null && enabledAgentIds.contains(b.getAgentId())) + .map(AgentSkillBinding::getSkillId) + .collect(Collectors.toSet()); + } + + /** + * Binding-protected skills with the detail the lifecycle run report + * needs: {@code {skillId, name, agentIds, daysIdle}}. Hard-exempt skills + * (builtin / mcp / acp / pinned) are excluded since they would not be + * archival candidates regardless of bindings. + */ + public List blockedByBindingCandidates(LocalDateTime now) { + Set enabledAgentIds = enabledAgentIds(); + if (enabledAgentIds.isEmpty()) { + return List.of(); + } + Map> bySkill = new HashMap<>(); + for (AgentSkillBinding b : skillBindingMapper.selectList(new LambdaQueryWrapper() + .eq(AgentSkillBinding::getEnabled, true))) { + if (b.getSkillId() == null || !enabledAgentIds.contains(b.getAgentId())) { + continue; + } + bySkill.computeIfAbsent(b.getSkillId(), k -> new ArrayList<>()).add(b.getAgentId()); + } + if (bySkill.isEmpty()) { + return List.of(); + } + List rows = new ArrayList<>(); + for (SkillEntity skill : skillMapper.selectBatchIds(bySkill.keySet())) { + if (Boolean.TRUE.equals(skill.getBuiltin()) || Boolean.TRUE.equals(skill.getPinned())) { + continue; + } + String type = skill.getSkillType(); + if (type != null && List.of("builtin", "mcp", "acp").contains(type)) { + continue; + } + LocalDateTime anchor = skill.getLastActivityAt() != null + ? skill.getLastActivityAt() : skill.getCreateTime(); + long daysIdle = anchor == null ? 0L : Duration.between(anchor, now).toDays(); + rows.add(new BlockedByBindingRow(skill.getId(), skill.getName(), + bySkill.get(skill.getId()), daysIdle)); + } + return rows; + } + + /** + * Enabled agents that explicitly bind {@code skillId}. Used by manual + * archive to list the agents an admin would affect before confirming. + */ + public List enabledAgentsBoundToSkill(Long skillId) { + if (skillId == null) { + return List.of(); + } + Set agentIds = skillBindingMapper.selectList(new LambdaQueryWrapper() + .eq(AgentSkillBinding::getSkillId, skillId) + .eq(AgentSkillBinding::getEnabled, true)) + .stream() + .map(AgentSkillBinding::getAgentId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (agentIds.isEmpty()) { + return List.of(); + } + return agentMapper.selectList(new LambdaQueryWrapper() + .in(AgentEntity::getId, agentIds) + .eq(AgentEntity::getEnabled, true)) + .stream() + .map(a -> new ConfirmRequiredException.AgentRow(a.getId(), a.getName())) + .collect(Collectors.toList()); + } + + /** Ids of every currently-enabled agent. */ + private Set enabledAgentIds() { + return agentMapper.selectList(new LambdaQueryWrapper() + .eq(AgentEntity::getEnabled, true) + .select(AgentEntity::getId)) + .stream() + .map(AgentEntity::getId) + .collect(Collectors.toSet()); + } + /** * Refuse to bind a skill that doesn't share the agent's workspace. * Skills are per-workspace installable artifacts (each workspace has diff --git a/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java b/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java index ccfe8da2..74d1889c 100644 --- a/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java @@ -14,6 +14,10 @@ import org.springframework.web.context.request.async.AsyncRequestTimeoutExceptio import org.springframework.web.servlet.resource.NoResourceFoundException; import vip.mate.common.result.R; import vip.mate.i18n.I18nService; +import vip.mate.skill.lifecycle.ConfirmRequiredException; + +import java.util.LinkedHashMap; +import java.util.Map; /** * Global exception handler. @@ -68,6 +72,21 @@ public class GlobalExceptionHandler { return e.getMessage(); } + /** + * A mutation that needs a second, explicit confirmation. Returns a real + * HTTP 409 with a structured body so the client can branch on the status + * code and render a confirm dialog from {@code boundAgents}. + */ + @ExceptionHandler(ConfirmRequiredException.class) + public ResponseEntity> handleConfirmRequired(ConfirmRequiredException e) { + log.info("Confirm required: [{}] {}", e.getCode(), e.getMessage()); + Map body = new LinkedHashMap<>(); + body.put("code", e.getCode()); + body.put("message", e.getMessage()); + body.put("boundAgents", e.getBoundAgents()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(body); + } + @ExceptionHandler(BindException.class) public ResponseEntity> handleBindException(BindException e) { String msg = e.getBindingResult().getFieldErrors().stream() diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index fc984d9c..ce3c0243 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -26,7 +26,15 @@ import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.workspace.BundledSkillSyncer; import vip.mate.skill.workspace.SkillFileSyncer; import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.exception.MateClawException; +import vip.mate.skill.lifecycle.ConfirmRequiredException; +import vip.mate.skill.lifecycle.LifecycleTransition; +import vip.mate.skill.lifecycle.SkillCuratorJob; +import vip.mate.skill.lifecycle.SkillCuratorReport; +import vip.mate.skill.lifecycle.SkillCuratorReportStore; +import vip.mate.skill.lifecycle.SkillLifecycleService; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -60,6 +68,9 @@ public class SkillController { private final AgentBindingService agentBindingService; private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge; private final vip.mate.skill.acp.AcpSkillBridge acpSkillBridge; + private final SkillLifecycleService skillLifecycleService; + private final SkillCuratorJob skillCuratorJob; + private final SkillCuratorReportStore skillCuratorReportStore; @Operation(summary = "获取技能分页列表(RFC-042 §2.1)") @GetMapping @@ -75,14 +86,20 @@ public class SkillController { @RequestParam(required = false) String sort, @RequestParam(required = false) String source, @RequestParam(required = false) String runtime, + @RequestParam(required = false) String lifecycleState, @RequestParam(required = false) Long agentId) { Set pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of(); if (pinnedSkillIds == null) pinnedSkillIds = Set.of(); IPage dbPage = skillService.pageSkills( page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime, - pinnedSkillIds, workspaceId); - List virtualSkills = visibleVirtualSkills( - workspaceId, keyword, skillType, enabled, scanStatus, sort, source, runtime); + pinnedSkillIds, workspaceId, lifecycleState); + // Virtual MCP/ACP skills mirror live servers and carry no lifecycle + // state — exclude them whenever the caller filters by lifecycleState + // (stale / archived / active), otherwise they leak into every tab. + List virtualSkills = (lifecycleState != null && !lifecycleState.isBlank()) + ? List.of() + : visibleVirtualSkills( + workspaceId, keyword, skillType, enabled, scanStatus, sort, source, runtime); if (!virtualSkills.isEmpty()) { VirtualPageMergeResult merged = mergeVirtualTailPageRecords( dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size); @@ -784,4 +801,129 @@ public class SkillController { verifyResourceWorkspace(skill, workspaceId); return R.ok(workspaceManager.getWorkspaceInfo(skill.getName())); } + + // ==================== Skill lifecycle & curator ==================== + + @Operation(summary = "钉住/取消钉住技能(钉住的技能不会被自动归档)") + @PostMapping("/{id}/pin") + @RequireWorkspaceRole("admin") + public R pin(@PathVariable Long id, + @RequestBody(required = false) PinRequest body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); + boolean pinned = body != null && Boolean.TRUE.equals(body.pinned()); + return R.ok(skillLifecycleService.setPinned(id, pinned)); + } + + @Operation(summary = "手动归档技能") + @PostMapping("/{id}/archive") + @RequireWorkspaceRole("admin") + public R archive(@PathVariable Long id, + @RequestParam(defaultValue = "false") boolean force, + @RequestBody(required = false) ArchiveRequest body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + rejectVirtualSkillMutation(id); + SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); + if (Boolean.TRUE.equals(skill.getBuiltin())) { + throw new MateClawException("err.skill.builtin_not_archivable", 400, + "Cannot archive builtin skill: " + skill.getName()); + } + String state = skill.getLifecycleState() == null ? "active" : skill.getLifecycleState(); + if ("archived".equals(state)) { + throw new MateClawException("err.skill.already_archived", 409, + "Skill already archived: " + skill.getName()); + } + // Bound skills are not silently archived: require an explicit + // second-pass confirmation (force=true) so the admin sees which + // agents lose the capability. + if (!force) { + List bound = + agentBindingService.enabledAgentsBoundToSkill(id); + if (!bound.isEmpty()) { + throw new ConfirmRequiredException("BOUND_SKILL_CONFIRM_REQUIRED", + "Skill is explicitly bound to " + bound.size() + + " agent(s); pass force=true to confirm", bound); + } + } + String reason = body != null && body.reason() != null ? body.reason() : "manual:admin"; + skillLifecycleService.applyManual(skill, LifecycleTransition.TO_ARCHIVED, + LocalDateTime.now(), reason); + return R.ok(skillService.getSkill(id)); + } + + @Operation(summary = "恢复已归档的技能") + @PostMapping("/{id}/restore") + @RequireWorkspaceRole("admin") + public R restore(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); + return R.ok(skillLifecycleService.restore(id)); + } + + @Operation(summary = "立即运行一次 curator 预览(dry-run)") + @PostMapping("/curator/dry-run") + @RequireWorkspaceRole("admin") + public R curatorDryRun() { + return R.ok(skillCuratorJob.dryRunNow()); + } + + @Operation(summary = "激活/取消激活 curator(真正归档 vs 仅预览)") + @PostMapping("/curator/activate") + @RequireWorkspaceRole("admin") + public R> curatorActivate( + @RequestParam(defaultValue = "true") boolean activate) { + skillCuratorJob.activate(activate); + return R.ok(skillCuratorJob.status()); + } + + @Operation(summary = "暂停 curator 定时扫描") + @PostMapping("/curator/pause") + @RequireWorkspaceRole("admin") + public R> curatorPause() { + skillCuratorJob.setPaused(true); + return R.ok(skillCuratorJob.status()); + } + + @Operation(summary = "恢复 curator 定时扫描") + @PostMapping("/curator/resume") + @RequireWorkspaceRole("admin") + public R> curatorResume() { + skillCuratorJob.setPaused(false); + return R.ok(skillCuratorJob.status()); + } + + @Operation(summary = "curator 控制面状态") + @GetMapping("/curator/status") + @RequireWorkspaceRole("member") + public R> curatorStatus() { + return R.ok(skillCuratorJob.status()); + } + + @Operation(summary = "列出最近的 curator 运行报告") + @GetMapping("/curator/reports") + @RequireWorkspaceRole("member") + public R> curatorReports() { + return R.ok(skillCuratorReportStore.listRunIds(20)); + } + + @Operation(summary = "读取某次 curator 运行报告") + @GetMapping("/curator/reports/{runId}") + @RequireWorkspaceRole("member") + public R curatorReport(@PathVariable String runId) { + Object report = skillCuratorReportStore.readRun(runId); + if (report == null) { + throw new MateClawException("err.skill.curator_report_not_found", 404, + "Curator report not found: " + runId); + } + return R.ok(report); + } + + /** Body of {@code POST /skills/{id}/pin}. */ + public record PinRequest(Boolean pinned) {} + + /** Optional body of {@code POST /skills/{id}/archive}. */ + public record ArchiveRequest(String reason) {} } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/BlockedByBindingRow.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/BlockedByBindingRow.java new file mode 100644 index 00000000..5e2cbc3e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/BlockedByBindingRow.java @@ -0,0 +1,17 @@ +package vip.mate.skill.lifecycle; + +import java.util.List; + +/** + * A skill that satisfies the curator's idle time window but is kept out of + * the candidate set because it is explicitly bound to one or more enabled + * agents. Surfaced in the run report so an admin can see what was held back. + * + * @author MateClaw Team + */ +public record BlockedByBindingRow( + Long skillId, + String name, + List agentIds, + long daysIdle) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/ConfirmRequiredException.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/ConfirmRequiredException.java new file mode 100644 index 00000000..e6dbc55a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/ConfirmRequiredException.java @@ -0,0 +1,35 @@ +package vip.mate.skill.lifecycle; + +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.util.List; + +/** + * Thrown by manual-archive when the requested action would impact resources + * the caller has not explicitly opted in to touching (a skill that is still + * explicitly bound to one or more enabled agents). + * + *

The caller resolves the conflict by retrying with {@code force=true}. + * {@link ResponseStatus} maps this to HTTP 409 so clients can branch on the + * status code rather than parsing the body. + * + * @author MateClaw Team + */ +@Getter +@ResponseStatus(HttpStatus.CONFLICT) +public class ConfirmRequiredException extends RuntimeException { + + private final String code; + private final List boundAgents; + + public ConfirmRequiredException(String code, String message, List boundAgents) { + super(message); + this.code = code; + this.boundAgents = boundAgents == null ? List.of() : List.copyOf(boundAgents); + } + + /** Minimal agent identity surfaced to the client so it can render a confirm dialog. */ + public record AgentRow(Long id, String name) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/CuratorRunNotifier.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/CuratorRunNotifier.java new file mode 100644 index 00000000..fff2e821 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/CuratorRunNotifier.java @@ -0,0 +1,49 @@ +package vip.mate.skill.lifecycle; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import vip.mate.audit.service.AuditEventService; + +import java.util.Map; + +/** + * Surfaces a completed lifecycle sweep through two decoupled channels: a + * durable {@code mate_audit_event} row, and a Spring application event that + * a notification subsystem may listen for. Neither channel couples the + * curator to any subsystem that may not be present. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CuratorRunNotifier { + + private final AuditEventService auditEventService; + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + + public void onRunComplete(SkillCuratorReport report) { + // (1) Durable audit trail — always recorded. + try { + String detail = objectMapper.writeValueAsString(Map.of( + "marked_stale", report.markedStale(), + "archived", report.archived(), + "reactivated", report.reactivated(), + "dryRun", report.isDryRun(), + "reportPath", String.valueOf(report.getPath()))); + auditEventService.record("CURATOR_RUN", "SKILL", report.getRunId(), null, detail); + } catch (Exception e) { + log.debug("Failed to record curator run audit event: {}", e.getMessage()); + } + + // (2) Application event — no listener is required; if none exists + // the event is simply discarded. + eventPublisher.publishEvent(new SkillCuratorRunCompletedEvent( + report.getRunId(), report.markedStale(), report.archived(), + report.reactivated(), report.isDryRun(), report.getPath(), report.getRunAt())); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/LifecycleTransition.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/LifecycleTransition.java new file mode 100644 index 00000000..2aec43ae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/LifecycleTransition.java @@ -0,0 +1,17 @@ +package vip.mate.skill.lifecycle; + +/** + * The transition a skill should undergo on a lifecycle sweep. + * + * @author MateClaw Team + */ +public enum LifecycleTransition { + /** No change needed. */ + NONE, + /** active -> stale (idle past the stale threshold). */ + TO_STALE, + /** stale -> archived (idle past the archive threshold). */ + TO_ARCHIVED, + /** stale -> active (activity observed again). */ + REACTIVATE +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java new file mode 100644 index 00000000..28620ae0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java @@ -0,0 +1,289 @@ +package vip.mate.skill.lifecycle; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Component; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.system.service.SystemSettingService; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Daily sweep that ages idle, agent-created skills through the lifecycle + * state machine. Three gates guard the sweep: the config-level + * {@code enabled} switch, an operational {@code paused} kill switch, and a + * first-run throttle that keeps the pre-activation dry-run from flooding the + * report directory. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillCuratorJob { + + /** Admin flipped the curator from preview-only to applying transitions. */ + static final String FIRST_RUN_KEY = "skill.curator.firstRunCompleted"; + /** Runtime kill switch — pauses the scheduled sweep without a redeploy. */ + static final String PAUSED_KEY = "skill.curator.paused"; + /** ISO-8601 timestamp of the last auto dry-run, for throttling. */ + static final String LAST_DRY_RUN_KEY = "skill.curator.lastDryRunAt"; + /** ISO-8601 timestamp of the first sweep observation after install. */ + static final String LAST_OBSERVED_KEY = "skill.curator.lastObservedAt"; + /** ISO-8601 timestamp of the last sweep that produced a report. */ + static final String LAST_RUN_KEY = "skill.curator.lastRunAt"; + + /** Minimum hours between auto dry-runs while the curator is not activated. */ + private static final long DRY_RUN_THROTTLE_HOURS = 23; + + private final SkillLifecycleService lifecycleService; + private final SkillMapper skillMapper; + private final SkillCuratorReportStore reportStore; + private final SkillLifecycleProperties properties; + private final SystemSettingService systemSettingService; + private final AgentBindingService agentBindingService; + private final SkillWorkspaceManager workspaceManager; + private final CuratorRunNotifier notifier; + + @Scheduled(cron = "${mateclaw.skill.curator.cron:0 0 2 * * *}") + @SchedulerLock(name = "skill-curator", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S") + public void run() { + // Gate 1: config-level enable. + if (!properties.isEnabled() || "OFF".equals(properties.getScope())) { + return; + } + // Gate 2: operational pause. + if (systemSettingService.getBool(PAUSED_KEY, false)) { + log.debug("Curator paused via {} — skipping this tick", PAUSED_KEY); + return; + } + + LocalDateTime now = LocalDateTime.now(); + boolean activated = systemSettingService.getBool(FIRST_RUN_KEY, false); + + // Gate 3: first-run throttle. Before activation the sweep is + // informational; bound it to once per ~day so the report directory + // doesn't fill with identical previews. + if (!activated) { + LocalDateTime lastObserved = parseTs(systemSettingService.getString(LAST_OBSERVED_KEY, null)); + LocalDateTime lastDry = parseTs(systemSettingService.getString(LAST_DRY_RUN_KEY, null)); + if (lastObserved == null) { + systemSettingService.saveString(LAST_OBSERVED_KEY, now.toString(), + "Skill curator first observed timestamp"); + log.info("Curator first observation — deferring; preview on demand via /curator/dry-run"); + return; + } + Duration sinceLastDry = lastDry == null + ? Duration.between(lastObserved, now) + : Duration.between(lastDry, now); + if (sinceLastDry.toHours() < DRY_RUN_THROTTLE_HOURS) { + log.debug("Curator dry-run throttled ({}h since last)", sinceLastDry.toHours()); + return; + } + } + + boolean dryRun = !activated; + SkillCuratorReport report = sweep(now, dryRun); + + if (dryRun) { + systemSettingService.saveString(LAST_DRY_RUN_KEY, now.toString(), + "Skill curator last dry-run timestamp"); + } + systemSettingService.saveString(LAST_RUN_KEY, now.toString(), + "Skill curator last run timestamp"); + notifier.onRunComplete(report); + } + + /** + * Run a dry-run sweep immediately, bypassing the first-run throttle and + * the scheduler lock — for the admin "preview now" action. + */ + public SkillCuratorReport dryRunNow() { + SkillCuratorReport report = sweep(LocalDateTime.now(), true); + notifier.onRunComplete(report); + return report; + } + + /** Flip the activation flag (preview-only ⇄ applying). */ + public void activate(boolean activate) { + systemSettingService.saveBool(FIRST_RUN_KEY, activate, "Skill curator activated"); + } + + /** Set the runtime pause flag. */ + public void setPaused(boolean paused) { + systemSettingService.saveBool(PAUSED_KEY, paused, "Skill curator paused"); + } + + /** Aggregated control-panel state for the admin UI. */ + public Map status() { + Map config = new LinkedHashMap<>(); + config.put("enabled", properties.isEnabled()); + config.put("scope", properties.getScope()); + config.put("staleAfterDays", properties.getStaleAfterDays()); + config.put("archiveAfterDays", properties.getArchiveAfterDays()); + config.put("cron", properties.getCron()); + + Map control = new LinkedHashMap<>(); + control.put("activated", systemSettingService.getBool(FIRST_RUN_KEY, false)); + control.put("paused", systemSettingService.getBool(PAUSED_KEY, false)); + control.put("lastObservedAt", systemSettingService.getString(LAST_OBSERVED_KEY, null)); + control.put("lastDryRunAt", systemSettingService.getString(LAST_DRY_RUN_KEY, null)); + control.put("lastRunAt", systemSettingService.getString(LAST_RUN_KEY, null)); + control.put("nextScheduledRun", nextScheduledRun()); + + Map counts = new LinkedHashMap<>(); + counts.put("active", countState("active")); + counts.put("stale", countState("stale")); + counts.put("archived", countState("archived")); + counts.put("pinned", skillMapper.selectCount( + new LambdaQueryWrapper().eq(SkillEntity::getPinned, true))); + // Count only archival-relevant skills held back by a binding — same + // set the run report's blockedByBindings array shows, so the status + // count and the report stay consistent (builtin / mcp / acp / pinned + // skills are exempt regardless of bindings and are not counted here). + counts.put("blockedByBindings", + agentBindingService.blockedByBindingCandidates(LocalDateTime.now()).size()); + + Map out = new LinkedHashMap<>(); + out.put("config", config); + out.put("control", control); + out.put("counts", counts); + String latest = reportStore.latestRunId(); + out.put("lastReport", latest == null ? null : Map.of( + "id", latest, + "url", "/api/v1/skills/curator/reports/" + latest)); + return out; + } + + // ==================== Internals ==================== + + private SkillCuratorReport sweep(LocalDateTime now, boolean dryRun) { + SkillCuratorReport.Builder report = SkillCuratorReport.builder() + .runAt(now) + .dryRun(dryRun) + .config(properties.getStaleAfterDays(), properties.getArchiveAfterDays(), + properties.getScope()); + + reconcileOrphans(now, report, dryRun); + + List candidates = loadCandidates(); + int plannedStale = 0, plannedArchived = 0, plannedReactivate = 0; + int appliedStale = 0, appliedArchived = 0, appliedReactivate = 0; + for (SkillEntity skill : candidates) { + LifecycleTransition t = lifecycleService.planTransition(skill, now); + report.add(skill, t); + if (t == LifecycleTransition.TO_STALE) { + plannedStale++; + } else if (t == LifecycleTransition.TO_ARCHIVED) { + plannedArchived++; + } else if (t == LifecycleTransition.REACTIVATE) { + plannedReactivate++; + } + if (dryRun) { + continue; + } + boolean applied = lifecycleService.apply(skill, t, now); + if (applied) { + if (t == LifecycleTransition.TO_STALE) { + appliedStale++; + } else if (t == LifecycleTransition.TO_ARCHIVED) { + appliedArchived++; + } else if (t == LifecycleTransition.REACTIVATE) { + appliedReactivate++; + } + } + } + + report.scanned(candidates.size()) + .plannedCounts(plannedStale, plannedArchived, plannedReactivate) + .appliedCounts(appliedStale, appliedArchived, appliedReactivate) + .blockedByBindings(agentBindingService.blockedByBindingCandidates(now)); + + return reportStore.write(report.build()); + } + + /** + * Candidate skills for the state machine: not builtin, not pinned, not a + * builtin/mcp/acp type, not bound to any enabled agent, and — under the + * default {@code AGENT_CREATED} scope — created by an agent. + */ + private List loadCandidates() { + Set bindingProtected = agentBindingService.skillIdsBoundToEnabledAgents(); + + LambdaQueryWrapper w = new LambdaQueryWrapper() + .eq(SkillEntity::getBuiltin, false) + .eq(SkillEntity::getPinned, false) + .notIn(SkillEntity::getSkillType, List.of("builtin", "mcp", "acp")); + if (!bindingProtected.isEmpty()) { + w.notIn(SkillEntity::getId, bindingProtected); + } + if ("AGENT_CREATED".equals(properties.getScope())) { + w.isNotNull(SkillEntity::getSourceConversationId); + } + return skillMapper.selectList(w); + } + + /** + * Heal the unambiguous divergence class: a row marked {@code archived} + * whose convention workspace is back in place (an admin moved a directory + * or a re-install ran). The reverse class — workspace moved but the DB + * write failed — is handled inline by the archive compensation path. + */ + private void reconcileOrphans(LocalDateTime now, SkillCuratorReport.Builder report, boolean dryRun) { + List archived = skillMapper.selectList(new LambdaQueryWrapper() + .eq(SkillEntity::getLifecycleState, "archived")); + for (SkillEntity skill : archived) { + if (skill.getName() == null || !workspaceManager.conventionWorkspaceExists(skill.getName())) { + continue; + } + report.reconciliation("skill '" + skill.getName() + "' (id=" + skill.getId() + + ") archived in DB but workspace present — reactivating"); + if (!dryRun) { + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, skill.getId()) + .set(SkillEntity::getLifecycleState, "active") + .set(SkillEntity::getEnabled, true) + .set(SkillEntity::getArchivedAt, null) + .set(SkillEntity::getLastActivityAt, now)); + } + } + } + + private long countState(String state) { + return skillMapper.selectCount(new LambdaQueryWrapper() + .eq(SkillEntity::getLifecycleState, state)); + } + + private String nextScheduledRun() { + try { + LocalDateTime next = CronExpression.parse(properties.getCron()).next(LocalDateTime.now()); + return next != null ? next.toString() : null; + } catch (Exception e) { + return null; + } + } + + private static LocalDateTime parseTs(String s) { + if (s == null || s.isBlank()) { + return null; + } + try { + return LocalDateTime.parse(s); + } catch (Exception e) { + return null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java new file mode 100644 index 00000000..a2cb8b79 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java @@ -0,0 +1,173 @@ +package vip.mate.skill.lifecycle; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import vip.mate.skill.model.SkillEntity; + +import java.nio.file.Path; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Structured result of one lifecycle sweep — serialized to {@code run.json} + * and rendered to {@code REPORT.md}. Built incrementally during the sweep + * via {@link Builder}. + * + *

{@code planned} counts reflect what {@code planTransition} decided and + * are populated in both dry-run and applied modes. {@code applied} counts + * reflect what actually committed and stay zero for a dry-run. + * + * @author MateClaw Team + */ +@Getter +public class SkillCuratorReport { + + private static final DateTimeFormatter RUN_ID = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + private final String runId; + private final LocalDateTime runAt; + private final boolean dryRun; + private final Config config; + private final int scanned; + private final Counts planned; + private final Counts applied; + private final List transitions; + private final List blockedByBindings; + private final List reconciliations; + + /** Set by the report store after the run directory is written. */ + @JsonIgnore + private Path path; + + private SkillCuratorReport(Builder b) { + this.runAt = b.runAt != null ? b.runAt : LocalDateTime.now(); + this.runId = this.runAt.format(RUN_ID); + this.dryRun = b.dryRun; + this.config = new Config(b.staleAfterDays, b.archiveAfterDays, b.scope); + this.scanned = b.scanned; + this.planned = new Counts(b.plannedStale, b.plannedArchived, b.plannedReactivated); + this.applied = new Counts(b.appliedStale, b.appliedArchived, b.appliedReactivated); + this.transitions = List.copyOf(b.transitions); + this.blockedByBindings = List.copyOf(b.blockedByBindings); + this.reconciliations = List.copyOf(b.reconciliations); + } + + public void setPath(Path path) { + this.path = path; + } + + /** Applied count of skills marked stale (0 for a dry-run). */ + public int markedStale() { + return applied.stale(); + } + + /** Applied count of skills archived (0 for a dry-run). */ + public int archived() { + return applied.archived(); + } + + /** Applied count of skills reactivated (0 for a dry-run). */ + public int reactivated() { + return applied.reactivated(); + } + + public record Config(int staleAfterDays, int archiveAfterDays, String scope) {} + + public record Counts(int stale, int archived, int reactivated) {} + + public record TransitionRow(Long skillId, String name, String from, String to, long daysIdle) {} + + public static Builder builder() { + return new Builder(); + } + + /** Incremental builder used by the sweep. */ + public static final class Builder { + private LocalDateTime runAt; + private boolean dryRun; + private int staleAfterDays; + private int archiveAfterDays; + private String scope; + private int scanned; + private int plannedStale, plannedArchived, plannedReactivated; + private int appliedStale, appliedArchived, appliedReactivated; + private final List transitions = new ArrayList<>(); + private List blockedByBindings = new ArrayList<>(); + private final List reconciliations = new ArrayList<>(); + + public Builder runAt(LocalDateTime runAt) { + this.runAt = runAt; + return this; + } + + public Builder dryRun(boolean dryRun) { + this.dryRun = dryRun; + return this; + } + + public Builder config(int staleAfterDays, int archiveAfterDays, String scope) { + this.staleAfterDays = staleAfterDays; + this.archiveAfterDays = archiveAfterDays; + this.scope = scope; + return this; + } + + public Builder scanned(int scanned) { + this.scanned = scanned; + return this; + } + + /** Record a non-NONE transition for a skill in the {@code transitions} list. */ + public Builder add(SkillEntity skill, LifecycleTransition t) { + if (t == null || t == LifecycleTransition.NONE) { + return this; + } + LocalDateTime anchor = skill.getLastActivityAt() != null + ? skill.getLastActivityAt() : skill.getCreateTime(); + long days = anchor == null || runAt == null ? 0L : Duration.between(anchor, runAt).toDays(); + String from = Optional.ofNullable(skill.getLifecycleState()).orElse("active"); + String to = switch (t) { + case TO_STALE -> "stale"; + case TO_ARCHIVED -> "archived"; + case REACTIVATE -> "active"; + case NONE -> from; + }; + transitions.add(new TransitionRow(skill.getId(), skill.getName(), from, to, days)); + return this; + } + + public Builder plannedCounts(int stale, int archived, int reactivated) { + this.plannedStale = stale; + this.plannedArchived = archived; + this.plannedReactivated = reactivated; + return this; + } + + public Builder appliedCounts(int stale, int archived, int reactivated) { + this.appliedStale = stale; + this.appliedArchived = archived; + this.appliedReactivated = reactivated; + return this; + } + + public Builder blockedByBindings(List rows) { + this.blockedByBindings = rows != null ? rows : new ArrayList<>(); + return this; + } + + public Builder reconciliation(String message) { + if (message != null && !message.isBlank()) { + this.reconciliations.add(message); + } + return this; + } + + public SkillCuratorReport build() { + return new SkillCuratorReport(this); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java new file mode 100644 index 00000000..1843d2c3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java @@ -0,0 +1,204 @@ +package vip.mate.skill.lifecycle; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.skill.workspace.SkillWorkspaceManager; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Persists lifecycle sweep reports to {@code {workspace-root}/.curator/}. + * Each run gets a {@code {runId}/} directory holding {@code run.json} (the + * structured record) and {@code REPORT.md} (a human-readable render); a + * {@code latest} symlink points at the newest run. + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillCuratorReportStore { + + /** Number of run directories kept on disk; older ones are pruned. */ + private static final int KEEP_RUNS = 50; + + /** Run ids are {@code yyyyMMdd-HHmmss} — validated before any path resolve. */ + private static final Pattern RUN_ID = Pattern.compile("\\d{8}-\\d{6}"); + + private final SkillWorkspaceManager workspaceManager; + private final ObjectMapper objectMapper; + + private Path curatorRoot() { + return workspaceManager.getWorkspaceRoot().resolve(".curator"); + } + + /** + * Write the report's run directory and update the {@code latest} + * symlink. The report's {@code path} is populated on success. + */ + public SkillCuratorReport write(SkillCuratorReport report) { + Path runDir = curatorRoot().resolve(report.getRunId()); + try { + Files.createDirectories(runDir); + objectMapper.writerWithDefaultPrettyPrinter() + .writeValue(runDir.resolve("run.json").toFile(), report); + Files.writeString(runDir.resolve("REPORT.md"), renderMarkdown(report)); + report.setPath(runDir); + updateLatest(runDir); + pruneOld(); + } catch (IOException e) { + log.warn("Failed to write curator report {}: {}", report.getRunId(), e.getMessage()); + } + return report; + } + + /** Most recent run ids, newest first, capped at {@code limit}. */ + public List listRunIds(int limit) { + Path root = curatorRoot(); + if (!Files.isDirectory(root)) { + return List.of(); + } + try (var stream = Files.list(root)) { + return stream + .filter(Files::isDirectory) + .map(p -> p.getFileName().toString()) + .filter(n -> RUN_ID.matcher(n).matches()) + .sorted(Comparator.reverseOrder()) + .limit(limit > 0 ? limit : 20) + .toList(); + } catch (IOException e) { + log.warn("Failed to list curator reports: {}", e.getMessage()); + return List.of(); + } + } + + /** Newest run id, or {@code null} when no run has been recorded yet. */ + public String latestRunId() { + List ids = listRunIds(1); + return ids.isEmpty() ? null : ids.get(0); + } + + /** + * Parsed {@code run.json} for a run, or {@code null} when the run is + * unknown. The {@code runId} is validated against the timestamp pattern + * before being resolved as a path component. + */ + public Object readRun(String runId) { + if (runId == null || !RUN_ID.matcher(runId).matches()) { + return null; + } + Path runJson = curatorRoot().resolve(runId).resolve("run.json"); + if (!Files.isRegularFile(runJson)) { + return null; + } + try { + return objectMapper.readValue(runJson.toFile(), Object.class); + } catch (IOException e) { + log.warn("Failed to read curator report {}: {}", runId, e.getMessage()); + return null; + } + } + + private void updateLatest(Path runDir) { + Path latest = curatorRoot().resolve("latest"); + try { + Files.deleteIfExists(latest); + Files.createSymbolicLink(latest, runDir.getFileName()); + } catch (IOException | UnsupportedOperationException e) { + // Symlinks may be unsupported (Windows without privilege) — the + // latest run is still discoverable via listRunIds(). + log.debug("Curator 'latest' symlink not updated: {}", e.getMessage()); + } + } + + private void pruneOld() { + List ids = listRunIds(Integer.MAX_VALUE); + if (ids.size() <= KEEP_RUNS) { + return; + } + for (String old : ids.subList(KEEP_RUNS, ids.size())) { + deleteRecursively(curatorRoot().resolve(old)); + } + } + + private void deleteRecursively(Path dir) { + if (!Files.exists(dir)) { + return; + } + try (var stream = Files.walk(dir)) { + stream.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + /* best-effort prune */ + } + }); + } catch (IOException e) { + log.debug("Failed to prune curator report {}: {}", dir, e.getMessage()); + } + } + + private String renderMarkdown(SkillCuratorReport r) { + StringBuilder sb = new StringBuilder(); + sb.append("# Skill Curator Run ").append(r.getRunId()).append("\n\n"); + sb.append("- Run at: ").append(r.getRunAt()).append('\n'); + sb.append("- Mode: ").append(r.isDryRun() ? "dry-run (preview)" : "applied").append('\n'); + sb.append("- Scope: ").append(r.getConfig().scope()) + .append(" (stale ≥ ").append(r.getConfig().staleAfterDays()) + .append("d, archive ≥ ").append(r.getConfig().archiveAfterDays()).append("d)\n"); + sb.append("- Scanned: ").append(r.getScanned()).append(" candidate(s)\n\n"); + + sb.append("## Planned\n\n"); + sb.append("| stale | archived | reactivated |\n|---|---|---|\n"); + sb.append("| ").append(r.getPlanned().stale()) + .append(" | ").append(r.getPlanned().archived()) + .append(" | ").append(r.getPlanned().reactivated()).append(" |\n\n"); + + sb.append("## Applied\n\n"); + sb.append("| stale | archived | reactivated |\n|---|---|---|\n"); + sb.append("| ").append(r.getApplied().stale()) + .append(" | ").append(r.getApplied().archived()) + .append(" | ").append(r.getApplied().reactivated()).append(" |\n\n"); + + if (!r.getTransitions().isEmpty()) { + sb.append("## Transitions\n\n"); + sb.append("| skill | from | to | days idle |\n|---|---|---|---|\n"); + for (SkillCuratorReport.TransitionRow t : r.getTransitions()) { + sb.append("| ").append(t.name()).append(" (").append(t.skillId()).append(')') + .append(" | ").append(t.from()) + .append(" | ").append(t.to()) + .append(" | ").append(t.daysIdle()).append(" |\n"); + } + sb.append('\n'); + } + + if (!r.getBlockedByBindings().isEmpty()) { + sb.append("## Blocked by agent bindings\n\n"); + sb.append("These skills satisfy the idle window but are kept because an " + + "enabled agent explicitly binds them.\n\n"); + sb.append("| skill | bound agents | days idle |\n|---|---|---|\n"); + for (BlockedByBindingRow b : r.getBlockedByBindings()) { + sb.append("| ").append(b.name()).append(" (").append(b.skillId()).append(')') + .append(" | ").append(b.agentIds()) + .append(" | ").append(b.daysIdle()).append(" |\n"); + } + sb.append('\n'); + } + + if (!r.getReconciliations().isEmpty()) { + sb.append("## Reconciliations\n\n"); + for (String line : r.getReconciliations()) { + sb.append("- ").append(line).append('\n'); + } + sb.append('\n'); + } + return sb.toString(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorRunCompletedEvent.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorRunCompletedEvent.java new file mode 100644 index 00000000..1c593403 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorRunCompletedEvent.java @@ -0,0 +1,24 @@ +package vip.mate.skill.lifecycle; + +import java.nio.file.Path; +import java.time.LocalDateTime; + +/** + * Published after every lifecycle sweep completes. Carries the applied + * counts (zero for a dry-run) so a downstream notification listener can + * surface the run without re-reading the report file. + * + *

This event has no compile-time dependency on any notification + * subsystem: if nothing listens, it is simply a no-op. + * + * @author MateClaw Team + */ +public record SkillCuratorRunCompletedEvent( + String runId, + int markedStale, + int archived, + int reactivated, + boolean dryRun, + Path reportPath, + LocalDateTime runAt) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleAutoConfiguration.java new file mode 100644 index 00000000..4c386593 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleAutoConfiguration.java @@ -0,0 +1,14 @@ +package vip.mate.skill.lifecycle; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * Auto-configuration for the skill lifecycle curator. + * + * @author MateClaw Team + */ +@Configuration +@EnableConfigurationProperties(SkillLifecycleProperties.class) +public class SkillLifecycleAutoConfiguration { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java new file mode 100644 index 00000000..9ed64593 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java @@ -0,0 +1,45 @@ +package vip.mate.skill.lifecycle; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +/** + * Configuration for the skill lifecycle curator — the daily job that moves + * idle, agent-created skills through {@code active -> stale -> archived}. + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mateclaw.skill.curator") +public class SkillLifecycleProperties { + + /** Master switch. When {@code false} the daily sweep never runs. */ + private boolean enabled = true; + + /** Cron expression for the daily sweep. Defaults to 02:00 every day. */ + private String cron = "0 0 2 * * *"; + + /** Days of inactivity after which an active skill becomes {@code stale}. */ + private int staleAfterDays = 30; + + /** Days of inactivity after which a stale skill becomes {@code archived}. */ + private int archiveAfterDays = 90; + + /** + * Which skills the curator considers: + *

    + *
  • {@code AGENT_CREATED} — only skills with a source conversation + * (created by an agent); the most conservative default.
  • + *
  • {@code ALL_DYNAMIC} — also includes manually-created dynamic + * skills.
  • + *
  • {@code OFF} — disables the sweep regardless of {@link #enabled}.
  • + *
+ */ + private String scope = "AGENT_CREATED"; + + /** Skills whose name starts with any of these prefixes are never touched. */ + private List protectPrefixes = new ArrayList<>(List.of("sys-", "ops-")); +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java new file mode 100644 index 00000000..be149025 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java @@ -0,0 +1,329 @@ +package vip.mate.skill.lifecycle; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import vip.mate.audit.service.AuditEventService; +import vip.mate.exception.MateClawException; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.skill.workspace.SkillWorkspaceProperties; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * State machine primitives for the skill lifecycle curator. Holds every + * mutation a skill can undergo as it ages out: {@code active -> stale -> + * archived}, plus the reverse {@code restore} and the activity bump that + * keeps an in-use skill anchored to the present. + * + *

All writes use {@link LambdaUpdateWrapper} whitelists rather than + * {@code updateById(entity)} so {@code FieldStrategy.ALWAYS} columns are + * never wiped by a partially-populated entity. + * + * @author MateClaw Team + */ +@Slf4j +@Service +public class SkillLifecycleService { + + private final SkillMapper skillMapper; + private final SkillWorkspaceManager workspaceManager; + private final SkillWorkspaceProperties workspaceProperties; + private final SkillRuntimeService runtimeService; + private final AuditEventService auditEventService; + private final ObjectMapper objectMapper; + private final SkillLifecycleProperties properties; + + /** + * {@code @Lazy} on {@code runtimeService} breaks the construction cycle + * {@code SkillService -> SkillLifecycleService -> SkillRuntimeService -> + * SkillService}. + */ + @Autowired + public SkillLifecycleService(SkillMapper skillMapper, + SkillWorkspaceManager workspaceManager, + SkillWorkspaceProperties workspaceProperties, + @Lazy SkillRuntimeService runtimeService, + AuditEventService auditEventService, + ObjectMapper objectMapper, + SkillLifecycleProperties properties) { + this.skillMapper = skillMapper; + this.workspaceManager = workspaceManager; + this.workspaceProperties = workspaceProperties; + this.runtimeService = runtimeService; + this.auditEventService = auditEventService; + this.objectMapper = objectMapper; + this.properties = properties; + } + + // ==================== Pure decision functions ==================== + + /** Activity anchor: last recorded activity, falling back to creation time. */ + public LocalDateTime anchor(SkillEntity skill) { + if (skill.getLastActivityAt() != null) { + return skill.getLastActivityAt(); + } + return skill.getCreateTime(); + } + + /** Skills the curator must never touch (filtered out before the state machine). */ + public boolean isExempt(SkillEntity skill) { + if (Boolean.TRUE.equals(skill.getBuiltin())) { + return true; + } + if (Boolean.TRUE.equals(skill.getPinned())) { + return true; + } + String type = skill.getSkillType(); + if (type == null || List.of("builtin", "mcp", "acp").contains(type)) { + return true; + } + String name = skill.getName(); + if (name != null) { + for (String prefix : properties.getProtectPrefixes()) { + if (prefix != null && !prefix.isBlank() && name.startsWith(prefix)) { + return true; + } + } + } + return false; + } + + /** + * Decide the transition for a skill at time {@code now}. Pure function: + * no side effects, no I/O — driven entirely by the entity's anchor and + * lifecycle state against the configured day thresholds. + */ + public LifecycleTransition planTransition(SkillEntity skill, LocalDateTime now) { + if (isExempt(skill)) { + return LifecycleTransition.NONE; + } + LocalDateTime anchor = anchor(skill); + if (anchor == null) { + return LifecycleTransition.NONE; + } + long days = Duration.between(anchor, now).toDays(); + String state = Optional.ofNullable(skill.getLifecycleState()).orElse("active"); + if (days >= properties.getArchiveAfterDays()) { + return "archived".equals(state) ? LifecycleTransition.NONE : LifecycleTransition.TO_ARCHIVED; + } + if (days >= properties.getStaleAfterDays()) { + return ("stale".equals(state) || "archived".equals(state)) + ? LifecycleTransition.NONE : LifecycleTransition.TO_STALE; + } + return "stale".equals(state) ? LifecycleTransition.REACTIVATE : LifecycleTransition.NONE; + } + + // ==================== Mutations ==================== + + /** + * Apply a planned transition. Returns {@code true} when the transition + * actually committed — an archive that fails at the workspace move or + * the DB write returns {@code false} so the caller can report + * {@code applied < planned}. + */ + public boolean apply(SkillEntity skill, LifecycleTransition t, LocalDateTime now) { + return applyManual(skill, t, now, defaultReason(t)); + } + + /** + * Same as {@link #apply} but with an explicit audit reason — used by the + * admin-triggered manual archive so the audit trail records intent. + */ + public boolean applyManual(SkillEntity skill, LifecycleTransition t, LocalDateTime now, String reason) { + return switch (t) { + case TO_STALE -> mark(skill, "stale"); + case TO_ARCHIVED -> archive(skill, now, reason); + case REACTIVATE -> mark(skill, "active"); + case NONE -> false; + }; + } + + /** + * Restore an archived skill: move its workspace back (when one was + * archived), flip the row to {@code active}, and refresh the runtime + * cache. DB-only skills with no archived workspace are a legitimate path + * — they restore on the DB write alone as long as {@code skill_content} + * still holds the body. + */ + public SkillEntity restore(Long id) { + SkillEntity skill = skillMapper.selectById(id); + if (skill == null) { + throw new MateClawException("err.skill.not_found", 404, "Skill not found: " + id); + } + if (!"archived".equals(skill.getLifecycleState())) { + throw new MateClawException("err.skill.not_archived", 409, + "Skill is not archived: " + skill.getName()); + } + + SkillWorkspaceManager.RestoreResult fs = workspaceManager.restoreWorkspace(skill.getName()); + switch (fs) { + case MOVED -> { /* normal path */ } + case MISSING -> { + if (skill.getSkillContent() == null || skill.getSkillContent().isBlank()) { + throw new MateClawException("err.skill.unrecoverable", 409, + "Skill has no workspace archive and no skill content — cannot restore"); + } + log.warn("Restoring DB-only skill '{}' (no workspace archive)", skill.getName()); + } + case FAILED -> throw new MateClawException("err.skill.restore_failed", 500, + "Workspace archive exists but move-back failed; check disk / permissions"); + } + + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, id) + .set(SkillEntity::getEnabled, true) + .set(SkillEntity::getLifecycleState, "active") + .set(SkillEntity::getArchivedAt, null) + .set(SkillEntity::getLastActivityAt, LocalDateTime.now())); + + runtimeService.refreshActiveSkills(); + recordAudit("RESTORE", skill, Map.of("fs", fs.name(), "to", "active")); + return skillMapper.selectById(id); + } + + /** + * Pin or unpin a skill. A pinned skill is permanently exempt from the + * automatic state machine until unpinned. + */ + public SkillEntity setPinned(Long id, boolean pinned) { + SkillEntity skill = skillMapper.selectById(id); + if (skill == null) { + throw new MateClawException("err.skill.not_found", 404, "Skill not found: " + id); + } + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, id) + .set(SkillEntity::getPinned, pinned)); + recordAudit(pinned ? "PIN" : "UNPIN", skill, Map.of("pinned", pinned)); + return skillMapper.selectById(id); + } + + /** + * Push the activity anchor of a skill to now and pull it back to + * {@code active} if it had drifted to {@code stale}. Best-effort: a + * write failure is logged, never thrown — losing one bump only delays + * the curator by a day. Archived skills are left untouched (recovering + * an archived skill must go through {@link #restore}). + */ + public void bumpActivity(Long skillId) { + if (skillId == null) { + return; + } + try { + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, skillId) + .and(w -> w.isNull(SkillEntity::getLifecycleState) + .or().ne(SkillEntity::getLifecycleState, "archived")) + .set(SkillEntity::getLastActivityAt, LocalDateTime.now()) + .set(SkillEntity::getLifecycleState, "active")); + } catch (Exception e) { + log.debug("Failed to bump activity for skill {}: {}", skillId, e.getMessage()); + } + } + + // ==================== Internals ==================== + + private boolean mark(SkillEntity skill, String toState) { + String prevState = Optional.ofNullable(skill.getLifecycleState()).orElse("active"); + if (prevState.equals(toState)) { + return false; + } + int rows; + try { + rows = skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, skill.getId()) + .set(SkillEntity::getLifecycleState, toState)); + } catch (Exception e) { + log.warn("Skill '{}' lifecycle mark to {} failed: {}", skill.getName(), toState, e.getMessage()); + return false; + } + if (rows == 0) { + return false; + } + recordAudit("LIFECYCLE", skill, Map.of("from", prevState, "to", toState)); + return true; + } + + /** + * Archive a skill: move its workspace to {@code .archived/}, then flip + * the row. The filesystem move runs before the DB write so a DB failure + * can be compensated by moving the workspace back. Returns {@code false} + * (no commit) when the workspace move fails or the DB write touches no + * rows — the next sweep retries. + */ + private boolean archive(SkillEntity skill, LocalDateTime now, String reason) { + // Step 1: workspace move. MISSING is commit-safe (DB-only skill); + // FAILED defers the whole transition. + SkillWorkspaceManager.ArchiveResult fsResult = SkillWorkspaceManager.ArchiveResult.MISSING; + if ("archive".equals(workspaceProperties.getDeletePolicy())) { + fsResult = workspaceManager.archiveWorkspace(skill.getName()); + } + if (fsResult == SkillWorkspaceManager.ArchiveResult.FAILED) { + log.warn("Skill '{}' workspace archive failed; deferring DB transition", skill.getName()); + return false; + } + + // Step 2: DB flip — guarded by affected-row count + compensation. + String prevState = Optional.ofNullable(skill.getLifecycleState()).orElse("active"); + int rows = 0; + try { + rows = skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, skill.getId()) + .set(SkillEntity::getEnabled, false) + .set(SkillEntity::getLifecycleState, "archived") + .set(SkillEntity::getArchivedAt, now)); + } catch (Exception e) { + log.error("Skill '{}' DB archive write failed; attempting compensation", skill.getName(), e); + } + if (rows == 0) { + log.warn("Skill '{}' DB archive update touched 0 rows; compensating workspace", skill.getName()); + if (fsResult == SkillWorkspaceManager.ArchiveResult.MOVED) { + workspaceManager.restoreWorkspace(skill.getName()); + } + return false; + } + + // Mirror the uninstall path: deregister wrapper tools AND refresh the + // active-skill cache so an in-flight prompt build stops seeing the row. + runtimeService.deregisterSkillWrappers(skill.getId()); + runtimeService.refreshActiveSkills(); + + recordAudit("ARCHIVE", skill, Map.of( + "reason", reason, + "anchor", String.valueOf(anchor(skill)), + "from", prevState, + "to", "archived", + "fs", fsResult.name())); + return true; + } + + private String defaultReason(LifecycleTransition t) { + return switch (t) { + case TO_STALE -> "idle>=" + properties.getStaleAfterDays() + "d"; + case TO_ARCHIVED -> "idle>=" + properties.getArchiveAfterDays() + "d"; + case REACTIVATE -> "activity-observed"; + case NONE -> ""; + }; + } + + private void recordAudit(String action, SkillEntity skill, Map detail) { + String json; + try { + json = objectMapper.writeValueAsString(detail); + } catch (Exception e) { + json = String.valueOf(detail); + } + auditEventService.record(action, "SKILL", + String.valueOf(skill.getId()), skill.getName(), json); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java index 44c29730..9ab88d18 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java @@ -130,6 +130,26 @@ public class SkillEntity { /** RFC-042 §2.3 — wall-clock time of the last scan write-back. */ private LocalDateTime securityScanTime; + /** + * Lifecycle state for the time-window archival state machine: + * {@code active} / {@code stale} / {@code archived}. Defaults to + * {@code active} via the column DEFAULT. + */ + private String lifecycleState; + + /** User-pinned skill — exempt from automatic archival. */ + private Boolean pinned; + + /** + * Activity anchor, cached from {@code mate_skill_usage_stat.last_loaded_at} + * so the daily lifecycle sweep is a single indexed select instead of a + * join. {@code null} falls back to {@code createTime} as the anchor. + */ + private LocalDateTime lastActivityAt; + + /** Wall-clock time the skill entered the archived state. */ + private LocalDateTime archivedAt; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index d99fc59f..d228f777 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -9,6 +9,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import vip.mate.exception.MateClawException; import vip.mate.skill.event.SkillRemovedEvent; +import vip.mate.skill.lifecycle.SkillLifecycleService; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.repository.SkillFileMapper; import vip.mate.skill.repository.SkillMapper; @@ -56,6 +57,8 @@ public class SkillService { * rows that the UI can no longer clear from the picker. */ private final ApplicationEventPublisher eventPublisher; + /** Stamps the activity anchor on create / update / enable so the curator sees fresh skills as active. */ + private final SkillLifecycleService lifecycleService; private vip.mate.skill.runtime.SkillRuntimeService runtimeService; /** @@ -132,7 +135,8 @@ public class SkillService { String source, String runtime, Set pinnedSkillIds, - Long workspaceId) { + Long workspaceId, + String lifecycleState) { Page pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1)); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); applyWorkspaceScope(wrapper, workspaceId); @@ -157,6 +161,13 @@ public class SkillService { if (scanStatus != null && !scanStatus.isBlank()) { wrapper.eq(SkillEntity::getSecurityScanStatus, scanStatus.trim().toUpperCase()); } + if (lifecycleState != null && !lifecycleState.isBlank()) { + wrapper.eq(SkillEntity::getLifecycleState, lifecycleState.trim().toLowerCase()); + } else { + // Default catalog view hides archived skills — they have their own tab. + wrapper.and(w -> w.isNull(SkillEntity::getLifecycleState) + .or().ne(SkillEntity::getLifecycleState, "archived")); + } SkillCatalogSort catalogSort = SkillCatalogSort.parse(sort); if (runtime != null && !runtime.isBlank() && !"all".equalsIgnoreCase(runtime) @@ -339,6 +350,10 @@ public class SkillService { skillMapper.insert(skill); log.info("Created skill: {} (type={})", skill.getName(), skill.getSkillType()); + // Stamp the activity anchor so a freshly-created skill is anchored to + // now rather than ageing from create_time alone. + lifecycleService.bumpActivity(skill.getId()); + // 自动初始化工作区目录 if (workspaceProperties.isAutoInit() && !hasExplicitSkillDir(skill)) { workspaceManager.initWorkspace(skill.getName(), skill.getSkillContent()); @@ -449,6 +464,9 @@ public class SkillService { skillMapper.updateById(existing); log.info("Updated skill: {}", existing.getName()); + // A manual edit counts as activity — keep the skill anchored to now. + lifecycleService.bumpActivity(existing.getId()); + // 若 skillContent 变更且约定工作区存在,同步 SKILL.md syncSkillContentToWorkspace(existing); @@ -568,6 +586,11 @@ public class SkillService { skillMapper.updateById(skill); log.info("Skill {} {}", skill.getName(), enabled ? "enabled" : "disabled"); + // Re-enabling a skill is an explicit "I use this again" signal. + if (enabled) { + lifecycleService.bumpActivity(id); + } + // RFC-090 review #3 — when disabling, explicitly tear down any // registered wrapper tools (knowledge / acp). Without this the // wrappers stay advertised because the availability supplier diff --git a/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java b/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java index cea08e4c..27438823 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/usage/SkillUsageService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import vip.mate.skill.lifecycle.SkillLifecycleService; import vip.mate.skill.repository.SkillUsageStatMapper; import vip.mate.skill.runtime.model.ResolvedSkill; @@ -18,6 +19,8 @@ import java.util.stream.Collectors; public class SkillUsageService { private final SkillUsageStatMapper mapper; + /** Bubbles the load event up to {@code mate_skill.last_activity_at} for the lifecycle curator. */ + private final SkillLifecycleService lifecycleService; public void recordLoaded(ResolvedSkill skill, Long agentId, String conversationId, String filePath, int tokenEstimate) { @@ -51,6 +54,9 @@ public class SkillUsageService { row.setLastTokenEstimate(tokenEstimate); mapper.updateById(row); } + // Mirror the activity anchor onto mate_skill so the lifecycle + // curator's daily scan stays a single indexed select. + lifecycleService.bumpActivity(skill.getId()); } catch (Exception e) { log.debug("Failed to record skill usage for {}: {}", skill.getName(), e.getMessage()); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java index 16414841..6f3aa1ab 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/workspace/SkillWorkspaceManager.java @@ -160,12 +160,45 @@ public class SkillWorkspaceManager { } /** - * 归档 workspace 到 {root}/.archived/{name}-{timestamp}/ + * Tri-state outcome of {@link #archiveWorkspace}. {@code MISSING} is a + * commit-safe no-op — the runtime accepts skills that live only in + * {@code mate_skill.skill_content} with no convention workspace — while + * {@code FAILED} is a real error callers requiring atomicity must honor. */ - public void archiveWorkspace(String skillName) { + public enum ArchiveResult { + /** Workspace directory existed and was moved to {@code .archived/}. */ + MOVED, + /** No convention workspace directory — commit-safe no-op. */ + MISSING, + /** Workspace existed but the move failed (IOException). */ + FAILED + } + + /** Symmetric tri-state outcome of {@link #restoreWorkspace}. */ + public enum RestoreResult { + /** Archive directory existed and was moved back into place. */ + MOVED, + /** No archive directory found — DB-only skill or nothing to restore. */ + MISSING, + /** Archive directory existed but the move-back failed (IOException). */ + FAILED + } + + /** + * Move {@code {root}/{name}/} to {@code {root}/.archived/{name}-{ts}/}. + * + *

Returns {@link ArchiveResult#MISSING} when the workspace doesn't + * exist — callers may treat this as a successful no-op since the runtime + * accepts skills that live only in {@code mate_skill.skill_content}. + * Returns {@link ArchiveResult#FAILED} on IOException; callers requiring + * atomicity must refuse to commit derived state. Returns + * {@link ArchiveResult#MOVED} on success, having already published + * {@link SkillWorkspaceEvent.Type#ARCHIVED}. + */ + public ArchiveResult archiveWorkspace(String skillName) { Path workspaceDir = resolveConventionPath(skillName); if (!Files.exists(workspaceDir)) { - return; + return ArchiveResult.MISSING; } try { @@ -178,8 +211,69 @@ public class SkillWorkspaceManager { Files.move(workspaceDir, archiveDir, StandardCopyOption.ATOMIC_MOVE); log.info("Archived skill workspace: {} → {}", workspaceDir, archiveDir); eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.ARCHIVED, archiveDir)); + return ArchiveResult.MOVED; } catch (IOException e) { log.warn("Failed to archive workspace for skill '{}': {}", skillName, e.getMessage()); + return ArchiveResult.FAILED; + } + } + + /** + * Move the most recent {@code .archived/{name}-{ts}/} directory back to + * the convention path. Symmetric to {@link #archiveWorkspace}. + * + *

Returns {@link RestoreResult#MISSING} when there is no archive + * directory to restore (a DB-only skill, or the convention path is + * already populated) — callers treat this as a no-op. Returns + * {@link RestoreResult#FAILED} when an archive directory exists but the + * move-back fails. + */ + public RestoreResult restoreWorkspace(String skillName) { + Path target = resolveConventionPath(skillName); + if (Files.exists(target)) { + log.warn("restoreWorkspace skipped: target {} already exists", target); + return RestoreResult.MISSING; + } + Path archiveRoot = getWorkspaceRoot().resolve(".archived"); + if (!Files.exists(archiveRoot)) { + return RestoreResult.MISSING; + } + + Optional newest = listArchivedFor(archiveRoot, sanitizeName(skillName)); + if (newest.isEmpty()) { + return RestoreResult.MISSING; + } + + try { + Files.move(newest.get(), target, StandardCopyOption.ATOMIC_MOVE); + log.info("Restored skill workspace: {} → {}", newest.get(), target); + eventPublisher.publishEvent(new SkillWorkspaceEvent( + skillName, SkillWorkspaceEvent.Type.CREATED, target)); + return RestoreResult.MOVED; + } catch (IOException e) { + log.warn("Failed to restore workspace for skill '{}': {}", skillName, e.getMessage()); + return RestoreResult.FAILED; + } + } + + /** + * Most recent archive directory for {@code sanitizedName}. Archive names + * are {@code {sanitizedName}-{yyyyMMdd-HHmmss}}; the timestamp suffix is + * matched exactly so a name like {@code foo} never picks up an archive of + * {@code foo-bar}. Lexical order on the fixed-width timestamp equals + * chronological order. + */ + private Optional listArchivedFor(Path archiveRoot, String sanitizedName) { + java.util.regex.Pattern suffix = + java.util.regex.Pattern.compile(java.util.regex.Pattern.quote(sanitizedName) + "-\\d{8}-\\d{6}"); + try (var stream = Files.list(archiveRoot)) { + return stream + .filter(Files::isDirectory) + .filter(p -> suffix.matcher(p.getFileName().toString()).matches()) + .max(Comparator.comparing(p -> p.getFileName().toString())); + } catch (IOException e) { + log.warn("Failed to list archive directory {}: {}", archiveRoot, e.getMessage()); + return Optional.empty(); } } diff --git a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java index 1a22ca6a..8384afa8 100644 --- a/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java +++ b/mateclaw-server/src/main/java/vip/mate/system/service/SystemSettingService.java @@ -409,6 +409,32 @@ public class SystemSettingService { return Boolean.parseBoolean(getValue(STATEGRAPH_ENABLED_KEY, "false")); } + /** + * Read a boolean setting. Returns {@code defaultValue} when the key is + * absent or stored as a non-boolean string. + */ + public boolean getBool(String key, boolean defaultValue) { + return Boolean.parseBoolean(getValue(key, String.valueOf(defaultValue))); + } + + /** Persist a boolean setting. */ + public void saveBool(String key, boolean value, String description) { + saveValue(key, String.valueOf(value), description); + } + + /** + * Read a raw string setting. Returns {@code defaultValue} (which may be + * {@code null}) when the key is absent. + */ + public String getString(String key, String defaultValue) { + return getValue(key, defaultValue); + } + + /** Persist a raw string setting. */ + public void saveString(String key, String value, String description) { + saveValue(key, value, description); + } + private String getValue(String key, String defaultValue) { SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper() .eq(SystemSettingEntity::getSettingKey, key) diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 77b1c9eb..1e548c86 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -136,6 +136,15 @@ mateclaw: root: ${user.home}/.mateclaw/skills auto-init: true delete-policy: archive + curator: + enabled: true + cron: "0 0 2 * * *" # daily 02:00 — staggered away from wiki / backup jobs + stale-after-days: 30 + archive-after-days: 90 + scope: AGENT_CREATED # AGENT_CREATED | ALL_DYNAMIC | OFF + protect-prefixes: + - "sys-" + - "ops-" hub: base-url: https://clawhub.ai search-path: /api/v1/search diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V117__skill_lifecycle.sql b/mateclaw-server/src/main/resources/db/migration/h2/V117__skill_lifecycle.sql new file mode 100644 index 00000000..be44e3f0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V117__skill_lifecycle.sql @@ -0,0 +1,33 @@ +-- Skill lifecycle columns: drive the time-window archival state machine +-- (active -> stale -> archived) for agent-created skills. +-- +-- lifecycle_state — current state; defaults to 'active'. +-- pinned — user-pinned skills are never auto-archived. +-- archived_at — wall-clock time the skill entered the archived state. +-- last_activity_at — cached activity anchor, mirrored from +-- mate_skill_usage_stat.last_loaded_at so the daily +-- sweep is a single indexed select instead of a join. +-- +-- last_activity_at uses the same TIMESTAMP type as +-- mate_skill_usage_stat.last_loaded_at so comparisons keep precision. + +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS lifecycle_state VARCHAR(16) DEFAULT 'active'; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS pinned TINYINT(1) DEFAULT 0; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP NULL; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMP NULL; + +CREATE INDEX IF NOT EXISTS idx_skill_lifecycle_state ON mate_skill(lifecycle_state); +CREATE INDEX IF NOT EXISTS idx_skill_last_activity_at ON mate_skill(last_activity_at); + +-- One-time backfill: existing rows take their newest usage tick as the +-- activity anchor. Rows with no usage stat stay NULL and fall through to +-- create_time at query time via the anchor() helper. +UPDATE mate_skill SET last_activity_at = ( + SELECT MAX(last_loaded_at) FROM mate_skill_usage_stat s + WHERE s.skill_name = mate_skill.name +) +WHERE last_activity_at IS NULL; + +-- Defensive: ensure no NULL state survives even if a prior partial run left +-- the column unset (the ADD COLUMN DEFAULT already covers the normal path). +UPDATE mate_skill SET lifecycle_state = 'active' WHERE lifecycle_state IS NULL; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V117__skill_lifecycle.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V117__skill_lifecycle.sql new file mode 100644 index 00000000..c33fbb60 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V117__skill_lifecycle.sql @@ -0,0 +1,85 @@ +-- See the H2 file for context. MySQL 8.0 supports neither +-- `ADD COLUMN IF NOT EXISTS` nor `CREATE INDEX IF NOT EXISTS`, so each +-- column and index is guarded by an INFORMATION_SCHEMA check applied via a +-- prepared statement (matches the pattern in V113 / V116). +-- +-- Column types: archived_at / last_activity_at use DATETIME(3) to match +-- mate_skill_usage_stat.last_loaded_at; lifecycle_state VARCHAR(16); +-- pinned TINYINT(1). + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'lifecycle_state' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_skill ADD COLUMN lifecycle_state VARCHAR(16) DEFAULT ''active''', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'pinned' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_skill ADD COLUMN pinned TINYINT(1) DEFAULT 0', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'archived_at' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_skill ADD COLUMN archived_at DATETIME(3) NULL', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND COLUMN_NAME = 'last_activity_at' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_skill ADD COLUMN last_activity_at DATETIME(3) NULL', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND INDEX_NAME = 'idx_skill_lifecycle_state' +); +SET @ddl := IF(@idx_exists = 0, + 'CREATE INDEX idx_skill_lifecycle_state ON mate_skill (lifecycle_state)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_skill' + AND INDEX_NAME = 'idx_skill_last_activity_at' +); +SET @ddl := IF(@idx_exists = 0, + 'CREATE INDEX idx_skill_last_activity_at ON mate_skill (last_activity_at)', + 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- One-time backfill: existing rows take their newest usage tick as the +-- activity anchor. Rows with no usage stat stay NULL and fall through to +-- create_time at query time via the anchor() helper. +UPDATE mate_skill SET last_activity_at = ( + SELECT MAX(last_loaded_at) FROM mate_skill_usage_stat s + WHERE s.skill_name = mate_skill.name +) +WHERE last_activity_at IS NULL; + +UPDATE mate_skill SET lifecycle_state = 'active' WHERE lifecycle_state IS NULL; diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index 4c8c054b..64b23c2d 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -182,6 +182,7 @@ err.skill.not_found=\u6280\u80fd\u4e0d\u5b58\u5728 err.skill.name_required=\u6280\u80fd\u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a err.skill.name_exists=\u6280\u80fd\u540d\u79f0\u5df2\u5b58\u5728 err.skill.builtin_readonly=\u5185\u7f6e\u6280\u80fd\u4e0d\u53ef\u5220\u9664 +err.skill.builtin_not_archivable=\u5185\u7f6e\u6280\u80fd\u4e0d\u53ef\u5f52\u6863 err.skill.cross_workspace_binding=\u4e0d\u80fd\u5c06\u5176\u5b83\u5de5\u4f5c\u533a\u7684\u6280\u80fd\u7ed1\u5b9a\u5230\u5f53\u524d Agent err.mcp.not_found=MCP server \u4e0d\u5b58\u5728 err.mcp.builtin_readonly=\u5185\u7f6e MCP server \u4e0d\u53ef\u5220\u9664 diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index 74a77a3d..9454cfc8 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -192,6 +192,7 @@ err.skill.not_found=Skill not found err.skill.name_required=Skill name cannot be empty err.skill.name_exists=Skill name already exists err.skill.builtin_readonly=Built-in skill cannot be deleted +err.skill.builtin_not_archivable=Built-in skill cannot be archived err.skill.cross_workspace_binding=Cannot bind a skill from a different workspace to this Agent # mcp err.mcp.not_found=MCP server not found diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java new file mode 100644 index 00000000..750bf133 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/service/AgentBindingServiceCuratorTest.java @@ -0,0 +1,153 @@ +package vip.mate.agent.binding.service; + +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.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.binding.model.AgentSkillBinding; +import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper; +import vip.mate.agent.binding.repository.AgentSkillBindingMapper; +import vip.mate.agent.binding.repository.AgentToolBindingMapper; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.skill.acp.AcpSkillBridge; +import vip.mate.skill.lifecycle.BlockedByBindingRow; +import vip.mate.skill.lifecycle.ConfirmRequiredException; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.tool.service.AvailableToolService; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Covers the lifecycle-curator support queries on {@link AgentBindingService}: + * the binding hard guard and the manual-archive agent lookup. + */ +@ExtendWith(MockitoExtension.class) +class AgentBindingServiceCuratorTest { + + @Mock + private AgentSkillBindingMapper skillBindingMapper; + @Mock + private AgentToolBindingMapper toolBindingMapper; + @Mock + private AgentProviderPreferenceMapper providerPreferenceMapper; + @Mock + private SkillRuntimeService skillRuntimeService; + @Mock + private AvailableToolService availableToolService; + @Mock + private AgentMapper agentMapper; + @Mock + private SkillMapper skillMapper; + @Mock + private AcpSkillBridge acpSkillBridge; + + private AgentBindingService service; + + private final LocalDateTime now = LocalDateTime.now(); + + @BeforeAll + static void initTableInfo() { + // Lambda wrappers resolve column names from MyBatis-Plus's static + // TableInfo cache; trigger it manually for this plain unit test. + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), ""); + TableInfoHelper.initTableInfo(assistant, AgentEntity.class); + TableInfoHelper.initTableInfo(assistant, AgentSkillBinding.class); + TableInfoHelper.initTableInfo(assistant, SkillEntity.class); + } + + @BeforeEach + void setUp() { + service = new AgentBindingService(skillBindingMapper, toolBindingMapper, providerPreferenceMapper, + skillRuntimeService, availableToolService, agentMapper, skillMapper, acpSkillBridge); + } + + private AgentEntity agent(long id, String name) { + AgentEntity a = new AgentEntity(); + a.setId(id); + a.setName(name); + a.setEnabled(true); + return a; + } + + private AgentSkillBinding binding(long skillId, long agentId) { + AgentSkillBinding b = new AgentSkillBinding(); + b.setSkillId(skillId); + b.setAgentId(agentId); + b.setEnabled(true); + return b; + } + + private SkillEntity skill(long id, String name, LocalDateTime lastActivity) { + SkillEntity s = new SkillEntity(); + s.setId(id); + s.setName(name); + s.setSkillType("dynamic"); + s.setBuiltin(false); + s.setPinned(false); + s.setLastActivityAt(lastActivity); + s.setCreateTime(lastActivity); + return s; + } + + @Test + void onlyBindingsToEnabledAgentsCountAsProtected() { + when(agentMapper.selectList(any())).thenReturn(List.of(agent(1L, "Alpha"), agent(2L, "Beta"))); + // skill 10 bound to enabled agent 1; skill 20 bound to a non-enabled agent 99. + when(skillBindingMapper.selectList(any())) + .thenReturn(List.of(binding(10L, 1L), binding(20L, 99L))); + + Set protectedIds = service.skillIdsBoundToEnabledAgents(); + + assertEquals(Set.of(10L), protectedIds); + } + + @Test + void blockedByBindingCandidatesCarrySkillDetailAndDaysIdle() { + when(agentMapper.selectList(any())).thenReturn(List.of(agent(1L, "Alpha"))); + when(skillBindingMapper.selectList(any())).thenReturn(List.of(binding(10L, 1L))); + when(skillMapper.selectBatchIds(any())) + .thenReturn(List.of(skill(10L, "weekly-report", now.minusDays(50)))); + + List rows = service.blockedByBindingCandidates(now); + + assertEquals(1, rows.size()); + assertEquals(10L, rows.get(0).skillId()); + assertEquals("weekly-report", rows.get(0).name()); + assertEquals(50L, rows.get(0).daysIdle()); + assertTrue(rows.get(0).agentIds().contains(1L)); + } + + @Test + void enabledAgentsBoundToSkillListsTheAffectedAgents() { + when(skillBindingMapper.selectList(any())).thenReturn(List.of(binding(10L, 1L))); + when(agentMapper.selectList(any())).thenReturn(List.of(agent(1L, "Alpha"))); + + List agents = service.enabledAgentsBoundToSkill(10L); + + assertEquals(1, agents.size()); + assertEquals(1L, agents.get(0).id()); + assertEquals("Alpha", agents.get(0).name()); + } + + @Test + void noEnabledAgentsMeansNothingIsProtected() { + when(agentMapper.selectList(any())).thenReturn(List.of()); + + assertTrue(service.skillIdsBoundToEnabledAgents().isEmpty()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerConfirmTest.java b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerConfirmTest.java new file mode 100644 index 00000000..a38889b6 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerConfirmTest.java @@ -0,0 +1,42 @@ +package vip.mate.exception; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import vip.mate.i18n.I18nService; +import vip.mate.skill.lifecycle.ConfirmRequiredException; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies the manual-archive confirm contract: a {@link ConfirmRequiredException} + * maps to a real HTTP 409 with a structured body the client can branch on. + */ +@ExtendWith(MockitoExtension.class) +class GlobalExceptionHandlerConfirmTest { + + @Mock + private I18nService i18nService; + + @Test + void confirmRequiredMapsToHttp409WithStructuredBody() { + GlobalExceptionHandler handler = new GlobalExceptionHandler(i18nService); + ConfirmRequiredException ex = new ConfirmRequiredException( + "BOUND_SKILL_CONFIRM_REQUIRED", + "Skill is explicitly bound to 2 agent(s); pass force=true to confirm", + List.of(new ConfirmRequiredException.AgentRow(42L, "DataAnalyst"), + new ConfirmRequiredException.AgentRow(71L, "ReportWriter"))); + + ResponseEntity> response = handler.handleConfirmRequired(ex); + + assertEquals(409, response.getStatusCode().value()); + assertEquals("BOUND_SKILL_CONFIRM_REQUIRED", response.getBody().get("code")); + Object boundAgents = response.getBody().get("boundAgents"); + assertEquals(2, ((List) boundAgents).size()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java index d253f875..b8dd7d92 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java @@ -51,7 +51,10 @@ class SkillControllerListEnabledTest { /* agentService */ null, /* agentBindingService */ null, mcpSkillBridge, - acpSkillBridge); + acpSkillBridge, + /* skillLifecycleService */ null, + /* skillCuratorJob */ null, + /* skillCuratorReportStore */ null); // listSkills() supplies realSkillNames() for shadow base — default // to empty so each test can override. when(skillService.listSkills(null)).thenReturn(List.of()); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java index a9f267ac..d9463b0c 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java @@ -25,7 +25,8 @@ import static org.mockito.Mockito.when; 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, 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 +65,7 @@ class SkillControllerVirtualGuardTest { McpSkillBridge bridge = mock(McpSkillBridge.class); SkillController c = new SkillController( null, null, null, null, null, null, null, null, null, null, null, - bridge, null); + bridge, null, null, null, null); long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; SkillEntity toggled = new SkillEntity(); toggled.setName("github"); @@ -96,7 +97,8 @@ class SkillControllerVirtualGuardTest { // not the guard. 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, 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. diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorReportTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorReportTest.java new file mode 100644 index 00000000..b97dc1ba --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorReportTest.java @@ -0,0 +1,92 @@ +package vip.mate.skill.lifecycle; + +import org.junit.jupiter.api.Test; +import vip.mate.skill.model.SkillEntity; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers the report builder — in particular the planned / applied count + * split so a dry-run preview still shows what would happen. + */ +class SkillCuratorReportTest { + + private final LocalDateTime now = LocalDateTime.now(); + + private SkillEntity skill(long id, String name, LocalDateTime lastActivity) { + SkillEntity s = new SkillEntity(); + s.setId(id); + s.setName(name); + s.setLifecycleState("active"); + s.setLastActivityAt(lastActivity); + s.setCreateTime(lastActivity); + return s; + } + + @Test + void dryRunReportKeepsPlannedCountsButZeroApplied() { + SkillCuratorReport report = SkillCuratorReport.builder() + .runAt(now) + .dryRun(true) + .config(30, 90, "AGENT_CREATED") + .add(skill(1L, "tmp-helper", now.minusDays(40)), LifecycleTransition.TO_STALE) + .add(skill(2L, "old-grep", now.minusDays(95)), LifecycleTransition.TO_ARCHIVED) + .scanned(2) + .plannedCounts(1, 1, 0) + .appliedCounts(0, 0, 0) + .build(); + + assertTrue(report.isDryRun()); + assertEquals(1, report.getPlanned().stale()); + assertEquals(1, report.getPlanned().archived()); + assertEquals(0, report.getApplied().stale()); + assertEquals(0, report.getApplied().archived()); + assertEquals(2, report.getTransitions().size()); + // Convenience accessors report what actually happened — zero for a dry-run. + assertEquals(0, report.markedStale()); + assertEquals(0, report.archived()); + } + + @Test + void appliedReportCountsMatchPlannedOnCleanRun() { + SkillCuratorReport report = SkillCuratorReport.builder() + .runAt(now) + .dryRun(false) + .config(30, 90, "AGENT_CREATED") + .scanned(3) + .plannedCounts(2, 1, 0) + .appliedCounts(2, 1, 0) + .build(); + + assertFalse(report.isDryRun()); + assertEquals(2, report.markedStale()); + assertEquals(1, report.archived()); + assertEquals(0, report.reactivated()); + } + + @Test + void transitionRowCarriesDaysIdleFromAnchor() { + SkillCuratorReport report = SkillCuratorReport.builder() + .runAt(now) + .config(30, 90, "AGENT_CREATED") + .add(skill(7L, "stale-thing", now.minusDays(42)), LifecycleTransition.TO_STALE) + .build(); + + SkillCuratorReport.TransitionRow row = report.getTransitions().get(0); + assertEquals(7L, row.skillId()); + assertEquals("active", row.from()); + assertEquals("stale", row.to()); + assertEquals(42L, row.daysIdle()); + } + + @Test + void runIdIsDerivedFromRunTimestamp() { + LocalDateTime fixed = LocalDateTime.of(2026, 5, 19, 2, 0, 0); + SkillCuratorReport report = SkillCuratorReport.builder().runAt(fixed).build(); + assertEquals("20260519-020000", report.getRunId()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java new file mode 100644 index 00000000..919e5a20 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java @@ -0,0 +1,169 @@ +package vip.mate.skill.lifecycle; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; +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.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.audit.service.AuditEventService; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.skill.workspace.SkillWorkspaceProperties; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Covers the lifecycle state machine ({@code planTransition}) and the + * archive atomicity / compensation path. + */ +@ExtendWith(MockitoExtension.class) +class SkillLifecycleServiceTest { + + @Mock + private SkillMapper skillMapper; + @Mock + private SkillWorkspaceManager workspaceManager; + @Mock + private SkillRuntimeService runtimeService; + @Mock + private AuditEventService auditEventService; + + private SkillLifecycleService service; + + private final LocalDateTime now = LocalDateTime.now(); + + @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 MockitoExtension test we trigger it manually. + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + SkillEntity.class); + } + + @BeforeEach + void setUp() { + SkillWorkspaceProperties workspaceProperties = new SkillWorkspaceProperties(); + SkillLifecycleProperties properties = new SkillLifecycleProperties(); + service = new SkillLifecycleService(skillMapper, workspaceManager, workspaceProperties, + runtimeService, auditEventService, new ObjectMapper(), properties); + } + + private SkillEntity skill(String type, String state, LocalDateTime lastActivity) { + SkillEntity s = new SkillEntity(); + s.setId(1L); + s.setName("demo-skill"); + s.setSkillType(type); + s.setBuiltin(false); + s.setPinned(false); + s.setLifecycleState(state); + s.setLastActivityAt(lastActivity); + s.setCreateTime(lastActivity); + return s; + } + + // ==================== planTransition ==================== + + @Test + void activeIdlePastStaleThresholdBecomesStale() { + SkillEntity s = skill("dynamic", "active", now.minusDays(31)); + assertEquals(LifecycleTransition.TO_STALE, service.planTransition(s, now)); + } + + @Test + void staleIdlePastArchiveThresholdBecomesArchived() { + SkillEntity s = skill("custom", "stale", now.minusDays(91)); + assertEquals(LifecycleTransition.TO_ARCHIVED, service.planTransition(s, now)); + } + + @Test + void staleSkillWithRecentActivityReactivates() { + SkillEntity s = skill("dynamic", "stale", now.minusDays(5)); + assertEquals(LifecycleTransition.REACTIVATE, service.planTransition(s, now)); + } + + @Test + void pinnedSkillIsNeverTouched() { + SkillEntity s = skill("dynamic", "active", now.minusDays(120)); + s.setPinned(true); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + @Test + void builtinSkillIsNeverTouched() { + SkillEntity s = skill("builtin", "active", now.minusDays(120)); + s.setBuiltin(true); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + @Test + void protectedPrefixSkillIsNeverTouched() { + SkillEntity s = skill("dynamic", "active", now.minusDays(120)); + s.setName("sys-health-probe"); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + @Test + void freshSkillStaysActive() { + SkillEntity s = skill("dynamic", "active", now.minusDays(3)); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + // ==================== archive atomicity ==================== + + @Test + void archiveDefersWhenWorkspaceMoveFails() { + SkillEntity s = skill("dynamic", "stale", now.minusDays(100)); + when(workspaceManager.archiveWorkspace(anyString())) + .thenReturn(SkillWorkspaceManager.ArchiveResult.FAILED); + + boolean applied = service.apply(s, LifecycleTransition.TO_ARCHIVED, now); + + assertFalse(applied); + verify(skillMapper, never()).update(any(), any()); + } + + @Test + void archiveCommitsForDbOnlySkillWithNoWorkspace() { + SkillEntity s = skill("dynamic", "stale", now.minusDays(100)); + when(workspaceManager.archiveWorkspace(anyString())) + .thenReturn(SkillWorkspaceManager.ArchiveResult.MISSING); + when(skillMapper.update(any(), any())).thenReturn(1); + + boolean applied = service.apply(s, LifecycleTransition.TO_ARCHIVED, now); + + assertTrue(applied); + verify(runtimeService).deregisterSkillWrappers(1L); + verify(runtimeService).refreshActiveSkills(); + } + + @Test + void archiveCompensatesWorkspaceWhenDbWriteTouchesNoRows() { + SkillEntity s = skill("dynamic", "stale", now.minusDays(100)); + when(workspaceManager.archiveWorkspace(anyString())) + .thenReturn(SkillWorkspaceManager.ArchiveResult.MOVED); + when(skillMapper.update(any(), any())).thenReturn(0); + + boolean applied = service.apply(s, LifecycleTransition.TO_ARCHIVED, now); + + assertFalse(applied); + verify(workspaceManager).restoreWorkspace("demo-skill"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceRemovalEventTest.java b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceRemovalEventTest.java index c1d9c130..e71fa2ac 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceRemovalEventTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceRemovalEventTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.context.ApplicationEventPublisher; import vip.mate.skill.event.SkillRemovedEvent; +import vip.mate.skill.lifecycle.SkillLifecycleService; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.repository.SkillFileMapper; import vip.mate.skill.repository.SkillMapper; @@ -49,7 +50,8 @@ class SkillServiceRemovalEventTest { when(workspaceProps.getDeletePolicy()).thenReturn("purge"); SkillService service = new SkillService( - mapper, fileMapper, workspaceManager, workspaceProps, secretService, publisher); + mapper, fileMapper, workspaceManager, workspaceProps, secretService, publisher, + mock(SkillLifecycleService.class)); service.setRuntimeService(runtimeService); service.uninstallSkill(42L); @@ -80,7 +82,8 @@ class SkillServiceRemovalEventTest { when(fileMapper.deleteBySkillId(99L)).thenReturn(0); SkillService service = new SkillService( - mapper, fileMapper, workspaceManager, workspaceProps, secretService, publisher); + mapper, fileMapper, workspaceManager, workspaceProps, secretService, publisher, + mock(SkillLifecycleService.class)); service.setRuntimeService(runtimeService); service.hardDeleteSkill(99L); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java index dcd54bd1..094c0b3d 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceUpdatePartialTest.java @@ -3,6 +3,7 @@ package vip.mate.skill.service; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import vip.mate.skill.lifecycle.SkillLifecycleService; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.SkillRuntimeService; @@ -60,7 +61,8 @@ class SkillServiceUpdatePartialTest { SkillService service = new SkillService( mapper, mock(vip.mate.skill.repository.SkillFileMapper.class), workspaceManager, workspaceProps, secretService, - mock(org.springframework.context.ApplicationEventPublisher.class)); + mock(org.springframework.context.ApplicationEventPublisher.class), + mock(SkillLifecycleService.class)); service.setRuntimeService(runtimeService); SkillEntity existing = new SkillEntity(); @@ -139,7 +141,8 @@ class SkillServiceUpdatePartialTest { SkillService service = new SkillService( mapper, mock(vip.mate.skill.repository.SkillFileMapper.class), workspaceManager, workspaceProps, secretService, - mock(org.springframework.context.ApplicationEventPublisher.class)); + mock(org.springframework.context.ApplicationEventPublisher.class), + mock(SkillLifecycleService.class)); service.setRuntimeService(runtimeService); SkillEntity existing = new SkillEntity(); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceWorkspaceScopeTest.java b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceWorkspaceScopeTest.java index af6d7947..f63505bd 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceWorkspaceScopeTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceWorkspaceScopeTest.java @@ -81,7 +81,7 @@ class SkillServiceWorkspaceScopeTest { seedSkill(ws2Name, 2L, false); IPage ws2Page = skillService.pageSkills( - 1, 200, null, null, null, null, null, null, null, Set.of(), 2L); + 1, 200, null, null, null, null, null, null, null, Set.of(), 2L, null); Set names = ws2Page.getRecords().stream() .map(SkillEntity::getName) .collect(java.util.stream.Collectors.toSet());