feat(skill): automatic lifecycle archival for idle skills

This commit is contained in:
matevip 2026-05-19 09:55:59 +08:00
parent cfb123dda2
commit 4cd21056a0
33 changed files with 2243 additions and 16 deletions

View File

@ -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<Long> skillIdsBoundToEnabledAgents() {
Set<Long> enabledAgentIds = enabledAgentIds();
if (enabledAgentIds.isEmpty()) {
return Set.of();
}
return skillBindingMapper.selectList(new LambdaQueryWrapper<AgentSkillBinding>()
.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<BlockedByBindingRow> blockedByBindingCandidates(LocalDateTime now) {
Set<Long> enabledAgentIds = enabledAgentIds();
if (enabledAgentIds.isEmpty()) {
return List.of();
}
Map<Long, List<Long>> bySkill = new HashMap<>();
for (AgentSkillBinding b : skillBindingMapper.selectList(new LambdaQueryWrapper<AgentSkillBinding>()
.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<BlockedByBindingRow> 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<ConfirmRequiredException.AgentRow> enabledAgentsBoundToSkill(Long skillId) {
if (skillId == null) {
return List.of();
}
Set<Long> agentIds = skillBindingMapper.selectList(new LambdaQueryWrapper<AgentSkillBinding>()
.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<AgentEntity>()
.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<Long> enabledAgentIds() {
return agentMapper.selectList(new LambdaQueryWrapper<AgentEntity>()
.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

View File

@ -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<Map<String, Object>> handleConfirmRequired(ConfirmRequiredException e) {
log.info("Confirm required: [{}] {}", e.getCode(), e.getMessage());
Map<String, Object> 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<R<Void>> handleBindException(BindException e) {
String msg = e.getBindingResult().getFieldErrors().stream()

View File

@ -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<Long> pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of();
if (pinnedSkillIds == null) pinnedSkillIds = Set.of();
IPage<SkillEntity> dbPage = skillService.pageSkills(
page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime,
pinnedSkillIds, workspaceId);
List<SkillEntity> 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<SkillEntity> 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<SkillEntity> 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<SkillEntity> 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<ConfirmRequiredException.AgentRow> 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<SkillEntity> 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<SkillCuratorReport> curatorDryRun() {
return R.ok(skillCuratorJob.dryRunNow());
}
@Operation(summary = "激活/取消激活 curator真正归档 vs 仅预览)")
@PostMapping("/curator/activate")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> curatorActivate(
@RequestParam(defaultValue = "true") boolean activate) {
skillCuratorJob.activate(activate);
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "暂停 curator 定时扫描")
@PostMapping("/curator/pause")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> curatorPause() {
skillCuratorJob.setPaused(true);
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "恢复 curator 定时扫描")
@PostMapping("/curator/resume")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> curatorResume() {
skillCuratorJob.setPaused(false);
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "curator 控制面状态")
@GetMapping("/curator/status")
@RequireWorkspaceRole("member")
public R<Map<String, Object>> curatorStatus() {
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "列出最近的 curator 运行报告")
@GetMapping("/curator/reports")
@RequireWorkspaceRole("member")
public R<List<String>> curatorReports() {
return R.ok(skillCuratorReportStore.listRunIds(20));
}
@Operation(summary = "读取某次 curator 运行报告")
@GetMapping("/curator/reports/{runId}")
@RequireWorkspaceRole("member")
public R<Object> 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) {}
}

View File

@ -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<Long> agentIds,
long daysIdle) {
}

View File

@ -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).
*
* <p>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<AgentRow> boundAgents;
public ConfirmRequiredException(String code, String message, List<AgentRow> 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) {}
}

View File

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

View File

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

View File

@ -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<String, Object> status() {
Map<String, Object> 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<String, Object> 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<String, Object> 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<SkillEntity>().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<String, Object> 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<SkillEntity> 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<SkillEntity> loadCandidates() {
Set<Long> bindingProtected = agentBindingService.skillIdsBoundToEnabledAgents();
LambdaQueryWrapper<SkillEntity> w = new LambdaQueryWrapper<SkillEntity>()
.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<SkillEntity> archived = skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.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<SkillEntity>()
.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<SkillEntity>()
.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;
}
}
}

View File

@ -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}.
*
* <p>{@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<TransitionRow> transitions;
private final List<BlockedByBindingRow> blockedByBindings;
private final List<String> 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<TransitionRow> transitions = new ArrayList<>();
private List<BlockedByBindingRow> blockedByBindings = new ArrayList<>();
private final List<String> 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<BlockedByBindingRow> 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);
}
}
}

View File

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

View File

@ -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.
*
* <p>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) {
}

View File

@ -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 {
}

View File

@ -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:
* <ul>
* <li>{@code AGENT_CREATED} only skills with a source conversation
* (created by an agent); the most conservative default.</li>
* <li>{@code ALL_DYNAMIC} also includes manually-created dynamic
* skills.</li>
* <li>{@code OFF} disables the sweep regardless of {@link #enabled}.</li>
* </ul>
*/
private String scope = "AGENT_CREATED";
/** Skills whose name starts with any of these prefixes are never touched. */
private List<String> protectPrefixes = new ArrayList<>(List.of("sys-", "ops-"));
}

View File

@ -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.
*
* <p>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<SkillEntity>()
.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<SkillEntity>()
.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<SkillEntity>()
.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<SkillEntity>()
.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<SkillEntity>()
.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<String, Object> 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);
}
}

View File

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

View File

@ -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<Long> pinnedSkillIds,
Long workspaceId) {
Long workspaceId,
String lifecycleState) {
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
LambdaQueryWrapper<SkillEntity> 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

View File

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

View File

@ -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}/}.
*
* <p>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}.
*
* <p>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<Path> 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<Path> 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();
}
}

View File

@ -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<SystemSettingEntity>()
.eq(SystemSettingEntity::getSettingKey, key)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Long> 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<BlockedByBindingRow> 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<ConfirmRequiredException.AgentRow> 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());
}
}

View File

@ -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<Map<String, Object>> 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());
}
}

View File

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

View File

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

View File

@ -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 <em>would</em> 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());
}
}

View File

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

View File

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

View File

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

View File

@ -81,7 +81,7 @@ class SkillServiceWorkspaceScopeTest {
seedSkill(ws2Name, 2L, false);
IPage<SkillEntity> 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<String> names = ws2Page.getRecords().stream()
.map(SkillEntity::getName)
.collect(java.util.stream.Collectors.toSet());