mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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.llm.routing.AgentBindingResolver;
|
||||||
import vip.mate.skill.acp.AcpSkillBridge;
|
import vip.mate.skill.acp.AcpSkillBridge;
|
||||||
import vip.mate.skill.mcp.McpSkillBridge;
|
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.model.SkillEntity;
|
||||||
import vip.mate.skill.repository.SkillMapper;
|
import vip.mate.skill.repository.SkillMapper;
|
||||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
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.model.AvailableToolDTO;
|
||||||
import vip.mate.tool.service.AvailableToolService;
|
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.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
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.
|
* Refuse to bind a skill that doesn't share the agent's workspace.
|
||||||
* Skills are per-workspace installable artifacts (each workspace has
|
* 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 org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
import vip.mate.i18n.I18nService;
|
import vip.mate.i18n.I18nService;
|
||||||
|
import vip.mate.skill.lifecycle.ConfirmRequiredException;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global exception handler.
|
* Global exception handler.
|
||||||
@ -68,6 +72,21 @@ public class GlobalExceptionHandler {
|
|||||||
return e.getMessage();
|
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)
|
@ExceptionHandler(BindException.class)
|
||||||
public ResponseEntity<R<Void>> handleBindException(BindException e) {
|
public ResponseEntity<R<Void>> handleBindException(BindException e) {
|
||||||
String msg = e.getBindingResult().getFieldErrors().stream()
|
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.BundledSkillSyncer;
|
||||||
import vip.mate.skill.workspace.SkillFileSyncer;
|
import vip.mate.skill.workspace.SkillFileSyncer;
|
||||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
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.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -60,6 +68,9 @@ public class SkillController {
|
|||||||
private final AgentBindingService agentBindingService;
|
private final AgentBindingService agentBindingService;
|
||||||
private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge;
|
private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge;
|
||||||
private final vip.mate.skill.acp.AcpSkillBridge acpSkillBridge;
|
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)")
|
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@ -75,14 +86,20 @@ public class SkillController {
|
|||||||
@RequestParam(required = false) String sort,
|
@RequestParam(required = false) String sort,
|
||||||
@RequestParam(required = false) String source,
|
@RequestParam(required = false) String source,
|
||||||
@RequestParam(required = false) String runtime,
|
@RequestParam(required = false) String runtime,
|
||||||
|
@RequestParam(required = false) String lifecycleState,
|
||||||
@RequestParam(required = false) Long agentId) {
|
@RequestParam(required = false) Long agentId) {
|
||||||
Set<Long> pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of();
|
Set<Long> pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of();
|
||||||
if (pinnedSkillIds == null) pinnedSkillIds = Set.of();
|
if (pinnedSkillIds == null) pinnedSkillIds = Set.of();
|
||||||
IPage<SkillEntity> dbPage = skillService.pageSkills(
|
IPage<SkillEntity> dbPage = skillService.pageSkills(
|
||||||
page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime,
|
page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime,
|
||||||
pinnedSkillIds, workspaceId);
|
pinnedSkillIds, workspaceId, lifecycleState);
|
||||||
List<SkillEntity> virtualSkills = visibleVirtualSkills(
|
// Virtual MCP/ACP skills mirror live servers and carry no lifecycle
|
||||||
workspaceId, keyword, skillType, enabled, scanStatus, sort, source, runtime);
|
// 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()) {
|
if (!virtualSkills.isEmpty()) {
|
||||||
VirtualPageMergeResult merged = mergeVirtualTailPageRecords(
|
VirtualPageMergeResult merged = mergeVirtualTailPageRecords(
|
||||||
dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size);
|
dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size);
|
||||||
@ -784,4 +801,129 @@ public class SkillController {
|
|||||||
verifyResourceWorkspace(skill, workspaceId);
|
verifyResourceWorkspace(skill, workspaceId);
|
||||||
return R.ok(workspaceManager.getWorkspaceInfo(skill.getName()));
|
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. */
|
/** RFC-042 §2.3 — wall-clock time of the last scan write-back. */
|
||||||
private LocalDateTime securityScanTime;
|
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)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import org.springframework.context.ApplicationEventPublisher;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.exception.MateClawException;
|
import vip.mate.exception.MateClawException;
|
||||||
import vip.mate.skill.event.SkillRemovedEvent;
|
import vip.mate.skill.event.SkillRemovedEvent;
|
||||||
|
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
||||||
import vip.mate.skill.model.SkillEntity;
|
import vip.mate.skill.model.SkillEntity;
|
||||||
import vip.mate.skill.repository.SkillFileMapper;
|
import vip.mate.skill.repository.SkillFileMapper;
|
||||||
import vip.mate.skill.repository.SkillMapper;
|
import vip.mate.skill.repository.SkillMapper;
|
||||||
@ -56,6 +57,8 @@ public class SkillService {
|
|||||||
* rows that the UI can no longer clear from the picker.
|
* rows that the UI can no longer clear from the picker.
|
||||||
*/
|
*/
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
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;
|
private vip.mate.skill.runtime.SkillRuntimeService runtimeService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -132,7 +135,8 @@ public class SkillService {
|
|||||||
String source,
|
String source,
|
||||||
String runtime,
|
String runtime,
|
||||||
Set<Long> pinnedSkillIds,
|
Set<Long> pinnedSkillIds,
|
||||||
Long workspaceId) {
|
Long workspaceId,
|
||||||
|
String lifecycleState) {
|
||||||
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
|
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
|
||||||
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>();
|
||||||
applyWorkspaceScope(wrapper, workspaceId);
|
applyWorkspaceScope(wrapper, workspaceId);
|
||||||
@ -157,6 +161,13 @@ public class SkillService {
|
|||||||
if (scanStatus != null && !scanStatus.isBlank()) {
|
if (scanStatus != null && !scanStatus.isBlank()) {
|
||||||
wrapper.eq(SkillEntity::getSecurityScanStatus, scanStatus.trim().toUpperCase());
|
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);
|
SkillCatalogSort catalogSort = SkillCatalogSort.parse(sort);
|
||||||
if (runtime != null && !runtime.isBlank() && !"all".equalsIgnoreCase(runtime)
|
if (runtime != null && !runtime.isBlank() && !"all".equalsIgnoreCase(runtime)
|
||||||
@ -339,6 +350,10 @@ public class SkillService {
|
|||||||
skillMapper.insert(skill);
|
skillMapper.insert(skill);
|
||||||
log.info("Created skill: {} (type={})", skill.getName(), skill.getSkillType());
|
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)) {
|
if (workspaceProperties.isAutoInit() && !hasExplicitSkillDir(skill)) {
|
||||||
workspaceManager.initWorkspace(skill.getName(), skill.getSkillContent());
|
workspaceManager.initWorkspace(skill.getName(), skill.getSkillContent());
|
||||||
@ -449,6 +464,9 @@ public class SkillService {
|
|||||||
skillMapper.updateById(existing);
|
skillMapper.updateById(existing);
|
||||||
log.info("Updated skill: {}", existing.getName());
|
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
|
// 若 skillContent 变更且约定工作区存在,同步 SKILL.md
|
||||||
syncSkillContentToWorkspace(existing);
|
syncSkillContentToWorkspace(existing);
|
||||||
|
|
||||||
@ -568,6 +586,11 @@ public class SkillService {
|
|||||||
skillMapper.updateById(skill);
|
skillMapper.updateById(skill);
|
||||||
log.info("Skill {} {}", skill.getName(), enabled ? "enabled" : "disabled");
|
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
|
// RFC-090 review #3 — when disabling, explicitly tear down any
|
||||||
// registered wrapper tools (knowledge / acp). Without this the
|
// registered wrapper tools (knowledge / acp). Without this the
|
||||||
// wrappers stay advertised because the availability supplier
|
// wrappers stay advertised because the availability supplier
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
||||||
import vip.mate.skill.repository.SkillUsageStatMapper;
|
import vip.mate.skill.repository.SkillUsageStatMapper;
|
||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
|
|
||||||
@ -18,6 +19,8 @@ import java.util.stream.Collectors;
|
|||||||
public class SkillUsageService {
|
public class SkillUsageService {
|
||||||
|
|
||||||
private final SkillUsageStatMapper mapper;
|
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,
|
public void recordLoaded(ResolvedSkill skill, Long agentId, String conversationId,
|
||||||
String filePath, int tokenEstimate) {
|
String filePath, int tokenEstimate) {
|
||||||
@ -51,6 +54,9 @@ public class SkillUsageService {
|
|||||||
row.setLastTokenEstimate(tokenEstimate);
|
row.setLastTokenEstimate(tokenEstimate);
|
||||||
mapper.updateById(row);
|
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) {
|
} catch (Exception e) {
|
||||||
log.debug("Failed to record skill usage for {}: {}", skill.getName(), e.getMessage());
|
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);
|
Path workspaceDir = resolveConventionPath(skillName);
|
||||||
if (!Files.exists(workspaceDir)) {
|
if (!Files.exists(workspaceDir)) {
|
||||||
return;
|
return ArchiveResult.MISSING;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -178,8 +211,69 @@ public class SkillWorkspaceManager {
|
|||||||
Files.move(workspaceDir, archiveDir, StandardCopyOption.ATOMIC_MOVE);
|
Files.move(workspaceDir, archiveDir, StandardCopyOption.ATOMIC_MOVE);
|
||||||
log.info("Archived skill workspace: {} → {}", workspaceDir, archiveDir);
|
log.info("Archived skill workspace: {} → {}", workspaceDir, archiveDir);
|
||||||
eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.ARCHIVED, archiveDir));
|
eventPublisher.publishEvent(new SkillWorkspaceEvent(skillName, SkillWorkspaceEvent.Type.ARCHIVED, archiveDir));
|
||||||
|
return ArchiveResult.MOVED;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.warn("Failed to archive workspace for skill '{}': {}", skillName, e.getMessage());
|
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"));
|
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) {
|
private String getValue(String key, String defaultValue) {
|
||||||
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper<SystemSettingEntity>()
|
SystemSettingEntity entity = systemSettingMapper.selectOne(new LambdaQueryWrapper<SystemSettingEntity>()
|
||||||
.eq(SystemSettingEntity::getSettingKey, key)
|
.eq(SystemSettingEntity::getSettingKey, key)
|
||||||
|
|||||||
@ -136,6 +136,15 @@ mateclaw:
|
|||||||
root: ${user.home}/.mateclaw/skills
|
root: ${user.home}/.mateclaw/skills
|
||||||
auto-init: true
|
auto-init: true
|
||||||
delete-policy: archive
|
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:
|
hub:
|
||||||
base-url: https://clawhub.ai
|
base-url: https://clawhub.ai
|
||||||
search-path: /api/v1/search
|
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_required=\u6280\u80fd\u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a
|
||||||
err.skill.name_exists=\u6280\u80fd\u540d\u79f0\u5df2\u5b58\u5728
|
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_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.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.not_found=MCP server \u4e0d\u5b58\u5728
|
||||||
err.mcp.builtin_readonly=\u5185\u7f6e MCP server \u4e0d\u53ef\u5220\u9664
|
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_required=Skill name cannot be empty
|
||||||
err.skill.name_exists=Skill name already exists
|
err.skill.name_exists=Skill name already exists
|
||||||
err.skill.builtin_readonly=Built-in skill cannot be deleted
|
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
|
err.skill.cross_workspace_binding=Cannot bind a skill from a different workspace to this Agent
|
||||||
# mcp
|
# mcp
|
||||||
err.mcp.not_found=MCP server not found
|
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,
|
/* agentService */ null,
|
||||||
/* agentBindingService */ null,
|
/* agentBindingService */ null,
|
||||||
mcpSkillBridge,
|
mcpSkillBridge,
|
||||||
acpSkillBridge);
|
acpSkillBridge,
|
||||||
|
/* skillLifecycleService */ null,
|
||||||
|
/* skillCuratorJob */ null,
|
||||||
|
/* skillCuratorReportStore */ null);
|
||||||
// listSkills() supplies realSkillNames() for shadow base — default
|
// listSkills() supplies realSkillNames() for shadow base — default
|
||||||
// to empty so each test can override.
|
// to empty so each test can override.
|
||||||
when(skillService.listSkills(null)).thenReturn(List.of());
|
when(skillService.listSkills(null)).thenReturn(List.of());
|
||||||
|
|||||||
@ -25,7 +25,8 @@ import static org.mockito.Mockito.when;
|
|||||||
class SkillControllerVirtualGuardTest {
|
class SkillControllerVirtualGuardTest {
|
||||||
|
|
||||||
private final SkillController controller = new SkillController(
|
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
|
@Test
|
||||||
@DisplayName("update on a virtual MCP skill id is rejected before hitting the service")
|
@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);
|
McpSkillBridge bridge = mock(McpSkillBridge.class);
|
||||||
SkillController c = new SkillController(
|
SkillController c = new SkillController(
|
||||||
null, null, null, null, null, null, null, null, null, null, null,
|
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;
|
long virtualMcpId = McpSkillBridge.VIRTUAL_ID_BASE + 42L;
|
||||||
SkillEntity toggled = new SkillEntity();
|
SkillEntity toggled = new SkillEntity();
|
||||||
toggled.setName("github");
|
toggled.setName("github");
|
||||||
@ -96,7 +97,8 @@ class SkillControllerVirtualGuardTest {
|
|||||||
// not the guard.
|
// not the guard.
|
||||||
SkillController real = new SkillController(
|
SkillController real = new SkillController(
|
||||||
mock(vip.mate.skill.service.SkillService.class),
|
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;
|
long snowflakeId = 1_900_000_001_000_000_902L;
|
||||||
// updateSkill on a mocked SkillService returns null without throwing,
|
// updateSkill on a mocked SkillService returns null without throwing,
|
||||||
// which is fine — we just need to confirm the guard didn't fire.
|
// 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.mockito.ArgumentCaptor;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import vip.mate.skill.event.SkillRemovedEvent;
|
import vip.mate.skill.event.SkillRemovedEvent;
|
||||||
|
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
||||||
import vip.mate.skill.model.SkillEntity;
|
import vip.mate.skill.model.SkillEntity;
|
||||||
import vip.mate.skill.repository.SkillFileMapper;
|
import vip.mate.skill.repository.SkillFileMapper;
|
||||||
import vip.mate.skill.repository.SkillMapper;
|
import vip.mate.skill.repository.SkillMapper;
|
||||||
@ -49,7 +50,8 @@ class SkillServiceRemovalEventTest {
|
|||||||
when(workspaceProps.getDeletePolicy()).thenReturn("purge");
|
when(workspaceProps.getDeletePolicy()).thenReturn("purge");
|
||||||
|
|
||||||
SkillService service = new SkillService(
|
SkillService service = new SkillService(
|
||||||
mapper, fileMapper, workspaceManager, workspaceProps, secretService, publisher);
|
mapper, fileMapper, workspaceManager, workspaceProps, secretService, publisher,
|
||||||
|
mock(SkillLifecycleService.class));
|
||||||
service.setRuntimeService(runtimeService);
|
service.setRuntimeService(runtimeService);
|
||||||
|
|
||||||
service.uninstallSkill(42L);
|
service.uninstallSkill(42L);
|
||||||
@ -80,7 +82,8 @@ class SkillServiceRemovalEventTest {
|
|||||||
when(fileMapper.deleteBySkillId(99L)).thenReturn(0);
|
when(fileMapper.deleteBySkillId(99L)).thenReturn(0);
|
||||||
|
|
||||||
SkillService service = new SkillService(
|
SkillService service = new SkillService(
|
||||||
mapper, fileMapper, workspaceManager, workspaceProps, secretService, publisher);
|
mapper, fileMapper, workspaceManager, workspaceProps, secretService, publisher,
|
||||||
|
mock(SkillLifecycleService.class));
|
||||||
service.setRuntimeService(runtimeService);
|
service.setRuntimeService(runtimeService);
|
||||||
|
|
||||||
service.hardDeleteSkill(99L);
|
service.hardDeleteSkill(99L);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package vip.mate.skill.service;
|
|||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import vip.mate.skill.lifecycle.SkillLifecycleService;
|
||||||
import vip.mate.skill.model.SkillEntity;
|
import vip.mate.skill.model.SkillEntity;
|
||||||
import vip.mate.skill.repository.SkillMapper;
|
import vip.mate.skill.repository.SkillMapper;
|
||||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||||
@ -60,7 +61,8 @@ class SkillServiceUpdatePartialTest {
|
|||||||
SkillService service = new SkillService(
|
SkillService service = new SkillService(
|
||||||
mapper, mock(vip.mate.skill.repository.SkillFileMapper.class),
|
mapper, mock(vip.mate.skill.repository.SkillFileMapper.class),
|
||||||
workspaceManager, workspaceProps, secretService,
|
workspaceManager, workspaceProps, secretService,
|
||||||
mock(org.springframework.context.ApplicationEventPublisher.class));
|
mock(org.springframework.context.ApplicationEventPublisher.class),
|
||||||
|
mock(SkillLifecycleService.class));
|
||||||
service.setRuntimeService(runtimeService);
|
service.setRuntimeService(runtimeService);
|
||||||
|
|
||||||
SkillEntity existing = new SkillEntity();
|
SkillEntity existing = new SkillEntity();
|
||||||
@ -139,7 +141,8 @@ class SkillServiceUpdatePartialTest {
|
|||||||
SkillService service = new SkillService(
|
SkillService service = new SkillService(
|
||||||
mapper, mock(vip.mate.skill.repository.SkillFileMapper.class),
|
mapper, mock(vip.mate.skill.repository.SkillFileMapper.class),
|
||||||
workspaceManager, workspaceProps, secretService,
|
workspaceManager, workspaceProps, secretService,
|
||||||
mock(org.springframework.context.ApplicationEventPublisher.class));
|
mock(org.springframework.context.ApplicationEventPublisher.class),
|
||||||
|
mock(SkillLifecycleService.class));
|
||||||
service.setRuntimeService(runtimeService);
|
service.setRuntimeService(runtimeService);
|
||||||
|
|
||||||
SkillEntity existing = new SkillEntity();
|
SkillEntity existing = new SkillEntity();
|
||||||
|
|||||||
@ -81,7 +81,7 @@ class SkillServiceWorkspaceScopeTest {
|
|||||||
seedSkill(ws2Name, 2L, false);
|
seedSkill(ws2Name, 2L, false);
|
||||||
|
|
||||||
IPage<SkillEntity> ws2Page = skillService.pageSkills(
|
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()
|
Set<String> names = ws2Page.getRecords().stream()
|
||||||
.map(SkillEntity::getName)
|
.map(SkillEntity::getName)
|
||||||
.collect(java.util.stream.Collectors.toSet());
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user