diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java index 19faa464..869aa3ab 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanSummaryNode.java @@ -85,32 +85,68 @@ public class PlanSummaryNode implements NodeAction { chatModel, prompt, conversationId, "plan_summary"); String summary = result.text(); + String thinking = result.thinking() == null ? "" : result.thinking(); + + // An interleaved-thinking model can spend its whole turn reasoning and + // return empty text. That empty string used to pass straight through: + // it became the plan's summary, then the run's terminal answer, and the + // goal evaluator skipped on "terminalAnswer empty" — so a plan whose + // steps had all succeeded ended with a dangling reasoning block and no + // report. The step results are already in hand, so answer from those + // rather than hand back nothing. + if (summary == null || summary.isBlank()) { + log.warn("[PlanSummary] Plan {} produced an empty summary " + + "(thinking={} chars, {} step results); falling back to step results", + planId, thinking.length(), completedResults.size()); + summary = buildFallbackSummary(goal, completedResults, SUMMARY_EMPTY_NOTE); + } + + // The steps themselves succeeded — only the summary text was missing — + // so the plan completes rather than being marked failed. planningService.completePlan(planId, summary); log.info("[PlanSummary] Plan {} completed with summary: {}", - planId, summary.length() > 100 ? summary.substring(0, 100) + "..." : summary); + planId, truncate(summary, 100)); return PlanStateAccessor.output() .finalSummary(summary) - .finalSummaryThinking(result.thinking()) + .finalSummaryThinking(thinking) .contentStreamed(true) - .thinkingStreamed(!result.thinking().isEmpty()) + .thinkingStreamed(!thinking.isEmpty()) .mergeUsage(state, result) .build(); } catch (Exception e) { log.error("[PlanSummary] Failed to summarize plan {}: {}", planId, e.getMessage(), e); - String fallbackSummary = buildFallbackSummary(goal, completedResults); + String fallbackSummary = buildFallbackSummary(goal, completedResults, SUMMARY_FAILED_NOTE); planningService.markPlanFailed(planId, "汇总阶段失败:" + truncate(e.getMessage(), 100)); return Map.of(PlanStateKeys.FINAL_SUMMARY, fallbackSummary); } } + /** Reason line for the summary call throwing. */ + private static final String SUMMARY_FAILED_NOTE = "LLM 汇总失败,以下为步骤原始结果"; + /** - * 在 LLM 汇总调用失败时生成本地 fallback 摘要。 - * 每条步骤结果截断至 300 字,避免把过长内容(包括错误体)直接暴露给用户。 + * Reason line for the summary call returning nothing. Distinct from the + * failure note because nothing actually failed — the steps ran, the model + * simply produced no text — and telling the user their run failed would be + * wrong. */ - private static String buildFallbackSummary(String goal, List completedResults) { - StringBuilder sb = new StringBuilder("目标:").append(goal).append("\n\n执行摘要(LLM 汇总失败,以下为步骤原始结果):\n"); + private static final String SUMMARY_EMPTY_NOTE = "模型未产出汇总正文,以下为步骤原始结果"; + + /** + * 在 LLM 汇总不可用时生成本地 fallback 摘要。 + * 每条步骤结果截断至 300 字,避免把过长内容(包括错误体)直接暴露给用户。 + * + * @param note 说明为何回落到步骤原始结果 + */ + private static String buildFallbackSummary(String goal, List completedResults, String note) { + StringBuilder sb = new StringBuilder("目标:").append(goal) + .append("\n\n执行摘要(").append(note).append("):\n"); + if (completedResults == null || completedResults.isEmpty()) { + sb.append("(没有已完成的步骤结果可供汇总)\n"); + return sb.toString(); + } for (String r : completedResults) { sb.append(truncate(r, 300)).append("\n"); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index c2075a7f..c156cecf 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -1149,6 +1149,61 @@ public class SkillController { } } + @Operation(summary = "列出未纳入自治治理的技能") + @GetMapping("/curator/unmanaged") + @RequireWorkspaceRole("member") + public R>> curatorUnmanaged() { + return R.ok(skillLifecycleService.listUnmanaged()); + } + + @Operation(summary = "列出已纳入自治治理的技能") + @GetMapping("/curator/managed") + @RequireWorkspaceRole("member") + public R>> curatorManaged() { + return R.ok(skillLifecycleService.listManaged()); + } + + @Operation(summary = "将技能移交给自治治理(不重置闲置时钟)") + @PostMapping("/curator/adopt") + @RequireWorkspaceRole("admin") + public R> curatorAdopt(@RequestBody List skillIds) { + return R.ok(setAdoptedBulk(skillIds, true)); + } + + @Operation(summary = "撤销移交,技能归还用户所有") + @PostMapping("/curator/release") + @RequireWorkspaceRole("admin") + public R> curatorRelease(@RequestBody List skillIds) { + return R.ok(setAdoptedBulk(skillIds, false)); + } + + /** + * Apply adopt/release across a batch, reporting per-skill outcomes rather + * than failing the whole call on one bad id — a partial batch that silently + * rolled back would leave the operator unsure which skills moved. + */ + private Map setAdoptedBulk(List skillIds, boolean adopt) { + List changed = new ArrayList<>(); + List> rejected = new ArrayList<>(); + for (String raw : skillIds == null ? List.of() : skillIds) { + // Ids stay strings end-to-end; parse once here so a malformed one + // is a reported rejection rather than a framework-level failure. + try { + skillLifecycleService.setAdopted(Long.parseLong(String.valueOf(raw).strip()), adopt); + changed.add(String.valueOf(raw)); + } catch (Exception e) { + Map row = new LinkedHashMap<>(); + row.put("id", String.valueOf(raw)); + row.put("message", e.getMessage()); + rejected.add(row); + } + } + Map out = new LinkedHashMap<>(); + out.put("changed", changed); + out.put("rejected", rejected); + return out; + } + @Operation(summary = "列出技能库还原点") @GetMapping("/curator/snapshots") @RequireWorkspaceRole("member") diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java index d0705b84..a25cde7d 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java @@ -210,7 +210,24 @@ public class SkillCuratorJob { List candidates = loadCandidates(); int plannedStale = 0, plannedArchived = 0, plannedReactivate = 0; int appliedStale = 0, appliedArchived = 0, appliedReactivate = 0; + int newlyObserved = 0; for (SkillEntity skill : candidates) { + // A candidate no sweep has seen before starts its idle clock now + // rather than being judged on time it spent outside curation. + // planTransition already returns NONE for these; stamping the + // anchor is what lets the next sweep judge it for real. + // + // A dry run must not write, but it must still reach the same + // verdict a real run would — this report is what an operator reads + // to decide whether widening the scope is safe, so predicting + // archives that a real run would defer would be a lie. + if (SkillLifecycleService.isUnobserved(skill)) { + newlyObserved++; + if (!dryRun) { + lifecycleService.markObserved(skill, now); + } + continue; + } LifecycleTransition t = lifecycleService.planTransition(skill, now); report.add(skill, t); if (t == LifecycleTransition.TO_STALE) { @@ -236,6 +253,7 @@ public class SkillCuratorJob { } report.scanned(candidates.size()) + .newlyObserved(newlyObserved) .plannedCounts(plannedStale, plannedArchived, plannedReactivate) .appliedCounts(appliedStale, appliedArchived, appliedReactivate) .blockedByBindings(agentBindingService.blockedByBindingCandidates(now)); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java index f03d5287..cd148c69 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java @@ -33,6 +33,7 @@ public class SkillCuratorReport { private final boolean dryRun; private final Config config; private final int scanned; + private final int newlyObserved; private final Counts planned; private final Counts applied; private final List transitions; @@ -50,6 +51,7 @@ public class SkillCuratorReport { this.dryRun = b.dryRun; this.config = new Config(b.staleAfterDays, b.archiveAfterDays, b.scope); this.scanned = b.scanned; + this.newlyObserved = b.newlyObserved; 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); @@ -62,6 +64,16 @@ public class SkillCuratorReport { this.path = path; } + /** + * Candidates seen by curation for the first time this run. They are + * deferred rather than judged, so an operator reading a first sweep + * after widening the scope can tell "nothing was archived because it is + * all brand new to the curator" from "nothing needed archiving". + */ + public int newlyObserved() { + return newlyObserved; + } + /** Applied count of skills marked stale (0 for a dry-run). */ public int markedStale() { return applied.stale(); @@ -104,6 +116,7 @@ public class SkillCuratorReport { private int archiveAfterDays; private String scope; private int scanned; + private int newlyObserved; private int plannedStale, plannedArchived, plannedReactivated; private int appliedStale, appliedArchived, appliedReactivated; private final List transitions = new ArrayList<>(); @@ -128,6 +141,11 @@ public class SkillCuratorReport { return this; } + public Builder newlyObserved(int newlyObserved) { + this.newlyObserved = newlyObserved; + return this; + } + public Builder scanned(int scanned) { this.scanned = scanned; return this; @@ -138,8 +156,9 @@ public class SkillCuratorReport { if (t == null || t == LifecycleTransition.NONE) { return this; } - LocalDateTime anchor = skill.getLastActivityAt() != null - ? skill.getLastActivityAt() : skill.getCreateTime(); + // Shared with the decision path — a report that computed idle days + // its own way could contradict the transition it is describing. + LocalDateTime anchor = SkillLifecycleService.anchor(skill); long days = anchor == null || runAt == null ? 0L : Duration.between(anchor, runAt).toDays(); String from = Optional.ofNullable(skill.getLifecycleState()).orElse("active"); String to = switch (t) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java index b8515474..abc804d7 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleService.java @@ -1,5 +1,6 @@ package vip.mate.skill.lifecycle; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -9,6 +10,7 @@ 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.model.SkillOrigin; import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.workspace.SkillWorkspaceManager; @@ -16,6 +18,8 @@ import vip.mate.skill.workspace.SkillWorkspaceProperties; import java.time.Duration; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -68,14 +72,45 @@ public class SkillLifecycleService { // ==================== Pure decision functions ==================== - /** Activity anchor: last recorded activity, falling back to creation time. */ - public LocalDateTime anchor(SkillEntity skill) { + /** + * Activity anchor, in order of preference: real recorded activity, then + * the moment curation first saw the skill, then creation time. + * + *

The middle term is what keeps a newly-eligible skill from being + * judged on time it spent outside curation entirely. Creation time stays + * as the final fallback so rows predating the column, and bare entities + * built in tests, behave exactly as before. + * + *

Single source of truth: the run report renders idle days from this + * same method, so a report can never disagree with the decision it + * describes. + */ + public static LocalDateTime anchor(SkillEntity skill) { if (skill.getLastActivityAt() != null) { return skill.getLastActivityAt(); } + if (skill.getCuratorSeenAt() != null) { + return skill.getCuratorSeenAt(); + } return skill.getCreateTime(); } + /** + * Whether no sweep has observed this skill yet, so judging it now would + * apply the idle thresholds to time it spent outside curation. + */ + public static boolean isUnobserved(SkillEntity skill) { + return skill.getCuratorSeenAt() == null && skill.getLastActivityAt() == null; + } + + /** Stamp the observation anchor, starting the skill's idle clock now. */ + public void markObserved(SkillEntity skill, LocalDateTime now) { + skillMapper.update(null, new LambdaUpdateWrapper() + .eq(SkillEntity::getId, skill.getId()) + .set(SkillEntity::getCuratorSeenAt, now)); + skill.setCuratorSeenAt(now); + } + /** Skills the curator must never touch (filtered out before the state machine). */ public boolean isExempt(SkillEntity skill) { if (Boolean.TRUE.equals(skill.getBuiltin())) { @@ -108,6 +143,12 @@ public class SkillLifecycleService { if (isExempt(skill)) { return LifecycleTransition.NONE; } + // Never observed: the thresholds would be measured against time this + // skill spent outside curation. Defer for a full cycle instead; the + // sweep stamps the observation anchor so the next pass has a real one. + if (isUnobserved(skill)) { + return LifecycleTransition.NONE; + } LocalDateTime anchor = anchor(skill); if (anchor == null) { return LifecycleTransition.NONE; @@ -208,6 +249,107 @@ public class SkillLifecycleService { return skillMapper.selectById(id); } + /** + * Hand a skill over to autonomous curation, or take it back. + * + *

Adoption deliberately does not buy a fresh idle window. An + * operator hands over a skill knowing it is already idle, so the + * observation anchor is set to creation time — the same anchor the skill + * would have had if it had been curator-managed all along. Handing over a + * library you stopped using therefore ages it out, which is the point of + * handing it over. This is the deliberate difference from the implicit + * first-sight seeding, where a skill arrives in scope through no decision + * of the operator's and must not be judged on time spent outside it. + * + *

Releasing restores user ownership, so adoption is reversible. + * + * @param adopt {@code true} to hand over, {@code false} to take back + * @throws MateClawException when the skill does not exist, or when it is + * builtin (never curatable in the first place) + */ + public SkillEntity setAdopted(Long id, boolean adopt) { + SkillEntity skill = skillMapper.selectById(id); + if (skill == null) { + throw new MateClawException("err.skill.not_found", 404, "Skill not found: " + id); + } + if (isExempt(skill)) { + throw new MateClawException("err.skill.not_adoptable", 400, + "Skill '" + skill.getName() + "' is exempt from curation (builtin, pinned, " + + "protected prefix, or a virtual mcp/acp skill)"); + } + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(SkillEntity::getId, id) + .set(SkillEntity::getOrigin, + adopt ? SkillOrigin.AGENT.code() : SkillOrigin.USER.code()); + if (adopt) { + // No fresh window: anchor where an always-managed skill would be. + update.set(SkillEntity::getCuratorSeenAt, skill.getCreateTime()); + } + skillMapper.update(null, update); + recordAudit(adopt ? "ADOPT" : "RELEASE", skill, + Map.of("origin", adopt ? SkillOrigin.AGENT.code() : SkillOrigin.USER.code())); + return skillMapper.selectById(id); + } + + /** + * Skills outside autonomous curation, with the reason each one is out. + * + *

Without this a large library can look fully curated while most of it + * is invisible to the sweep, and the only lever was widening the scope for + * everything at once. + */ + public List> listUnmanaged() { + return roster(false); + } + + /** + * Skills currently under autonomous curation — the set an operator can + * hand back. Without it adoption would be one-way from the UI. + */ + public List> listManaged() { + return roster(true); + } + + /** + * Shared roster projection. {@code managed} selects skills the curator may + * touch ({@code origin} agent/routine) or the complement; exempt skills are + * dropped from both sides because they are not curatable either way, so + * offering adopt or release on them would be a lie. + */ + private List> roster(boolean managed) { + LambdaQueryWrapper q = new LambdaQueryWrapper() + .eq(SkillEntity::getBuiltin, false); + if (managed) { + q.in(SkillEntity::getOrigin, SkillOrigin.curatorManagedCodes()); + } else { + q.and(w -> w.isNull(SkillEntity::getOrigin) + .or().eq(SkillEntity::getOrigin, SkillOrigin.USER.code())); + } + List rows = skillMapper.selectList(q); + LocalDateTime now = LocalDateTime.now(); + List> out = new ArrayList<>(); + for (SkillEntity skill : rows) { + if (isExempt(skill)) { + continue; + } + LocalDateTime anchor = anchor(skill); + Map row = new LinkedHashMap<>(); + // Snowflake id as a string: 19 digits exceed JS Number precision. + row.put("id", String.valueOf(skill.getId())); + row.put("name", skill.getName()); + row.put("description", skill.getDescription()); + row.put("lifecycleState", skill.getLifecycleState()); + row.put("origin", skill.getOrigin()); + row.put("reason", managed + ? skill.getOrigin() + : (skill.getOrigin() == null ? "predates-provenance" : "user-authored")); + row.put("unobserved", isUnobserved(skill)); + row.put("daysIdle", anchor == null ? null : Duration.between(anchor, now).toDays()); + out.add(row); + } + return out; + } + /** * 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 diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java index 09be80da..42816536 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java @@ -163,6 +163,22 @@ public class SkillEntity { */ private LocalDateTime lastActivityAt; + /** + * When autonomous curation first saw this skill as a candidate. + * + *

Distinct from {@link #createTime}: a skill can exist for a long time + * before it falls under curation at all — widening the curator scope pulls + * a whole library in at once. Anchoring the idle clock on creation would + * have such a skill enter curation already looking long-idle and archive it + * on the very first sweep, so the moment curation began watching is + * recorded separately. + * + *

{@code null} means no sweep has seen it yet; the next one stamps it + * and defers judgement for a full cycle. + */ + @TableField(value = "curator_seen_at", updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime curatorSeenAt; + /** Wall-clock time the skill entered the archived state. */ private LocalDateTime archivedAt; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V178__skill_curator_seen_at.sql b/mateclaw-server/src/main/resources/db/migration/h2/V178__skill_curator_seen_at.sql new file mode 100644 index 00000000..7c93af01 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V178__skill_curator_seen_at.sql @@ -0,0 +1,18 @@ +-- V178: Record when curation first saw a skill, separately from when it was created. +-- +-- The idle clock anchored on create_time, which conflated two different +-- moments: when a skill was written, and when it first fell under curation. +-- For a skill that only becomes eligible later — widening the curator scope +-- brings a whole library in at once — those differ by however long the skill +-- existed unmanaged, so it entered curation already looking years idle and was +-- archived on the very next sweep. +-- +-- The backfill covers only rows already inside the default AGENT_CREATED scope, +-- so their clocks continue exactly as before and upgrading changes nothing. +-- Rows outside the scope stay NULL and get seeded the first time a sweep +-- actually sees them. + +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS curator_seen_at TIMESTAMP; + +UPDATE mate_skill SET curator_seen_at = create_time + WHERE curator_seen_at IS NULL AND origin IN ('agent', 'routine'); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V178__skill_curator_seen_at.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V178__skill_curator_seen_at.sql new file mode 100644 index 00000000..ea60854f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V178__skill_curator_seen_at.sql @@ -0,0 +1,18 @@ +-- V178: Record when curation first saw a skill, separately from when it was created. +-- +-- The idle clock anchored on create_time, which conflated two different +-- moments: when a skill was written, and when it first fell under curation. +-- For a skill that only becomes eligible later — widening the curator scope +-- brings a whole library in at once — those differ by however long the skill +-- existed unmanaged, so it entered curation already looking years idle and was +-- archived on the very next sweep. +-- +-- The backfill covers only rows already inside the default AGENT_CREATED scope, +-- so their clocks continue exactly as before and upgrading changes nothing. +-- Rows outside the scope stay NULL and get seeded the first time a sweep +-- actually sees them. + +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS curator_seen_at TIMESTAMP(3); + +UPDATE mate_skill SET curator_seen_at = create_time + WHERE curator_seen_at IS NULL AND origin IN ('agent', 'routine'); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V178__skill_curator_seen_at.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V178__skill_curator_seen_at.sql new file mode 100644 index 00000000..bbc2cedf --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V178__skill_curator_seen_at.sql @@ -0,0 +1,22 @@ +-- V178: Record when curation first saw a skill, separately from when it was created. +-- +-- The idle clock anchored on create_time, which conflated two different +-- moments: when a skill was written, and when it first fell under curation. +-- For a skill that only becomes eligible later — widening the curator scope +-- brings a whole library in at once — those differ by however long the skill +-- existed unmanaged, so it entered curation already looking years idle and was +-- archived on the very next sweep. +-- +-- The backfill covers only rows already inside the default AGENT_CREATED scope, +-- so their clocks continue exactly as before and upgrading changes nothing. +-- Rows outside the scope stay NULL and get seeded the first time a sweep +-- actually sees them. +-- +-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use an INFORMATION_SCHEMA guard. + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_skill' AND COLUMN_NAME = 'curator_seen_at'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_skill ADD COLUMN curator_seen_at DATETIME DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +UPDATE mate_skill SET curator_seen_at = create_time + WHERE curator_seen_at IS NULL AND origin IN ('agent', 'routine'); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanSummaryEmptyResultTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanSummaryEmptyResultTest.java new file mode 100644 index 00000000..3290bbd7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/node/PlanSummaryEmptyResultTest.java @@ -0,0 +1,158 @@ +package vip.mate.agent.graph.plan.node; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ChatModel; +import vip.mate.agent.graph.NodeStreamingChatHelper; +import vip.mate.agent.graph.plan.state.PlanStateKeys; +import vip.mate.planning.service.PlanningService; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Pins the summary node's behaviour when the model returns no text. + * + *

An interleaved-thinking model can burn its whole turn on reasoning and + * come back with an empty string. That empty summary used to flow through as + * the run's terminal answer, the goal evaluator skipped on "terminalAnswer + * empty", and a plan whose steps had all succeeded ended with a dangling + * reasoning block and nothing to show for it. The step results are already in + * hand at that point, so the node must answer from those. + */ +class PlanSummaryEmptyResultTest { + + private ChatModel chatModel; + private PlanningService planningService; + private NodeStreamingChatHelper streamingHelper; + private PlanSummaryNode node; + + @BeforeEach + void setUp() { + chatModel = mock(ChatModel.class); + planningService = mock(PlanningService.class); + streamingHelper = mock(NodeStreamingChatHelper.class); + node = new PlanSummaryNode(chatModel, planningService, streamingHelper); + } + + private static NodeStreamingChatHelper.StreamResult result(String text, String thinking) { + return new NodeStreamingChatHelper.StreamResult(text, thinking, null, List.of(), false, 0, 0); + } + + private OverAllState state() { + Map vals = new HashMap<>(); + vals.put(PlanStateKeys.PLAN_ID, 42L); + vals.put(PlanStateKeys.GOAL, "对订单数据做质量体检"); + vals.put(PlanStateKeys.COMPLETED_RESULTS, + List.of("第 1 步:主键唯一性,发现 3 条重复", "第 2 步:缺失率统计完成")); + return new OverAllState(vals); + } + + private String summaryOf(Map out) { + return String.valueOf(out.get(PlanStateKeys.FINAL_SUMMARY)); + } + + @Test + @DisplayName("empty summary text falls back to the step results") + void emptyTextFallsBackToStepResults() throws Exception { + // The exact shape that slipped through: empty string, not null. A null + // tripped an NPE and reached the catch-block fallback by accident; "" did + // not, so it was the only unhandled case. + when(streamingHelper.streamCall(any(), any(), any(), anyString())) + .thenReturn(result("", "let me think about this for 36 seconds")); + + Map out = node.apply(state()); + String summary = summaryOf(out); + + assertFalse(summary.isBlank(), "an empty model summary must not become an empty answer"); + assertTrue(summary.contains("主键唯一性"), summary); + assertTrue(summary.contains("缺失率统计"), summary); + } + + @Test + @DisplayName("whitespace-only summary is treated the same as empty") + void whitespaceOnlyTextFallsBack() throws Exception { + when(streamingHelper.streamCall(any(), any(), any(), anyString())) + .thenReturn(result(" \n ", "")); + + assertTrue(summaryOf(node.apply(state())).contains("主键唯一性")); + } + + @Test + @DisplayName("an empty summary completes the plan — the steps did succeed") + void emptySummaryStillCompletesThePlan() throws Exception { + when(streamingHelper.streamCall(any(), any(), any(), anyString())) + .thenReturn(result("", "thinking")); + + node.apply(state()); + + // Nothing failed: every step ran, only the summary text was missing. + // Marking the plan failed would misreport a successful run. + verify(planningService).completePlan(eq(42L), anyString()); + verify(planningService, never()).markPlanFailed(any(), anyString()); + } + + @Test + @DisplayName("the fallback says the model produced nothing, not that the run failed") + void emptySummaryNoteDistinguishesFromFailure() throws Exception { + when(streamingHelper.streamCall(any(), any(), any(), anyString())) + .thenReturn(result("", "thinking")); + + String summary = summaryOf(node.apply(state())); + + assertTrue(summary.contains("未产出汇总正文"), summary); + assertFalse(summary.contains("汇总失败"), + "nothing failed here; calling it a failure misreports a successful run: " + summary); + } + + @Test + @DisplayName("null thinking does not blow up the node") + void nullThinkingIsTolerated() throws Exception { + when(streamingHelper.streamCall(any(), any(), any(), anyString())) + .thenReturn(result("", null)); + + assertTrue(summaryOf(node.apply(state())).contains("主键唯一性")); + } + + @Test + @DisplayName("a real summary is passed through untouched") + void realSummaryIsUnchanged() throws Exception { + when(streamingHelper.streamCall(any(), any(), any(), anyString())) + .thenReturn(result("体检完成:共发现 5 类问题。", "thinking")); + + Map out = node.apply(state()); + + assertEquals("体检完成:共发现 5 类问题。", summaryOf(out)); + verify(planningService).completePlan(eq(42L), eq("体检完成:共发现 5 类问题。")); + } + + @Test + @DisplayName("empty summary with no step results still yields a readable answer") + void emptySummaryWithNoStepResults() throws Exception { + when(streamingHelper.streamCall(any(), any(), any(), anyString())) + .thenReturn(result("", "thinking")); + Map vals = new HashMap<>(); + vals.put(PlanStateKeys.PLAN_ID, 42L); + vals.put(PlanStateKeys.GOAL, "空计划"); + vals.put(PlanStateKeys.COMPLETED_RESULTS, List.of()); + + String summary = summaryOf(node.apply(new OverAllState(vals))); + + assertFalse(summary.isBlank()); + assertTrue(summary.contains("没有已完成的步骤结果"), summary); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java index 78689965..2375ba99 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java @@ -199,6 +199,54 @@ class SkillCuratorJobTest { assertEquals(1, cap.getValue().getApplied().stale()); } + /** A candidate curation has never seen: no activity, no observation stamp. */ + private SkillEntity unobservedCandidate(long id, LocalDateTime createdAt) { + SkillEntity s = candidate(id, "active", null); + s.setCreateTime(createdAt); + return s; + } + + @Test + void unobservedCandidateIsSeededAndNotJudged() { + when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(true); + stubSweep(List.of(unobservedCandidate(1L, now.minusDays(900)))); + + job.run(); + + // Stamped so the next sweep has a real anchor, but never judged this time. + verify(lifecycleService).markObserved(any(), any()); + verify(lifecycleService, never()).planTransition(any(), any()); + verify(lifecycleService, never()).apply(any(), any(), any()); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SkillCuratorReport.class); + verify(reportStore).write(cap.capture()); + assertEquals(1, cap.getValue().getNewlyObserved()); + assertEquals(0, cap.getValue().getPlanned().archived()); + } + + @Test + void dryRunDoesNotSeedButReachesTheSameVerdict() { + // The preview is what an operator reads before widening the scope, so + // it must not predict archives that a real run would defer — while + // still writing nothing. + when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(false); + when(systemSettingService.getString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), any())) + .thenReturn(now.minusDays(2).toString()); + stubSweep(List.of(unobservedCandidate(1L, now.minusDays(900)))); + + job.run(); + + verify(lifecycleService, never()).markObserved(any(), any()); + verify(lifecycleService, never()).planTransition(any(), any()); + + ArgumentCaptor cap = ArgumentCaptor.forClass(SkillCuratorReport.class); + verify(reportStore).write(cap.capture()); + assertEquals(1, cap.getValue().getNewlyObserved()); + assertEquals(0, cap.getValue().getPlanned().archived()); + } + @Test void reconcileReactivatesArchivedRowWhoseWorkspaceReturned() { when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java index 0e77e2e2..63144ff7 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillLifecycleServiceTest.java @@ -1,4 +1,7 @@ package vip.mate.skill.lifecycle; +import vip.mate.skill.model.SkillOrigin; +import org.mockito.ArgumentCaptor; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.fasterxml.jackson.databind.ObjectMapper; @@ -6,6 +9,7 @@ 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.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -21,6 +25,7 @@ import vip.mate.skill.workspace.SkillWorkspaceProperties; import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -82,6 +87,125 @@ class SkillLifecycleServiceTest { return s; } + // ==================== adopt / release ==================== + + @Test + @DisplayName("adopting anchors to creation time — it does not buy a fresh window") + void adoptDoesNotResetTheIdleClock() { + // The operator hands over a skill knowing it is idle; granting it a new + // 90-day lease would defeat the reason they handed it over. + SkillEntity s = unobserved(now.minusDays(400)); + s.setOrigin(null); + when(skillMapper.selectById(1L)).thenReturn(s); + + service.setAdopted(1L, true); + + ArgumentCaptor> cap = updateCaptor(); + verify(skillMapper).update(eq(null), cap.capture()); + String sql = cap.getValue().getSqlSet(); + assertTrue(sql.contains("origin"), sql); + assertTrue(sql.contains("curator_seen_at"), sql); + + // With the anchor at creation time the skill ages immediately. + s.setCuratorSeenAt(s.getCreateTime()); + assertEquals(LifecycleTransition.TO_ARCHIVED, service.planTransition(s, now)); + } + + @Test + @DisplayName("releasing hands ownership back and leaves the clock alone") + void releaseRestoresUserOwnership() { + SkillEntity s = skill("dynamic", "active", now.minusDays(10)); + s.setOrigin(SkillOrigin.AGENT.code()); + when(skillMapper.selectById(1L)).thenReturn(s); + + service.setAdopted(1L, false); + + ArgumentCaptor> cap = updateCaptor(); + verify(skillMapper).update(eq(null), cap.capture()); + String sql = cap.getValue().getSqlSet(); + assertTrue(sql.contains("origin"), sql); + assertFalse(sql.contains("curator_seen_at"), "release must not touch the clock: " + sql); + } + + @Test + @DisplayName("an exempt skill cannot be adopted") + void exemptSkillIsNotAdoptable() { + SkillEntity builtin = skill("builtin", "active", now.minusDays(10)); + builtin.setBuiltin(true); + when(skillMapper.selectById(1L)).thenReturn(builtin); + + assertThrows(MateClawException.class, () -> service.setAdopted(1L, true)); + verify(skillMapper, never()).update(any(), any()); + } + + @Test + @DisplayName("adopting a missing skill is a 404, not a silent no-op") + void adoptMissingSkillThrows() { + when(skillMapper.selectById(404L)).thenReturn(null); + assertThrows(MateClawException.class, () -> service.setAdopted(404L, true)); + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> updateCaptor() { + return ArgumentCaptor.forClass((Class>) (Class) LambdaUpdateWrapper.class); + } + + // ==================== observation anchor ==================== + + /** A skill curation has never seen: no activity, no observation stamp. */ + private SkillEntity unobserved(LocalDateTime createdAt) { + SkillEntity s = skill("dynamic", "active", null); + s.setCreateTime(createdAt); + return s; + } + + @Test + @DisplayName("a never-observed skill is deferred however old it is") + void unobservedSkillIsDeferred() { + // Widening the curator scope pulls in skills created years ago. Judging + // them on creation time would archive the whole batch on the first sweep. + SkillEntity s = unobserved(now.minusDays(900)); + assertTrue(SkillLifecycleService.isUnobserved(s)); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + @Test + @DisplayName("once observed, the idle clock runs from the observation, not creation") + void observationAnchorReplacesCreationTime() { + SkillEntity s = unobserved(now.minusDays(900)); + s.setCuratorSeenAt(now.minusDays(2)); + + assertFalse(SkillLifecycleService.isUnobserved(s)); + assertEquals(now.minusDays(2), SkillLifecycleService.anchor(s)); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + @Test + @DisplayName("an observed skill ages normally once the threshold passes") + void observedSkillStillAges() { + SkillEntity s = unobserved(now.minusDays(900)); + s.setCuratorSeenAt(now.minusDays(95)); + + assertEquals(LifecycleTransition.TO_ARCHIVED, service.planTransition(s, now)); + } + + @Test + @DisplayName("real activity outranks the observation stamp") + void activityOutranksObservation() { + SkillEntity s = skill("dynamic", "active", now.minusDays(1)); + s.setCuratorSeenAt(now.minusDays(400)); + + assertEquals(now.minusDays(1), SkillLifecycleService.anchor(s)); + assertEquals(LifecycleTransition.NONE, service.planTransition(s, now)); + } + + @Test + @DisplayName("creation time remains the fallback for rows predating the column") + void creationTimeRemainsFallback() { + SkillEntity s = skill("dynamic", "active", now.minusDays(95)); + assertEquals(now.minusDays(95), SkillLifecycleService.anchor(s)); + } + // ==================== planTransition ==================== @Test diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 30a1ae6c..8288c55c 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -327,6 +327,17 @@ export const skillApi = { // ---- Curator restore points ---- /** List recent skill-library restore points (newest first). */ + /** Skills currently under autonomous curation — the set that can be released. */ + curatorManaged: () => http.get('/skills/curator/managed'), + /** Skills outside autonomous curation, with the reason each one is out. */ + curatorUnmanaged: () => http.get('/skills/curator/unmanaged'), + /** + * Hand skills over to autonomous curation. Ids stay strings — 19-digit + * snowflake ids lose precision through the JS Number type. + */ + curatorAdopt: (skillIds: string[]) => http.post('/skills/curator/adopt', skillIds), + /** Take skills back from autonomous curation. */ + curatorRelease: (skillIds: string[]) => http.post('/skills/curator/release', skillIds), curatorSnapshots: () => http.get('/skills/curator/snapshots'), /** Capture a restore point on demand. */ curatorSnapshotCapture: (reason?: string) => diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 13c84c8e..cdd47706 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -4217,6 +4217,21 @@ export default { snapshotRestore: 'Roll back to this', snapshotRestoreConfirm: 'This rewrites every skill body and lifecycle state back to how they were at {time} ({n} skills), overwriting the current content. A restore point is captured first, so this is itself undoable. Continue?', snapshotRestoreSuccess: 'Restored {restored} skill(s); {missing} no longer present and skipped', + unmanaged: 'Skills outside curation', + unmanagedHint: 'These skills are not subject to automatic archival. Handing one over puts it on the idle clock — adoption does not grant a fresh window, so an already-idle skill may be archived on the next sweep.', + managedList: 'Under curation', + unmanagedList: 'Outside curation', + noManaged: 'No skills under curation yet', + release: 'Release', + releaseSuccess: "Released '{name}'", + unobserved: 'Clock not started yet', + noUnmanaged: 'No unmanaged skills', + unmanagedReasonLegacy: 'Predates the provenance field; authorship unknowable', + unmanagedReasonUser: 'Created by a user', + unmanagedDaysIdle: 'Idle {n} day(s)', + adopt: 'Adopt', + adoptConfirm: 'Hand \'{name}\' over to autonomous curation? This does not reset its idle clock — if it is already long idle, the next sweep may archive it.', + adoptSuccess: 'Adopted \'{name}\'', consolidateCreate: 'new', consolidateEdit: 'edit', activateSuccess: 'Skill curator activated', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index f8c887c9..028d583d 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -4309,6 +4309,21 @@ export default { snapshotRestore: '回滚到此', snapshotRestoreConfirm: '将把所有技能的正文和生命周期状态回滚到 {time} 的状态(共 {n} 个技能),当前内容会被覆盖。回滚前会自动留一个还原点,所以此操作可以再撤销。确认继续?', snapshotRestoreSuccess: '已恢复 {restored} 个技能,{missing} 个已不存在而跳过', + unmanaged: '未纳入治理的技能', + unmanagedHint: '这些技能不受自动归档管理。移交后由 curator 按闲置时长治理——移交不会重置闲置时钟,已经长期闲置的技能可能在下一轮就被归档。', + managedList: '已纳管', + unmanagedList: '未纳管', + noManaged: '还没有纳入治理的技能', + release: '撤销移交', + releaseSuccess: '已撤销「{name}」的移交', + unobserved: '尚未开始计时', + noUnmanaged: '没有未纳管的技能', + unmanagedReasonLegacy: '早于溯源字段,作者不可考', + unmanagedReasonUser: '用户创建', + unmanagedDaysIdle: '已闲置 {n} 天', + adopt: '移交治理', + adoptConfirm: '确定把「{name}」移交给自治治理?移交不会重置闲置时钟,若它已长期闲置,下一轮扫描可能直接归档。', + adoptSuccess: '已移交「{name}」', consolidateCreate: '新建', consolidateEdit: '更新', activateSuccess: '技能管家已激活', diff --git a/mateclaw-ui/src/views/Settings/SkillCurator/index.vue b/mateclaw-ui/src/views/Settings/SkillCurator/index.vue index fa205910..d1530b4f 100644 --- a/mateclaw-ui/src/views/Settings/SkillCurator/index.vue +++ b/mateclaw-ui/src/views/Settings/SkillCurator/index.vue @@ -157,6 +157,64 @@ + +

+
+

{{ t('skillCurator.unmanaged') }}

+
+ +
+
+

{{ t('skillCurator.unmanagedHint') }}

+ +

{{ t('skillCurator.managedList') }}

+

{{ t('skillCurator.noManaged') }}

+
    +
  • +
    +

    {{ m.name }}

    +
    + {{ m.reason }} + {{ t('skillCurator.unobserved') }} + + {{ t('skillCurator.unmanagedDaysIdle', { n: m.daysIdle }) }} + +
    +
    +
    + +
    +
  • +
+ +

{{ t('skillCurator.unmanagedList') }}

+

{{ t('skillCurator.noUnmanaged') }}

+
    +
  • +
    +

    {{ u.name }}

    +
    + {{ u.reason === 'predates-provenance' + ? t('skillCurator.unmanagedReasonLegacy') + : t('skillCurator.unmanagedReasonUser') }} + + {{ t('skillCurator.unmanagedDaysIdle', { n: u.daysIdle }) }} + +
    +
    +
    + +
    +
  • +
+
+
@@ -299,6 +357,19 @@ interface SkillSnapshot { createdAt: string | null } +interface UnmanagedSkill { + /** Snowflake id kept as a string end-to-end — 19 digits exceed Number precision. */ + id: string + name: string + description?: string | null + lifecycleState?: string | null + origin?: string | null + reason: string + /** No sweep has observed it yet, so its idle clock has not started. */ + unobserved?: boolean + daysIdle?: number | null +} + const loading = ref(true) const error = ref('') const busy = ref(false) @@ -307,6 +378,8 @@ const routines = ref([]) const gates = ref({ minOccurrences: 3, minDistinctDays: 3, enabled: true }) const routineFilter = ref('observing') const snapshots = ref([]) +const unmanaged = ref([]) +const managed = ref([]) const routineFilters = [ { value: 'observing', label: 'skillCurator.routineFilterObserving' }, @@ -331,7 +404,7 @@ async function load() { try { const res: any = await skillApi.curatorStatus() status.value = res.data as CuratorStatus - await Promise.all([loadReports(), loadRoutines(), loadSnapshots()]) + await Promise.all([loadReports(), loadRoutines(), loadSnapshots(), loadUnmanaged()]) } catch (e: any) { error.value = e?.message || t('skillCurator.loadFailed') } finally { @@ -508,6 +581,59 @@ async function reopenRoutine(r: RoutineCandidate) { } } +// ==================== Skills outside curation ==================== + +async function loadUnmanaged() { + try { + const [un, mg]: any[] = await Promise.all([ + skillApi.curatorUnmanaged(), + skillApi.curatorManaged(), + ]) + unmanaged.value = Array.isArray(un.data) ? un.data : [] + managed.value = Array.isArray(mg.data) ? mg.data : [] + } catch { + unmanaged.value = [] + managed.value = [] + } +} + +async function release(u: UnmanagedSkill) { + busy.value = true + try { + await skillApi.curatorRelease([u.id]) + mcToast.success(t('skillCurator.releaseSuccess', { name: u.name })) + await loadUnmanaged() + } catch (e: any) { + mcToast.error(e?.message || t('skillCurator.actionFailed')) + } finally { + busy.value = false + } +} + +async function adopt(u: UnmanagedSkill) { + // Adoption does not grant a fresh idle window — an already-idle skill can + // age out on the very next sweep — so state that before acting on it. + try { + await ElMessageBox.confirm( + t('skillCurator.adoptConfirm', { name: u.name }), + t('skillCurator.adopt'), + { type: 'warning' }, + ) + } catch { + return + } + busy.value = true + try { + await skillApi.curatorAdopt([u.id]) + mcToast.success(t('skillCurator.adoptSuccess', { name: u.name })) + await loadUnmanaged() + } catch (e: any) { + mcToast.error(e?.message || t('skillCurator.actionFailed')) + } finally { + busy.value = false + } +} + // ==================== Restore points ==================== async function loadSnapshots() { @@ -604,6 +730,13 @@ onMounted(load) .kv-row code { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; } .empty-note { margin: 0; font-size: 13px; color: var(--mc-text-tertiary); } +.roster-heading { + margin: 16px 0 8px; + font-size: 13px; + font-weight: 600; + color: var(--mc-text-secondary); +} +.roster-heading:first-of-type { margin-top: 8px; } .report-list { display: flex; gap: 8px; flex-wrap: wrap; } .report-item { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; padding: 5px 10px; border-radius: 8px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; } .report-item:hover { border-color: var(--mc-text-tertiary); }