mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): automatic lifecycle archival for idle skills
This commit is contained in:
parent
cfb123dda2
commit
4cd21056a0
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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 {
|
||||
}
|
||||
@ -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-"));
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
|
||||
Loading…
Reference in New Issue
Block a user