diff --git a/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java new file mode 100644 index 00000000..7678f9d5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java @@ -0,0 +1,41 @@ +package vip.mate.goal.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Configuration knobs for the persistent-goal subsystem. + * + *

{@link #enabled} is the master gate: when {@code false} (PR1-4 default) + * the StateGraph wiring stays inactive and {@code findActiveByConversation} + * still works for tests, but no graph node touches the table. PR5 flips it + * to {@code true}. + */ +@Data +@Component +@ConfigurationProperties(prefix = "mateclaw.goal") +public class GoalProperties { + + /** Master switch — when off, the graph never invokes GoalEvaluationNode. */ + private boolean enabled = false; + + /** Default turn budget when the user doesn't override. */ + private int defaultTurnBudget = 20; + + /** Default combined (agent + eval) LLM call budget. */ + private int defaultLlmCallBudget = 200; + + /** Default cooldown between auto-followups in seconds. */ + private int autoFollowupCooldownSeconds = 0; + + /** + * Provider/model id for the evaluator. Empty string means "use the + * same model as the chat agent" — convenient for dev, expensive in + * production. Operators should point this at a cheap model. + */ + private String evaluatorModel = ""; + + /** Max messages from parent conversation included in evaluator prompt. */ + private int evaluatorContextMessages = 8; +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java new file mode 100644 index 00000000..aa87642e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalController.java @@ -0,0 +1,159 @@ +package vip.mate.goal.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEventEntity; +import vip.mate.goal.model.GoalUpdateRequest; +import vip.mate.goal.service.GoalService; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.List; +import java.util.Map; + +/** + * REST surface for persistent goals. + * + *

Authorization model: every write authorizes the caller against the + * goal's bound conversation via + * {@link ConversationService#isConversationOwner}, mirroring the + * {@code SubagentController} pattern. Read endpoints follow the same rule + * so non-owners cannot enumerate someone else's goals by guessing IDs. + * + *

Snowflake IDs are serialized as strings by the project-wide Jackson + * config (see CLAUDE.md "ID Handling"); request DTOs accept either number + * or string forms. + */ +@Slf4j +@Tag(name = "Goals") +@RestController +@RequestMapping("/api/v1/goals") +@RequiredArgsConstructor +public class GoalController { + + private final GoalService goalService; + private final ConversationService conversationService; + + @Operation(summary = "Create a persistent goal for a conversation") + @PostMapping + public R create(@RequestBody GoalCreateRequest req, Authentication auth) { + String username = currentUsername(auth); + requireOwner(req.getConversationId(), username); + GoalEntity g = goalService.create(req, username); + return R.ok(g); + } + + @Operation(summary = "Get the active goal bound to a conversation (or null)") + @GetMapping("/by-conversation/{conversationId}") + public R findActive(@PathVariable String conversationId, Authentication auth) { + requireOwner(conversationId, currentUsername(auth)); + return R.ok(goalService.findActiveByConversation(conversationId)); + } + + @Operation(summary = "Get goal detail by id") + @GetMapping("/{id}") + public R get(@PathVariable Long id, Authentication auth) { + GoalEntity g = goalService.getById(id); + requireOwner(g.getConversationId(), currentUsername(auth)); + return R.ok(g); + } + + @Operation(summary = "Get the event timeline for a goal") + @GetMapping("/{id}/events") + public R> events(@PathVariable Long id, + @RequestParam(defaultValue = "100") int limit, + Authentication auth) { + GoalEntity g = goalService.getById(id); + requireOwner(g.getConversationId(), currentUsername(auth)); + return R.ok(goalService.listEvents(id, limit)); + } + + @Operation(summary = "List goals (optionally filtered by status)") + @GetMapping + public R> list(@RequestParam(required = false) String status, + @RequestParam(defaultValue = "50") int limit, + Authentication auth) { + // List is owner-scoped — only your own goals are visible. + return R.ok(goalService.list(status, currentUsername(auth), limit)); + } + + @Operation(summary = "Sparse update of a non-terminal goal") + @PatchMapping("/{id}") + public R update(@PathVariable Long id, + @RequestBody GoalUpdateRequest req, + Authentication auth) { + GoalEntity g = goalService.getById(id); + String username = currentUsername(auth); + requireOwner(g.getConversationId(), username); + return R.ok(goalService.update(id, req, username)); + } + + @Operation(summary = "Pause an active goal") + @PostMapping("/{id}/pause") + public R pause(@PathVariable Long id, Authentication auth) { + GoalEntity g = goalService.getById(id); + String username = currentUsername(auth); + requireOwner(g.getConversationId(), username); + return R.ok(goalService.pause(id, username)); + } + + @Operation(summary = "Resume a paused goal") + @PostMapping("/{id}/resume") + public R resume(@PathVariable Long id, Authentication auth) { + GoalEntity g = goalService.getById(id); + String username = currentUsername(auth); + requireOwner(g.getConversationId(), username); + return R.ok(goalService.resume(id, username)); + } + + @Operation(summary = "Abandon a goal (terminal)") + @PostMapping("/{id}/abandon") + public R abandon(@PathVariable Long id, Authentication auth) { + GoalEntity g = goalService.getById(id); + String username = currentUsername(auth); + requireOwner(g.getConversationId(), username); + return R.ok(goalService.abandon(id, username)); + } + + @Operation(summary = "Append a sub-criterion to an active goal") + @PostMapping("/{id}/criteria") + public R addCriterion(@PathVariable Long id, + @RequestBody Map body, + Authentication auth) { + GoalEntity g = goalService.getById(id); + String username = currentUsername(auth); + requireOwner(g.getConversationId(), username); + String criterion = body != null ? body.get("criterion") : null; + return R.ok(goalService.appendCriterion(id, criterion, username)); + } + + // ==================== Helpers ==================== + + private String currentUsername(Authentication auth) { + return auth != null ? auth.getName() : "anonymous"; + } + + private void requireOwner(String conversationId, String username) { + if (conversationId == null || conversationId.isBlank()) { + throw new MateClawException("err.goal.bad_request", 400, "conversationId required"); + } + if (!conversationService.isConversationOwner(conversationId, username)) { + throw new MateClawException("err.goal.forbidden", 403, + "Not the owner of conversation " + conversationId); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java new file mode 100644 index 00000000..3b2bcae7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalCreateRequest.java @@ -0,0 +1,33 @@ +package vip.mate.goal.model; + +import lombok.Data; + +/** + * Request body for {@code POST /api/v1/goals}. + * + *

Only {@code conversationId}, {@code agentId}, {@code workspaceId} and + * {@code title} are mandatory. Budgets default to the values in + * {@link vip.mate.goal.config.GoalProperties}. + * + *

ID fields stay as {@code Long} on the wire (Jackson accepts both + * numeric and string forms via the project's default coercion), but the + * frontend must send them as strings to preserve snowflake precision — + * see CLAUDE.md "ID Handling" section. + */ +@Data +public class GoalCreateRequest { + + private String conversationId; + private Long agentId; + private Long workspaceId; + + private String title; + private String description; + private String exitCriteria; + private String successCheckPrompt; + + private Integer turnBudget; + private Integer llmCallBudget; + private Boolean autoFollowupEnabled; + private Integer followupCooldownSeconds; +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java new file mode 100644 index 00000000..96159277 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java @@ -0,0 +1,120 @@ +package vip.mate.goal.model; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Persistent goal bound to one conversation. + * + *

Lifecycle and DB uniqueness are documented in the V120 migration and + * the {@link GoalStatus} enum. Concurrency safety relies on: + *

+ * + *

The {@code active_conv_key} generated column (MySQL) is intentionally + * NOT mapped to a Java field — MyBatis Plus would otherwise try to write + * to it on insert. H2 has no such column. + */ +@Data +@TableName("mate_agent_goal") +public class GoalEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** FK to mate_conversation.conversation_id. */ + private String conversationId; + + /** FK to mate_agent.id. */ + private Long agentId; + + /** FK to mate_workspace.id. */ + private Long workspaceId; + + /** Username of the goal creator; matches mate_user.username. */ + private String createdBy; + + /** Short title displayed on the avatar tooltip and timeline. */ + private String title; + + /** Long-form objective. Always non-null but may be short. */ + private String description; + + /** LLM-readable exit criteria; evaluator scores against this. Nullable. */ + @TableField(value = "exit_criteria", updateStrategy = FieldStrategy.ALWAYS) + private String exitCriteria; + + /** Optional per-goal evaluator prompt override; nullable -> default. */ + @TableField(value = "success_check_prompt", updateStrategy = FieldStrategy.ALWAYS) + private String successCheckPrompt; + + /** + * One of the {@link GoalStatus} lowercase values. Persistence handled + * by MyBatis Plus + @EnumValue annotation on the enum. + */ + private GoalStatus status; + + /** Maximum evaluation turns before exhaustion. */ + private Integer turnBudget; + + /** Cumulative turns evaluated; bumped by GoalEvaluationNode. */ + private Integer turnsUsed; + + /** Single cap covering both agent-side and evaluator-side LLM calls. */ + private Integer llmCallBudget; + + /** Main-graph LLM calls observed via state.LLM_CALL_COUNT delta. */ + private Integer agentLlmCallsUsed; + + /** Evaluator LLM calls consumed by GoalEvaluationService. */ + private Integer evalLlmCallsUsed; + + /** Last evaluator gap text — shown in the hover tooltip. Nullable. */ + @TableField(value = "progress_summary", updateStrategy = FieldStrategy.ALWAYS) + private String progressSummary; + + /** Last completion score 0.0..1.0. Nullable (no eval yet). */ + @TableField(value = "completion_score", updateStrategy = FieldStrategy.ALWAYS) + private Double completionScore; + + @TableField(value = "last_evaluation_at", updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime lastEvaluationAt; + + /** Whether the evaluator may inject a follow-up user prompt. */ + private Boolean autoFollowupEnabled; + + /** Minimum interval between two auto-followups for the same goal. */ + private Integer followupCooldownSeconds; + + @TableField(value = "last_followup_at", updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime lastFollowupAt; + + /** Optimistic lock version; service updates must pass {@code WHERE version=?}. */ + private Integer version; + + @TableLogic + private Integer deleted; + + private LocalDateTime createTime; + private LocalDateTime updateTime; + + /** Convenience derived counter used by SSE event payloads. */ + public int totalLlmCallsUsed() { + int a = agentLlmCallsUsed != null ? agentLlmCallsUsed : 0; + int e = evalLlmCallsUsed != null ? evalLlmCallsUsed : 0; + return a + e; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java new file mode 100644 index 00000000..d1d87a4d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEvaluationResult.java @@ -0,0 +1,54 @@ +package vip.mate.goal.model; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Value object carrying one evaluation pass result from + * {@code GoalEvaluationService} to {@code GoalEvaluationNode} and on to + * {@code GoalService.recordEvaluation}. + * + *

Defined in PR1 so the service-layer signature is stable; the actual + * evaluator implementation lands in PR2. + * + *

{@link #completed} means "evaluator judged this turn satisfies all + * exit criteria". It does not mean "graph FINISH_REASON should change" — + * goal status and graph FinishReason are independent (RFC 48 §3.1 v2). + * + *

{@link #llmCallsConsumed} is the evaluator-side delta only; the + * agent-side delta is read from graph state by the node itself. + */ +public record GoalEvaluationResult( + double score, + String gap, + String decision, + boolean completed, + String evaluatorModel, + int llmCallsConsumed, + long latencyMs) { + + public static final String DECISION_COMPLETED = "completed"; + public static final String DECISION_CONTINUE = "continue"; + public static final String DECISION_FALLBACK = "fallback"; + + /** Failure fallback used when the evaluator LLM call errors out. + * Does NOT charge eval_llm_calls_used. */ + public static GoalEvaluationResult fallback(String reason) { + return new GoalEvaluationResult( + 0.0, "evaluator unavailable: " + reason, + DECISION_FALLBACK, false, + "", 0, 0L); + } + + public Map toMap() { + Map m = new LinkedHashMap<>(); + m.put("completionScore", score); + m.put("gap", gap == null ? "" : gap); + m.put("decision", decision); + m.put("completed", completed); + m.put("evaluatorModel", evaluatorModel == null ? "" : evaluatorModel); + m.put("llmCallsConsumed", llmCallsConsumed); + m.put("latencyMs", latencyMs); + return m; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventEntity.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventEntity.java new file mode 100644 index 00000000..9f5daf1a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventEntity.java @@ -0,0 +1,35 @@ +package vip.mate.goal.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Append-only event row in the goal's audit trail. + * + *

{@link #eventType} values are defined in {@link GoalEventType}. + * The drawer timeline reads these rows in reverse chronological order. + */ +@Data +@TableName("mate_agent_goal_event") +public class GoalEventEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long goalId; + + /** One of the {@link GoalEventType} string values. */ + private String eventType; + + /** Optional FK to mate_message.id for the assistant turn this event ties to. */ + private Long messageId; + + /** JSON detail payload — schema varies per event_type. */ + private String detailJson; + + private LocalDateTime createTime; +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventType.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventType.java new file mode 100644 index 00000000..e41256af --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEventType.java @@ -0,0 +1,41 @@ +package vip.mate.goal.model; + +/** + * String constants for {@link GoalEventEntity#getEventType()}. + * + *

Kept as constants (not an enum) because the column is queried by + * MyBatis Plus as a plain string, and SSE event payloads serialize the + * value verbatim — having the same literal in code, DB, and wire format + * eliminates one source of accidental drift. + */ +public final class GoalEventType { + + /** Goal created by the user or by the agent via setGoal tool. */ + public static final String CREATED = "created"; + + /** One evaluation pass completed; carries score/gap/decision. */ + public static final String EVALUATED = "evaluated"; + + /** Auto-followup prompt was injected for the next reasoning loop. */ + public static final String FOLLOWUP_INJECTED = "followup_injected"; + + /** Evaluator judged completion; goal status flipped to completed. */ + public static final String COMPLETED = "completed"; + + /** turn_budget or llm_call_budget consumed; status flipped to exhausted. */ + public static final String EXHAUSTED = "exhausted"; + + /** User paused active goal. */ + public static final String PAUSED = "paused"; + + /** User resumed a paused goal. */ + public static final String RESUMED = "resumed"; + + /** User abandoned the goal; final state. */ + public static final String ABANDONED = "abandoned"; + + /** Sub-criterion appended without restarting the goal. */ + public static final String CRITERION_ADDED = "criterion_added"; + + private GoalEventType() {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalStatus.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalStatus.java new file mode 100644 index 00000000..112512c9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalStatus.java @@ -0,0 +1,47 @@ +package vip.mate.goal.model; + +import com.baomidou.mybatisplus.annotation.EnumValue; + +/** + * Persistent goal lifecycle states. + * + *

State machine: + *

+ *   create -> active
+ *   active <-> paused
+ *   active -> { completed | abandoned | exhausted }   (terminal states)
+ * 
+ * + *

The persisted DB value is the lowercase string carried by + * {@link #value} via the MyBatis Plus {@link EnumValue} annotation. This + * is load-bearing — the V120 migration's predicate unique index (H2) and + * generated-column unique index (MySQL) both compare {@code status = + * 'active'} as a literal string. Writing the enum's {@link #name()} (all + * uppercase) would silently bypass the uniqueness guarantee and let + * concurrent creates land two ACTIVE rows on the same conversation. + */ +public enum GoalStatus { + + ACTIVE("active"), + PAUSED("paused"), + COMPLETED("completed"), + ABANDONED("abandoned"), + EXHAUSTED("exhausted"); + + @EnumValue + private final String value; + + GoalStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + /** Terminal states do not transition; they free the conversation + * uniqueness slot for a fresh active goal. */ + public boolean isTerminal() { + return this == COMPLETED || this == ABANDONED || this == EXHAUSTED; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java new file mode 100644 index 00000000..1f6b1f5f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalUpdateRequest.java @@ -0,0 +1,25 @@ +package vip.mate.goal.model; + +import lombok.Data; + +/** + * Request body for {@code PATCH /api/v1/goals/{id}}. All fields nullable; + * only present fields are applied (sparse update). + * + *

Fields not editable post-create: {@code conversationId}, + * {@code agentId}, {@code workspaceId}, {@code createdBy}, {@code status} + * (use the dedicated pause/resume/abandon endpoints). + */ +@Data +public class GoalUpdateRequest { + + private String title; + private String description; + private String exitCriteria; + private String successCheckPrompt; + + private Integer turnBudget; + private Integer llmCallBudget; + private Boolean autoFollowupEnabled; + private Integer followupCooldownSeconds; +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalEventMapper.java b/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalEventMapper.java new file mode 100644 index 00000000..236872b5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalEventMapper.java @@ -0,0 +1,12 @@ +package vip.mate.goal.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.goal.model.GoalEventEntity; + +/** + * MyBatis Plus mapper for {@link GoalEventEntity}. + */ +@Mapper +public interface GoalEventMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalMapper.java b/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalMapper.java new file mode 100644 index 00000000..03ae8859 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/repository/GoalMapper.java @@ -0,0 +1,16 @@ +package vip.mate.goal.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.goal.model.GoalEntity; + +/** + * MyBatis Plus mapper for {@link GoalEntity}. + * + *

Lives under the {@code repository} sub-package per project convention — + * {@code MateClawApplication} uses {@code @MapperScan("vip.mate.**.repository")}. + * Putting the mapper elsewhere makes startup fail with a missing-bean error. + */ +@Mapper +public interface GoalMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java new file mode 100644 index 00000000..e8537e7e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java @@ -0,0 +1,81 @@ +package vip.mate.goal.service; + +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalEventEntity; +import vip.mate.goal.model.GoalUpdateRequest; + +import java.util.List; + +/** + * Persistent goal service — CRUD, status transitions, and bookkeeping + * called from {@code GoalEvaluationNode} (PR2). + * + *

Concurrency model: writes use a per-row {@code WHERE version=?} + * compare-and-set. On conflict the service retries up to 3 times before + * surfacing {@code MateClawException("err.goal.optimistic_lock_conflict", + * 409)}. Create additionally uses the DB-level + * {@code uk_agent_goal_active_conv} unique index as the last line of + * defence; service-layer pre-check is only a UX nicety. + */ +public interface GoalService { + + // ==================== CRUD ==================== + + /** + * Create a new goal. Fails 409 when the conversation already has an + * active goal (DB unique index hit). + */ + GoalEntity create(GoalCreateRequest req, String username); + + GoalEntity getById(Long id); + + /** Active goal for the conversation, or null. Used by buildInitialState. */ + GoalEntity findActiveByConversation(String conversationId); + + /** Paged list filtered by status / owner. */ + List list(String status, String username, int limit); + + /** Sparse update. Throws if any terminal-state goal is targeted. */ + GoalEntity update(Long id, GoalUpdateRequest req, String username); + + /** Events for the timeline drawer, newest first. */ + List listEvents(Long goalId, int limit); + + // ==================== State machine ==================== + + GoalEntity pause(Long id, String username); + GoalEntity resume(Long id, String username); + GoalEntity abandon(Long id, String username); + + /** Flip active->completed. Writes a 'completed' event. */ + GoalEntity markCompleted(Long id, GoalEvaluationResult result); + + /** Flip active->exhausted with the reason that triggered it. */ + GoalEntity markExhausted(Long id, String reason); + + // ==================== Evaluation bookkeeping ==================== + + /** + * Atomic bookkeeping for one evaluation pass. Bumps turns_used (+1), + * agent_llm_calls_used (+agentDelta), eval_llm_calls_used (+evalDelta), + * persists progress_summary / completion_score / last_evaluation_at, + * writes one 'evaluated' GoalEventEntity. Optimistic-lock retry x3. + */ + void recordEvaluation(Long id, + GoalEvaluationResult result, + int agentLlmCallsDelta, + int evalLlmCallsDelta); + + /** True when turns_used >= turn_budget OR (agent + eval) >= llm_call_budget. */ + boolean isBudgetExhausted(GoalEntity goal); + + /** "turn_budget" or "llm_call_budget" — the dimension that hit the cap. */ + String exhaustionReason(GoalEntity goal); + + void recordFollowupInjected(Long id, String prompt); + + /** Append a sub-criterion without restarting the goal. */ + GoalEntity appendCriterion(Long id, String criterion, String username); +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java new file mode 100644 index 00000000..d22161af --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -0,0 +1,484 @@ +package vip.mate.goal.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.audit.service.AuditEventService; +import vip.mate.exception.MateClawException; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalEventEntity; +import vip.mate.goal.model.GoalEventType; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.model.GoalUpdateRequest; +import vip.mate.goal.repository.GoalEventMapper; +import vip.mate.goal.repository.GoalMapper; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.IntSupplier; + +/** + * Default implementation. Concurrency safety relies on: + *

    + *
  1. Service-level pre-check + DB-level unique index for "at most one + * active goal per conversation" — see V120 migration.
  2. + *
  3. Per-write optimistic lock via {@code WHERE version=?} on + * state-mutating updates; retried up to 3 times on contention.
  4. + *
  5. {@code @Transactional} on every write so the goal row update and + * the matching event-log insert succeed or fail together.
  6. + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GoalServiceImpl implements GoalService { + + private static final int OPTIMISTIC_LOCK_MAX_RETRIES = 3; + + private final GoalMapper goalMapper; + private final GoalEventMapper eventMapper; + private final GoalProperties properties; + private final AuditEventService auditEventService; + private final ObjectMapper objectMapper; + + // ==================== CRUD ==================== + + @Override + @Transactional + public GoalEntity create(GoalCreateRequest req, String username) { + validateCreate(req); + + // Service-level pre-check is a UX nicety only — the DB unique + // index is the source of truth. Two concurrent creates can both + // pass this check; the second insert will surface a + // DuplicateKeyException and we map it to 409. + GoalEntity active = findActiveByConversation(req.getConversationId()); + if (active != null) { + throw new MateClawException("err.goal.conversation_has_active", 409, + "Conversation already has an active goal: " + active.getId()); + } + + GoalEntity entity = new GoalEntity(); + entity.setConversationId(req.getConversationId()); + entity.setAgentId(req.getAgentId()); + entity.setWorkspaceId(req.getWorkspaceId()); + entity.setCreatedBy(username); + entity.setTitle(req.getTitle().trim()); + entity.setDescription(req.getDescription() != null ? req.getDescription() : ""); + entity.setExitCriteria(req.getExitCriteria()); + entity.setSuccessCheckPrompt(req.getSuccessCheckPrompt()); + entity.setStatus(GoalStatus.ACTIVE); + entity.setTurnBudget(req.getTurnBudget() != null + ? req.getTurnBudget() : properties.getDefaultTurnBudget()); + entity.setTurnsUsed(0); + entity.setLlmCallBudget(req.getLlmCallBudget() != null + ? req.getLlmCallBudget() : properties.getDefaultLlmCallBudget()); + entity.setAgentLlmCallsUsed(0); + entity.setEvalLlmCallsUsed(0); + entity.setAutoFollowupEnabled(Boolean.TRUE.equals(req.getAutoFollowupEnabled())); + entity.setFollowupCooldownSeconds(req.getFollowupCooldownSeconds() != null + ? req.getFollowupCooldownSeconds() : properties.getAutoFollowupCooldownSeconds()); + entity.setVersion(0); + entity.setDeleted(0); + LocalDateTime now = LocalDateTime.now(); + entity.setCreateTime(now); + entity.setUpdateTime(now); + + try { + goalMapper.insert(entity); + } catch (DuplicateKeyException dup) { + // Lost the race against another create. The unique index hit + // is the authoritative "conversation already has active goal". + throw new MateClawException("err.goal.conversation_has_active", 409, + "Conversation already has an active goal (DB unique index)"); + } + + writeEvent(entity.getId(), GoalEventType.CREATED, null, Map.of( + "title", entity.getTitle(), + "turnBudget", entity.getTurnBudget(), + "llmCallBudget", entity.getLlmCallBudget(), + "by", username)); + recordAudit("goal.created", entity, Map.of("by", username, "title", entity.getTitle())); + return entity; + } + + @Override + public GoalEntity getById(Long id) { + GoalEntity g = goalMapper.selectById(id); + if (g == null) { + throw new MateClawException("err.goal.not_found", 404, "Goal not found: " + id); + } + return g; + } + + @Override + public GoalEntity findActiveByConversation(String conversationId) { + if (conversationId == null || conversationId.isBlank()) { + return null; + } + return goalMapper.selectOne(new LambdaQueryWrapper() + .eq(GoalEntity::getConversationId, conversationId) + .eq(GoalEntity::getStatus, GoalStatus.ACTIVE) + .last("LIMIT 1")); + } + + @Override + public List list(String status, String username, int limit) { + LambdaQueryWrapper w = new LambdaQueryWrapper() + .orderByDesc(GoalEntity::getCreateTime); + if (username != null && !username.isBlank()) { + w.eq(GoalEntity::getCreatedBy, username); + } + if (status != null && !status.isBlank()) { + GoalStatus s; + try { + s = GoalStatus.valueOf(status.toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new MateClawException("err.goal.bad_status", 400, "Unknown status: " + status); + } + w.eq(GoalEntity::getStatus, s); + } + w.last("LIMIT " + Math.max(1, Math.min(200, limit))); + return goalMapper.selectList(w); + } + + @Override + @Transactional + public GoalEntity update(Long id, GoalUpdateRequest req, String username) { + GoalEntity g = getById(id); + ensureNotTerminal(g, "update"); + + LambdaUpdateWrapper w = baseLockedUpdate(g); + boolean changed = false; + if (req.getTitle() != null && !req.getTitle().isBlank()) { + w.set(GoalEntity::getTitle, req.getTitle().trim()); changed = true; + } + if (req.getDescription() != null) { + w.set(GoalEntity::getDescription, req.getDescription()); changed = true; + } + if (req.getExitCriteria() != null) { + w.set(GoalEntity::getExitCriteria, req.getExitCriteria()); changed = true; + } + if (req.getSuccessCheckPrompt() != null) { + w.set(GoalEntity::getSuccessCheckPrompt, req.getSuccessCheckPrompt()); changed = true; + } + if (req.getTurnBudget() != null) { + validateBudget(req.getTurnBudget(), "turnBudget"); + w.set(GoalEntity::getTurnBudget, req.getTurnBudget()); changed = true; + } + if (req.getLlmCallBudget() != null) { + validateBudget(req.getLlmCallBudget(), "llmCallBudget"); + w.set(GoalEntity::getLlmCallBudget, req.getLlmCallBudget()); changed = true; + } + if (req.getAutoFollowupEnabled() != null) { + w.set(GoalEntity::getAutoFollowupEnabled, req.getAutoFollowupEnabled()); changed = true; + } + if (req.getFollowupCooldownSeconds() != null) { + w.set(GoalEntity::getFollowupCooldownSeconds, req.getFollowupCooldownSeconds()); + changed = true; + } + if (!changed) { + return g; + } + bumpVersionAndTime(w); + retryOptimistic(() -> goalMapper.update(null, w), "update"); + recordAudit("goal.updated", g, Map.of("by", username)); + return goalMapper.selectById(id); + } + + @Override + public List listEvents(Long goalId, int limit) { + return eventMapper.selectList(new LambdaQueryWrapper() + .eq(GoalEventEntity::getGoalId, goalId) + .orderByDesc(GoalEventEntity::getId) + .last("LIMIT " + Math.max(1, Math.min(500, limit)))); + } + + // ==================== State machine ==================== + + @Override + @Transactional + public GoalEntity pause(Long id, String username) { + return flipStatus(id, GoalStatus.ACTIVE, GoalStatus.PAUSED, + GoalEventType.PAUSED, "goal.paused", username); + } + + @Override + @Transactional + public GoalEntity resume(Long id, String username) { + return flipStatus(id, GoalStatus.PAUSED, GoalStatus.ACTIVE, + GoalEventType.RESUMED, "goal.resumed", username); + } + + @Override + @Transactional + public GoalEntity abandon(Long id, String username) { + GoalEntity g = getById(id); + ensureNotTerminal(g, "abandon"); + // Allows abandon from both ACTIVE and PAUSED. + LambdaUpdateWrapper w = baseLockedUpdate(g) + .set(GoalEntity::getStatus, GoalStatus.ABANDONED); + bumpVersionAndTime(w); + retryOptimistic(() -> goalMapper.update(null, w), "abandon"); + writeEvent(id, GoalEventType.ABANDONED, null, Map.of("by", username)); + recordAudit("goal.abandoned", g, Map.of("by", username)); + return goalMapper.selectById(id); + } + + @Override + @Transactional + public GoalEntity markCompleted(Long id, GoalEvaluationResult result) { + GoalEntity g = getById(id); + if (g.getStatus().isTerminal()) return g; // idempotent + LambdaUpdateWrapper w = baseLockedUpdate(g) + .set(GoalEntity::getStatus, GoalStatus.COMPLETED); + if (result != null) { + w.set(GoalEntity::getCompletionScore, result.score()) + .set(GoalEntity::getProgressSummary, result.gap()); + } + bumpVersionAndTime(w); + retryOptimistic(() -> goalMapper.update(null, w), "markCompleted"); + Map detail = new LinkedHashMap<>(); + detail.put("finalScore", result != null ? result.score() : null); + detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed()); + detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed()); + writeEvent(id, GoalEventType.COMPLETED, null, detail); + recordAudit("goal.completed", g, detail); + return goalMapper.selectById(id); + } + + @Override + @Transactional + public GoalEntity markExhausted(Long id, String reason) { + GoalEntity g = getById(id); + if (g.getStatus().isTerminal()) return g; + LambdaUpdateWrapper w = baseLockedUpdate(g) + .set(GoalEntity::getStatus, GoalStatus.EXHAUSTED); + bumpVersionAndTime(w); + retryOptimistic(() -> goalMapper.update(null, w), "markExhausted"); + Map detail = new LinkedHashMap<>(); + detail.put("reason", reason != null ? reason : "unknown"); + detail.put("turnsUsed", g.getTurnsUsed()); + detail.put("agentLlmCallsUsed", g.getAgentLlmCallsUsed()); + detail.put("evalLlmCallsUsed", g.getEvalLlmCallsUsed()); + writeEvent(id, GoalEventType.EXHAUSTED, null, detail); + recordAudit("goal.exhausted", g, detail); + return goalMapper.selectById(id); + } + + // ==================== Evaluation bookkeeping ==================== + + @Override + @Transactional + public void recordEvaluation(Long id, GoalEvaluationResult result, + int agentLlmCallsDelta, int evalLlmCallsDelta) { + GoalEntity g = getById(id); + if (g.getStatus().isTerminal()) return; // ignore late evaluations + + int agentDelta = Math.max(0, agentLlmCallsDelta); + int evalDelta = Math.max(0, evalLlmCallsDelta); + + LambdaUpdateWrapper w = baseLockedUpdate(g) + .setSql("turns_used = turns_used + 1") + .setSql("agent_llm_calls_used = agent_llm_calls_used + " + agentDelta) + .setSql("eval_llm_calls_used = eval_llm_calls_used + " + evalDelta) + .set(GoalEntity::getLastEvaluationAt, LocalDateTime.now()); + if (result != null) { + w.set(GoalEntity::getCompletionScore, result.score()) + .set(GoalEntity::getProgressSummary, result.gap()); + } + bumpVersionAndTime(w); + retryOptimistic(() -> goalMapper.update(null, w), "recordEvaluation"); + + Map detail = new LinkedHashMap<>(); + if (result != null) { + detail.put("completionScore", result.score()); + detail.put("gap", result.gap()); + detail.put("decision", result.decision()); + detail.put("evaluatorModel", result.evaluatorModel()); + detail.put("latencyMs", result.latencyMs()); + } + detail.put("agentLlmCallsDelta", agentDelta); + detail.put("evalLlmCallsDelta", evalDelta); + writeEvent(id, GoalEventType.EVALUATED, null, detail); + } + + @Override + public boolean isBudgetExhausted(GoalEntity goal) { + int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0; + int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE; + if (turns >= turnBudget) return true; + int callBudget = goal.getLlmCallBudget() != null ? goal.getLlmCallBudget() : Integer.MAX_VALUE; + return goal.totalLlmCallsUsed() >= callBudget; + } + + @Override + public String exhaustionReason(GoalEntity goal) { + int turns = goal.getTurnsUsed() != null ? goal.getTurnsUsed() : 0; + int turnBudget = goal.getTurnBudget() != null ? goal.getTurnBudget() : Integer.MAX_VALUE; + if (turns >= turnBudget) return "turn_budget"; + return "llm_call_budget"; + } + + @Override + @Transactional + public void recordFollowupInjected(Long id, String prompt) { + GoalEntity g = getById(id); + if (g.getStatus().isTerminal()) return; + LambdaUpdateWrapper w = baseLockedUpdate(g) + .set(GoalEntity::getLastFollowupAt, LocalDateTime.now()); + bumpVersionAndTime(w); + retryOptimistic(() -> goalMapper.update(null, w), "recordFollowupInjected"); + writeEvent(id, GoalEventType.FOLLOWUP_INJECTED, null, Map.of( + "prompt", prompt != null ? prompt : "", + "turnsUsed", g.getTurnsUsed())); + } + + @Override + @Transactional + public GoalEntity appendCriterion(Long id, String criterion, String username) { + if (criterion == null || criterion.isBlank()) { + throw new MateClawException("err.goal.criterion_empty", 400, "Criterion must not be empty"); + } + GoalEntity g = getById(id); + ensureNotTerminal(g, "appendCriterion"); + String existing = g.getExitCriteria() != null ? g.getExitCriteria() : ""; + String merged = existing.isEmpty() ? criterion.trim() + : existing + "\n+ " + criterion.trim(); + LambdaUpdateWrapper w = baseLockedUpdate(g) + .set(GoalEntity::getExitCriteria, merged); + bumpVersionAndTime(w); + retryOptimistic(() -> goalMapper.update(null, w), "appendCriterion"); + writeEvent(id, GoalEventType.CRITERION_ADDED, null, Map.of( + "criterion", criterion.trim(), + "by", username)); + return goalMapper.selectById(id); + } + + // ==================== Internals ==================== + + private void validateCreate(GoalCreateRequest req) { + if (req == null) { + throw new MateClawException("err.goal.bad_request", 400, "Request body required"); + } + if (req.getConversationId() == null || req.getConversationId().isBlank()) { + throw new MateClawException("err.goal.bad_request", 400, "conversationId required"); + } + if (req.getAgentId() == null) { + throw new MateClawException("err.goal.bad_request", 400, "agentId required"); + } + if (req.getWorkspaceId() == null) { + throw new MateClawException("err.goal.bad_request", 400, "workspaceId required"); + } + if (req.getTitle() == null || req.getTitle().isBlank()) { + throw new MateClawException("err.goal.bad_request", 400, "title required"); + } + if (req.getTitle().length() > 255) { + throw new MateClawException("err.goal.bad_request", 400, "title too long (>255)"); + } + if (req.getTurnBudget() != null) validateBudget(req.getTurnBudget(), "turnBudget"); + if (req.getLlmCallBudget() != null) validateBudget(req.getLlmCallBudget(), "llmCallBudget"); + } + + private static void validateBudget(int v, String name) { + if (v <= 0) { + throw new MateClawException("err.goal.invalid_budget", 400, + name + " must be > 0, got " + v); + } + } + + private void ensureNotTerminal(GoalEntity g, String op) { + if (g.getStatus().isTerminal()) { + throw new MateClawException("err.goal.terminal_state", 409, + "Cannot " + op + " a goal in terminal state " + g.getStatus().getValue()); + } + } + + /** Build an update wrapper that enforces version match + soft-delete guard. */ + private LambdaUpdateWrapper baseLockedUpdate(GoalEntity g) { + return new LambdaUpdateWrapper() + .eq(GoalEntity::getId, g.getId()) + .eq(GoalEntity::getVersion, g.getVersion()) + .eq(GoalEntity::getDeleted, 0); + } + + private static void bumpVersionAndTime(LambdaUpdateWrapper w) { + w.setSql("version = version + 1") + .set(GoalEntity::getUpdateTime, LocalDateTime.now()); + } + + private void retryOptimistic(IntSupplier update, String op) { + for (int i = 0; i < OPTIMISTIC_LOCK_MAX_RETRIES; i++) { + int rows = update.getAsInt(); + if (rows > 0) return; + log.debug("[GoalService] Optimistic lock miss on {} (attempt {}/{})", + op, i + 1, OPTIMISTIC_LOCK_MAX_RETRIES); + } + throw new MateClawException("err.goal.optimistic_lock_conflict", 409, + "Concurrent modification on " + op + " after " + + OPTIMISTIC_LOCK_MAX_RETRIES + " retries"); + } + + private GoalEntity flipStatus(Long id, GoalStatus from, GoalStatus to, + String eventType, String auditAction, String username) { + GoalEntity g = getById(id); + if (g.getStatus() != from) { + throw new MateClawException("err.goal.bad_transition", 409, + "Cannot transition " + g.getStatus().getValue() + " -> " + to.getValue()); + } + LambdaUpdateWrapper w = baseLockedUpdate(g) + .set(GoalEntity::getStatus, to); + bumpVersionAndTime(w); + retryOptimistic(() -> goalMapper.update(null, w), to.getValue()); + writeEvent(id, eventType, null, Map.of("by", username, + "from", from.getValue(), "to", to.getValue())); + recordAudit(auditAction, g, Map.of("by", username)); + return goalMapper.selectById(id); + } + + private void writeEvent(Long goalId, String type, Long messageId, Map detail) { + GoalEventEntity ev = new GoalEventEntity(); + ev.setGoalId(goalId); + ev.setEventType(type); + ev.setMessageId(messageId); + ev.setDetailJson(safeJson(detail)); + ev.setCreateTime(LocalDateTime.now()); + eventMapper.insert(ev); + } + + private void recordAudit(String action, GoalEntity g, Map detail) { + try { + auditEventService.record(action, "goal", + String.valueOf(g.getId()), g.getTitle(), + safeJson(detail), g.getWorkspaceId()); + } catch (Exception ex) { + log.warn("[GoalService] audit record failed: {}", ex.getMessage()); + } + } + + private String safeJson(Map m) { + try { + return objectMapper.writeValueAsString(m); + } catch (JsonProcessingException e) { + return "{}"; + } + } + + /** Returns the time elapsed since the last followup, or null if never. */ + public Duration timeSinceLastFollowup(GoalEntity g) { + if (g.getLastFollowupAt() == null) return null; + return Duration.between(g.getLastFollowupAt(), LocalDateTime.now()); + } +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V120__agent_goal.sql b/mateclaw-server/src/main/resources/db/migration/h2/V120__agent_goal.sql new file mode 100644 index 00000000..4b69d284 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V120__agent_goal.sql @@ -0,0 +1,88 @@ +-- Persistent goal: cross-turn objective lock-in with self-evaluation loop. +-- +-- A goal binds to a single conversation and stays active across many user +-- turns. After each agent reply the GoalEvaluationNode rates progress and +-- can optionally inject a follow-up prompt. The status state machine is: +-- active -> { paused | completed | abandoned | exhausted } +-- with paused -> active as the only reverse edge. +-- +-- DB-level uniqueness for "at most one active goal per conversation": +-- We use a virtual generated column that yields conversation_id only when +-- the row is in active state (and not soft-deleted) — NULL otherwise — and +-- a plain UNIQUE constraint over it. NULLs do not participate in +-- uniqueness checks (SQL standard), so terminal-state rows coexist with +-- the next active goal on the same conversation. H2 2.x supports the +-- exact same syntax as MySQL for virtual generated columns; we use it +-- here rather than a predicate index (which H2 lacks). + +CREATE TABLE IF NOT EXISTS mate_agent_goal ( + id BIGINT NOT NULL, + conversation_id VARCHAR(64) NOT NULL, + agent_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + created_by VARCHAR(64) NOT NULL, + + title VARCHAR(255) NOT NULL, + description CLOB NOT NULL, + exit_criteria CLOB NULL, + success_check_prompt CLOB NULL, + + -- DB values are always lowercase (active|paused|completed|abandoned| + -- exhausted) — enforced by the GoalStatus enum's @EnumValue + -- annotation. The generated column below depends on this convention. + status VARCHAR(16) NOT NULL DEFAULT 'active', + + turn_budget INT NOT NULL DEFAULT 20, + turns_used INT NOT NULL DEFAULT 0, + llm_call_budget INT NOT NULL DEFAULT 200, + agent_llm_calls_used INT NOT NULL DEFAULT 0, + eval_llm_calls_used INT NOT NULL DEFAULT 0, + + progress_summary CLOB NULL, + completion_score DOUBLE NULL, + last_evaluation_at TIMESTAMP NULL, + + auto_followup_enabled BOOLEAN NOT NULL DEFAULT FALSE, + followup_cooldown_seconds INT NOT NULL DEFAULT 0, + last_followup_at TIMESTAMP NULL, + + version INT NOT NULL DEFAULT 0, + deleted INT NOT NULL DEFAULT 0, + create_time TIMESTAMP NOT NULL, + update_time TIMESTAMP NOT NULL, + + -- Virtual generated column: yields the conversation id only for the + -- "live active" subset, NULL otherwise. A plain UNIQUE constraint + -- over it gives "at most one active row per conversation" while + -- letting any number of completed/abandoned/exhausted/deleted rows + -- coexist (NULLs are non-comparable under UNIQUE). + active_conv_key VARCHAR(80) GENERATED ALWAYS AS ( + CASE WHEN status = 'active' AND deleted = 0 + THEN conversation_id ELSE NULL END + ), + + PRIMARY KEY (id), + CONSTRAINT uk_agent_goal_active_conv UNIQUE (active_conv_key) +); + +CREATE INDEX IF NOT EXISTS idx_agent_goal_conv + ON mate_agent_goal(conversation_id, status); +CREATE INDEX IF NOT EXISTS idx_agent_goal_status + ON mate_agent_goal(status, last_evaluation_at); +CREATE INDEX IF NOT EXISTS idx_agent_goal_owner + ON mate_agent_goal(created_by, status); + +-- Append-only event log: timeline view in the drawer reads from here. +-- event_type values are documented in vip.mate.goal.model.GoalEventType. +CREATE TABLE IF NOT EXISTS mate_agent_goal_event ( + id BIGINT NOT NULL, + goal_id BIGINT NOT NULL, + event_type VARCHAR(32) NOT NULL, + message_id BIGINT NULL, + detail_json CLOB NULL, + create_time TIMESTAMP NOT NULL, + PRIMARY KEY (id) +); + +CREATE INDEX IF NOT EXISTS idx_agent_goal_event_goal + ON mate_agent_goal_event(goal_id, id); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V120__agent_goal.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V120__agent_goal.sql new file mode 100644 index 00000000..643a9671 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V120__agent_goal.sql @@ -0,0 +1,75 @@ +-- Persistent goal — see h2/V120__agent_goal.sql for full design notes. +-- +-- MySQL-specific differences vs H2: +-- 1. CLOB -> LONGTEXT +-- 2. TIMESTAMP -> DATETIME(3) for millisecond precision matching V117 +-- 3. BOOLEAN -> TINYINT(1) +-- 4. H2 uses a PREDICATE unique index for "one active goal per +-- conversation"; MySQL InnoDB does not support filtered indexes, +-- so we emulate it with a virtual generated column that is NULL for +-- non-active rows + a plain unique index. NULLs are excluded from +-- uniqueness enforcement by MySQL's default index semantics. + +CREATE TABLE IF NOT EXISTS mate_agent_goal ( + id BIGINT NOT NULL, + conversation_id VARCHAR(64) NOT NULL, + agent_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + created_by VARCHAR(64) NOT NULL, + + title VARCHAR(255) NOT NULL, + description LONGTEXT NOT NULL, + exit_criteria LONGTEXT NULL, + success_check_prompt LONGTEXT NULL, + + -- DB values are always lowercase (active|paused|completed|abandoned| + -- exhausted) — enforced by the GoalStatus enum's @EnumValue + -- annotation. The active_conv_key generated column below depends on + -- this convention; any uppercase write would defeat uniqueness. + status VARCHAR(16) NOT NULL DEFAULT 'active', + + turn_budget INT NOT NULL DEFAULT 20, + turns_used INT NOT NULL DEFAULT 0, + llm_call_budget INT NOT NULL DEFAULT 200, + agent_llm_calls_used INT NOT NULL DEFAULT 0, + eval_llm_calls_used INT NOT NULL DEFAULT 0, + + progress_summary LONGTEXT NULL, + completion_score DOUBLE NULL, + last_evaluation_at DATETIME(3) NULL, + + auto_followup_enabled TINYINT(1) NOT NULL DEFAULT 0, + followup_cooldown_seconds INT NOT NULL DEFAULT 0, + last_followup_at DATETIME(3) NULL, + + -- Virtual generated column: NULL for non-active or deleted rows so + -- they fall out of the unique-index check. InnoDB ignores NULL keys + -- for uniqueness, giving us "at most one active row per conversation". + active_conv_key VARCHAR(80) + GENERATED ALWAYS AS ( + CASE WHEN status = 'active' AND deleted = 0 + THEN conversation_id ELSE NULL END + ) VIRTUAL, + + version INT NOT NULL DEFAULT 0, + deleted TINYINT(1) NOT NULL DEFAULT 0, + create_time DATETIME(3) NOT NULL, + update_time DATETIME(3) NOT NULL, + + PRIMARY KEY (id), + UNIQUE KEY uk_agent_goal_active_conv (active_conv_key), + KEY idx_agent_goal_conv (conversation_id, status), + KEY idx_agent_goal_status (status, last_evaluation_at), + KEY idx_agent_goal_owner (created_by, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS mate_agent_goal_event ( + id BIGINT NOT NULL, + goal_id BIGINT NOT NULL, + event_type VARCHAR(32) NOT NULL, + message_id BIGINT NULL, + detail_json LONGTEXT NULL, + create_time DATETIME(3) NOT NULL, + PRIMARY KEY (id), + KEY idx_agent_goal_event_goal (goal_id, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java new file mode 100644 index 00000000..21f96eb5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalPersistenceIntegrationTest.java @@ -0,0 +1,140 @@ +package vip.mate.goal; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.exception.MateClawException; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.service.GoalService; + +import java.sql.Timestamp; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Integration test that pins two load-bearing DB invariants: + * + *
    + *
  1. {@code GoalStatus} persists as lowercase strings ({@code "active"} + * etc., NOT {@code "ACTIVE"}). The V120 predicate unique index + * compares {@code status = 'active'} as a literal — any uppercase + * write would silently defeat the uniqueness guarantee.
  2. + *
  3. The {@code uk_agent_goal_active_conv} unique index rejects a + * second active-row insert for the same conversation. Service-layer + * pre-check is a UX nicety; this is the source of truth.
  4. + *
+ * + *

Uses an in-memory H2 MySQL-compat database so Flyway runs V120 + * exactly as it would in dev. The {@code DATABASE_TO_LOWER=TRUE} flag is + * standard across mateclaw's other Spring tests. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:goal_persistence_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.goal.enabled=false" +}) +class GoalPersistenceIntegrationTest { + + @Autowired private GoalService goalService; + @Autowired private JdbcTemplate jdbc; + + private GoalCreateRequest req(String convId, String title) { + GoalCreateRequest r = new GoalCreateRequest(); + r.setConversationId(convId); + r.setAgentId(1L); + r.setWorkspaceId(1L); + r.setTitle(title); + r.setDescription("desc"); + return r; + } + + @Test + @DisplayName("GoalStatus values persist as lowercase literals — load-bearing for uk_agent_goal_active_conv") + void status_persistsAsLowercaseString() { + GoalEntity created = goalService.create(req("conv-status-1", "lower-case check"), "alice"); + String raw = jdbc.queryForObject( + "SELECT status FROM mate_agent_goal WHERE id = ?", + String.class, created.getId()); + assertEquals("active", raw, + "GoalStatus must persist as lowercase 'active' — uppercase 'ACTIVE' would " + + "silently bypass the V120 predicate unique index uk_agent_goal_active_conv."); + } + + @Test + @DisplayName("Each terminal status also persists lowercase") + void terminalStatuses_alsoPersistLowercase() { + GoalEntity g = goalService.create(req("conv-status-terminal", "terminal check"), "alice"); + + goalService.abandon(g.getId(), "alice"); + String s = jdbc.queryForObject( + "SELECT status FROM mate_agent_goal WHERE id = ?", + String.class, g.getId()); + assertEquals("abandoned", s); + } + + @Test + @DisplayName("Service rejects a second active goal on the same conversation (UX pre-check 409)") + void servicePreCheck_blocksDuplicateActiveCreation() { + goalService.create(req("conv-dup-1", "first"), "alice"); + MateClawException ex = assertThrows(MateClawException.class, + () -> goalService.create(req("conv-dup-1", "second"), "alice")); + assertEquals(409, ex.getCode()); + } + + @Test + @DisplayName("DB unique index rejects a second active row even when service pre-check is bypassed") + void uniqueIndex_isUltimateSourceOfTruth() { + // First goal — via service so it gets a real ID + workspace + timestamps. + goalService.create(req("conv-uq-1", "first"), "alice"); + + // Second insertion — bypass the service entirely and write through + // JdbcTemplate. Must hit DuplicateKeyException at the DB level. + LocalDateTime now = LocalDateTime.now(); + try { + jdbc.update( + "INSERT INTO mate_agent_goal " + + "(id, conversation_id, agent_id, workspace_id, created_by, " + + " title, description, status, turn_budget, turns_used, " + + " llm_call_budget, agent_llm_calls_used, eval_llm_calls_used, " + + " auto_followup_enabled, followup_cooldown_seconds, " + + " version, deleted, create_time, update_time) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + 99999L, "conv-uq-1", 1L, 1L, "alice", + "second", "desc", "active", + 20, 0, 200, 0, 0, + false, 0, + 0, 0, Timestamp.valueOf(now), Timestamp.valueOf(now)); + fail("Expected DuplicateKeyException from uk_agent_goal_active_conv"); + } catch (DuplicateKeyException expected) { + // good + } + } + + @Test + @DisplayName("A new active goal is allowed after the previous one entered a terminal state") + void terminalGoal_releasesUniquenessSlot() { + GoalEntity first = goalService.create(req("conv-recycle-1", "first"), "alice"); + goalService.abandon(first.getId(), "alice"); + + // After abandon, the conversation should be free to host a new active goal. + GoalEntity second = goalService.create(req("conv-recycle-1", "second"), "alice"); + assertNotNull(second); + assertEquals(GoalStatus.ACTIVE, second.getStatus()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java new file mode 100644 index 00000000..6ad92928 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/controller/GoalControllerTest.java @@ -0,0 +1,168 @@ +package vip.mate.goal.controller; + +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 org.springframework.security.core.Authentication; +import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.model.GoalUpdateRequest; +import vip.mate.goal.service.GoalService; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Authorization + happy-path coverage for {@link GoalController}. + * + *

Every write must: + * 1. Resolve the goal's conversation via service.getById (where applicable). + * 2. Reject non-owners with 403 before delegating to the service. + * 3. Delegate to the service when authorized. + */ +@ExtendWith(MockitoExtension.class) +class GoalControllerTest { + + @Mock private GoalService goalService; + @Mock private ConversationService conversationService; + @Mock private Authentication auth; + + private GoalController controller; + + @BeforeEach + void setUp() { + controller = new GoalController(goalService, conversationService); + when(auth.getName()).thenReturn("alice"); + } + + private GoalEntity goal(Long id, String convId, GoalStatus status) { + GoalEntity g = new GoalEntity(); + g.setId(id); + g.setConversationId(convId); + g.setAgentId(10L); + g.setWorkspaceId(1L); + g.setCreatedBy("alice"); + g.setTitle("ship"); + g.setStatus(status); + return g; + } + + private GoalCreateRequest req(String convId) { + GoalCreateRequest r = new GoalCreateRequest(); + r.setConversationId(convId); + r.setAgentId(10L); + r.setWorkspaceId(1L); + r.setTitle("ship"); + return r; + } + + // ==================== create ==================== + + @Test + void create_succeeds_whenOwner() { + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(goalService.create(any(), eq("alice"))) + .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + R result = controller.create(req("conv-1"), auth); + assertNotNull(result.getData()); + assertEquals(1L, result.getData().getId()); + } + + @Test + void create_returns403_whenNotOwner() { + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false); + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.create(req("conv-1"), auth)); + assertEquals(403, ex.getCode()); + verify(goalService, never()).create(any(), anyString()); + } + + @Test + void create_returns400_whenConversationIdBlank() { + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.create(req(""), auth)); + assertEquals(400, ex.getCode()); + } + + // ==================== find / get ==================== + + @Test + void findActive_returnsNull_whenNoActiveGoal() { + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(goalService.findActiveByConversation("conv-1")).thenReturn(null); + assertNull(controller.findActive("conv-1", auth).getData()); + } + + @Test + void get_returns403_whenCallerIsNotOwner() { + when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false); + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.get(1L, auth)); + assertEquals(403, ex.getCode()); + } + + // ==================== state machine ==================== + + @Test + void pause_delegatesToService_whenOwner() { + GoalEntity g = goal(1L, "conv-1", GoalStatus.ACTIVE); + when(goalService.getById(1L)).thenReturn(g); + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(goalService.pause(1L, "alice")).thenReturn(goal(1L, "conv-1", GoalStatus.PAUSED)); + + R result = controller.pause(1L, auth); + assertEquals(GoalStatus.PAUSED, result.getData().getStatus()); + } + + @Test + void abandon_returns403_whenCallerIsNotOwner() { + when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(false); + MateClawException ex = assertThrows(MateClawException.class, + () -> controller.abandon(1L, auth)); + assertEquals(403, ex.getCode()); + verify(goalService, never()).abandon(any(), anyString()); + } + + // ==================== update / criteria ==================== + + @Test + void update_delegatesToService_whenOwner() { + when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(goalService.update(eq(1L), any(), eq("alice"))) + .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + GoalUpdateRequest req = new GoalUpdateRequest(); + req.setTitle("new title"); + controller.update(1L, req, auth); + verify(goalService).update(eq(1L), any(), eq("alice")); + } + + @Test + void addCriterion_passesCriterionStringFromBody() { + when(goalService.getById(1L)).thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(goalService.appendCriterion(eq(1L), eq("tests pass"), eq("alice"))) + .thenReturn(goal(1L, "conv-1", GoalStatus.ACTIVE)); + + controller.addCriterion(1L, Map.of("criterion", "tests pass"), auth); + verify(goalService).appendCriterion(1L, "tests pass", "alice"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java new file mode 100644 index 00000000..03e77485 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/service/GoalServiceTest.java @@ -0,0 +1,370 @@ +package vip.mate.goal.service; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; +import vip.mate.audit.service.AuditEventService; +import vip.mate.exception.MateClawException; +import vip.mate.goal.config.GoalProperties; +import vip.mate.goal.model.GoalCreateRequest; +import vip.mate.goal.model.GoalEntity; +import vip.mate.goal.model.GoalEvaluationResult; +import vip.mate.goal.model.GoalEventEntity; +import vip.mate.goal.model.GoalStatus; +import vip.mate.goal.model.GoalUpdateRequest; +import vip.mate.goal.repository.GoalEventMapper; +import vip.mate.goal.repository.GoalMapper; + +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +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.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link GoalServiceImpl} — covers CRUD, state machine, + * evaluation bookkeeping, budget exhaustion, optimistic-lock retry, and + * the DB unique-index 409 mapping. + */ +@ExtendWith(MockitoExtension.class) +class GoalServiceTest { + + @Mock private GoalMapper goalMapper; + @Mock private GoalEventMapper eventMapper; + @Mock private AuditEventService auditEventService; + + private GoalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + GoalEntity.class); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new Configuration(), ""), + GoalEventEntity.class); + } + + @BeforeEach + void setUp() { + GoalProperties properties = new GoalProperties(); + service = new GoalServiceImpl(goalMapper, eventMapper, properties, + auditEventService, new ObjectMapper()); + } + + // ==================== Helpers ==================== + + private GoalCreateRequest validReq() { + GoalCreateRequest r = new GoalCreateRequest(); + r.setConversationId("conv-1"); + r.setAgentId(10L); + r.setWorkspaceId(1L); + r.setTitle("ship the blog"); + r.setDescription("deploy and verify"); + r.setExitCriteria("hello world accessible"); + return r; + } + + private GoalEntity persisted(Long id, GoalStatus status) { + GoalEntity g = new GoalEntity(); + g.setId(id); + g.setConversationId("conv-1"); + g.setAgentId(10L); + g.setWorkspaceId(1L); + g.setCreatedBy("alice"); + g.setTitle("ship the blog"); + g.setDescription("desc"); + g.setStatus(status); + g.setTurnBudget(20); + g.setTurnsUsed(0); + g.setLlmCallBudget(200); + g.setAgentLlmCallsUsed(0); + g.setEvalLlmCallsUsed(0); + g.setAutoFollowupEnabled(false); + g.setFollowupCooldownSeconds(0); + g.setVersion(0); + g.setDeleted(0); + g.setCreateTime(LocalDateTime.now()); + g.setUpdateTime(LocalDateTime.now()); + return g; + } + + // ==================== create ==================== + + @Test + void create_succeeds_whenNoActiveGoalExists() { + when(goalMapper.selectOne(any())).thenReturn(null); + when(goalMapper.insert(any(GoalEntity.class))).thenReturn(1); + + GoalEntity created = service.create(validReq(), "alice"); + + assertNotNull(created); + assertEquals("alice", created.getCreatedBy()); + assertEquals(GoalStatus.ACTIVE, created.getStatus()); + assertEquals(20, created.getTurnBudget()); + assertEquals(200, created.getLlmCallBudget()); + verify(eventMapper, times(1)).insert(any(GoalEventEntity.class)); + verify(auditEventService).record(eq("goal.created"), eq("goal"), + anyString(), anyString(), anyString(), any()); + } + + @Test + void create_returns409_whenActiveGoalAlreadyExists() { + when(goalMapper.selectOne(any())).thenReturn(persisted(99L, GoalStatus.ACTIVE)); + MateClawException ex = assertThrows(MateClawException.class, + () -> service.create(validReq(), "alice")); + assertEquals(409, ex.getCode()); + verify(goalMapper, never()).insert(any(GoalEntity.class)); + } + + @Test + void create_returns409_whenDbUniqueIndexHits() { + // Concurrent race: pre-check sees nothing, but the DB does. + when(goalMapper.selectOne(any())).thenReturn(null); + when(goalMapper.insert(any(GoalEntity.class))) + .thenThrow(new DuplicateKeyException("uk_agent_goal_active_conv")); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.create(validReq(), "alice")); + assertEquals(409, ex.getCode()); + } + + @Test + void create_rejectsBlankTitle() { + GoalCreateRequest r = validReq(); + r.setTitle(""); + MateClawException ex = assertThrows(MateClawException.class, + () -> service.create(r, "alice")); + assertEquals(400, ex.getCode()); + } + + @Test + void create_rejectsNonPositiveBudget() { + GoalCreateRequest r = validReq(); + r.setTurnBudget(0); + MateClawException ex = assertThrows(MateClawException.class, + () -> service.create(r, "alice")); + assertEquals(400, ex.getCode()); + } + + // ==================== state transitions ==================== + + @Test + void pause_flipsActiveToPaused_andWritesEvent() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.PAUSED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + + GoalEntity result = service.pause(1L, "alice"); + + assertEquals(GoalStatus.PAUSED, result.getStatus()); + ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(evCaptor.capture()); + assertEquals("paused", evCaptor.getValue().getEventType()); + } + + @Test + void pause_failsWhenGoalIsTerminal() { + when(goalMapper.selectById(1L)).thenReturn(persisted(1L, GoalStatus.COMPLETED)); + MateClawException ex = assertThrows(MateClawException.class, + () -> service.pause(1L, "alice")); + assertEquals(409, ex.getCode()); + } + + @Test + void resume_flipsPausedToActive() { + GoalEntity g = persisted(1L, GoalStatus.PAUSED); + when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.ACTIVE)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + assertEquals(GoalStatus.ACTIVE, service.resume(1L, "alice").getStatus()); + } + + @Test + void abandon_flipsAnyNonTerminalToAbandoned() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.ABANDONED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + assertEquals(GoalStatus.ABANDONED, service.abandon(1L, "alice").getStatus()); + } + + @Test + void markCompleted_isIdempotent_onTerminal() { + GoalEntity g = persisted(1L, GoalStatus.COMPLETED); + when(goalMapper.selectById(1L)).thenReturn(g); + GoalEntity result = service.markCompleted(1L, null); + assertEquals(GoalStatus.COMPLETED, result.getStatus()); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void markExhausted_carriesReasonInDetail() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(g, statusFlipped(g, GoalStatus.EXHAUSTED)); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + + service.markExhausted(1L, "turn_budget"); + + ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(evCaptor.capture()); + assertEquals("exhausted", evCaptor.getValue().getEventType()); + assertTrue(evCaptor.getValue().getDetailJson().contains("turn_budget")); + } + + // ==================== evaluation bookkeeping ==================== + + @Test + void recordEvaluation_bumpsCountersAndWritesEvent() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(g); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + + GoalEvaluationResult r = new GoalEvaluationResult( + 0.62, "DNS still missing", "continue", false, + "qwen-turbo", 1, 800L); + service.recordEvaluation(1L, r, 3, 1); + + ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(evCaptor.capture()); + assertEquals("evaluated", evCaptor.getValue().getEventType()); + String detail = evCaptor.getValue().getDetailJson(); + assertTrue(detail.contains("agentLlmCallsDelta")); + assertTrue(detail.contains("evalLlmCallsDelta")); + assertTrue(detail.contains("qwen-turbo")); + } + + @Test + void recordEvaluation_isNoop_onTerminalGoal() { + when(goalMapper.selectById(1L)).thenReturn(persisted(1L, GoalStatus.COMPLETED)); + service.recordEvaluation(1L, null, 5, 1); + verify(goalMapper, never()).update(any(), any(LambdaUpdateWrapper.class)); + verify(eventMapper, never()).insert(any(GoalEventEntity.class)); + } + + @Test + void isBudgetExhausted_detectsTurnBudgetHit() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + g.setTurnsUsed(20); + g.setTurnBudget(20); + assertTrue(service.isBudgetExhausted(g)); + assertEquals("turn_budget", service.exhaustionReason(g)); + } + + @Test + void isBudgetExhausted_detectsLlmBudgetHit() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + g.setAgentLlmCallsUsed(180); + g.setEvalLlmCallsUsed(25); + g.setLlmCallBudget(200); + assertTrue(service.isBudgetExhausted(g)); + assertEquals("llm_call_budget", service.exhaustionReason(g)); + } + + @Test + void isBudgetExhausted_returnsFalse_whenHeadroomRemains() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + g.setTurnsUsed(5); + g.setAgentLlmCallsUsed(30); + g.setEvalLlmCallsUsed(4); + assertFalse(service.isBudgetExhausted(g)); + } + + @Test + void appendCriterion_concatenatesWithMarker() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + g.setExitCriteria("DNS works"); + when(goalMapper.selectById(1L)).thenReturn(g, g); + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(1); + + service.appendCriterion(1L, "tests pass", "alice"); + + ArgumentCaptor evCaptor = ArgumentCaptor.forClass(GoalEventEntity.class); + verify(eventMapper).insert(evCaptor.capture()); + assertEquals("criterion_added", evCaptor.getValue().getEventType()); + assertTrue(evCaptor.getValue().getDetailJson().contains("tests pass")); + } + + @Test + void appendCriterion_rejectsBlankInput() { + // Validation happens before selectById, so we do NOT stub the mapper. + MateClawException ex = assertThrows(MateClawException.class, + () -> service.appendCriterion(1L, " ", "alice")); + assertEquals(400, ex.getCode()); + verify(goalMapper, never()).selectById(any()); + } + + // ==================== optimistic lock retry ==================== + + @Test + void update_failsAfterRetriesExhausted_whenVersionAlwaysStale() { + GoalEntity g = persisted(1L, GoalStatus.ACTIVE); + when(goalMapper.selectById(1L)).thenReturn(g); + // Always return 0 rows affected — simulates persistent version conflict. + when(goalMapper.update(any(), any(LambdaUpdateWrapper.class))).thenReturn(0); + + GoalUpdateRequest upd = new GoalUpdateRequest(); + upd.setTitle("new title"); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.update(1L, upd, "alice")); + assertEquals(409, ex.getCode()); + verify(goalMapper, times(3)).update(any(), any(LambdaUpdateWrapper.class)); + } + + @Test + void findActiveByConversation_returnsNull_forBlankInput() { + assertNull(service.findActiveByConversation("")); + assertNull(service.findActiveByConversation(null)); + verify(goalMapper, never()).selectOne(any()); + } + + @Test + void getById_throws404_whenMissing() { + when(goalMapper.selectById(1L)).thenReturn(null); + MateClawException ex = assertThrows(MateClawException.class, () -> service.getById(1L)); + assertEquals(404, ex.getCode()); + } + + /** Helper: mutate a copy of {@code g} with a new status, simulating + * what the post-update selectById would return. */ + private GoalEntity statusFlipped(GoalEntity g, GoalStatus newStatus) { + GoalEntity copy = new GoalEntity(); + copy.setId(g.getId()); + copy.setConversationId(g.getConversationId()); + copy.setAgentId(g.getAgentId()); + copy.setWorkspaceId(g.getWorkspaceId()); + copy.setCreatedBy(g.getCreatedBy()); + copy.setTitle(g.getTitle()); + copy.setStatus(newStatus); + copy.setTurnBudget(g.getTurnBudget()); + copy.setTurnsUsed(g.getTurnsUsed()); + copy.setLlmCallBudget(g.getLlmCallBudget()); + copy.setAgentLlmCallsUsed(g.getAgentLlmCallsUsed()); + copy.setEvalLlmCallsUsed(g.getEvalLlmCallsUsed()); + copy.setVersion(g.getVersion() + 1); + copy.setDeleted(0); + copy.setCreateTime(g.getCreateTime()); + copy.setUpdateTime(LocalDateTime.now()); + return copy; + } +}